@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 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
 
@@ -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 '.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.
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, setting currReq and logging when debug is enabled.
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
- .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.
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 ?? '.somewhere-over-the-rainbow.com',
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.currReq = null;
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 = 'Unkown Error') {
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
- console.error(`Error: Port ${this.config.apiPort} is already in use.`);
879
+ message = `Port ${this.config.apiPort} is already in use.`;
789
880
  }
790
881
  else if (error.code === 'EACCES') {
791
- console.error(`Error: Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`);
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
- console.error(`Error: Address ${this.config.apiHost} is not available on this machine.`);
885
+ message = `Address ${this.config.apiHost} is not available on this machine.`;
795
886
  }
796
887
  else {
797
- console.error(`Failed to start server: ${error.message}`);
888
+ message = `Failed to start server: ${error.message}`;
798
889
  }
799
- process.exit(1);
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 isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
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: true,
826
- sameSite: 'strict',
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
- options.secure = isHttps;
833
- options.httpOnly = false;
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: 'Unathorized Access - ' + error });
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.app.use(pathOrHandler, ...handlers);
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.app.use(mountPath, router);
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;