@technomoron/api-server-base 2.0.0-beta.17 → 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
 
@@ -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
@@ -351,7 +351,11 @@ function fillConfig(config) {
351
351
  swaggerPath: config.swaggerPath ?? '',
352
352
  accessSecret: config.accessSecret ?? '',
353
353
  refreshSecret: config.refreshSecret ?? '',
354
- 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,
355
359
  accessCookie: config.accessCookie ?? 'dat',
356
360
  refreshCookie: config.refreshCookie ?? 'drt',
357
361
  accessExpiry: config.accessExpiry ?? 60 * 15,
@@ -369,8 +373,19 @@ function fillConfig(config) {
369
373
  };
370
374
  }
371
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
+ }
372
387
  constructor(config = {}) {
373
- this.currReq = null;
388
+ this.finalized = false;
374
389
  this.serverAuthAdapter = null;
375
390
  this.apiNotFoundHandler = null;
376
391
  this.tokenStoreAdapter = null;
@@ -395,17 +410,67 @@ class ApiServer {
395
410
  this.storageAdapter = this.getServerAuthAdapter();
396
411
  }
397
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();
398
416
  if (config.uploadPath) {
399
- const upload = (0, multer_1.default)({ dest: config.uploadPath });
417
+ const upload = (0, multer_1.default)({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
400
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
+ });
401
434
  }
402
435
  this.middlewares();
403
436
  this.installStaticDirs();
404
437
  this.installPingHandler();
405
438
  this.installSwaggerHandler();
439
+ this.app.use(this.apiBasePath, this.apiRouter);
406
440
  // addSwaggerUi(this.app);
407
441
  this.installApiNotFoundHandler();
408
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
+ }
409
474
  authStorage(storage) {
410
475
  this.storageAdapter = storage;
411
476
  return this;
@@ -631,7 +696,7 @@ class ApiServer {
631
696
  }
632
697
  return false;
633
698
  }
634
- guessExceptionText(error, defMsg = 'Unkown Error') {
699
+ guessExceptionText(error, defMsg = 'Unknown Error') {
635
700
  return guess_exception_text(error, defMsg);
636
701
  }
637
702
  async authorize(apiReq, requiredClass) {
@@ -657,6 +722,26 @@ class ApiServer {
657
722
  credentials: true
658
723
  };
659
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
+ });
660
745
  }
661
746
  installStaticDirs() {
662
747
  const staticDirs = this.config.staticDirs;
@@ -770,28 +855,16 @@ class ApiServer {
770
855
  };
771
856
  this.app.use(this.apiBasePath, this.apiNotFoundHandler);
772
857
  }
773
- ensureApiNotFoundOrdering() {
774
- this.installApiNotFoundHandler();
775
- if (!this.apiNotFoundHandler) {
776
- return;
777
- }
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);
788
- }
789
858
  describeMissingEndpoint(req) {
790
859
  const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
791
860
  const target = req.originalUrl || req.url || this.apiBasePath;
792
861
  return `No such endpoint: ${method} ${target}`;
793
862
  }
794
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
+ }
795
868
  this.app
796
869
  .listen({
797
870
  port: this.config.apiPort,
@@ -801,19 +874,22 @@ class ApiServer {
801
874
  console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
802
875
  })
803
876
  .on('error', (error) => {
877
+ let message;
804
878
  if (error.code === 'EADDRINUSE') {
805
- console.error(`Error: Port ${this.config.apiPort} is already in use.`);
879
+ message = `Port ${this.config.apiPort} is already in use.`;
806
880
  }
807
881
  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.`);
882
+ message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
809
883
  }
810
884
  else if (error.code === 'EADDRNOTAVAIL') {
811
- 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.`;
812
886
  }
813
887
  else {
814
- console.error(`Failed to start server: ${error.message}`);
888
+ message = `Failed to start server: ${error.message}`;
815
889
  }
816
- process.exit(1);
890
+ const err = new Error(message);
891
+ err.cause = error;
892
+ throw err;
817
893
  });
818
894
  return this;
819
895
  }
@@ -835,23 +911,33 @@ class ApiServer {
835
911
  const forwarded = apiReq.req.headers['x-forwarded-proto'];
836
912
  const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
837
913
  const origin = typeof referer === 'string' ? referer : '';
838
- 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';
839
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
+ }
840
930
  const options = {
841
- httpOnly: true,
842
- secure: true,
843
- sameSite: 'strict',
931
+ httpOnly: conf.cookieHttpOnly ?? true,
932
+ secure: resolvedSecure,
933
+ sameSite,
844
934
  domain: conf.cookieDomain || undefined,
845
- path: '/',
935
+ path: conf.cookiePath || '/',
846
936
  maxAge: undefined
847
937
  };
848
- if (conf.devMode) {
849
- options.secure = isHttps;
850
- options.httpOnly = false;
851
- options.sameSite = 'lax';
852
- if (isLocalhost) {
853
- options.domain = undefined;
854
- }
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;
855
941
  }
856
942
  return options;
857
943
  }
@@ -999,7 +1085,7 @@ class ApiServer {
999
1085
  }
1000
1086
  }
1001
1087
  if (!tokenData) {
1002
- throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
1088
+ throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
1003
1089
  }
1004
1090
  const effectiveUserId = this.extractTokenUserId(tokenData);
1005
1091
  apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
@@ -1051,6 +1137,11 @@ class ApiServer {
1051
1137
  }
1052
1138
  apiReq.token = secret;
1053
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
+ }
1054
1145
  return {
1055
1146
  uid: key.uid,
1056
1147
  domain: '',
@@ -1110,13 +1201,19 @@ class ApiServer {
1110
1201
  return rawReal;
1111
1202
  }
1112
1203
  useExpress(pathOrHandler, ...handlers) {
1204
+ this.assertNotFinalized('useExpress');
1113
1205
  if (typeof pathOrHandler === 'string') {
1114
- 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
+ }
1115
1213
  }
1116
1214
  else {
1117
1215
  this.app.use(pathOrHandler, ...handlers);
1118
1216
  }
1119
- this.ensureApiNotFoundOrdering();
1120
1217
  return this;
1121
1218
  }
1122
1219
  createApiRequest(req, res) {
@@ -1150,7 +1247,6 @@ class ApiServer {
1150
1247
  const apiReq = this.createApiRequest(req, res);
1151
1248
  req.apiReq = apiReq;
1152
1249
  res.locals.apiReq = apiReq;
1153
- this.currReq = apiReq;
1154
1250
  try {
1155
1251
  if (this.config.hydrateGetBody) {
1156
1252
  hydrateGetBody(req);
@@ -1203,7 +1299,6 @@ class ApiServer {
1203
1299
  return async (req, res, next) => {
1204
1300
  void next;
1205
1301
  const apiReq = this.createApiRequest(req, res);
1206
- this.currReq = apiReq;
1207
1302
  try {
1208
1303
  if (this.config.hydrateGetBody) {
1209
1304
  hydrateGetBody(apiReq.req);
@@ -1266,13 +1361,18 @@ class ApiServer {
1266
1361
  };
1267
1362
  }
1268
1363
  api(module) {
1364
+ this.assertNotFinalized('api');
1269
1365
  const router = express_1.default.Router();
1270
1366
  module.server = this;
1271
1367
  const moduleType = module.moduleType;
1272
1368
  if (moduleType === 'auth') {
1273
1369
  this.authModule(module);
1274
1370
  }
1275
- 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
+ }
1276
1376
  const base = this.apiBasePath;
1277
1377
  const ns = module.namespace;
1278
1378
  const mountPath = `${base}${ns}`;
@@ -1297,8 +1397,7 @@ class ApiServer {
1297
1397
  console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
1298
1398
  }
1299
1399
  });
1300
- this.app.use(mountPath, router);
1301
- this.ensureApiNotFoundOrdering();
1400
+ this.apiRouter.use(ns, router);
1302
1401
  return this;
1303
1402
  }
1304
1403
  dumpRequest(apiReq) {
@@ -100,7 +100,21 @@ export interface ApiServerConf {
100
100
  swaggerPath?: string;
101
101
  accessSecret: string;
102
102
  refreshSecret: string;
103
+ /** Cookie domain for auth cookies. Prefer leaving empty for localhost/development. */
103
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;
104
118
  accessCookie: string;
105
119
  refreshCookie: string;
106
120
  accessExpiry: number;
@@ -118,10 +132,11 @@ export interface ApiServerConf {
118
132
  }
119
133
  export declare class ApiServer {
120
134
  app: Application;
121
- currReq: ApiRequest | null;
122
135
  readonly config: ApiServerConf;
123
136
  readonly startedAt: number;
124
137
  private readonly apiBasePath;
138
+ private readonly apiRouter;
139
+ private finalized;
125
140
  private storageAdapter;
126
141
  private moduleAdapter;
127
142
  private serverAuthAdapter;
@@ -132,7 +147,17 @@ export declare class ApiServer {
132
147
  private oauthStoreAdapter;
133
148
  private canImpersonateAdapter;
134
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);
135
157
  constructor(config?: Partial<ApiServerConf>);
158
+ private assertNotFinalized;
159
+ private toApiRouterPath;
160
+ finalize(): this;
136
161
  authStorage<UserRow, SafeUser>(storage: AuthAdapter<UserRow, SafeUser>): this;
137
162
  /**
138
163
  * @deprecated Use {@link ApiServer.authStorage} instead.
@@ -200,7 +225,6 @@ export declare class ApiServer {
200
225
  private installSwaggerHandler;
201
226
  private normalizeApiBasePath;
202
227
  private installApiNotFoundHandler;
203
- private ensureApiNotFoundOrdering;
204
228
  private describeMissingEndpoint;
205
229
  start(): this;
206
230
  private verifyJWT;
@@ -240,23 +240,31 @@ class AuthModule extends module_js_1.BaseAuthModule {
240
240
  const forwarded = apiReq.req.headers['x-forwarded-proto'];
241
241
  const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
242
242
  const origin = typeof referer === 'string' ? referer : '';
243
- const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
243
+ const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
244
+ .split(',')[0]
245
+ .trim()
246
+ .toLowerCase();
247
+ const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
244
248
  const isLocalhost = origin.includes('localhost');
249
+ const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
250
+ let sameSite = conf.cookieSameSite ?? 'lax';
251
+ if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
252
+ sameSite = 'lax';
253
+ }
254
+ let resolvedSecure = secure;
255
+ if (sameSite === 'none' && resolvedSecure !== true) {
256
+ resolvedSecure = true;
257
+ }
245
258
  const options = {
246
- httpOnly: true,
247
- secure: true,
248
- sameSite: 'strict',
259
+ httpOnly: conf.cookieHttpOnly ?? true,
260
+ secure: resolvedSecure,
261
+ sameSite,
249
262
  domain: conf.cookieDomain || undefined,
250
- path: '/',
263
+ path: conf.cookiePath || '/',
251
264
  maxAge: undefined
252
265
  };
253
- if (conf.devMode) {
254
- options.secure = isHttps;
255
- options.httpOnly = false;
256
- options.sameSite = 'lax';
257
- if (isLocalhost) {
258
- options.domain = undefined;
259
- }
266
+ if (conf.devMode && isLocalhost) {
267
+ options.domain = undefined;
260
268
  }
261
269
  return options;
262
270
  }
@@ -552,10 +560,39 @@ class AuthModule extends module_js_1.BaseAuthModule {
552
560
  apiReq.req.cookies[conf.accessCookie].trim().length > 0);
553
561
  const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
554
562
  if (shouldRefresh) {
555
- const access = this.server.jwtSign(this.buildTokenPayload(user, stored), conf.accessSecret, conf.accessExpiry);
563
+ const updateToken = this.storage.updateToken;
564
+ if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
565
+ throw new api_server_base_js_1.ApiError({ code: 501, message: 'Token update storage is not configured' });
566
+ }
567
+ // Sign a new access token without embedding stored token secrets into the JWT payload.
568
+ const metadata = {
569
+ ruid: stored.ruid,
570
+ domain: stored.domain,
571
+ fingerprint: stored.fingerprint,
572
+ label: stored.label,
573
+ clientId: stored.clientId,
574
+ scope: stored.scope,
575
+ browser: stored.browser,
576
+ device: stored.device,
577
+ ip: stored.ip,
578
+ os: stored.os,
579
+ loginType: stored.loginType,
580
+ refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
581
+ sessionCookie: stored.sessionCookie
582
+ };
583
+ const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
584
+ const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
556
585
  if (!access.success || !access.token) {
557
586
  throw new api_server_base_js_1.ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
558
587
  }
588
+ const updated = await updateToken.call(this.storage, {
589
+ refreshToken,
590
+ accessToken: access.token,
591
+ lastSeenAt: new Date()
592
+ });
593
+ if (!updated) {
594
+ throw new api_server_base_js_1.ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
595
+ }
559
596
  const cookiePrefs = this.mergeSessionPreferences({
560
597
  sessionCookie: stored.sessionCookie,
561
598
  refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
@@ -1001,14 +1038,11 @@ class AuthModule extends module_js_1.BaseAuthModule {
1001
1038
  if (!secretProvided) {
1002
1039
  throw new api_server_base_js_1.ApiError({ code: 400, message: 'Client authentication is required' });
1003
1040
  }
1004
- let valid = false;
1005
- if (this.storage.verifyClientSecret) {
1006
- const verifySecret = this.storage.verifyClientSecret.bind(this.storage);
1007
- valid = await verifySecret(client, clientSecret);
1008
- }
1009
- else {
1010
- valid = client.clientSecret === clientSecret;
1041
+ const verifySecret = this.storage.verifyClientSecret;
1042
+ if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
1043
+ throw new api_server_base_js_1.ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
1011
1044
  }
1045
+ const valid = await verifySecret.call(this.storage, client, clientSecret);
1012
1046
  if (!valid) {
1013
1047
  throw new api_server_base_js_1.ApiError({ code: 401, message: 'Invalid client credentials' });
1014
1048
  }
@@ -1156,7 +1190,7 @@ class AuthModule extends module_js_1.BaseAuthModule {
1156
1190
  auth: { type: 'strict', req: 'any' }
1157
1191
  }, {
1158
1192
  method: 'delete',
1159
- path: '/v1/passkeys/:credentialId?',
1193
+ path: '/v1/passkeys/:credentialId',
1160
1194
  handler: (req) => this.deletePasskey(req),
1161
1195
  auth: { type: 'strict', req: 'any' }
1162
1196
  });
@@ -25,7 +25,8 @@ function normalizePasskeyConfig(config = {}) {
25
25
  timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
26
26
  ? config.timeoutMs
27
27
  : DEFAULT_PASSKEY_CONFIG.timeoutMs,
28
- userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
28
+ userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
29
+ debug: Boolean(config.debug)
29
30
  };
30
31
  }
31
32
  class MemAuthStore {