@technomoron/api-server-base 2.0.0-beta.16 → 2.0.0-beta.18
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 +49 -35
- package/dist/cjs/api-server-base.cjs +161 -45
- package/dist/cjs/api-server-base.d.ts +28 -2
- package/dist/cjs/auth-api/auth-module.js +55 -21
- package/dist/cjs/auth-api/mem-auth-store.js +2 -1
- package/dist/cjs/auth-api/sql-auth-store.js +2 -1
- package/dist/cjs/oauth/memory.js +2 -1
- package/dist/cjs/oauth/sequelize.js +2 -1
- package/dist/cjs/passkey/service.js +1 -1
- package/dist/cjs/passkey/types.d.ts +5 -0
- package/dist/esm/api-server-base.d.ts +28 -2
- package/dist/esm/api-server-base.js +161 -45
- package/dist/esm/auth-api/auth-module.js +55 -21
- package/dist/esm/auth-api/mem-auth-store.js +2 -1
- package/dist/esm/auth-api/sql-auth-store.js +2 -1
- package/dist/esm/oauth/memory.js +2 -1
- package/dist/esm/oauth/sequelize.js +2 -1
- package/dist/esm/passkey/service.js +1 -1
- package/dist/esm/passkey/types.d.ts +5 -0
- package/docs/swagger/openapi.json +11 -145
- package/package.json +17 -17
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
|
|
|
@@ -102,9 +103,14 @@ apiBasePath (string, default '/api') Prefix applied to every module namespace.
|
|
|
102
103
|
origins (string array, default empty array) CORS allowlist; empty allows all origins.
|
|
103
104
|
uploadPath (string, default empty string) Enables multer.any() when provided.
|
|
104
105
|
uploadMax (number, default 30 * 1024 * 1024) Maximum upload size in bytes.
|
|
106
|
+
staticDirs (record, default empty object) Map of mount path => disk path for serving static files as-is (ex: { '/assets': './public' }).
|
|
105
107
|
accessSecret (string, default empty string) Required for JWT signing and verification.
|
|
106
108
|
refreshSecret (string, default empty string) Used for refresh tokens if you implement them.
|
|
107
|
-
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.
|
|
108
114
|
accessCookie (string, default 'dat') Access token cookie name.
|
|
109
115
|
refreshCookie (string, default 'drt') Refresh token cookie name.
|
|
110
116
|
accessExpiry (number, default 60 * 15) Access token lifetime in seconds.
|
|
@@ -119,14 +125,14 @@ refreshMaybe (boolean, default false) When true, `auth: maybe` routes will try t
|
|
|
119
125
|
|
|
120
126
|
Tip: If you add new configuration fields in downstream projects, extend ApiServerConf and update fillConfig so defaults stay aligned.
|
|
121
127
|
|
|
122
|
-
Request Lifecycle
|
|
123
|
-
-----------------
|
|
124
|
-
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
125
|
-
2. ApiServer wraps the route inside handle_request,
|
|
126
|
-
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.
|
|
127
|
-
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
128
|
-
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
129
|
-
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.
|
|
130
136
|
|
|
131
137
|
Client IP Helpers
|
|
132
138
|
-----------------
|
|
@@ -134,16 +140,20 @@ Call `apiReq.getClientInfo()` when you need the entire client fingerprint captur
|
|
|
134
140
|
|
|
135
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()`.
|
|
136
142
|
|
|
137
|
-
Extending the Base Classes
|
|
138
|
-
--------------------------
|
|
139
|
-
Implement the AuthStorage contract (getUser, verifyPassword, storeToken, updateToken, etc.) to integrate with your persistence layer, then supply it via authStorage().
|
|
140
|
-
Use your storage adapter's filterUser helper to trim sensitive data before returning responses.
|
|
141
|
-
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
142
|
-
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.
|
|
143
153
|
|
|
144
|
-
Sequelize Table Prefixes
|
|
145
|
-
------------------------
|
|
146
|
-
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`).
|
|
147
157
|
|
|
148
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.
|
|
149
159
|
|
|
@@ -157,16 +167,16 @@ Example:
|
|
|
157
167
|
|
|
158
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.
|
|
159
169
|
|
|
160
|
-
Custom Express Endpoints
|
|
161
|
-
------------------------
|
|
162
|
-
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.
|
|
163
173
|
|
|
164
174
|
- `server.useExpress(...)` mounts middleware/routes and keeps the built-in `/api` 404 handler ordered last, so mounts under `apiBasePath` are not intercepted.
|
|
165
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`.
|
|
166
176
|
- On success, `expressAuth` attaches the computed ApiRequest to both `req.apiReq` and `res.locals.apiReq`.
|
|
167
177
|
- If you want the same JSON error envelope for custom endpoints, mount `server.expressErrorHandler()` after your custom routes.
|
|
168
178
|
|
|
169
|
-
Example:
|
|
179
|
+
Example:
|
|
170
180
|
|
|
171
181
|
server
|
|
172
182
|
.useExpress(
|
|
@@ -177,7 +187,11 @@ Example:
|
|
|
177
187
|
res.status(200).json({ uid: apiReq.tokenData?.uid ?? null });
|
|
178
188
|
}
|
|
179
189
|
)
|
|
180
|
-
|
|
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.
|
|
181
195
|
|
|
182
196
|
|
|
183
197
|
Tooling and Scripts
|
|
@@ -343,6 +343,7 @@ function fillConfig(config) {
|
|
|
343
343
|
apiHost: config.apiHost ?? 'localhost',
|
|
344
344
|
uploadPath: config.uploadPath ?? '',
|
|
345
345
|
uploadMax: config.uploadMax ?? 30 * 1024 * 1024,
|
|
346
|
+
staticDirs: config.staticDirs,
|
|
346
347
|
origins: config.origins ?? [],
|
|
347
348
|
debug: config.debug ?? false,
|
|
348
349
|
apiBasePath: config.apiBasePath ?? '/api',
|
|
@@ -350,7 +351,11 @@ function fillConfig(config) {
|
|
|
350
351
|
swaggerPath: config.swaggerPath ?? '',
|
|
351
352
|
accessSecret: config.accessSecret ?? '',
|
|
352
353
|
refreshSecret: config.refreshSecret ?? '',
|
|
353
|
-
cookieDomain: config.cookieDomain ?? '
|
|
354
|
+
cookieDomain: config.cookieDomain ?? '',
|
|
355
|
+
cookiePath: config.cookiePath ?? '/',
|
|
356
|
+
cookieSameSite: config.cookieSameSite ?? 'lax',
|
|
357
|
+
cookieSecure: config.cookieSecure ?? 'auto',
|
|
358
|
+
cookieHttpOnly: config.cookieHttpOnly ?? true,
|
|
354
359
|
accessCookie: config.accessCookie ?? 'dat',
|
|
355
360
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
356
361
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
@@ -368,8 +373,19 @@ function fillConfig(config) {
|
|
|
368
373
|
};
|
|
369
374
|
}
|
|
370
375
|
class ApiServer {
|
|
376
|
+
/**
|
|
377
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
378
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
379
|
+
* when mounting raw Express endpoints.
|
|
380
|
+
*/
|
|
381
|
+
get currReq() {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
set currReq(_value) {
|
|
385
|
+
void _value;
|
|
386
|
+
}
|
|
371
387
|
constructor(config = {}) {
|
|
372
|
-
this.
|
|
388
|
+
this.finalized = false;
|
|
373
389
|
this.serverAuthAdapter = null;
|
|
374
390
|
this.apiNotFoundHandler = null;
|
|
375
391
|
this.tokenStoreAdapter = null;
|
|
@@ -394,16 +410,67 @@ class ApiServer {
|
|
|
394
410
|
this.storageAdapter = this.getServerAuthAdapter();
|
|
395
411
|
}
|
|
396
412
|
this.app = (0, express_1.default)();
|
|
413
|
+
// Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
|
|
414
|
+
// the API 404 handler ordered last without relying on Express internals.
|
|
415
|
+
this.apiRouter = express_1.default.Router();
|
|
397
416
|
if (config.uploadPath) {
|
|
398
|
-
const upload = (0, multer_1.default)({ dest: config.uploadPath });
|
|
417
|
+
const upload = (0, multer_1.default)({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
|
|
399
418
|
this.app.use(upload.any());
|
|
419
|
+
// Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
|
|
420
|
+
this.app.use((err, _req, res, next) => {
|
|
421
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
422
|
+
if (code === 'LIMIT_FILE_SIZE') {
|
|
423
|
+
res.status(413).json({
|
|
424
|
+
success: false,
|
|
425
|
+
code: 413,
|
|
426
|
+
message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
|
|
427
|
+
data: null,
|
|
428
|
+
errors: {}
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
next(err);
|
|
433
|
+
});
|
|
400
434
|
}
|
|
401
435
|
this.middlewares();
|
|
436
|
+
this.installStaticDirs();
|
|
402
437
|
this.installPingHandler();
|
|
403
438
|
this.installSwaggerHandler();
|
|
439
|
+
this.app.use(this.apiBasePath, this.apiRouter);
|
|
404
440
|
// addSwaggerUi(this.app);
|
|
405
441
|
this.installApiNotFoundHandler();
|
|
406
442
|
}
|
|
443
|
+
assertNotFinalized(action) {
|
|
444
|
+
if (this.finalized) {
|
|
445
|
+
throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
toApiRouterPath(candidate) {
|
|
449
|
+
if (typeof candidate !== 'string') {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const trimmed = candidate.trim();
|
|
453
|
+
if (!trimmed) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
457
|
+
const base = this.apiBasePath;
|
|
458
|
+
if (base === '/') {
|
|
459
|
+
return normalized;
|
|
460
|
+
}
|
|
461
|
+
if (normalized === base) {
|
|
462
|
+
return '/';
|
|
463
|
+
}
|
|
464
|
+
if (normalized.startsWith(`${base}/`)) {
|
|
465
|
+
return normalized.slice(base.length) || '/';
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
finalize() {
|
|
470
|
+
this.installApiNotFoundHandler();
|
|
471
|
+
this.finalized = true;
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
407
474
|
authStorage(storage) {
|
|
408
475
|
this.storageAdapter = storage;
|
|
409
476
|
return this;
|
|
@@ -629,7 +696,7 @@ class ApiServer {
|
|
|
629
696
|
}
|
|
630
697
|
return false;
|
|
631
698
|
}
|
|
632
|
-
guessExceptionText(error, defMsg = '
|
|
699
|
+
guessExceptionText(error, defMsg = 'Unknown Error') {
|
|
633
700
|
return guess_exception_text(error, defMsg);
|
|
634
701
|
}
|
|
635
702
|
async authorize(apiReq, requiredClass) {
|
|
@@ -655,6 +722,41 @@ class ApiServer {
|
|
|
655
722
|
credentials: true
|
|
656
723
|
};
|
|
657
724
|
this.app.use((0, cors_1.default)(corsOptions));
|
|
725
|
+
// Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
|
|
726
|
+
this.app.use((err, req, res, next) => {
|
|
727
|
+
const message = err instanceof Error ? err.message : '';
|
|
728
|
+
if (message.includes('Not allowed by CORS')) {
|
|
729
|
+
const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
|
|
730
|
+
if (isApiRequest) {
|
|
731
|
+
res.status(403).json({
|
|
732
|
+
success: false,
|
|
733
|
+
code: 403,
|
|
734
|
+
message: 'Origin not allowed by CORS',
|
|
735
|
+
data: null,
|
|
736
|
+
errors: {}
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
res.status(403).send('Origin not allowed by CORS');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
next(err);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
installStaticDirs() {
|
|
747
|
+
const staticDirs = this.config.staticDirs;
|
|
748
|
+
if (!staticDirs || !isPlainObject(staticDirs)) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
for (const [mountRaw, dirRaw] of Object.entries(staticDirs)) {
|
|
752
|
+
const mount = typeof mountRaw === 'string' ? mountRaw.trim() : '';
|
|
753
|
+
const dir = typeof dirRaw === 'string' ? dirRaw.trim() : '';
|
|
754
|
+
if (!mount || !dir) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
|
|
758
|
+
this.app.use(resolvedMount, express_1.default.static(dir));
|
|
759
|
+
}
|
|
658
760
|
}
|
|
659
761
|
installPingHandler() {
|
|
660
762
|
const path = `${this.apiBasePath}/v1/ping`;
|
|
@@ -753,28 +855,16 @@ class ApiServer {
|
|
|
753
855
|
};
|
|
754
856
|
this.app.use(this.apiBasePath, this.apiNotFoundHandler);
|
|
755
857
|
}
|
|
756
|
-
ensureApiNotFoundOrdering() {
|
|
757
|
-
this.installApiNotFoundHandler();
|
|
758
|
-
if (!this.apiNotFoundHandler) {
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
const stack = this.app._router?.stack;
|
|
762
|
-
if (!stack) {
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
const index = stack.findIndex((layer) => layer.handle === this.apiNotFoundHandler);
|
|
766
|
-
if (index === -1 || index === stack.length - 1) {
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
const [layer] = stack.splice(index, 1);
|
|
770
|
-
stack.push(layer);
|
|
771
|
-
}
|
|
772
858
|
describeMissingEndpoint(req) {
|
|
773
859
|
const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
774
860
|
const target = req.originalUrl || req.url || this.apiBasePath;
|
|
775
861
|
return `No such endpoint: ${method} ${target}`;
|
|
776
862
|
}
|
|
777
863
|
start() {
|
|
864
|
+
if (!this.finalized) {
|
|
865
|
+
console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
|
|
866
|
+
this.finalize();
|
|
867
|
+
}
|
|
778
868
|
this.app
|
|
779
869
|
.listen({
|
|
780
870
|
port: this.config.apiPort,
|
|
@@ -784,19 +874,22 @@ class ApiServer {
|
|
|
784
874
|
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
785
875
|
})
|
|
786
876
|
.on('error', (error) => {
|
|
877
|
+
let message;
|
|
787
878
|
if (error.code === 'EADDRINUSE') {
|
|
788
|
-
|
|
879
|
+
message = `Port ${this.config.apiPort} is already in use.`;
|
|
789
880
|
}
|
|
790
881
|
else if (error.code === 'EACCES') {
|
|
791
|
-
|
|
882
|
+
message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
|
|
792
883
|
}
|
|
793
884
|
else if (error.code === 'EADDRNOTAVAIL') {
|
|
794
|
-
|
|
885
|
+
message = `Address ${this.config.apiHost} is not available on this machine.`;
|
|
795
886
|
}
|
|
796
887
|
else {
|
|
797
|
-
|
|
888
|
+
message = `Failed to start server: ${error.message}`;
|
|
798
889
|
}
|
|
799
|
-
|
|
890
|
+
const err = new Error(message);
|
|
891
|
+
err.cause = error;
|
|
892
|
+
throw err;
|
|
800
893
|
});
|
|
801
894
|
return this;
|
|
802
895
|
}
|
|
@@ -818,23 +911,33 @@ class ApiServer {
|
|
|
818
911
|
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
819
912
|
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
820
913
|
const origin = typeof referer === 'string' ? referer : '';
|
|
821
|
-
const
|
|
914
|
+
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
915
|
+
.split(',')[0]
|
|
916
|
+
.trim()
|
|
917
|
+
.toLowerCase();
|
|
918
|
+
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
822
919
|
const isLocalhost = origin.includes('localhost');
|
|
920
|
+
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
921
|
+
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
922
|
+
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
923
|
+
sameSite = 'lax';
|
|
924
|
+
}
|
|
925
|
+
let resolvedSecure = secure;
|
|
926
|
+
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
927
|
+
// Modern browsers reject SameSite=None cookies unless Secure is set.
|
|
928
|
+
resolvedSecure = true;
|
|
929
|
+
}
|
|
823
930
|
const options = {
|
|
824
|
-
httpOnly: true,
|
|
825
|
-
secure:
|
|
826
|
-
sameSite
|
|
931
|
+
httpOnly: conf.cookieHttpOnly ?? true,
|
|
932
|
+
secure: resolvedSecure,
|
|
933
|
+
sameSite,
|
|
827
934
|
domain: conf.cookieDomain || undefined,
|
|
828
|
-
path: '/',
|
|
935
|
+
path: conf.cookiePath || '/',
|
|
829
936
|
maxAge: undefined
|
|
830
937
|
};
|
|
831
|
-
if (conf.devMode) {
|
|
832
|
-
|
|
833
|
-
options.
|
|
834
|
-
options.sameSite = 'lax';
|
|
835
|
-
if (isLocalhost) {
|
|
836
|
-
options.domain = undefined;
|
|
837
|
-
}
|
|
938
|
+
if (conf.devMode && isLocalhost) {
|
|
939
|
+
// Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
|
|
940
|
+
options.domain = undefined;
|
|
838
941
|
}
|
|
839
942
|
return options;
|
|
840
943
|
}
|
|
@@ -982,7 +1085,7 @@ class ApiServer {
|
|
|
982
1085
|
}
|
|
983
1086
|
}
|
|
984
1087
|
if (!tokenData) {
|
|
985
|
-
throw new ApiError({ code: 401, message: '
|
|
1088
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
|
|
986
1089
|
}
|
|
987
1090
|
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
988
1091
|
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
@@ -1034,6 +1137,11 @@ class ApiServer {
|
|
|
1034
1137
|
}
|
|
1035
1138
|
apiReq.token = secret;
|
|
1036
1139
|
apiReq.apiKey = key;
|
|
1140
|
+
// Treat API keys as authenticated identities, consistent with JWT-based flows.
|
|
1141
|
+
const resolvedUid = this.normalizeAuthIdentifier(key.uid);
|
|
1142
|
+
if (resolvedUid !== null) {
|
|
1143
|
+
apiReq.realUid = resolvedUid;
|
|
1144
|
+
}
|
|
1037
1145
|
return {
|
|
1038
1146
|
uid: key.uid,
|
|
1039
1147
|
domain: '',
|
|
@@ -1093,13 +1201,19 @@ class ApiServer {
|
|
|
1093
1201
|
return rawReal;
|
|
1094
1202
|
}
|
|
1095
1203
|
useExpress(pathOrHandler, ...handlers) {
|
|
1204
|
+
this.assertNotFinalized('useExpress');
|
|
1096
1205
|
if (typeof pathOrHandler === 'string') {
|
|
1097
|
-
this.
|
|
1206
|
+
const apiPath = this.toApiRouterPath(pathOrHandler);
|
|
1207
|
+
if (apiPath) {
|
|
1208
|
+
this.apiRouter.use(apiPath, ...handlers);
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1212
|
+
}
|
|
1098
1213
|
}
|
|
1099
1214
|
else {
|
|
1100
1215
|
this.app.use(pathOrHandler, ...handlers);
|
|
1101
1216
|
}
|
|
1102
|
-
this.ensureApiNotFoundOrdering();
|
|
1103
1217
|
return this;
|
|
1104
1218
|
}
|
|
1105
1219
|
createApiRequest(req, res) {
|
|
@@ -1133,7 +1247,6 @@ class ApiServer {
|
|
|
1133
1247
|
const apiReq = this.createApiRequest(req, res);
|
|
1134
1248
|
req.apiReq = apiReq;
|
|
1135
1249
|
res.locals.apiReq = apiReq;
|
|
1136
|
-
this.currReq = apiReq;
|
|
1137
1250
|
try {
|
|
1138
1251
|
if (this.config.hydrateGetBody) {
|
|
1139
1252
|
hydrateGetBody(req);
|
|
@@ -1186,7 +1299,6 @@ class ApiServer {
|
|
|
1186
1299
|
return async (req, res, next) => {
|
|
1187
1300
|
void next;
|
|
1188
1301
|
const apiReq = this.createApiRequest(req, res);
|
|
1189
|
-
this.currReq = apiReq;
|
|
1190
1302
|
try {
|
|
1191
1303
|
if (this.config.hydrateGetBody) {
|
|
1192
1304
|
hydrateGetBody(apiReq.req);
|
|
@@ -1249,13 +1361,18 @@ class ApiServer {
|
|
|
1249
1361
|
};
|
|
1250
1362
|
}
|
|
1251
1363
|
api(module) {
|
|
1364
|
+
this.assertNotFinalized('api');
|
|
1252
1365
|
const router = express_1.default.Router();
|
|
1253
1366
|
module.server = this;
|
|
1254
1367
|
const moduleType = module.moduleType;
|
|
1255
1368
|
if (moduleType === 'auth') {
|
|
1256
1369
|
this.authModule(module);
|
|
1257
1370
|
}
|
|
1258
|
-
module.checkConfig();
|
|
1371
|
+
const configOk = module.checkConfig();
|
|
1372
|
+
if (configOk === false) {
|
|
1373
|
+
const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
|
|
1374
|
+
throw new Error(`${name}.checkConfig() returned false`);
|
|
1375
|
+
}
|
|
1259
1376
|
const base = this.apiBasePath;
|
|
1260
1377
|
const ns = module.namespace;
|
|
1261
1378
|
const mountPath = `${base}${ns}`;
|
|
@@ -1280,8 +1397,7 @@ class ApiServer {
|
|
|
1280
1397
|
console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
|
|
1281
1398
|
}
|
|
1282
1399
|
});
|
|
1283
|
-
this.
|
|
1284
|
-
this.ensureApiNotFoundOrdering();
|
|
1400
|
+
this.apiRouter.use(ns, router);
|
|
1285
1401
|
return this;
|
|
1286
1402
|
}
|
|
1287
1403
|
dumpRequest(apiReq) {
|
|
@@ -92,6 +92,7 @@ export interface ApiServerConf {
|
|
|
92
92
|
apiHost: string;
|
|
93
93
|
uploadPath: string;
|
|
94
94
|
uploadMax: number;
|
|
95
|
+
staticDirs?: Record<string, string>;
|
|
95
96
|
origins: string[];
|
|
96
97
|
debug: boolean;
|
|
97
98
|
apiBasePath: string;
|
|
@@ -99,7 +100,21 @@ export interface ApiServerConf {
|
|
|
99
100
|
swaggerPath?: string;
|
|
100
101
|
accessSecret: string;
|
|
101
102
|
refreshSecret: string;
|
|
103
|
+
/** Cookie domain for auth cookies. Prefer leaving empty for localhost/development. */
|
|
102
104
|
cookieDomain: string;
|
|
105
|
+
/** Cookie path for auth cookies. */
|
|
106
|
+
cookiePath?: string;
|
|
107
|
+
/** Cookie SameSite attribute for auth cookies. */
|
|
108
|
+
cookieSameSite?: 'lax' | 'strict' | 'none';
|
|
109
|
+
/**
|
|
110
|
+
* Cookie Secure attribute for auth cookies.
|
|
111
|
+
* - true: always secure
|
|
112
|
+
* - false: never secure
|
|
113
|
+
* - 'auto': secure when request is HTTPS (or forwarded as HTTPS)
|
|
114
|
+
*/
|
|
115
|
+
cookieSecure?: boolean | 'auto';
|
|
116
|
+
/** Cookie HttpOnly attribute for auth cookies. */
|
|
117
|
+
cookieHttpOnly?: boolean;
|
|
103
118
|
accessCookie: string;
|
|
104
119
|
refreshCookie: string;
|
|
105
120
|
accessExpiry: number;
|
|
@@ -117,10 +132,11 @@ export interface ApiServerConf {
|
|
|
117
132
|
}
|
|
118
133
|
export declare class ApiServer {
|
|
119
134
|
app: Application;
|
|
120
|
-
currReq: ApiRequest | null;
|
|
121
135
|
readonly config: ApiServerConf;
|
|
122
136
|
readonly startedAt: number;
|
|
123
137
|
private readonly apiBasePath;
|
|
138
|
+
private readonly apiRouter;
|
|
139
|
+
private finalized;
|
|
124
140
|
private storageAdapter;
|
|
125
141
|
private moduleAdapter;
|
|
126
142
|
private serverAuthAdapter;
|
|
@@ -131,7 +147,17 @@ export declare class ApiServer {
|
|
|
131
147
|
private oauthStoreAdapter;
|
|
132
148
|
private canImpersonateAdapter;
|
|
133
149
|
private readonly jwtHelper;
|
|
150
|
+
/**
|
|
151
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
152
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
153
|
+
* when mounting raw Express endpoints.
|
|
154
|
+
*/
|
|
155
|
+
get currReq(): ApiRequest | null;
|
|
156
|
+
set currReq(_value: ApiRequest | null);
|
|
134
157
|
constructor(config?: Partial<ApiServerConf>);
|
|
158
|
+
private assertNotFinalized;
|
|
159
|
+
private toApiRouterPath;
|
|
160
|
+
finalize(): this;
|
|
135
161
|
authStorage<UserRow, SafeUser>(storage: AuthAdapter<UserRow, SafeUser>): this;
|
|
136
162
|
/**
|
|
137
163
|
* @deprecated Use {@link ApiServer.authStorage} instead.
|
|
@@ -193,12 +219,12 @@ export declare class ApiServer {
|
|
|
193
219
|
guessExceptionText(error: unknown, defMsg?: string): string;
|
|
194
220
|
protected authorize(apiReq: ApiRequest, requiredClass: ApiAuthClass): Promise<void>;
|
|
195
221
|
private middlewares;
|
|
222
|
+
private installStaticDirs;
|
|
196
223
|
private installPingHandler;
|
|
197
224
|
private loadSwaggerSpec;
|
|
198
225
|
private installSwaggerHandler;
|
|
199
226
|
private normalizeApiBasePath;
|
|
200
227
|
private installApiNotFoundHandler;
|
|
201
|
-
private ensureApiNotFoundOrdering;
|
|
202
228
|
private describeMissingEndpoint;
|
|
203
229
|
start(): this;
|
|
204
230
|
private verifyJWT;
|