@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.
@@ -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 {
@@ -41,7 +41,8 @@ function normalizePasskeyConfig(config = {}) {
41
41
  timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
42
42
  ? config.timeoutMs
43
43
  : DEFAULT_PASSKEY_CONFIG.timeoutMs,
44
- userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
44
+ userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
45
+ debug: Boolean(config.debug)
45
46
  };
46
47
  }
47
48
  class SqlAuthStore {
@@ -12,7 +12,8 @@ function cloneClient(client) {
12
12
  }
13
13
  return {
14
14
  clientId: client.clientId,
15
- clientSecret: client.clientSecret,
15
+ // clientSecret is stored hashed; do not return the hash.
16
+ clientSecret: client.clientSecret ? '__stored__' : undefined,
16
17
  name: client.name,
17
18
  redirectUris: [...client.redirectUris],
18
19
  scope: client.scope ? [...client.scope] : undefined,
@@ -204,7 +204,8 @@ class SequelizeOAuthStore extends base_js_1.OAuthStore {
204
204
  toOAuthClient(model) {
205
205
  return {
206
206
  clientId: model.client_id,
207
- clientSecret: model.client_secret,
207
+ // client_secret is stored hashed; do not return the hash.
208
+ clientSecret: model.client_secret ? '__stored__' : undefined,
208
209
  name: model.name ?? undefined,
209
210
  redirectUris: decodeStringArray(model.redirect_uris),
210
211
  scope: decodeStringArray(model.scope),
@@ -268,7 +268,7 @@ class PasskeyService {
268
268
  }
269
269
  }
270
270
  const publicKey = publicKeyPrimary && publicKeyPrimary.length > 0 ? publicKeyPrimary : publicKeyFallback;
271
- if (this.logger?.warn) {
271
+ if (this.config.debug && this.logger?.warn) {
272
272
  const pkPrimaryHex = publicKeyPrimary ? publicKeyPrimary.slice(0, 4).toString('hex') : 'null';
273
273
  const pkFallbackHex = publicKeyFallback ? publicKeyFallback.slice(0, 4).toString('hex') : 'null';
274
274
  this.logger.warn(`Passkey registration: pkPrimary len=${publicKeyPrimary?.length ?? 0} head=${pkPrimaryHex}, pkFallback len=${publicKeyFallback?.length ?? 0} head=${pkFallbackHex}`);
@@ -8,6 +8,11 @@ export interface PasskeyServiceConfig {
8
8
  origins: string[];
9
9
  timeoutMs: number;
10
10
  userVerification?: 'preferred' | 'required' | 'discouraged';
11
+ /**
12
+ * When enabled, PasskeyService emits additional diagnostic logs during registration/authentication.
13
+ * Defaults to false.
14
+ */
15
+ debug?: boolean;
11
16
  }
12
17
  export interface PasskeyChallengeRecord {
13
18
  challenge: string;
@@ -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;
@@ -335,6 +335,7 @@ function fillConfig(config) {
335
335
  apiHost: config.apiHost ?? 'localhost',
336
336
  uploadPath: config.uploadPath ?? '',
337
337
  uploadMax: config.uploadMax ?? 30 * 1024 * 1024,
338
+ staticDirs: config.staticDirs,
338
339
  origins: config.origins ?? [],
339
340
  debug: config.debug ?? false,
340
341
  apiBasePath: config.apiBasePath ?? '/api',
@@ -342,7 +343,11 @@ function fillConfig(config) {
342
343
  swaggerPath: config.swaggerPath ?? '',
343
344
  accessSecret: config.accessSecret ?? '',
344
345
  refreshSecret: config.refreshSecret ?? '',
345
- cookieDomain: config.cookieDomain ?? '.somewhere-over-the-rainbow.com',
346
+ cookieDomain: config.cookieDomain ?? '',
347
+ cookiePath: config.cookiePath ?? '/',
348
+ cookieSameSite: config.cookieSameSite ?? 'lax',
349
+ cookieSecure: config.cookieSecure ?? 'auto',
350
+ cookieHttpOnly: config.cookieHttpOnly ?? true,
346
351
  accessCookie: config.accessCookie ?? 'dat',
347
352
  refreshCookie: config.refreshCookie ?? 'drt',
348
353
  accessExpiry: config.accessExpiry ?? 60 * 15,
@@ -360,8 +365,19 @@ function fillConfig(config) {
360
365
  };
361
366
  }
362
367
  export class ApiServer {
368
+ /**
369
+ * @deprecated ApiServer does not track a global "current request". This value is always null.
370
+ * Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
371
+ * when mounting raw Express endpoints.
372
+ */
373
+ get currReq() {
374
+ return null;
375
+ }
376
+ set currReq(_value) {
377
+ void _value;
378
+ }
363
379
  constructor(config = {}) {
364
- this.currReq = null;
380
+ this.finalized = false;
365
381
  this.serverAuthAdapter = null;
366
382
  this.apiNotFoundHandler = null;
367
383
  this.tokenStoreAdapter = null;
@@ -386,16 +402,67 @@ export class ApiServer {
386
402
  this.storageAdapter = this.getServerAuthAdapter();
387
403
  }
388
404
  this.app = express();
405
+ // Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
406
+ // the API 404 handler ordered last without relying on Express internals.
407
+ this.apiRouter = express.Router();
389
408
  if (config.uploadPath) {
390
- const upload = multer({ dest: config.uploadPath });
409
+ const upload = multer({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
391
410
  this.app.use(upload.any());
411
+ // Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
412
+ this.app.use((err, _req, res, next) => {
413
+ const code = err && typeof err === 'object' ? err.code : undefined;
414
+ if (code === 'LIMIT_FILE_SIZE') {
415
+ res.status(413).json({
416
+ success: false,
417
+ code: 413,
418
+ message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
419
+ data: null,
420
+ errors: {}
421
+ });
422
+ return;
423
+ }
424
+ next(err);
425
+ });
392
426
  }
393
427
  this.middlewares();
428
+ this.installStaticDirs();
394
429
  this.installPingHandler();
395
430
  this.installSwaggerHandler();
431
+ this.app.use(this.apiBasePath, this.apiRouter);
396
432
  // addSwaggerUi(this.app);
397
433
  this.installApiNotFoundHandler();
398
434
  }
435
+ assertNotFinalized(action) {
436
+ if (this.finalized) {
437
+ throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
438
+ }
439
+ }
440
+ toApiRouterPath(candidate) {
441
+ if (typeof candidate !== 'string') {
442
+ return null;
443
+ }
444
+ const trimmed = candidate.trim();
445
+ if (!trimmed) {
446
+ return null;
447
+ }
448
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
449
+ const base = this.apiBasePath;
450
+ if (base === '/') {
451
+ return normalized;
452
+ }
453
+ if (normalized === base) {
454
+ return '/';
455
+ }
456
+ if (normalized.startsWith(`${base}/`)) {
457
+ return normalized.slice(base.length) || '/';
458
+ }
459
+ return null;
460
+ }
461
+ finalize() {
462
+ this.installApiNotFoundHandler();
463
+ this.finalized = true;
464
+ return this;
465
+ }
399
466
  authStorage(storage) {
400
467
  this.storageAdapter = storage;
401
468
  return this;
@@ -621,7 +688,7 @@ export class ApiServer {
621
688
  }
622
689
  return false;
623
690
  }
624
- guessExceptionText(error, defMsg = 'Unkown Error') {
691
+ guessExceptionText(error, defMsg = 'Unknown Error') {
625
692
  return guess_exception_text(error, defMsg);
626
693
  }
627
694
  async authorize(apiReq, requiredClass) {
@@ -647,6 +714,41 @@ export class ApiServer {
647
714
  credentials: true
648
715
  };
649
716
  this.app.use(cors(corsOptions));
717
+ // Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
718
+ this.app.use((err, req, res, next) => {
719
+ const message = err instanceof Error ? err.message : '';
720
+ if (message.includes('Not allowed by CORS')) {
721
+ const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
722
+ if (isApiRequest) {
723
+ res.status(403).json({
724
+ success: false,
725
+ code: 403,
726
+ message: 'Origin not allowed by CORS',
727
+ data: null,
728
+ errors: {}
729
+ });
730
+ return;
731
+ }
732
+ res.status(403).send('Origin not allowed by CORS');
733
+ return;
734
+ }
735
+ next(err);
736
+ });
737
+ }
738
+ installStaticDirs() {
739
+ const staticDirs = this.config.staticDirs;
740
+ if (!staticDirs || !isPlainObject(staticDirs)) {
741
+ return;
742
+ }
743
+ for (const [mountRaw, dirRaw] of Object.entries(staticDirs)) {
744
+ const mount = typeof mountRaw === 'string' ? mountRaw.trim() : '';
745
+ const dir = typeof dirRaw === 'string' ? dirRaw.trim() : '';
746
+ if (!mount || !dir) {
747
+ continue;
748
+ }
749
+ const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
750
+ this.app.use(resolvedMount, express.static(dir));
751
+ }
650
752
  }
651
753
  installPingHandler() {
652
754
  const path = `${this.apiBasePath}/v1/ping`;
@@ -745,28 +847,16 @@ export class ApiServer {
745
847
  };
746
848
  this.app.use(this.apiBasePath, this.apiNotFoundHandler);
747
849
  }
748
- ensureApiNotFoundOrdering() {
749
- this.installApiNotFoundHandler();
750
- if (!this.apiNotFoundHandler) {
751
- return;
752
- }
753
- const stack = this.app._router?.stack;
754
- if (!stack) {
755
- return;
756
- }
757
- const index = stack.findIndex((layer) => layer.handle === this.apiNotFoundHandler);
758
- if (index === -1 || index === stack.length - 1) {
759
- return;
760
- }
761
- const [layer] = stack.splice(index, 1);
762
- stack.push(layer);
763
- }
764
850
  describeMissingEndpoint(req) {
765
851
  const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
766
852
  const target = req.originalUrl || req.url || this.apiBasePath;
767
853
  return `No such endpoint: ${method} ${target}`;
768
854
  }
769
855
  start() {
856
+ if (!this.finalized) {
857
+ console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
858
+ this.finalize();
859
+ }
770
860
  this.app
771
861
  .listen({
772
862
  port: this.config.apiPort,
@@ -776,19 +866,22 @@ export class ApiServer {
776
866
  console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
777
867
  })
778
868
  .on('error', (error) => {
869
+ let message;
779
870
  if (error.code === 'EADDRINUSE') {
780
- console.error(`Error: Port ${this.config.apiPort} is already in use.`);
871
+ message = `Port ${this.config.apiPort} is already in use.`;
781
872
  }
782
873
  else if (error.code === 'EACCES') {
783
- console.error(`Error: Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`);
874
+ message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
784
875
  }
785
876
  else if (error.code === 'EADDRNOTAVAIL') {
786
- console.error(`Error: Address ${this.config.apiHost} is not available on this machine.`);
877
+ message = `Address ${this.config.apiHost} is not available on this machine.`;
787
878
  }
788
879
  else {
789
- console.error(`Failed to start server: ${error.message}`);
880
+ message = `Failed to start server: ${error.message}`;
790
881
  }
791
- process.exit(1);
882
+ const err = new Error(message);
883
+ err.cause = error;
884
+ throw err;
792
885
  });
793
886
  return this;
794
887
  }
@@ -810,23 +903,33 @@ export class ApiServer {
810
903
  const forwarded = apiReq.req.headers['x-forwarded-proto'];
811
904
  const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
812
905
  const origin = typeof referer === 'string' ? referer : '';
813
- const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
906
+ const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
907
+ .split(',')[0]
908
+ .trim()
909
+ .toLowerCase();
910
+ const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
814
911
  const isLocalhost = origin.includes('localhost');
912
+ const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
913
+ let sameSite = conf.cookieSameSite ?? 'lax';
914
+ if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
915
+ sameSite = 'lax';
916
+ }
917
+ let resolvedSecure = secure;
918
+ if (sameSite === 'none' && resolvedSecure !== true) {
919
+ // Modern browsers reject SameSite=None cookies unless Secure is set.
920
+ resolvedSecure = true;
921
+ }
815
922
  const options = {
816
- httpOnly: true,
817
- secure: true,
818
- sameSite: 'strict',
923
+ httpOnly: conf.cookieHttpOnly ?? true,
924
+ secure: resolvedSecure,
925
+ sameSite,
819
926
  domain: conf.cookieDomain || undefined,
820
- path: '/',
927
+ path: conf.cookiePath || '/',
821
928
  maxAge: undefined
822
929
  };
823
- if (conf.devMode) {
824
- options.secure = isHttps;
825
- options.httpOnly = false;
826
- options.sameSite = 'lax';
827
- if (isLocalhost) {
828
- options.domain = undefined;
829
- }
930
+ if (conf.devMode && isLocalhost) {
931
+ // Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
932
+ options.domain = undefined;
830
933
  }
831
934
  return options;
832
935
  }
@@ -974,7 +1077,7 @@ export class ApiServer {
974
1077
  }
975
1078
  }
976
1079
  if (!tokenData) {
977
- throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
1080
+ throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
978
1081
  }
979
1082
  const effectiveUserId = this.extractTokenUserId(tokenData);
980
1083
  apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
@@ -1026,6 +1129,11 @@ export class ApiServer {
1026
1129
  }
1027
1130
  apiReq.token = secret;
1028
1131
  apiReq.apiKey = key;
1132
+ // Treat API keys as authenticated identities, consistent with JWT-based flows.
1133
+ const resolvedUid = this.normalizeAuthIdentifier(key.uid);
1134
+ if (resolvedUid !== null) {
1135
+ apiReq.realUid = resolvedUid;
1136
+ }
1029
1137
  return {
1030
1138
  uid: key.uid,
1031
1139
  domain: '',
@@ -1085,13 +1193,19 @@ export class ApiServer {
1085
1193
  return rawReal;
1086
1194
  }
1087
1195
  useExpress(pathOrHandler, ...handlers) {
1196
+ this.assertNotFinalized('useExpress');
1088
1197
  if (typeof pathOrHandler === 'string') {
1089
- this.app.use(pathOrHandler, ...handlers);
1198
+ const apiPath = this.toApiRouterPath(pathOrHandler);
1199
+ if (apiPath) {
1200
+ this.apiRouter.use(apiPath, ...handlers);
1201
+ }
1202
+ else {
1203
+ this.app.use(pathOrHandler, ...handlers);
1204
+ }
1090
1205
  }
1091
1206
  else {
1092
1207
  this.app.use(pathOrHandler, ...handlers);
1093
1208
  }
1094
- this.ensureApiNotFoundOrdering();
1095
1209
  return this;
1096
1210
  }
1097
1211
  createApiRequest(req, res) {
@@ -1125,7 +1239,6 @@ export class ApiServer {
1125
1239
  const apiReq = this.createApiRequest(req, res);
1126
1240
  req.apiReq = apiReq;
1127
1241
  res.locals.apiReq = apiReq;
1128
- this.currReq = apiReq;
1129
1242
  try {
1130
1243
  if (this.config.hydrateGetBody) {
1131
1244
  hydrateGetBody(req);
@@ -1178,7 +1291,6 @@ export class ApiServer {
1178
1291
  return async (req, res, next) => {
1179
1292
  void next;
1180
1293
  const apiReq = this.createApiRequest(req, res);
1181
- this.currReq = apiReq;
1182
1294
  try {
1183
1295
  if (this.config.hydrateGetBody) {
1184
1296
  hydrateGetBody(apiReq.req);
@@ -1241,13 +1353,18 @@ export class ApiServer {
1241
1353
  };
1242
1354
  }
1243
1355
  api(module) {
1356
+ this.assertNotFinalized('api');
1244
1357
  const router = express.Router();
1245
1358
  module.server = this;
1246
1359
  const moduleType = module.moduleType;
1247
1360
  if (moduleType === 'auth') {
1248
1361
  this.authModule(module);
1249
1362
  }
1250
- module.checkConfig();
1363
+ const configOk = module.checkConfig();
1364
+ if (configOk === false) {
1365
+ const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
1366
+ throw new Error(`${name}.checkConfig() returned false`);
1367
+ }
1251
1368
  const base = this.apiBasePath;
1252
1369
  const ns = module.namespace;
1253
1370
  const mountPath = `${base}${ns}`;
@@ -1272,8 +1389,7 @@ export class ApiServer {
1272
1389
  console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
1273
1390
  }
1274
1391
  });
1275
- this.app.use(mountPath, router);
1276
- this.ensureApiNotFoundOrdering();
1392
+ this.apiRouter.use(ns, router);
1277
1393
  return this;
1278
1394
  }
1279
1395
  dumpRequest(apiReq) {