@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.
Files changed (63) hide show
  1. package/README.txt +48 -35
  2. package/dist/cjs/api-module.cjs +9 -0
  3. package/dist/cjs/api-module.d.ts +4 -2
  4. package/dist/cjs/api-server-base.cjs +178 -57
  5. package/dist/cjs/api-server-base.d.ts +31 -2
  6. package/dist/cjs/auth-api/auth-module.d.ts +12 -1
  7. package/dist/cjs/auth-api/auth-module.js +77 -35
  8. package/dist/cjs/auth-api/mem-auth-store.js +2 -23
  9. package/dist/cjs/auth-api/sql-auth-store.js +4 -31
  10. package/dist/cjs/auth-api/user-id.d.ts +4 -0
  11. package/dist/cjs/auth-api/user-id.js +31 -0
  12. package/dist/cjs/auth-cookie-options.d.ts +11 -0
  13. package/dist/cjs/auth-cookie-options.js +57 -0
  14. package/dist/cjs/oauth/memory.js +4 -10
  15. package/dist/cjs/oauth/models.js +4 -15
  16. package/dist/cjs/oauth/sequelize.js +8 -23
  17. package/dist/cjs/passkey/config.d.ts +2 -0
  18. package/dist/cjs/passkey/config.js +26 -0
  19. package/dist/cjs/passkey/memory.js +2 -9
  20. package/dist/cjs/passkey/models.js +4 -15
  21. package/dist/cjs/passkey/sequelize.js +6 -22
  22. package/dist/cjs/passkey/service.js +1 -1
  23. package/dist/cjs/passkey/types.d.ts +5 -0
  24. package/dist/cjs/sequelize-utils.d.ts +3 -0
  25. package/dist/cjs/sequelize-utils.js +17 -0
  26. package/dist/cjs/token/memory.d.ts +4 -0
  27. package/dist/cjs/token/memory.js +90 -25
  28. package/dist/cjs/token/sequelize.js +16 -22
  29. package/dist/cjs/token/types.d.ts +7 -0
  30. package/dist/cjs/user/memory.js +2 -9
  31. package/dist/cjs/user/sequelize.js +6 -22
  32. package/dist/esm/api-module.d.ts +4 -2
  33. package/dist/esm/api-module.js +9 -0
  34. package/dist/esm/api-server-base.d.ts +31 -2
  35. package/dist/esm/api-server-base.js +178 -57
  36. package/dist/esm/auth-api/auth-module.d.ts +12 -1
  37. package/dist/esm/auth-api/auth-module.js +77 -35
  38. package/dist/esm/auth-api/mem-auth-store.js +1 -22
  39. package/dist/esm/auth-api/sql-auth-store.js +2 -29
  40. package/dist/esm/auth-api/user-id.d.ts +4 -0
  41. package/dist/esm/auth-api/user-id.js +26 -0
  42. package/dist/esm/auth-cookie-options.d.ts +11 -0
  43. package/dist/esm/auth-cookie-options.js +54 -0
  44. package/dist/esm/oauth/memory.js +4 -10
  45. package/dist/esm/oauth/models.js +1 -12
  46. package/dist/esm/oauth/sequelize.js +5 -20
  47. package/dist/esm/passkey/config.d.ts +2 -0
  48. package/dist/esm/passkey/config.js +23 -0
  49. package/dist/esm/passkey/memory.js +2 -9
  50. package/dist/esm/passkey/models.js +1 -12
  51. package/dist/esm/passkey/sequelize.js +3 -19
  52. package/dist/esm/passkey/service.js +1 -1
  53. package/dist/esm/passkey/types.d.ts +5 -0
  54. package/dist/esm/sequelize-utils.d.ts +3 -0
  55. package/dist/esm/sequelize-utils.js +12 -0
  56. package/dist/esm/token/memory.d.ts +4 -0
  57. package/dist/esm/token/memory.js +90 -25
  58. package/dist/esm/token/sequelize.js +12 -18
  59. package/dist/esm/token/types.d.ts +7 -0
  60. package/dist/esm/user/memory.js +2 -9
  61. package/dist/esm/user/sequelize.js +3 -19
  62. package/docs/swagger/openapi.json +11 -145
  63. 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, BaseAuthStorage } from '@technomoron/api-server-base';
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 BaseAuthStorage<DemoUser, Omit<DemoUser, 'password'>> {
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
- apiPort: 3101,
84
- apiHost: '127.0.0.1',
85
- accessSecret: 'replace-me'
86
- })
87
- .authStorage(yourStorageAdapter)
88
- .api(new UserModule())
89
- .start();
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 '.somewhere-over-the-rainbow.com') Domain applied to auth cookies.
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, setting currReq and logging when debug is enabled.
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
- .useExpress(server.expressErrorHandler());
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
@@ -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 ?? '';
@@ -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
- server: T;
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 ?? '.somewhere-over-the-rainbow.com',
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.currReq = null;
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 = 'Unkown Error') {
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
- const spec = this.loadSwaggerSpec();
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
- ensureApiNotFoundOrdering() {
774
- this.installApiNotFoundHandler();
775
- if (!this.apiNotFoundHandler) {
884
+ installApiErrorHandler() {
885
+ if (this.apiErrorHandlerInstalled) {
776
886
  return;
777
887
  }
778
- const stack = this.app._router?.stack;
779
- if (!stack) {
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
- console.error(`Error: Port ${this.config.apiPort} is already in use.`);
912
+ message = `Port ${this.config.apiPort} is already in use.`;
806
913
  }
807
914
  else if (error.code === 'EACCES') {
808
- console.error(`Error: Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`);
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
- console.error(`Error: Address ${this.config.apiHost} is not available on this machine.`);
918
+ message = `Address ${this.config.apiHost} is not available on this machine.`;
812
919
  }
813
920
  else {
814
- console.error(`Failed to start server: ${error.message}`);
921
+ message = `Failed to start server: ${error.message}`;
815
922
  }
816
- process.exit(1);
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
- const conf = this.config;
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: 'Unathorized Access - ' + error });
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.app.use(pathOrHandler, ...handlers);
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.guessExceptionText(error),
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.guessExceptionText(error),
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.app.use(mountPath, router);
1301
- this.ensureApiNotFoundOrdering();
1422
+ this.apiRouter.use(ns, router);
1302
1423
  return this;
1303
1424
  }
1304
1425
  dumpRequest(apiReq) {