@technomoron/apicore-server 1.0.0-beta.1
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/LICENSE +21 -0
- package/dist/cjs/api-module.cjs +34 -0
- package/dist/cjs/api-module.d.ts +45 -0
- package/dist/cjs/apicore-server.cjs +1561 -0
- package/dist/cjs/apicore-server.d.ts +288 -0
- package/dist/cjs/auth-api/auth-module.cjs +1248 -0
- package/dist/cjs/auth-api/auth-module.d.ts +116 -0
- package/dist/cjs/auth-api/compat-auth-storage.cjs +128 -0
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +57 -0
- package/dist/cjs/auth-api/mem-auth-store.cjs +121 -0
- package/dist/cjs/auth-api/mem-auth-store.d.ts +68 -0
- package/dist/cjs/auth-api/module.cjs +25 -0
- package/dist/cjs/auth-api/module.d.ts +20 -0
- 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.cjs +179 -0
- package/dist/cjs/auth-api/sql-auth-store.d.ts +87 -0
- package/dist/cjs/auth-api/storage.cjs +102 -0
- package/dist/cjs/auth-api/storage.d.ts +38 -0
- package/dist/cjs/auth-api/types.cjs +2 -0
- package/dist/cjs/auth-api/types.d.ts +34 -0
- package/dist/cjs/auth-api/user-id.cjs +47 -0
- package/dist/cjs/auth-api/user-id.d.ts +5 -0
- package/dist/cjs/auth-cookie-options.cjs +66 -0
- package/dist/cjs/auth-cookie-options.d.ts +13 -0
- 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 +51 -0
- package/dist/cjs/index.d.ts +34 -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.cjs +7 -0
- package/dist/cjs/oauth/base.d.ts +17 -0
- package/dist/cjs/oauth/memory.cjs +135 -0
- package/dist/cjs/oauth/memory.d.ts +22 -0
- package/dist/cjs/oauth/models.cjs +47 -0
- package/dist/cjs/oauth/models.d.ts +50 -0
- package/dist/cjs/oauth/sequelize.cjs +159 -0
- package/dist/cjs/oauth/sequelize.d.ts +30 -0
- package/dist/cjs/oauth/types.cjs +3 -0
- package/dist/cjs/oauth/types.d.ts +51 -0
- package/dist/cjs/passkey/base.cjs +7 -0
- package/dist/cjs/passkey/base.d.ts +28 -0
- package/dist/cjs/passkey/config.cjs +26 -0
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/memory.cjs +123 -0
- package/dist/cjs/passkey/memory.d.ts +34 -0
- package/dist/cjs/passkey/models.cjs +142 -0
- package/dist/cjs/passkey/models.d.ts +34 -0
- package/dist/cjs/passkey/sequelize.cjs +126 -0
- package/dist/cjs/passkey/sequelize.d.ts +42 -0
- package/dist/cjs/passkey/service.cjs +413 -0
- package/dist/cjs/passkey/service.d.ts +21 -0
- package/dist/cjs/passkey/types.cjs +2 -0
- package/dist/cjs/passkey/types.d.ts +84 -0
- package/dist/cjs/sequelize-utils.cjs +56 -0
- package/dist/cjs/sequelize-utils.d.ts +8 -0
- package/dist/cjs/token/base.cjs +120 -0
- package/dist/cjs/token/base.d.ts +46 -0
- package/dist/cjs/token/memory.cjs +234 -0
- package/dist/cjs/token/memory.d.ts +29 -0
- package/dist/cjs/token/sequelize.cjs +400 -0
- package/dist/cjs/token/sequelize.d.ts +58 -0
- package/dist/cjs/token/types.cjs +2 -0
- package/dist/cjs/token/types.d.ts +34 -0
- 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.cjs +2 -0
- package/dist/cjs/upload/types.d.ts +28 -0
- package/dist/cjs/user/base.cjs +53 -0
- package/dist/cjs/user/base.d.ts +36 -0
- package/dist/cjs/user/memory.cjs +194 -0
- package/dist/cjs/user/memory.d.ts +37 -0
- package/dist/cjs/user/sequelize.cjs +194 -0
- package/dist/cjs/user/sequelize.d.ts +46 -0
- package/dist/cjs/user/types.cjs +2 -0
- package/dist/cjs/user/types.d.ts +11 -0
- package/dist/esm/api-module.d.ts +45 -0
- package/dist/esm/api-module.js +30 -0
- package/dist/esm/apicore-server.d.ts +288 -0
- package/dist/esm/apicore-server.js +1552 -0
- package/dist/esm/auth-api/auth-module.d.ts +116 -0
- package/dist/esm/auth-api/auth-module.js +1246 -0
- package/dist/esm/auth-api/compat-auth-storage.d.ts +57 -0
- package/dist/esm/auth-api/compat-auth-storage.js +124 -0
- package/dist/esm/auth-api/mem-auth-store.d.ts +68 -0
- package/dist/esm/auth-api/mem-auth-store.js +117 -0
- package/dist/esm/auth-api/module.d.ts +20 -0
- package/dist/esm/auth-api/module.js +21 -0
- package/dist/esm/auth-api/schemas.d.ts +21 -0
- package/dist/esm/auth-api/schemas.js +168 -0
- package/dist/esm/auth-api/sql-auth-store.d.ts +87 -0
- package/dist/esm/auth-api/sql-auth-store.js +175 -0
- package/dist/esm/auth-api/storage.d.ts +38 -0
- package/dist/esm/auth-api/storage.js +98 -0
- package/dist/esm/auth-api/types.d.ts +34 -0
- package/dist/esm/auth-api/types.js +1 -0
- package/dist/esm/auth-api/user-id.d.ts +5 -0
- package/dist/esm/auth-api/user-id.js +41 -0
- package/dist/esm/auth-cookie-options.d.ts +13 -0
- package/dist/esm/auth-cookie-options.js +63 -0
- 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 +34 -0
- package/dist/esm/index.js +21 -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 +17 -0
- package/dist/esm/oauth/base.js +3 -0
- package/dist/esm/oauth/memory.d.ts +22 -0
- package/dist/esm/oauth/memory.js +128 -0
- package/dist/esm/oauth/models.d.ts +50 -0
- package/dist/esm/oauth/models.js +38 -0
- package/dist/esm/oauth/sequelize.d.ts +30 -0
- package/dist/esm/oauth/sequelize.js +148 -0
- package/dist/esm/oauth/types.d.ts +51 -0
- package/dist/esm/oauth/types.js +2 -0
- package/dist/esm/passkey/base.d.ts +28 -0
- package/dist/esm/passkey/base.js +3 -0
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.d.ts +34 -0
- package/dist/esm/passkey/memory.js +119 -0
- package/dist/esm/passkey/models.d.ts +34 -0
- package/dist/esm/passkey/models.js +135 -0
- package/dist/esm/passkey/sequelize.d.ts +42 -0
- package/dist/esm/passkey/sequelize.js +122 -0
- package/dist/esm/passkey/service.d.ts +21 -0
- package/dist/esm/passkey/service.js +376 -0
- package/dist/esm/passkey/types.d.ts +84 -0
- package/dist/esm/passkey/types.js +1 -0
- package/dist/esm/sequelize-utils.d.ts +8 -0
- package/dist/esm/sequelize-utils.js +47 -0
- package/dist/esm/token/base.d.ts +46 -0
- package/dist/esm/token/base.js +113 -0
- package/dist/esm/token/memory.d.ts +29 -0
- package/dist/esm/token/memory.js +230 -0
- package/dist/esm/token/sequelize.d.ts +58 -0
- package/dist/esm/token/sequelize.js +396 -0
- package/dist/esm/token/types.d.ts +34 -0
- package/dist/esm/token/types.js +1 -0
- 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 +36 -0
- package/dist/esm/user/base.js +46 -0
- package/dist/esm/user/memory.d.ts +37 -0
- package/dist/esm/user/memory.js +190 -0
- package/dist/esm/user/sequelize.d.ts +46 -0
- package/dist/esm/user/sequelize.js +188 -0
- package/dist/esm/user/types.d.ts +11 -0
- package/dist/esm/user/types.js +1 -0
- package/docs/swagger/openapi.json +2162 -0
- package/package.json +131 -0
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Bjørn Erik Jacobsen / Technomoron
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import { access, readFile } from 'node:fs/promises';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import path from 'node:path';
|
|
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
|
+
import { nullAuthModule } from './auth-api/module.js';
|
|
16
|
+
import { nullAuthAdapter } from './auth-api/storage.js';
|
|
17
|
+
import { toOptionalStringId } from './auth-api/user-id.js';
|
|
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';
|
|
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
|
+
}
|
|
54
|
+
class JwtHelperStore extends TokenStore {
|
|
55
|
+
async save() {
|
|
56
|
+
throw new Error('Token store is not configured');
|
|
57
|
+
}
|
|
58
|
+
async get() {
|
|
59
|
+
throw new Error('Token store is not configured');
|
|
60
|
+
}
|
|
61
|
+
async delete() {
|
|
62
|
+
throw new Error('Token store is not configured');
|
|
63
|
+
}
|
|
64
|
+
async update() {
|
|
65
|
+
throw new Error('Token store is not configured');
|
|
66
|
+
}
|
|
67
|
+
async list() {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
async close() {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export { ApiModule } from './api-module.js';
|
|
75
|
+
export class ApiError extends Error {
|
|
76
|
+
constructor({ code, message, data, errors }) {
|
|
77
|
+
const msg = guessExceptionText(message, '[Unknown error (null/undefined)]');
|
|
78
|
+
super(msg);
|
|
79
|
+
this.message = msg;
|
|
80
|
+
this.code = typeof code === 'number' ? code : 500;
|
|
81
|
+
this.data = data !== undefined ? data : null;
|
|
82
|
+
this.errors = errors !== undefined ? errors : {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function fillConfig(config) {
|
|
86
|
+
return {
|
|
87
|
+
apiPort: config.apiPort ?? 3101,
|
|
88
|
+
apiHost: config.apiHost ?? 'localhost',
|
|
89
|
+
uploadPath: config.uploadPath ?? '',
|
|
90
|
+
uploadMax: config.uploadMax ?? 30 * 1024 * 1024,
|
|
91
|
+
staticDirs: config.staticDirs,
|
|
92
|
+
origins: config.origins ?? [],
|
|
93
|
+
debug: config.debug ?? false,
|
|
94
|
+
apiBasePath: config.apiBasePath ?? '/api',
|
|
95
|
+
swaggerEnabled: config.swaggerEnabled ?? false,
|
|
96
|
+
swaggerPath: config.swaggerPath ?? '',
|
|
97
|
+
accessSecret: config.accessSecret ?? '',
|
|
98
|
+
refreshSecret: config.refreshSecret ?? '',
|
|
99
|
+
cookieDomain: config.cookieDomain ?? '',
|
|
100
|
+
cookiePath: config.cookiePath ?? '/',
|
|
101
|
+
cookieSameSite: config.cookieSameSite ?? 'lax',
|
|
102
|
+
cookieSecure: config.cookieSecure ?? 'auto',
|
|
103
|
+
cookieHttpOnly: config.cookieHttpOnly ?? true,
|
|
104
|
+
apiKeyPrefix: config.apiKeyPrefix ?? '',
|
|
105
|
+
apiKeyEnabled: config.apiKeyEnabled ?? false,
|
|
106
|
+
tokenParam: config.tokenParam ?? '',
|
|
107
|
+
tokenParamLocation: config.tokenParamLocation ?? 'body',
|
|
108
|
+
accessCookie: config.accessCookie ?? 'dat',
|
|
109
|
+
refreshCookie: config.refreshCookie ?? 'drt',
|
|
110
|
+
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
111
|
+
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60,
|
|
112
|
+
sessionRefreshExpiry: config.sessionRefreshExpiry ?? 24 * 60 * 60,
|
|
113
|
+
authApi: config.authApi ?? false,
|
|
114
|
+
devMode: config.devMode ?? false,
|
|
115
|
+
hydrateGetBody: config.hydrateGetBody ?? true,
|
|
116
|
+
validateTokens: config.validateTokens ?? false,
|
|
117
|
+
refreshMaybe: config.refreshMaybe ?? false,
|
|
118
|
+
apiVersion: config.apiVersion ?? '',
|
|
119
|
+
minClientVersion: config.minClientVersion ?? '',
|
|
120
|
+
tokenStore: config.tokenStore,
|
|
121
|
+
onStartError: config.onStartError,
|
|
122
|
+
trustProxy: config.trustProxy ?? true
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Core Fastify-based API server with module mounting and auth integration hooks. */
|
|
126
|
+
export class ApiServer {
|
|
127
|
+
get currReq() {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
set currReq(_value) {
|
|
131
|
+
if (this.config.devMode && !this.currReqDeprecationWarned) {
|
|
132
|
+
this.currReqDeprecationWarned = true;
|
|
133
|
+
console.warn('[apicore] ApiServer.currReq is deprecated and always null. Use per-request ApiRequest in handlers.');
|
|
134
|
+
}
|
|
135
|
+
void _value;
|
|
136
|
+
}
|
|
137
|
+
constructor(config = {}) {
|
|
138
|
+
this.finalized = false;
|
|
139
|
+
this.apiErrorHandlerInstalled = false;
|
|
140
|
+
this.tokenStoreAdapter = null;
|
|
141
|
+
this.compatGlobalErrorHandler = null;
|
|
142
|
+
this.currReqDeprecationWarned = false;
|
|
143
|
+
this.config = fillConfig(config);
|
|
144
|
+
this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
|
|
145
|
+
this.startedAt = Date.now();
|
|
146
|
+
this.storageAdapter = nullAuthAdapter;
|
|
147
|
+
this.moduleAdapter = nullAuthModule;
|
|
148
|
+
this.jwtHelper = new JwtHelperStore();
|
|
149
|
+
this.tokenStoreAdapter = this.config.tokenStore ?? null;
|
|
150
|
+
if (this.config.authApi && (!this.config.accessSecret || !this.config.refreshSecret)) {
|
|
151
|
+
console.warn('[apicore] Auth is enabled but accessSecret and/or refreshSecret are empty. JWT signing will fail at runtime.');
|
|
152
|
+
}
|
|
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);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
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
|
+
});
|
|
238
|
+
this.installStaticDirs();
|
|
239
|
+
this.installPingHandler();
|
|
240
|
+
this.installSwaggerHandler();
|
|
241
|
+
this.installApiNotFoundHandler();
|
|
242
|
+
this.installApiErrorHandler();
|
|
243
|
+
}
|
|
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);
|
|
254
|
+
}
|
|
255
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
256
|
+
}
|
|
257
|
+
parseRawBody(raw, contentType) {
|
|
258
|
+
if (!raw) {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
if (contentType.includes('application/json')) {
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(raw);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
270
|
+
const params = new URLSearchParams(raw);
|
|
271
|
+
return Object.fromEntries(params.entries());
|
|
272
|
+
}
|
|
273
|
+
return raw;
|
|
274
|
+
}
|
|
275
|
+
assertNotFinalized(action) {
|
|
276
|
+
if (this.finalized) {
|
|
277
|
+
throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
finalize() {
|
|
281
|
+
this.finalized = true;
|
|
282
|
+
return this;
|
|
283
|
+
}
|
|
284
|
+
authStorage(storage) {
|
|
285
|
+
this.assertNotFinalized('authStorage');
|
|
286
|
+
this.storageAdapter = storage;
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
useAuthStorage(storage) {
|
|
290
|
+
return this.authStorage(storage);
|
|
291
|
+
}
|
|
292
|
+
authModule(module) {
|
|
293
|
+
this.assertNotFinalized('authModule');
|
|
294
|
+
this.moduleAdapter = module;
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
useAuthModule(module) {
|
|
298
|
+
return this.authModule(module);
|
|
299
|
+
}
|
|
300
|
+
getAuthStorage() {
|
|
301
|
+
return this.storageAdapter;
|
|
302
|
+
}
|
|
303
|
+
getAuthModule() {
|
|
304
|
+
return this.moduleAdapter;
|
|
305
|
+
}
|
|
306
|
+
setTokenStore(store) {
|
|
307
|
+
this.assertNotFinalized('setTokenStore');
|
|
308
|
+
this.tokenStoreAdapter = store;
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
311
|
+
getTokenStore() {
|
|
312
|
+
return this.tokenStoreAdapter;
|
|
313
|
+
}
|
|
314
|
+
async listUserCredentials(userId) {
|
|
315
|
+
if (typeof this.storageAdapter.listUserCredentials !== 'function') {
|
|
316
|
+
throw new Error('Passkey service is not configured');
|
|
317
|
+
}
|
|
318
|
+
return this.storageAdapter.listUserCredentials(userId);
|
|
319
|
+
}
|
|
320
|
+
async deletePasskeyCredential(credentialId) {
|
|
321
|
+
if (typeof this.storageAdapter.deletePasskeyCredential !== 'function') {
|
|
322
|
+
throw new Error('Passkey service is not configured');
|
|
323
|
+
}
|
|
324
|
+
return this.storageAdapter.deletePasskeyCredential(credentialId);
|
|
325
|
+
}
|
|
326
|
+
async getUser(identifier) {
|
|
327
|
+
return this.storageAdapter.getUser(identifier);
|
|
328
|
+
}
|
|
329
|
+
getUserPasswordHash(user) {
|
|
330
|
+
return this.storageAdapter.getUserPasswordHash(user);
|
|
331
|
+
}
|
|
332
|
+
getUserId(user) {
|
|
333
|
+
return this.storageAdapter.getUserId(user);
|
|
334
|
+
}
|
|
335
|
+
filterUser(user) {
|
|
336
|
+
return this.storageAdapter.filterUser(user);
|
|
337
|
+
}
|
|
338
|
+
async verifyPassword(password, hash) {
|
|
339
|
+
return this.storageAdapter.verifyPassword(password, hash);
|
|
340
|
+
}
|
|
341
|
+
async storeToken(data) {
|
|
342
|
+
if (this.tokenStoreAdapter) {
|
|
343
|
+
return this.tokenStoreAdapter.save(data);
|
|
344
|
+
}
|
|
345
|
+
const storage = this.storageAdapter;
|
|
346
|
+
if (typeof storage.storeToken === 'function') {
|
|
347
|
+
return storage.storeToken(data);
|
|
348
|
+
}
|
|
349
|
+
throw new Error('Token store is not configured');
|
|
350
|
+
}
|
|
351
|
+
async getToken(query, opts) {
|
|
352
|
+
const normalized = {
|
|
353
|
+
...query,
|
|
354
|
+
userId: toOptionalStringId(query.userId),
|
|
355
|
+
ruid: toOptionalStringId(query.ruid)
|
|
356
|
+
};
|
|
357
|
+
if (this.tokenStoreAdapter) {
|
|
358
|
+
return this.tokenStoreAdapter.get(normalized, opts);
|
|
359
|
+
}
|
|
360
|
+
const storage = this.storageAdapter;
|
|
361
|
+
if (typeof storage.getToken === 'function') {
|
|
362
|
+
return storage.getToken(normalized, opts);
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
async deleteToken(query) {
|
|
367
|
+
const normalized = {
|
|
368
|
+
...query,
|
|
369
|
+
userId: toOptionalStringId(query.userId),
|
|
370
|
+
ruid: toOptionalStringId(query.ruid)
|
|
371
|
+
};
|
|
372
|
+
if (this.tokenStoreAdapter) {
|
|
373
|
+
return this.tokenStoreAdapter.delete(normalized);
|
|
374
|
+
}
|
|
375
|
+
const storage = this.storageAdapter;
|
|
376
|
+
if (typeof storage.deleteToken === 'function') {
|
|
377
|
+
return storage.deleteToken(normalized);
|
|
378
|
+
}
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
async createPasskeyChallenge(params) {
|
|
382
|
+
if (typeof this.storageAdapter.createPasskeyChallenge !== 'function') {
|
|
383
|
+
throw new Error('Passkey service is not configured');
|
|
384
|
+
}
|
|
385
|
+
return this.storageAdapter.createPasskeyChallenge(params);
|
|
386
|
+
}
|
|
387
|
+
async verifyPasskeyResponse(params) {
|
|
388
|
+
if (typeof this.storageAdapter.verifyPasskeyResponse !== 'function') {
|
|
389
|
+
throw new Error('Passkey service is not configured');
|
|
390
|
+
}
|
|
391
|
+
return this.storageAdapter.verifyPasskeyResponse(params);
|
|
392
|
+
}
|
|
393
|
+
async getClient(clientId) {
|
|
394
|
+
if (typeof this.storageAdapter.getClient !== 'function') {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return this.storageAdapter.getClient(clientId);
|
|
398
|
+
}
|
|
399
|
+
async verifyClientSecret(client, clientSecret) {
|
|
400
|
+
if (typeof this.storageAdapter.verifyClientSecret !== 'function') {
|
|
401
|
+
throw new Error('OAuth store is not configured');
|
|
402
|
+
}
|
|
403
|
+
return this.storageAdapter.verifyClientSecret(client, clientSecret);
|
|
404
|
+
}
|
|
405
|
+
async createAuthCode(request) {
|
|
406
|
+
if (typeof this.storageAdapter.createAuthCode !== 'function') {
|
|
407
|
+
throw new Error('OAuth store is not configured');
|
|
408
|
+
}
|
|
409
|
+
return this.storageAdapter.createAuthCode(request);
|
|
410
|
+
}
|
|
411
|
+
async consumeAuthCode(code, clientId) {
|
|
412
|
+
if (typeof this.storageAdapter.consumeAuthCode !== 'function') {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
const consumed = await this.storageAdapter.consumeAuthCode(code, clientId);
|
|
416
|
+
if (!consumed || consumed.clientId !== clientId) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
return consumed;
|
|
420
|
+
}
|
|
421
|
+
async canImpersonate(params) {
|
|
422
|
+
if (typeof this.storageAdapter.canImpersonate === 'function') {
|
|
423
|
+
return !!(await this.storageAdapter.canImpersonate(params));
|
|
424
|
+
}
|
|
425
|
+
return params.realUserId === params.effectiveUserId;
|
|
426
|
+
}
|
|
427
|
+
jwtSign(payload, secret, expiresInSeconds, options) {
|
|
428
|
+
if (!secret) {
|
|
429
|
+
return { success: false, error: 'JWT secret is not configured' };
|
|
430
|
+
}
|
|
431
|
+
return (this.tokenStoreAdapter ?? this.jwtHelper).jwtSign(payload, secret, expiresInSeconds, options);
|
|
432
|
+
}
|
|
433
|
+
jwtVerify(token, secret, options) {
|
|
434
|
+
return (this.tokenStoreAdapter ?? this.jwtHelper).jwtVerify(token, secret, options);
|
|
435
|
+
}
|
|
436
|
+
jwtDecode(token, options) {
|
|
437
|
+
return (this.tokenStoreAdapter ?? this.jwtHelper).jwtDecode(token, options);
|
|
438
|
+
}
|
|
439
|
+
async getApiKey(token) {
|
|
440
|
+
void token;
|
|
441
|
+
console.warn('[apicore] getApiKey() is not implemented. ' +
|
|
442
|
+
'Override getApiKey() in your ApiServer subclass to support API key authentication.');
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
async authenticateUser(params) {
|
|
446
|
+
if (!params?.login || !params?.password) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
const user = await this.storageAdapter.getUser(params.login);
|
|
450
|
+
if (!user) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
const hash = this.storageAdapter.getUserPasswordHash(user);
|
|
454
|
+
return this.storageAdapter.verifyPassword(params.password, hash);
|
|
455
|
+
}
|
|
456
|
+
async updateToken(updates) {
|
|
457
|
+
if (this.tokenStoreAdapter) {
|
|
458
|
+
return this.tokenStoreAdapter.update(updates);
|
|
459
|
+
}
|
|
460
|
+
const storage = this.storageAdapter;
|
|
461
|
+
if (typeof storage.updateToken === 'function') {
|
|
462
|
+
return storage.updateToken(updates);
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
guessExceptionText(error, defMsg = 'Unknown Error') {
|
|
467
|
+
return guessExceptionText(error, defMsg);
|
|
468
|
+
}
|
|
469
|
+
async authorize(apiReq, requiredClass) {
|
|
470
|
+
void apiReq;
|
|
471
|
+
if (requiredClass && requiredClass !== 'any') {
|
|
472
|
+
console.warn(`[apicore] 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
|
+
}
|
|
475
|
+
}
|
|
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;
|
|
491
|
+
}
|
|
492
|
+
installStaticDirs() {
|
|
493
|
+
const staticDirs = this.config.staticDirs;
|
|
494
|
+
if (!staticDirs || !isPlainObject(staticDirs)) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
for (const [mountRaw, dirRaw] of Object.entries(staticDirs)) {
|
|
498
|
+
const mount = typeof mountRaw === 'string' ? mountRaw.trim() : '';
|
|
499
|
+
const dir = typeof dirRaw === 'string' ? dirRaw.trim() : '';
|
|
500
|
+
if (!mount || !dir) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
|
|
504
|
+
void this.fastify.register(fastifyStatic, {
|
|
505
|
+
root: dir,
|
|
506
|
+
prefix: resolvedMount.endsWith('/') ? resolvedMount : `${resolvedMount}/`,
|
|
507
|
+
decorateReply: false
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
installPingHandler() {
|
|
512
|
+
this.fastify.get(`${this.apiBasePath}/v1/ping`, async () => {
|
|
513
|
+
const payload = {
|
|
514
|
+
success: true,
|
|
515
|
+
status: 'ok',
|
|
516
|
+
apiVersion: this.config.apiVersion ?? '',
|
|
517
|
+
minClientVersion: this.config.minClientVersion ?? '',
|
|
518
|
+
uptimeSec: process.uptime(),
|
|
519
|
+
startedAt: this.startedAt,
|
|
520
|
+
timestamp: new Date().toISOString()
|
|
521
|
+
};
|
|
522
|
+
return { success: true, code: 200, message: 'Success', data: payload, errors: {} };
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async loadSwaggerSpec() {
|
|
526
|
+
const candidates = [path.resolve(process.cwd(), 'docs/swagger/openapi.json')];
|
|
527
|
+
if (typeof __dirname === 'string') {
|
|
528
|
+
candidates.push(path.resolve(__dirname, '../../docs/swagger/openapi.json'));
|
|
529
|
+
}
|
|
530
|
+
let packageRoot;
|
|
531
|
+
try {
|
|
532
|
+
const require = createRequire(path.join(process.cwd(), 'package.json'));
|
|
533
|
+
const entry = require.resolve('@technomoron/apicore-server');
|
|
534
|
+
packageRoot = path.resolve(path.dirname(entry), '..', '..');
|
|
535
|
+
candidates.push(path.join(packageRoot, 'docs/swagger/openapi.json'));
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// Ignore resolution failures; fall back to any existing candidate.
|
|
539
|
+
}
|
|
540
|
+
for (const candidate of candidates) {
|
|
541
|
+
try {
|
|
542
|
+
await access(candidate);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const raw = await readFile(candidate, 'utf8');
|
|
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;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
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
|
+
}
|
|
584
|
+
installSwaggerHandler() {
|
|
585
|
+
const rawPath = typeof this.config.swaggerPath === 'string' ? this.config.swaggerPath.trim() : '';
|
|
586
|
+
const enabled = Boolean(this.config.swaggerEnabled) || rawPath.length > 0;
|
|
587
|
+
if (!enabled) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const base = this.apiBasePath === '/' ? '' : this.apiBasePath;
|
|
591
|
+
const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
|
|
592
|
+
const routePath = resolved.startsWith('/') ? resolved : `/${resolved}`;
|
|
593
|
+
let specPromise;
|
|
594
|
+
this.fastify.get(routePath, async (_request, reply) => {
|
|
595
|
+
if (!specPromise) {
|
|
596
|
+
specPromise = this.loadSwaggerSpec();
|
|
597
|
+
}
|
|
598
|
+
const spec = await specPromise;
|
|
599
|
+
if (!spec) {
|
|
600
|
+
// Clear cached failure so next request retries
|
|
601
|
+
specPromise = undefined;
|
|
602
|
+
}
|
|
603
|
+
if (!spec) {
|
|
604
|
+
reply.code(500);
|
|
605
|
+
return {
|
|
606
|
+
success: false,
|
|
607
|
+
code: 500,
|
|
608
|
+
message: 'Swagger spec is unavailable',
|
|
609
|
+
data: null,
|
|
610
|
+
errors: {}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return spec;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
normalizeApiBasePath(routePath) {
|
|
617
|
+
if (!routePath || typeof routePath !== 'string') {
|
|
618
|
+
return '/api';
|
|
619
|
+
}
|
|
620
|
+
const trimmed = routePath.trim();
|
|
621
|
+
if (!trimmed) {
|
|
622
|
+
return '/api';
|
|
623
|
+
}
|
|
624
|
+
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
625
|
+
if (withLeadingSlash.length === 1) {
|
|
626
|
+
return withLeadingSlash;
|
|
627
|
+
}
|
|
628
|
+
return withLeadingSlash.replace(/\/+$/, '') || '/api';
|
|
629
|
+
}
|
|
630
|
+
installApiNotFoundHandler() {
|
|
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({
|
|
639
|
+
success: false,
|
|
640
|
+
code: 404,
|
|
641
|
+
message: `No such endpoint: ${method} ${target}`,
|
|
642
|
+
data: null,
|
|
643
|
+
errors: {}
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
installApiErrorHandler() {
|
|
648
|
+
if (this.apiErrorHandlerInstalled) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
this.apiErrorHandlerInstalled = true;
|
|
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
|
+
});
|
|
731
|
+
}
|
|
732
|
+
start() {
|
|
733
|
+
if (!this.finalized) {
|
|
734
|
+
console.warn('[apicore] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
|
|
735
|
+
this.finalize();
|
|
736
|
+
}
|
|
737
|
+
void this.fastify
|
|
738
|
+
.listen({
|
|
739
|
+
port: this.config.apiPort,
|
|
740
|
+
host: this.config.apiHost
|
|
741
|
+
})
|
|
742
|
+
.then(() => {
|
|
743
|
+
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
744
|
+
})
|
|
745
|
+
.catch((error) => {
|
|
746
|
+
let message;
|
|
747
|
+
if (error.code === 'EADDRINUSE') {
|
|
748
|
+
message = `Port ${this.config.apiPort} is already in use.`;
|
|
749
|
+
}
|
|
750
|
+
else if (error.code === 'EACCES') {
|
|
751
|
+
message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
|
|
752
|
+
}
|
|
753
|
+
else if (error.code === 'EADDRNOTAVAIL') {
|
|
754
|
+
message = `Address ${this.config.apiHost} is not available on this machine.`;
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
message = `Failed to start server: ${error.message}`;
|
|
758
|
+
}
|
|
759
|
+
const err = new Error(message);
|
|
760
|
+
err.cause = error;
|
|
761
|
+
if (typeof this.config.onStartError === 'function') {
|
|
762
|
+
this.config.onStartError(err);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
this.logUnhandledError('Server startup failed', err);
|
|
766
|
+
process.exitCode = 1;
|
|
767
|
+
});
|
|
768
|
+
return this;
|
|
769
|
+
}
|
|
770
|
+
internalServerErrorMessage(error) {
|
|
771
|
+
return this.config.debug ? this.guessExceptionText(error) : 'Internal server error';
|
|
772
|
+
}
|
|
773
|
+
logUnhandledError(context, error) {
|
|
774
|
+
console.error(`[ApiServer] ${context}`, error);
|
|
775
|
+
}
|
|
776
|
+
async verifyJWT(token) {
|
|
777
|
+
if (!this.config.accessSecret) {
|
|
778
|
+
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
|
|
779
|
+
}
|
|
780
|
+
const result = this.jwtVerify(token, this.config.accessSecret);
|
|
781
|
+
if (!result.success) {
|
|
782
|
+
return { tokenData: undefined, error: result.error, expired: result.expired };
|
|
783
|
+
}
|
|
784
|
+
if (result.data.uid == null) {
|
|
785
|
+
return { tokenData: undefined, error: 'Missing/bad userid in token', expired: false };
|
|
786
|
+
}
|
|
787
|
+
return { tokenData: result.data, error: undefined, expired: false };
|
|
788
|
+
}
|
|
789
|
+
jwtCookieOptions(apiReq) {
|
|
790
|
+
return buildAuthCookieOptions(this.config, apiReq.req);
|
|
791
|
+
}
|
|
792
|
+
setAccessCookie(apiReq, accessToken, sessionCookie) {
|
|
793
|
+
const conf = this.config;
|
|
794
|
+
const options = this.jwtCookieOptions(apiReq);
|
|
795
|
+
const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
|
|
796
|
+
const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
|
|
797
|
+
apiReq.res.cookie(conf.accessCookie, accessToken, accessOptions);
|
|
798
|
+
}
|
|
799
|
+
async tryRefreshAccessToken(apiReq) {
|
|
800
|
+
const conf = this.config;
|
|
801
|
+
if (!conf.refreshSecret || !conf.accessSecret) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
const rawRefresh = apiReq.req.cookies?.[conf.refreshCookie];
|
|
805
|
+
if (typeof rawRefresh !== 'string') {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
const refreshToken = rawRefresh.trim();
|
|
809
|
+
if (!refreshToken) {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
const verify = this.jwtVerify(refreshToken, conf.refreshSecret);
|
|
813
|
+
if (!verify.success || !verify.data) {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
let stored = null;
|
|
817
|
+
try {
|
|
818
|
+
stored = await this.storageAdapter.getToken({ refreshToken });
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
if (!stored) {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
const storedUid = String(stored.userId);
|
|
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) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
const claims = verify.data;
|
|
833
|
+
const { exp: _exp, iat: _iat, nbf: _nbf, ...payload } = claims;
|
|
834
|
+
void _exp;
|
|
835
|
+
void _iat;
|
|
836
|
+
void _nbf;
|
|
837
|
+
delete payload.accessToken;
|
|
838
|
+
delete payload.refreshToken;
|
|
839
|
+
delete payload.userId;
|
|
840
|
+
delete payload.expires;
|
|
841
|
+
delete payload.issuedAt;
|
|
842
|
+
delete payload.lastSeenAt;
|
|
843
|
+
delete payload.status;
|
|
844
|
+
const access = this.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
845
|
+
if (!access.success || !access.token) {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
const updated = await this.updateToken({
|
|
849
|
+
refreshToken,
|
|
850
|
+
accessToken: access.token,
|
|
851
|
+
lastSeenAt: new Date()
|
|
852
|
+
});
|
|
853
|
+
if (!updated) {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
this.setAccessCookie(apiReq, access.token, stored.sessionCookie ?? false);
|
|
857
|
+
if (apiReq.req.cookies) {
|
|
858
|
+
apiReq.req.cookies[conf.accessCookie] = access.token;
|
|
859
|
+
}
|
|
860
|
+
const verifiedAccess = await this.verifyJWT(access.token);
|
|
861
|
+
if (!verifiedAccess.tokenData) {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
const refreshedStored = { ...stored, accessToken: access.token };
|
|
865
|
+
apiReq.authToken = refreshedStored;
|
|
866
|
+
return { token: access.token, tokenData: verifiedAccess.tokenData, stored: refreshedStored };
|
|
867
|
+
}
|
|
868
|
+
async authenticate(apiReq, authType) {
|
|
869
|
+
if (authType === 'none') {
|
|
870
|
+
apiReq.realUid = null;
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
let token = null;
|
|
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);
|
|
878
|
+
const requiresAuthToken = this.requiresAuthToken(authType);
|
|
879
|
+
const allowRefresh = requiresAuthToken || (authType === 'maybe' && this.config.refreshMaybe);
|
|
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';
|
|
889
|
+
}
|
|
890
|
+
else if (paramToken) {
|
|
891
|
+
apiReq.authMethod = 'param';
|
|
892
|
+
}
|
|
893
|
+
if (!token) {
|
|
894
|
+
const access = apiReq.req.cookies?.[this.config.accessCookie];
|
|
895
|
+
if (access) {
|
|
896
|
+
token = access;
|
|
897
|
+
apiReq.authMethod = 'cookie';
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
let tokenData;
|
|
901
|
+
let error;
|
|
902
|
+
let expired = false;
|
|
903
|
+
if (!token) {
|
|
904
|
+
if (authType === 'maybe') {
|
|
905
|
+
if (!this.config.refreshMaybe) {
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
909
|
+
if (!refreshed) {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
token = refreshed.token;
|
|
913
|
+
tokenData = refreshed.tokenData;
|
|
914
|
+
error = undefined;
|
|
915
|
+
expired = false;
|
|
916
|
+
}
|
|
917
|
+
else if (requiresAuthToken) {
|
|
918
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
919
|
+
if (!refreshed) {
|
|
920
|
+
throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
|
|
921
|
+
}
|
|
922
|
+
token = refreshed.token;
|
|
923
|
+
tokenData = refreshed.tokenData;
|
|
924
|
+
error = undefined;
|
|
925
|
+
expired = false;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (!token) {
|
|
929
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - requires authentication' });
|
|
930
|
+
}
|
|
931
|
+
if (!tokenData) {
|
|
932
|
+
const verified = await this.verifyJWT(token);
|
|
933
|
+
tokenData = verified.tokenData;
|
|
934
|
+
error = verified.error;
|
|
935
|
+
expired = verified.expired ?? false;
|
|
936
|
+
}
|
|
937
|
+
if (!tokenData && allowRefresh && expired) {
|
|
938
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
939
|
+
if (refreshed) {
|
|
940
|
+
token = refreshed.token;
|
|
941
|
+
tokenData = refreshed.tokenData;
|
|
942
|
+
error = undefined;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (!tokenData) {
|
|
946
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
|
|
947
|
+
}
|
|
948
|
+
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
949
|
+
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
950
|
+
if (this.shouldValidateStoredToken(authType)) {
|
|
951
|
+
try {
|
|
952
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
if (allowRefresh &&
|
|
956
|
+
error instanceof ApiError &&
|
|
957
|
+
error.code === 401 &&
|
|
958
|
+
error.message === 'Authorization token is no longer valid') {
|
|
959
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
960
|
+
if (!refreshed) {
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
token = refreshed.token;
|
|
964
|
+
tokenData = refreshed.tokenData;
|
|
965
|
+
const refreshedUserId = this.extractTokenUserId(tokenData);
|
|
966
|
+
apiReq.realUid = this.resolveRealUserId(tokenData, refreshedUserId);
|
|
967
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
apiReq.token = token;
|
|
975
|
+
return tokenData;
|
|
976
|
+
}
|
|
977
|
+
async tryAuthenticateApiKey(apiReq, authType, tokenCandidate) {
|
|
978
|
+
if (!tokenCandidate) {
|
|
979
|
+
if (authType === 'apikey') {
|
|
980
|
+
throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
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) {
|
|
988
|
+
if (authType === 'apikey') {
|
|
989
|
+
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
990
|
+
}
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
const key = await this.getApiKey(secret);
|
|
994
|
+
if (!key) {
|
|
995
|
+
if (authType === 'apikey') {
|
|
996
|
+
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
apiReq.token = secret;
|
|
1001
|
+
apiReq.apiKey = key;
|
|
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;
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
uid: effectiveUid,
|
|
1011
|
+
...(resolvedEuid !== null ? { ruid: resolvedRuid !== null ? String(resolvedRuid) : undefined } : {}),
|
|
1012
|
+
domain: '',
|
|
1013
|
+
fingerprint: '',
|
|
1014
|
+
iat: 0,
|
|
1015
|
+
exp: 0
|
|
1016
|
+
};
|
|
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
|
+
}
|
|
1053
|
+
requiresAuthToken(authType) {
|
|
1054
|
+
return authType === 'yes' || authType === 'strict';
|
|
1055
|
+
}
|
|
1056
|
+
shouldValidateStoredToken(authType) {
|
|
1057
|
+
return this.config.validateTokens || authType === 'strict';
|
|
1058
|
+
}
|
|
1059
|
+
async assertStoredAccessToken(apiReq, token, tokenData) {
|
|
1060
|
+
const userId = String(this.extractTokenUserId(tokenData));
|
|
1061
|
+
if (apiReq.authToken && apiReq.authToken.accessToken === token && String(apiReq.authToken.userId) === userId) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const stored = await this.storageAdapter.getToken({
|
|
1065
|
+
accessToken: token,
|
|
1066
|
+
userId
|
|
1067
|
+
});
|
|
1068
|
+
if (!stored) {
|
|
1069
|
+
throw new ApiError({ code: 401, message: 'Authorization token is no longer valid' });
|
|
1070
|
+
}
|
|
1071
|
+
apiReq.authToken = stored;
|
|
1072
|
+
}
|
|
1073
|
+
normalizeAuthIdentifier(candidate) {
|
|
1074
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
|
1075
|
+
return candidate;
|
|
1076
|
+
}
|
|
1077
|
+
if (typeof candidate === 'string') {
|
|
1078
|
+
const trimmed = candidate.trim();
|
|
1079
|
+
if (trimmed.length > 0) {
|
|
1080
|
+
return trimmed;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
extractTokenUserId(tokenData) {
|
|
1086
|
+
const normalized = this.normalizeAuthIdentifier(tokenData.uid);
|
|
1087
|
+
if (normalized === null) {
|
|
1088
|
+
throw new ApiError({ code: 401, message: 'Authorization token is malformed' });
|
|
1089
|
+
}
|
|
1090
|
+
return normalized;
|
|
1091
|
+
}
|
|
1092
|
+
resolveRealUserId(tokenData, effectiveUserId) {
|
|
1093
|
+
const withReal = tokenData;
|
|
1094
|
+
const rawReal = this.normalizeAuthIdentifier(withReal.ruid);
|
|
1095
|
+
if (rawReal === null) {
|
|
1096
|
+
return effectiveUserId;
|
|
1097
|
+
}
|
|
1098
|
+
return rawReal;
|
|
1099
|
+
}
|
|
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
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
createApiRequest(req, res) {
|
|
1121
|
+
const apiReq = {
|
|
1122
|
+
server: this,
|
|
1123
|
+
req,
|
|
1124
|
+
res,
|
|
1125
|
+
token: '',
|
|
1126
|
+
tokenData: null,
|
|
1127
|
+
authMethod: null,
|
|
1128
|
+
realUid: null,
|
|
1129
|
+
getClientInfo: () => ensureClientInfo(apiReq, this.config.trustProxy),
|
|
1130
|
+
getClientIp: () => ensureClientInfo(apiReq, this.config.trustProxy).ip,
|
|
1131
|
+
getClientIpChain: () => ensureClientInfo(apiReq, this.config.trustProxy).ipchain,
|
|
1132
|
+
getRealUid: () => apiReq.realUid ?? null,
|
|
1133
|
+
isImpersonating: () => {
|
|
1134
|
+
const realUid = apiReq.realUid;
|
|
1135
|
+
const tokenUid = apiReq.tokenData?.uid;
|
|
1136
|
+
if (realUid === null || realUid === undefined) {
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
if (tokenUid === null || tokenUid === undefined) {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
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);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
return apiReq;
|
|
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
|
+
}
|
|
1237
|
+
expressAuth(auth) {
|
|
1238
|
+
return async (req, res, next) => {
|
|
1239
|
+
const apiReq = this.createApiRequest(req, res);
|
|
1240
|
+
req.apiReq = apiReq;
|
|
1241
|
+
res.locals.apiReq = apiReq;
|
|
1242
|
+
try {
|
|
1243
|
+
if (this.config.hydrateGetBody) {
|
|
1244
|
+
hydrateGetBody(req);
|
|
1245
|
+
}
|
|
1246
|
+
if (this.config.debug) {
|
|
1247
|
+
this.dumpRequest(apiReq);
|
|
1248
|
+
}
|
|
1249
|
+
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
1250
|
+
await this.authorize(apiReq, auth.req);
|
|
1251
|
+
next();
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
next(error);
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
expressErrorHandler() {
|
|
1259
|
+
return (error, _req, res, next) => {
|
|
1260
|
+
void _req;
|
|
1261
|
+
if (res.headersSent) {
|
|
1262
|
+
next(error);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
1266
|
+
const apiError = error;
|
|
1267
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
1268
|
+
? apiError.errors
|
|
1269
|
+
: {};
|
|
1270
|
+
const errorPayload = {
|
|
1271
|
+
success: false,
|
|
1272
|
+
code: apiError.code,
|
|
1273
|
+
message: apiError.message,
|
|
1274
|
+
data: apiError.data ?? null,
|
|
1275
|
+
errors: normalizedErrors
|
|
1276
|
+
};
|
|
1277
|
+
res.status(apiError.code).json(errorPayload);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const status = asHttpStatus(error);
|
|
1281
|
+
if (status) {
|
|
1282
|
+
if (status >= 500) {
|
|
1283
|
+
this.logUnhandledError(`Unhandled compat middleware error mapped to HTTP ${status}`, error);
|
|
1284
|
+
}
|
|
1285
|
+
res.status(status).json({
|
|
1286
|
+
success: false,
|
|
1287
|
+
code: status,
|
|
1288
|
+
message: status === 400 ? 'Invalid request payload' : this.internalServerErrorMessage(error),
|
|
1289
|
+
data: null,
|
|
1290
|
+
errors: {}
|
|
1291
|
+
});
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
this.logUnhandledError('Unhandled compat middleware error fallback to HTTP 500', error);
|
|
1295
|
+
res.status(500).json({
|
|
1296
|
+
success: false,
|
|
1297
|
+
code: 500,
|
|
1298
|
+
message: this.internalServerErrorMessage(error),
|
|
1299
|
+
data: null,
|
|
1300
|
+
errors: {}
|
|
1301
|
+
});
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
handleRequest(handler, auth) {
|
|
1305
|
+
return async (request, reply) => {
|
|
1306
|
+
const req = this.toExtendedReq(request);
|
|
1307
|
+
const res = new FastifyResponseAdapter(reply);
|
|
1308
|
+
const apiReq = this.createApiRequest(req, res);
|
|
1309
|
+
try {
|
|
1310
|
+
if (this.config.hydrateGetBody) {
|
|
1311
|
+
hydrateGetBody(apiReq.req);
|
|
1312
|
+
}
|
|
1313
|
+
if (this.config.debug) {
|
|
1314
|
+
this.dumpRequest(apiReq);
|
|
1315
|
+
}
|
|
1316
|
+
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
1317
|
+
await this.authorize(apiReq, auth.req);
|
|
1318
|
+
const handlerResult = await handler(apiReq);
|
|
1319
|
+
if (!Array.isArray(handlerResult)) {
|
|
1320
|
+
throw new ApiError({
|
|
1321
|
+
code: 500,
|
|
1322
|
+
message: 'Handler result must be an array: [status, data?, message?]'
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const [code, data = null, rawMessage = 'Success'] = handlerResult;
|
|
1326
|
+
if (typeof code !== 'number' || Number.isNaN(code)) {
|
|
1327
|
+
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
1328
|
+
}
|
|
1329
|
+
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
1330
|
+
const responsePayload = { success: code < 400, code, message, data, errors: {} };
|
|
1331
|
+
if (this.config.debug) {
|
|
1332
|
+
this.dumpResponse(apiReq, responsePayload, code);
|
|
1333
|
+
}
|
|
1334
|
+
reply.code(code).send(responsePayload);
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
1338
|
+
const apiError = error;
|
|
1339
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
1340
|
+
? apiError.errors
|
|
1341
|
+
: {};
|
|
1342
|
+
const errorPayload = {
|
|
1343
|
+
success: false,
|
|
1344
|
+
code: apiError.code,
|
|
1345
|
+
message: apiError.message,
|
|
1346
|
+
data: apiError.data ?? null,
|
|
1347
|
+
errors: normalizedErrors
|
|
1348
|
+
};
|
|
1349
|
+
if (this.config.debug) {
|
|
1350
|
+
this.dumpResponse(apiReq, errorPayload, apiError.code);
|
|
1351
|
+
}
|
|
1352
|
+
reply.code(apiError.code).send(errorPayload);
|
|
1353
|
+
}
|
|
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);
|
|
1362
|
+
const errorPayload = {
|
|
1363
|
+
success: false,
|
|
1364
|
+
code: uploadTooLarge ? 413 : status,
|
|
1365
|
+
message: uploadTooLarge
|
|
1366
|
+
? `Upload exceeds maximum size of ${this.config.uploadMax} bytes`
|
|
1367
|
+
: this.internalServerErrorMessage(error),
|
|
1368
|
+
data: null,
|
|
1369
|
+
errors: {}
|
|
1370
|
+
};
|
|
1371
|
+
if (this.config.debug) {
|
|
1372
|
+
this.dumpResponse(apiReq, errorPayload, uploadTooLarge ? 413 : status);
|
|
1373
|
+
}
|
|
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);
|
|
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);
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
api(module) {
|
|
1398
|
+
this.assertNotFinalized('api');
|
|
1399
|
+
module.server = this;
|
|
1400
|
+
const moduleType = module.moduleType;
|
|
1401
|
+
if (moduleType === 'auth') {
|
|
1402
|
+
this.authModule(module);
|
|
1403
|
+
}
|
|
1404
|
+
const configOk = module.checkConfig();
|
|
1405
|
+
if (configOk === false) {
|
|
1406
|
+
const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
|
|
1407
|
+
throw new Error(`${name}.checkConfig() returned false`);
|
|
1408
|
+
}
|
|
1409
|
+
module.onMount();
|
|
1410
|
+
const ns = module.namespace;
|
|
1411
|
+
const mountPath = `${this.apiBasePath}${ns}`;
|
|
1412
|
+
module.mountpath = mountPath;
|
|
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 } : {}) });
|
|
1418
|
+
if (this.config.debug) {
|
|
1419
|
+
console.log(`Adding ${fullPath} (${method})`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return this;
|
|
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
|
+
}
|
|
1436
|
+
dumpRequest(apiReq) {
|
|
1437
|
+
const req = apiReq.req;
|
|
1438
|
+
const tokenParam = this.config.tokenParam.trim();
|
|
1439
|
+
console.log('--- Incoming Request! ---');
|
|
1440
|
+
const url = req.originalUrl || req.url;
|
|
1441
|
+
console.log('URL:', url);
|
|
1442
|
+
console.log('Method:', req.method);
|
|
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
|
+
}
|
|
1450
|
+
const sensitiveBodyKeys = ['password', 'client_secret', 'clientSecret', 'secret'];
|
|
1451
|
+
if (tokenParam) {
|
|
1452
|
+
sensitiveBodyKeys.push(tokenParam);
|
|
1453
|
+
}
|
|
1454
|
+
const body = req.body && typeof req.body === 'object' ? { ...req.body } : req.body;
|
|
1455
|
+
if (body && typeof body === 'object') {
|
|
1456
|
+
for (const key of sensitiveBodyKeys) {
|
|
1457
|
+
if (key in body) {
|
|
1458
|
+
body[key] = '[REDACTED]';
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
console.log('Body Params:', body || {});
|
|
1463
|
+
const cookies = req.cookies ? { ...req.cookies } : {};
|
|
1464
|
+
const sensitiveCookieKeys = [this.config.accessCookie, this.config.refreshCookie];
|
|
1465
|
+
for (const key of sensitiveCookieKeys) {
|
|
1466
|
+
if (key in cookies) {
|
|
1467
|
+
cookies[key] = '[REDACTED]';
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
console.log('Cookies:', cookies);
|
|
1471
|
+
const headers = { ...req.headers };
|
|
1472
|
+
if (headers.authorization) {
|
|
1473
|
+
headers.authorization = '[REDACTED]';
|
|
1474
|
+
}
|
|
1475
|
+
if (headers['x-api-key']) {
|
|
1476
|
+
headers['x-api-key'] = '[REDACTED]';
|
|
1477
|
+
}
|
|
1478
|
+
if (headers.cookie) {
|
|
1479
|
+
headers.cookie = '[REDACTED]';
|
|
1480
|
+
}
|
|
1481
|
+
console.log('Headers:', headers);
|
|
1482
|
+
console.log('------------------------');
|
|
1483
|
+
}
|
|
1484
|
+
formatDebugValue(value, maxLength = 50, seen = new WeakSet()) {
|
|
1485
|
+
if (value === null || value === undefined) {
|
|
1486
|
+
return value;
|
|
1487
|
+
}
|
|
1488
|
+
if (typeof value === 'string') {
|
|
1489
|
+
return value.length <= maxLength
|
|
1490
|
+
? value
|
|
1491
|
+
: `${value.slice(0, maxLength)}… [truncated ${value.length - maxLength} chars]`;
|
|
1492
|
+
}
|
|
1493
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
1494
|
+
return value;
|
|
1495
|
+
}
|
|
1496
|
+
if (typeof value === 'symbol') {
|
|
1497
|
+
return value.toString();
|
|
1498
|
+
}
|
|
1499
|
+
if (value instanceof Date) {
|
|
1500
|
+
return value.toISOString();
|
|
1501
|
+
}
|
|
1502
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
|
|
1503
|
+
return `<Buffer length=${value.length}>`;
|
|
1504
|
+
}
|
|
1505
|
+
if (typeof value === 'function') {
|
|
1506
|
+
return `[Function ${value.name || 'anonymous'}]`;
|
|
1507
|
+
}
|
|
1508
|
+
if (typeof value === 'object') {
|
|
1509
|
+
const obj = value;
|
|
1510
|
+
if (seen.has(obj)) {
|
|
1511
|
+
return '[Circular]';
|
|
1512
|
+
}
|
|
1513
|
+
seen.add(obj);
|
|
1514
|
+
if (Array.isArray(value)) {
|
|
1515
|
+
const arr = value.map((item) => this.formatDebugValue(item, maxLength, seen));
|
|
1516
|
+
seen.delete(obj);
|
|
1517
|
+
return arr;
|
|
1518
|
+
}
|
|
1519
|
+
const recordValue = value;
|
|
1520
|
+
const entries = Object.entries(recordValue).reduce((acc, [key, val]) => {
|
|
1521
|
+
acc[key] = this.formatDebugValue(val, maxLength, seen);
|
|
1522
|
+
return acc;
|
|
1523
|
+
}, {});
|
|
1524
|
+
seen.delete(obj);
|
|
1525
|
+
return entries;
|
|
1526
|
+
}
|
|
1527
|
+
return value;
|
|
1528
|
+
}
|
|
1529
|
+
dumpResponse(apiReq, payload, status) {
|
|
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
|
+
}
|
|
1545
|
+
console.log('--- Outgoing Response! ---');
|
|
1546
|
+
console.log('URL:', url);
|
|
1547
|
+
console.log('Status:', status);
|
|
1548
|
+
console.log('Payload:', this.formatDebugValue(payload));
|
|
1549
|
+
console.log('--------------------------');
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
export default ApiServer;
|