@technomoron/api-server-base 2.0.0-beta.17 → 2.0.0-beta.19
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/README.txt +48 -35
- package/dist/cjs/api-module.cjs +9 -0
- package/dist/cjs/api-module.d.ts +4 -2
- package/dist/cjs/api-server-base.cjs +178 -57
- package/dist/cjs/api-server-base.d.ts +31 -2
- package/dist/cjs/auth-api/auth-module.d.ts +12 -1
- package/dist/cjs/auth-api/auth-module.js +77 -35
- package/dist/cjs/auth-api/mem-auth-store.js +2 -23
- package/dist/cjs/auth-api/sql-auth-store.js +4 -31
- package/dist/cjs/auth-api/user-id.d.ts +4 -0
- package/dist/cjs/auth-api/user-id.js +31 -0
- package/dist/cjs/auth-cookie-options.d.ts +11 -0
- package/dist/cjs/auth-cookie-options.js +57 -0
- package/dist/cjs/oauth/memory.js +4 -10
- package/dist/cjs/oauth/models.js +4 -15
- package/dist/cjs/oauth/sequelize.js +8 -23
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/config.js +26 -0
- package/dist/cjs/passkey/memory.js +2 -9
- package/dist/cjs/passkey/models.js +4 -15
- package/dist/cjs/passkey/sequelize.js +6 -22
- package/dist/cjs/passkey/service.js +1 -1
- package/dist/cjs/passkey/types.d.ts +5 -0
- package/dist/cjs/sequelize-utils.d.ts +3 -0
- package/dist/cjs/sequelize-utils.js +17 -0
- package/dist/cjs/token/memory.d.ts +4 -0
- package/dist/cjs/token/memory.js +90 -25
- package/dist/cjs/token/sequelize.js +16 -22
- package/dist/cjs/token/types.d.ts +7 -0
- package/dist/cjs/user/memory.js +2 -9
- package/dist/cjs/user/sequelize.js +6 -22
- package/dist/esm/api-module.d.ts +4 -2
- package/dist/esm/api-module.js +9 -0
- package/dist/esm/api-server-base.d.ts +31 -2
- package/dist/esm/api-server-base.js +178 -57
- package/dist/esm/auth-api/auth-module.d.ts +12 -1
- package/dist/esm/auth-api/auth-module.js +77 -35
- package/dist/esm/auth-api/mem-auth-store.js +1 -22
- package/dist/esm/auth-api/sql-auth-store.js +2 -29
- package/dist/esm/auth-api/user-id.d.ts +4 -0
- package/dist/esm/auth-api/user-id.js +26 -0
- package/dist/esm/auth-cookie-options.d.ts +11 -0
- package/dist/esm/auth-cookie-options.js +54 -0
- package/dist/esm/oauth/memory.js +4 -10
- package/dist/esm/oauth/models.js +1 -12
- package/dist/esm/oauth/sequelize.js +5 -20
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.js +2 -9
- package/dist/esm/passkey/models.js +1 -12
- package/dist/esm/passkey/sequelize.js +3 -19
- package/dist/esm/passkey/service.js +1 -1
- package/dist/esm/passkey/types.d.ts +5 -0
- package/dist/esm/sequelize-utils.d.ts +3 -0
- package/dist/esm/sequelize-utils.js +12 -0
- package/dist/esm/token/memory.d.ts +4 -0
- package/dist/esm/token/memory.js +90 -25
- package/dist/esm/token/sequelize.js +12 -18
- package/dist/esm/token/types.d.ts +7 -0
- package/dist/esm/user/memory.js +2 -9
- package/dist/esm/user/sequelize.js +3 -19
- package/docs/swagger/openapi.json +11 -145
- package/package.json +12 -12
package/README.txt
CHANGED
|
@@ -25,13 +25,13 @@ pnpm add @technomoron/api-server-base
|
|
|
25
25
|
|
|
26
26
|
All runtime dependencies and `@types/*` packages are bundled with the distribution. The library exports ES modules by default. Consumers that rely on CommonJS can import via the require entry exposed in package.json.
|
|
27
27
|
|
|
28
|
-
Quick Start
|
|
29
|
-
-----------
|
|
30
|
-
import { ApiServer, ApiModule, ApiError,
|
|
28
|
+
Quick Start
|
|
29
|
+
-----------
|
|
30
|
+
import { ApiServer, ApiModule, ApiError, BaseAuthAdapter } from '@technomoron/api-server-base';
|
|
31
31
|
|
|
32
32
|
type DemoUser = { id: string; email: string; password: string };
|
|
33
33
|
|
|
34
|
-
class DemoStorage extends
|
|
34
|
+
class DemoStorage extends BaseAuthAdapter<DemoUser, Omit<DemoUser, 'password'>> {
|
|
35
35
|
private readonly users = new Map<string, DemoUser>([
|
|
36
36
|
['1', { id: '1', email: 'demo@example.com', password: 'secret' }]
|
|
37
37
|
]);
|
|
@@ -79,14 +79,15 @@ class UserModule extends ApiModule<AppServer> {
|
|
|
79
79
|
|
|
80
80
|
const yourStorageAdapter = new DemoStorage();
|
|
81
81
|
|
|
82
|
-
const server = new AppServer({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
const server = new AppServer({
|
|
83
|
+
apiPort: 3101,
|
|
84
|
+
apiHost: '127.0.0.1',
|
|
85
|
+
accessSecret: 'replace-me'
|
|
86
|
+
})
|
|
87
|
+
.authStorage(yourStorageAdapter)
|
|
88
|
+
.api(new UserModule())
|
|
89
|
+
.finalize()
|
|
90
|
+
.start();
|
|
90
91
|
|
|
91
92
|
Need a dedicated auth module as well? Chain `.authModule(...)` in the same spot.
|
|
92
93
|
|
|
@@ -105,7 +106,11 @@ uploadMax (number, default 30 * 1024 * 1024) Maximum upload size in bytes.
|
|
|
105
106
|
staticDirs (record, default empty object) Map of mount path => disk path for serving static files as-is (ex: { '/assets': './public' }).
|
|
106
107
|
accessSecret (string, default empty string) Required for JWT signing and verification.
|
|
107
108
|
refreshSecret (string, default empty string) Used for refresh tokens if you implement them.
|
|
108
|
-
cookieDomain (string, default '
|
|
109
|
+
cookieDomain (string, default '') Domain applied to auth cookies.
|
|
110
|
+
cookiePath (string, default '/') Path applied to auth cookies.
|
|
111
|
+
cookieSameSite ('lax' | 'strict' | 'none', default 'lax') SameSite attribute applied to auth cookies.
|
|
112
|
+
cookieSecure (boolean | 'auto', default 'auto') Secure attribute applied to auth cookies; 'auto' enables Secure only when the request is HTTPS (or forwarded as HTTPS).
|
|
113
|
+
cookieHttpOnly (boolean, default true) HttpOnly attribute applied to auth cookies.
|
|
109
114
|
accessCookie (string, default 'dat') Access token cookie name.
|
|
110
115
|
refreshCookie (string, default 'drt') Refresh token cookie name.
|
|
111
116
|
accessExpiry (number, default 60 * 15) Access token lifetime in seconds.
|
|
@@ -120,14 +125,14 @@ refreshMaybe (boolean, default false) When true, `auth: maybe` routes will try t
|
|
|
120
125
|
|
|
121
126
|
Tip: If you add new configuration fields in downstream projects, extend ApiServerConf and update fillConfig so defaults stay aligned.
|
|
122
127
|
|
|
123
|
-
Request Lifecycle
|
|
124
|
-
-----------------
|
|
125
|
-
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
126
|
-
2. ApiServer wraps the route inside handle_request,
|
|
127
|
-
3. authenticate enforces the ApiRoute auth type: `none`, `maybe`, `yes`, `strict`, or `apikey`. Bearer JWTs and the access cookie (`accessCookie`, default `dat`) are accepted for `yes`/`strict`, while API key tokens prefixed with `apikey-` always delegate to `getApiKey`. When `refreshSecret` is configured and your storage supports refresh lookups (`getToken({ refreshToken })` + `updateToken(...)`), `yes`/`strict` routes will automatically mint a new access token when it is missing or expired (and also recover from "Authorization token is no longer valid" by refreshing). `maybe` routes only do the same when `refreshMaybe: true`. The optional `strict` type (or server-wide `validateTokens` flag) requires the signed JWT to exist in storage; when it does, the persisted row is attached to `apiReq.authToken`. The dedicated `apikey` type simply means “an API key is required”; otherwise API keys are still accepted by `yes`/`strict` routes alongside JWTs, and `apiReq.apiKey` is populated when present.
|
|
128
|
-
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
129
|
-
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
130
|
-
6. Errors bubble into the wrapper. ApiError instances respect the provided status codes; other exceptions result in a 500 with text derived from guessExceptionText.
|
|
128
|
+
Request Lifecycle
|
|
129
|
+
-----------------
|
|
130
|
+
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
131
|
+
2. ApiServer wraps the route inside handle_request, creating an ApiRequest and logging when debug is enabled.
|
|
132
|
+
3. authenticate enforces the ApiRoute auth type: `none`, `maybe`, `yes`, `strict`, or `apikey`. Bearer JWTs and the access cookie (`accessCookie`, default `dat`) are accepted for `yes`/`strict`, while API key tokens prefixed with `apikey-` always delegate to `getApiKey`. When `refreshSecret` is configured and your storage supports refresh lookups (`getToken({ refreshToken })` + `updateToken(...)`), `yes`/`strict` routes will automatically mint a new access token when it is missing or expired (and also recover from "Authorization token is no longer valid" by refreshing). `maybe` routes only do the same when `refreshMaybe: true`. The optional `strict` type (or server-wide `validateTokens` flag) requires the signed JWT to exist in storage; when it does, the persisted row is attached to `apiReq.authToken`. The dedicated `apikey` type simply means “an API key is required”; otherwise API keys are still accepted by `yes`/`strict` routes alongside JWTs, and `apiReq.apiKey` is populated when present.
|
|
133
|
+
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
134
|
+
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
135
|
+
6. Errors bubble into the wrapper. ApiError instances respect the provided status codes; other exceptions result in a 500 with text derived from guessExceptionText.
|
|
131
136
|
|
|
132
137
|
Client IP Helpers
|
|
133
138
|
-----------------
|
|
@@ -135,16 +140,20 @@ Call `apiReq.getClientInfo()` when you need the entire client fingerprint captur
|
|
|
135
140
|
|
|
136
141
|
Call `apiReq.getClientIp()` to obtain the most likely client address, skipping loopback entries collected from proxy headers. Use `apiReq.getClientIpChain()` when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' `req.ip`/`req.ips` and the underlying socket. Both helpers reuse the cached payload returned by `apiReq.getClientInfo()`.
|
|
137
142
|
|
|
138
|
-
Extending the Base Classes
|
|
139
|
-
--------------------------
|
|
140
|
-
Implement the AuthStorage contract (getUser, verifyPassword, storeToken, updateToken, etc.) to integrate with your persistence layer, then supply it via authStorage().
|
|
141
|
-
Use your storage adapter's filterUser helper to trim sensitive data before returning responses.
|
|
142
|
-
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
143
|
-
Create feature modules by extending ApiModule. Use the optional checkConfig hook to validate prerequisites before routes mount.
|
|
143
|
+
Extending the Base Classes
|
|
144
|
+
--------------------------
|
|
145
|
+
Implement the AuthStorage contract (getUser, verifyPassword, storeToken, updateToken, etc.) to integrate with your persistence layer, then supply it via authStorage().
|
|
146
|
+
Use your storage adapter's filterUser helper to trim sensitive data before returning responses.
|
|
147
|
+
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
148
|
+
Create feature modules by extending ApiModule. Use the optional checkConfig hook to validate prerequisites before routes mount.
|
|
149
|
+
|
|
150
|
+
OAuth Client Secrets
|
|
151
|
+
--------------------
|
|
152
|
+
If your OAuth client records use a client secret, make `getClient(clientId)` return a client with a truthy `clientSecret` (do not return the stored hash/secret itself) and implement `verifyClientSecret(client, clientSecret)` on your storage adapter. If `clientSecret` is truthy but `verifyClientSecret` is not overridden, the `/auth/v1/oauth2/token` endpoint returns 501.
|
|
144
153
|
|
|
145
|
-
Sequelize Table Prefixes
|
|
146
|
-
------------------------
|
|
147
|
-
Sequelize-backed stores accept `tablePrefix` to prepend to the built-in table names (`users`, `jwttokens`, `passkey_credentials`, `passkey_challenges`, `oauth_clients`, `oauth_codes`).
|
|
154
|
+
Sequelize Table Prefixes
|
|
155
|
+
------------------------
|
|
156
|
+
Sequelize-backed stores accept `tablePrefix` to prepend to the built-in table names (`users`, `jwttokens`, `passkey_credentials`, `passkey_challenges`, `oauth_clients`, `oauth_codes`).
|
|
148
157
|
|
|
149
158
|
SqlAuthStore supports both a global prefix (`tablePrefix`) and per-module overrides (`tablePrefixes.user|token|passkey|oauth`). When present, `tokenStoreOptions.tablePrefix` and `oauthStoreOptions.tablePrefix` take precedence.
|
|
150
159
|
|
|
@@ -158,16 +167,16 @@ Example:
|
|
|
158
167
|
|
|
159
168
|
If you need a different base name (for example `myapp_tokens` instead of `myapp_jwttokens`), pass a custom model or model factory to the store and set the `tableName` yourself.
|
|
160
169
|
|
|
161
|
-
Custom Express Endpoints
|
|
162
|
-
------------------------
|
|
163
|
-
ApiModule routes run inside the tuple wrapper (always responding with a standardized JSON envelope). For endpoints that need raw Express control (streaming, webhooks, tus uploads, etc.), mount your own handlers directly.
|
|
170
|
+
Custom Express Endpoints
|
|
171
|
+
------------------------
|
|
172
|
+
ApiModule routes run inside the tuple wrapper (always responding with a standardized JSON envelope). For endpoints that need raw Express control (streaming, webhooks, tus uploads, etc.), mount your own handlers directly.
|
|
164
173
|
|
|
165
174
|
- `server.useExpress(...)` mounts middleware/routes and keeps the built-in `/api` 404 handler ordered last, so mounts under `apiBasePath` are not intercepted.
|
|
166
175
|
- Protect endpoints by inserting `server.expressAuth({ type, req })` as middleware. It authenticates using the same JWT/cookie/API-key logic as ApiModule routes and then runs `authorize`.
|
|
167
176
|
- On success, `expressAuth` attaches the computed ApiRequest to both `req.apiReq` and `res.locals.apiReq`.
|
|
168
177
|
- If you want the same JSON error envelope for custom endpoints, mount `server.expressErrorHandler()` after your custom routes.
|
|
169
178
|
|
|
170
|
-
Example:
|
|
179
|
+
Example:
|
|
171
180
|
|
|
172
181
|
server
|
|
173
182
|
.useExpress(
|
|
@@ -178,7 +187,11 @@ Example:
|
|
|
178
187
|
res.status(200).json({ uid: apiReq.tokenData?.uid ?? null });
|
|
179
188
|
}
|
|
180
189
|
)
|
|
181
|
-
|
|
190
|
+
.useExpress(server.expressErrorHandler());
|
|
191
|
+
|
|
192
|
+
Finalize And Start
|
|
193
|
+
------------------
|
|
194
|
+
Call `server.finalize()` after you have mounted all ApiModules and custom Express endpoints. After finalize (or after `start()`), calling `api()` / `useExpress()` will throw.
|
|
182
195
|
|
|
183
196
|
|
|
184
197
|
Tooling and Scripts
|
package/dist/cjs/api-module.cjs
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ApiModule = void 0;
|
|
4
4
|
class ApiModule {
|
|
5
|
+
get server() {
|
|
6
|
+
if (this._server === undefined) {
|
|
7
|
+
throw new Error('ApiModule.server is not set. Mount the module with ApiServer.api(...) before using it.');
|
|
8
|
+
}
|
|
9
|
+
return this._server;
|
|
10
|
+
}
|
|
11
|
+
set server(value) {
|
|
12
|
+
this._server = value;
|
|
13
|
+
}
|
|
5
14
|
constructor(opts = {}) {
|
|
6
15
|
this.mountpath = '';
|
|
7
16
|
this.namespace = opts.namespace ?? this.constructor.defaultNamespace ?? '';
|
package/dist/cjs/api-module.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface ApiKey {
|
|
|
7
7
|
uid: unknown;
|
|
8
8
|
}
|
|
9
9
|
export type ApiRoute = {
|
|
10
|
-
method: 'get' | 'post' | 'put' | 'delete';
|
|
10
|
+
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
11
11
|
path: string;
|
|
12
12
|
handler: ApiHandler;
|
|
13
13
|
auth: {
|
|
@@ -16,7 +16,9 @@ export type ApiRoute = {
|
|
|
16
16
|
};
|
|
17
17
|
};
|
|
18
18
|
export declare class ApiModule<T = unknown> {
|
|
19
|
-
|
|
19
|
+
private _server?;
|
|
20
|
+
get server(): T;
|
|
21
|
+
set server(value: T);
|
|
20
22
|
namespace: string;
|
|
21
23
|
mountpath: string;
|
|
22
24
|
static defaultNamespace: string;
|
|
@@ -20,6 +20,7 @@ const express_1 = __importDefault(require("express"));
|
|
|
20
20
|
const multer_1 = __importDefault(require("multer"));
|
|
21
21
|
const module_js_1 = require("./auth-api/module.js");
|
|
22
22
|
const storage_js_1 = require("./auth-api/storage.js");
|
|
23
|
+
const auth_cookie_options_js_1 = require("./auth-cookie-options.js");
|
|
23
24
|
const base_js_1 = require("./token/base.js");
|
|
24
25
|
class JwtHelperStore extends base_js_1.TokenStore {
|
|
25
26
|
async save() {
|
|
@@ -77,6 +78,7 @@ function hydrateGetBody(req) {
|
|
|
77
78
|
req.body = { ...query };
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
81
|
+
// Keep explicit body fields authoritative when both query and body provide the same key.
|
|
80
82
|
req.body = { ...query, ...body };
|
|
81
83
|
}
|
|
82
84
|
function normalizeIpAddress(candidate) {
|
|
@@ -337,6 +339,17 @@ function isApiErrorLike(candidate) {
|
|
|
337
339
|
const maybeError = candidate;
|
|
338
340
|
return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
|
|
339
341
|
}
|
|
342
|
+
function asHttpStatus(error) {
|
|
343
|
+
if (!error || typeof error !== 'object') {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const maybe = error;
|
|
347
|
+
const status = typeof maybe.status === 'number' ? maybe.status : maybe.statusCode;
|
|
348
|
+
if (typeof status === 'number' && status >= 400 && status <= 599) {
|
|
349
|
+
return status;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
340
353
|
function fillConfig(config) {
|
|
341
354
|
return {
|
|
342
355
|
apiPort: config.apiPort ?? 3101,
|
|
@@ -351,7 +364,11 @@ function fillConfig(config) {
|
|
|
351
364
|
swaggerPath: config.swaggerPath ?? '',
|
|
352
365
|
accessSecret: config.accessSecret ?? '',
|
|
353
366
|
refreshSecret: config.refreshSecret ?? '',
|
|
354
|
-
cookieDomain: config.cookieDomain ?? '
|
|
367
|
+
cookieDomain: config.cookieDomain ?? '',
|
|
368
|
+
cookiePath: config.cookiePath ?? '/',
|
|
369
|
+
cookieSameSite: config.cookieSameSite ?? 'lax',
|
|
370
|
+
cookieSecure: config.cookieSecure ?? 'auto',
|
|
371
|
+
cookieHttpOnly: config.cookieHttpOnly ?? true,
|
|
355
372
|
accessCookie: config.accessCookie ?? 'dat',
|
|
356
373
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
357
374
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
@@ -365,19 +382,37 @@ function fillConfig(config) {
|
|
|
365
382
|
apiVersion: config.apiVersion ?? '',
|
|
366
383
|
minClientVersion: config.minClientVersion ?? '',
|
|
367
384
|
tokenStore: config.tokenStore,
|
|
368
|
-
authStores: config.authStores
|
|
385
|
+
authStores: config.authStores,
|
|
386
|
+
onStartError: config.onStartError
|
|
369
387
|
};
|
|
370
388
|
}
|
|
371
389
|
class ApiServer {
|
|
390
|
+
/**
|
|
391
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
392
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
393
|
+
* when mounting raw Express endpoints.
|
|
394
|
+
*/
|
|
395
|
+
get currReq() {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
set currReq(_value) {
|
|
399
|
+
if (this.config.devMode && !this.currReqDeprecationWarned) {
|
|
400
|
+
this.currReqDeprecationWarned = true;
|
|
401
|
+
console.warn('[api-server-base] ApiServer.currReq is deprecated and always null. Use req.apiReq or res.locals.apiReq.');
|
|
402
|
+
}
|
|
403
|
+
void _value;
|
|
404
|
+
}
|
|
372
405
|
constructor(config = {}) {
|
|
373
|
-
this.
|
|
406
|
+
this.finalized = false;
|
|
374
407
|
this.serverAuthAdapter = null;
|
|
375
408
|
this.apiNotFoundHandler = null;
|
|
409
|
+
this.apiErrorHandlerInstalled = false;
|
|
376
410
|
this.tokenStoreAdapter = null;
|
|
377
411
|
this.userStoreAdapter = null;
|
|
378
412
|
this.passkeyServiceAdapter = null;
|
|
379
413
|
this.oauthStoreAdapter = null;
|
|
380
414
|
this.canImpersonateAdapter = null;
|
|
415
|
+
this.currReqDeprecationWarned = false;
|
|
381
416
|
this.config = fillConfig(config);
|
|
382
417
|
this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
|
|
383
418
|
this.startedAt = Date.now();
|
|
@@ -395,16 +430,68 @@ class ApiServer {
|
|
|
395
430
|
this.storageAdapter = this.getServerAuthAdapter();
|
|
396
431
|
}
|
|
397
432
|
this.app = (0, express_1.default)();
|
|
433
|
+
// Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
|
|
434
|
+
// the API 404 handler ordered last without relying on Express internals.
|
|
435
|
+
this.apiRouter = express_1.default.Router();
|
|
398
436
|
if (config.uploadPath) {
|
|
399
|
-
const upload = (0, multer_1.default)({ dest: config.uploadPath });
|
|
437
|
+
const upload = (0, multer_1.default)({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
|
|
400
438
|
this.app.use(upload.any());
|
|
439
|
+
// Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
|
|
440
|
+
this.app.use((err, _req, res, next) => {
|
|
441
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
442
|
+
if (code === 'LIMIT_FILE_SIZE') {
|
|
443
|
+
res.status(413).json({
|
|
444
|
+
success: false,
|
|
445
|
+
code: 413,
|
|
446
|
+
message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
|
|
447
|
+
data: null,
|
|
448
|
+
errors: {}
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
next(err);
|
|
453
|
+
});
|
|
401
454
|
}
|
|
402
455
|
this.middlewares();
|
|
403
456
|
this.installStaticDirs();
|
|
404
457
|
this.installPingHandler();
|
|
405
458
|
this.installSwaggerHandler();
|
|
459
|
+
this.app.use(this.apiBasePath, this.apiRouter);
|
|
406
460
|
// addSwaggerUi(this.app);
|
|
407
461
|
this.installApiNotFoundHandler();
|
|
462
|
+
this.installApiErrorHandler();
|
|
463
|
+
}
|
|
464
|
+
assertNotFinalized(action) {
|
|
465
|
+
if (this.finalized) {
|
|
466
|
+
throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
toApiRouterPath(candidate) {
|
|
470
|
+
if (typeof candidate !== 'string') {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const trimmed = candidate.trim();
|
|
474
|
+
if (!trimmed) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
478
|
+
const base = this.apiBasePath;
|
|
479
|
+
if (base === '/') {
|
|
480
|
+
return normalized;
|
|
481
|
+
}
|
|
482
|
+
if (normalized === base) {
|
|
483
|
+
return '/';
|
|
484
|
+
}
|
|
485
|
+
if (normalized.startsWith(`${base}/`)) {
|
|
486
|
+
return normalized.slice(base.length) || '/';
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
finalize() {
|
|
491
|
+
this.installApiNotFoundHandler();
|
|
492
|
+
this.installApiErrorHandler();
|
|
493
|
+
this.finalized = true;
|
|
494
|
+
return this;
|
|
408
495
|
}
|
|
409
496
|
authStorage(storage) {
|
|
410
497
|
this.storageAdapter = storage;
|
|
@@ -631,7 +718,7 @@ class ApiServer {
|
|
|
631
718
|
}
|
|
632
719
|
return false;
|
|
633
720
|
}
|
|
634
|
-
guessExceptionText(error, defMsg = '
|
|
721
|
+
guessExceptionText(error, defMsg = 'Unknown Error') {
|
|
635
722
|
return guess_exception_text(error, defMsg);
|
|
636
723
|
}
|
|
637
724
|
async authorize(apiReq, requiredClass) {
|
|
@@ -657,6 +744,26 @@ class ApiServer {
|
|
|
657
744
|
credentials: true
|
|
658
745
|
};
|
|
659
746
|
this.app.use((0, cors_1.default)(corsOptions));
|
|
747
|
+
// Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
|
|
748
|
+
this.app.use((err, req, res, next) => {
|
|
749
|
+
const message = err instanceof Error ? err.message : '';
|
|
750
|
+
if (message.includes('Not allowed by CORS')) {
|
|
751
|
+
const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
|
|
752
|
+
if (isApiRequest) {
|
|
753
|
+
res.status(403).json({
|
|
754
|
+
success: false,
|
|
755
|
+
code: 403,
|
|
756
|
+
message: 'Origin not allowed by CORS',
|
|
757
|
+
data: null,
|
|
758
|
+
errors: {}
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
res.status(403).send('Origin not allowed by CORS');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
next(err);
|
|
766
|
+
});
|
|
660
767
|
}
|
|
661
768
|
installStaticDirs() {
|
|
662
769
|
const staticDirs = this.config.staticDirs;
|
|
@@ -725,8 +832,12 @@ class ApiServer {
|
|
|
725
832
|
const base = this.apiBasePath === '/' ? '' : this.apiBasePath;
|
|
726
833
|
const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
|
|
727
834
|
const path = resolved.startsWith('/') ? resolved : `/${resolved}`;
|
|
728
|
-
|
|
835
|
+
let specCache;
|
|
729
836
|
this.app.get(path, (_req, res) => {
|
|
837
|
+
if (specCache === undefined) {
|
|
838
|
+
specCache = this.loadSwaggerSpec();
|
|
839
|
+
}
|
|
840
|
+
const spec = specCache;
|
|
730
841
|
if (!spec) {
|
|
731
842
|
res.status(500).json({
|
|
732
843
|
success: false,
|
|
@@ -770,21 +881,12 @@ class ApiServer {
|
|
|
770
881
|
};
|
|
771
882
|
this.app.use(this.apiBasePath, this.apiNotFoundHandler);
|
|
772
883
|
}
|
|
773
|
-
|
|
774
|
-
this.
|
|
775
|
-
if (!this.apiNotFoundHandler) {
|
|
884
|
+
installApiErrorHandler() {
|
|
885
|
+
if (this.apiErrorHandlerInstalled) {
|
|
776
886
|
return;
|
|
777
887
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
const index = stack.findIndex((layer) => layer.handle === this.apiNotFoundHandler);
|
|
783
|
-
if (index === -1 || index === stack.length - 1) {
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
const [layer] = stack.splice(index, 1);
|
|
787
|
-
stack.push(layer);
|
|
888
|
+
this.apiErrorHandlerInstalled = true;
|
|
889
|
+
this.app.use(this.apiBasePath, this.expressErrorHandler());
|
|
788
890
|
}
|
|
789
891
|
describeMissingEndpoint(req) {
|
|
790
892
|
const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
@@ -792,6 +894,10 @@ class ApiServer {
|
|
|
792
894
|
return `No such endpoint: ${method} ${target}`;
|
|
793
895
|
}
|
|
794
896
|
start() {
|
|
897
|
+
if (!this.finalized) {
|
|
898
|
+
console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
|
|
899
|
+
this.finalize();
|
|
900
|
+
}
|
|
795
901
|
this.app
|
|
796
902
|
.listen({
|
|
797
903
|
port: this.config.apiPort,
|
|
@@ -801,22 +907,32 @@ class ApiServer {
|
|
|
801
907
|
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
802
908
|
})
|
|
803
909
|
.on('error', (error) => {
|
|
910
|
+
let message;
|
|
804
911
|
if (error.code === 'EADDRINUSE') {
|
|
805
|
-
|
|
912
|
+
message = `Port ${this.config.apiPort} is already in use.`;
|
|
806
913
|
}
|
|
807
914
|
else if (error.code === 'EACCES') {
|
|
808
|
-
|
|
915
|
+
message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
|
|
809
916
|
}
|
|
810
917
|
else if (error.code === 'EADDRNOTAVAIL') {
|
|
811
|
-
|
|
918
|
+
message = `Address ${this.config.apiHost} is not available on this machine.`;
|
|
812
919
|
}
|
|
813
920
|
else {
|
|
814
|
-
|
|
921
|
+
message = `Failed to start server: ${error.message}`;
|
|
815
922
|
}
|
|
816
|
-
|
|
923
|
+
const err = new Error(message);
|
|
924
|
+
err.cause = error;
|
|
925
|
+
if (typeof this.config.onStartError === 'function') {
|
|
926
|
+
this.config.onStartError(err);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
throw err;
|
|
817
930
|
});
|
|
818
931
|
return this;
|
|
819
932
|
}
|
|
933
|
+
internalServerErrorMessage(error) {
|
|
934
|
+
return this.config.debug ? this.guessExceptionText(error) : 'Internal server error';
|
|
935
|
+
}
|
|
820
936
|
async verifyJWT(token) {
|
|
821
937
|
if (!this.config.accessSecret) {
|
|
822
938
|
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
|
|
@@ -831,29 +947,7 @@ class ApiServer {
|
|
|
831
947
|
return { tokenData: result.data, error: undefined, expired: false };
|
|
832
948
|
}
|
|
833
949
|
jwtCookieOptions(apiReq) {
|
|
834
|
-
|
|
835
|
-
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
836
|
-
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
837
|
-
const origin = typeof referer === 'string' ? referer : '';
|
|
838
|
-
const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
|
|
839
|
-
const isLocalhost = origin.includes('localhost');
|
|
840
|
-
const options = {
|
|
841
|
-
httpOnly: true,
|
|
842
|
-
secure: true,
|
|
843
|
-
sameSite: 'strict',
|
|
844
|
-
domain: conf.cookieDomain || undefined,
|
|
845
|
-
path: '/',
|
|
846
|
-
maxAge: undefined
|
|
847
|
-
};
|
|
848
|
-
if (conf.devMode) {
|
|
849
|
-
options.secure = isHttps;
|
|
850
|
-
options.httpOnly = false;
|
|
851
|
-
options.sameSite = 'lax';
|
|
852
|
-
if (isLocalhost) {
|
|
853
|
-
options.domain = undefined;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
return options;
|
|
950
|
+
return (0, auth_cookie_options_js_1.buildAuthCookieOptions)(this.config, apiReq.req);
|
|
857
951
|
}
|
|
858
952
|
setAccessCookie(apiReq, accessToken, sessionCookie) {
|
|
859
953
|
const conf = this.config;
|
|
@@ -999,7 +1093,7 @@ class ApiServer {
|
|
|
999
1093
|
}
|
|
1000
1094
|
}
|
|
1001
1095
|
if (!tokenData) {
|
|
1002
|
-
throw new ApiError({ code: 401, message: '
|
|
1096
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
|
|
1003
1097
|
}
|
|
1004
1098
|
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
1005
1099
|
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
@@ -1051,6 +1145,11 @@ class ApiServer {
|
|
|
1051
1145
|
}
|
|
1052
1146
|
apiReq.token = secret;
|
|
1053
1147
|
apiReq.apiKey = key;
|
|
1148
|
+
// Treat API keys as authenticated identities, consistent with JWT-based flows.
|
|
1149
|
+
const resolvedUid = this.normalizeAuthIdentifier(key.uid);
|
|
1150
|
+
if (resolvedUid !== null) {
|
|
1151
|
+
apiReq.realUid = resolvedUid;
|
|
1152
|
+
}
|
|
1054
1153
|
return {
|
|
1055
1154
|
uid: key.uid,
|
|
1056
1155
|
domain: '',
|
|
@@ -1110,13 +1209,19 @@ class ApiServer {
|
|
|
1110
1209
|
return rawReal;
|
|
1111
1210
|
}
|
|
1112
1211
|
useExpress(pathOrHandler, ...handlers) {
|
|
1212
|
+
this.assertNotFinalized('useExpress');
|
|
1113
1213
|
if (typeof pathOrHandler === 'string') {
|
|
1114
|
-
this.
|
|
1214
|
+
const apiPath = this.toApiRouterPath(pathOrHandler);
|
|
1215
|
+
if (apiPath) {
|
|
1216
|
+
this.apiRouter.use(apiPath, ...handlers);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1220
|
+
}
|
|
1115
1221
|
}
|
|
1116
1222
|
else {
|
|
1117
1223
|
this.app.use(pathOrHandler, ...handlers);
|
|
1118
1224
|
}
|
|
1119
|
-
this.ensureApiNotFoundOrdering();
|
|
1120
1225
|
return this;
|
|
1121
1226
|
}
|
|
1122
1227
|
createApiRequest(req, res) {
|
|
@@ -1150,7 +1255,6 @@ class ApiServer {
|
|
|
1150
1255
|
const apiReq = this.createApiRequest(req, res);
|
|
1151
1256
|
req.apiReq = apiReq;
|
|
1152
1257
|
res.locals.apiReq = apiReq;
|
|
1153
|
-
this.currReq = apiReq;
|
|
1154
1258
|
try {
|
|
1155
1259
|
if (this.config.hydrateGetBody) {
|
|
1156
1260
|
hydrateGetBody(req);
|
|
@@ -1189,10 +1293,21 @@ class ApiServer {
|
|
|
1189
1293
|
res.status(apiError.code).json(errorPayload);
|
|
1190
1294
|
return;
|
|
1191
1295
|
}
|
|
1296
|
+
const status = asHttpStatus(error);
|
|
1297
|
+
if (status) {
|
|
1298
|
+
res.status(status).json({
|
|
1299
|
+
success: false,
|
|
1300
|
+
code: status,
|
|
1301
|
+
message: status === 400 ? 'Invalid request payload' : this.internalServerErrorMessage(error),
|
|
1302
|
+
data: null,
|
|
1303
|
+
errors: {}
|
|
1304
|
+
});
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1192
1307
|
const errorPayload = {
|
|
1193
1308
|
success: false,
|
|
1194
1309
|
code: 500,
|
|
1195
|
-
message: this.
|
|
1310
|
+
message: this.internalServerErrorMessage(error),
|
|
1196
1311
|
data: null,
|
|
1197
1312
|
errors: {}
|
|
1198
1313
|
};
|
|
@@ -1203,7 +1318,6 @@ class ApiServer {
|
|
|
1203
1318
|
return async (req, res, next) => {
|
|
1204
1319
|
void next;
|
|
1205
1320
|
const apiReq = this.createApiRequest(req, res);
|
|
1206
|
-
this.currReq = apiReq;
|
|
1207
1321
|
try {
|
|
1208
1322
|
if (this.config.hydrateGetBody) {
|
|
1209
1323
|
hydrateGetBody(apiReq.req);
|
|
@@ -1253,7 +1367,7 @@ class ApiServer {
|
|
|
1253
1367
|
const errorPayload = {
|
|
1254
1368
|
success: false,
|
|
1255
1369
|
code: 500,
|
|
1256
|
-
message: this.
|
|
1370
|
+
message: this.internalServerErrorMessage(error),
|
|
1257
1371
|
data: null,
|
|
1258
1372
|
errors: {}
|
|
1259
1373
|
};
|
|
@@ -1266,13 +1380,18 @@ class ApiServer {
|
|
|
1266
1380
|
};
|
|
1267
1381
|
}
|
|
1268
1382
|
api(module) {
|
|
1383
|
+
this.assertNotFinalized('api');
|
|
1269
1384
|
const router = express_1.default.Router();
|
|
1270
1385
|
module.server = this;
|
|
1271
1386
|
const moduleType = module.moduleType;
|
|
1272
1387
|
if (moduleType === 'auth') {
|
|
1273
1388
|
this.authModule(module);
|
|
1274
1389
|
}
|
|
1275
|
-
module.checkConfig();
|
|
1390
|
+
const configOk = module.checkConfig();
|
|
1391
|
+
if (configOk === false) {
|
|
1392
|
+
const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
|
|
1393
|
+
throw new Error(`${name}.checkConfig() returned false`);
|
|
1394
|
+
}
|
|
1276
1395
|
const base = this.apiBasePath;
|
|
1277
1396
|
const ns = module.namespace;
|
|
1278
1397
|
const mountPath = `${base}${ns}`;
|
|
@@ -1289,6 +1408,9 @@ class ApiServer {
|
|
|
1289
1408
|
case 'put':
|
|
1290
1409
|
router.put(r.path, handler);
|
|
1291
1410
|
break;
|
|
1411
|
+
case 'patch':
|
|
1412
|
+
router.patch(r.path, handler);
|
|
1413
|
+
break;
|
|
1292
1414
|
case 'delete':
|
|
1293
1415
|
router.delete(r.path, handler);
|
|
1294
1416
|
break;
|
|
@@ -1297,8 +1419,7 @@ class ApiServer {
|
|
|
1297
1419
|
console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
|
|
1298
1420
|
}
|
|
1299
1421
|
});
|
|
1300
|
-
this.
|
|
1301
|
-
this.ensureApiNotFoundOrdering();
|
|
1422
|
+
this.apiRouter.use(ns, router);
|
|
1302
1423
|
return this;
|
|
1303
1424
|
}
|
|
1304
1425
|
dumpRequest(apiReq) {
|