strapi-plugin-magic-sessionmanager 4.5.1 → 4.5.3

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.
@@ -161,7 +161,16 @@ var register$1 = async ({ strapi: strapi2 }) => {
161
161
  try {
162
162
  await strapi2.documents(SESSION_UID$6).update({
163
163
  documentId: session2.documentId,
164
- data: { isActive: false, terminatedManually: true, logoutTime: now }
164
+ data: {
165
+ isActive: false,
166
+ // Blocked users are NOT a "manual" self-logout — mark
167
+ // this distinctly so the client receives a different
168
+ // error message ("account blocked") rather than
169
+ // "session terminated".
170
+ terminatedManually: false,
171
+ terminationReason: "blocked",
172
+ logoutTime: now
173
+ }
165
174
  });
166
175
  terminated++;
167
176
  } catch (updateErr) {
@@ -291,27 +300,27 @@ function generateSessionId$1(userId) {
291
300
  const userHash = crypto$1.createHash("sha256").update(userId.toString()).digest("hex").substring(0, 8);
292
301
  return `sess_${timestamp2}_${userHash}_${randomBytes}`;
293
302
  }
294
- function hashToken$5(token) {
303
+ function hashToken$6(token) {
295
304
  if (!token) return null;
296
305
  return crypto$1.createHash("sha256").update(token).digest("hex");
297
306
  }
298
307
  var encryption = {
299
308
  encryptToken: encryptToken$2,
300
309
  generateSessionId: generateSessionId$1,
301
- hashToken: hashToken$5
310
+ hashToken: hashToken$6
302
311
  };
303
312
  const USER_UID$1 = "plugin::users-permissions.user";
304
- const cache = /* @__PURE__ */ new Map();
313
+ const cache$1 = /* @__PURE__ */ new Map();
305
314
  const CACHE_TTL = 5 * 60 * 1e3;
306
315
  const CACHE_MAX_SIZE = 1e3;
307
316
  function evict() {
308
317
  const now = Date.now();
309
- for (const [key, value] of cache) {
310
- if (now - value.ts >= CACHE_TTL) cache.delete(key);
318
+ for (const [key, value] of cache$1) {
319
+ if (now - value.ts >= CACHE_TTL) cache$1.delete(key);
311
320
  }
312
- if (cache.size >= CACHE_MAX_SIZE) {
313
- const keysToDelete = [...cache.keys()].slice(0, Math.floor(CACHE_MAX_SIZE / 4));
314
- keysToDelete.forEach((k2) => cache.delete(k2));
321
+ if (cache$1.size >= CACHE_MAX_SIZE) {
322
+ const keysToDelete = [...cache$1.keys()].slice(0, Math.floor(CACHE_MAX_SIZE / 4));
323
+ keysToDelete.forEach((k2) => cache$1.delete(k2));
315
324
  }
316
325
  }
317
326
  async function resolveUserDocumentId$5(strapi2, userId) {
@@ -321,17 +330,17 @@ async function resolveUserDocumentId$5(strapi2, userId) {
321
330
  }
322
331
  const numericId = typeof userId === "number" ? userId : parseInt(userId, 10);
323
332
  const cacheKey = `u_${numericId}`;
324
- const cached2 = cache.get(cacheKey);
333
+ const cached2 = cache$1.get(cacheKey);
325
334
  if (cached2 && Date.now() - cached2.ts < CACHE_TTL) {
326
335
  return cached2.documentId;
327
336
  }
328
- if (cache.size >= CACHE_MAX_SIZE) evict();
337
+ if (cache$1.size >= CACHE_MAX_SIZE) evict();
329
338
  try {
330
339
  const user = await strapi2.entityService.findOne(USER_UID$1, numericId, {
331
340
  fields: ["documentId"]
332
341
  });
333
342
  if (user?.documentId) {
334
- cache.set(cacheKey, { documentId: user.documentId, ts: Date.now() });
343
+ cache$1.set(cacheKey, { documentId: user.documentId, ts: Date.now() });
335
344
  return user.documentId;
336
345
  }
337
346
  } catch {
@@ -392,6 +401,15 @@ function normalizeStoredSettings(stored) {
392
401
  if (stored.sessionCreationGraceMs !== void 0) {
393
402
  out.sessionCreationGraceMs = toIntInRange(stored.sessionCreationGraceMs, 5e3, 0, 3e4);
394
403
  }
404
+ if (stored.rateLimitWriteMax !== void 0) {
405
+ out.rateLimitWriteMax = toIntInRange(stored.rateLimitWriteMax, 10, 1, 1e3);
406
+ }
407
+ if (stored.rateLimitReadMax !== void 0) {
408
+ out.rateLimitReadMax = toIntInRange(stored.rateLimitReadMax, 120, 1, 1e4);
409
+ }
410
+ if (stored.rateLimitWindowSeconds !== void 0) {
411
+ out.rateLimitWindowSeconds = toIntInRange(stored.rateLimitWindowSeconds, 60, 10, 3600);
412
+ }
395
413
  for (const key of passthroughBooleans) {
396
414
  if (stored[key] !== void 0) out[key] = !!stored[key];
397
415
  }
@@ -415,7 +433,7 @@ function normalizeStoredSettings(stored) {
415
433
  }
416
434
  return out;
417
435
  }
418
- async function getPluginSettings$5(strapi2) {
436
+ async function getPluginSettings$6(strapi2) {
419
437
  const now = Date.now();
420
438
  if (cached$1 && now - cachedAt < CACHE_TTL_MS) {
421
439
  return cached$1;
@@ -439,12 +457,12 @@ function invalidateSettingsCache$1() {
439
457
  cachedAt = 0;
440
458
  }
441
459
  var settingsLoader = {
442
- getPluginSettings: getPluginSettings$5,
460
+ getPluginSettings: getPluginSettings$6,
443
461
  invalidateSettingsCache: invalidateSettingsCache$1
444
462
  };
445
463
  const MIN_TOKEN_LENGTH = 40;
446
464
  const MAX_TOKEN_LENGTH = 8192;
447
- function extractBearerToken$4(ctx) {
465
+ function extractBearerToken$5(ctx) {
448
466
  const headers = ctx?.request?.headers || ctx?.request?.header || {};
449
467
  const raw = headers.authorization || headers.Authorization;
450
468
  if (!raw || typeof raw !== "string") return null;
@@ -454,9 +472,76 @@ function extractBearerToken$4(ctx) {
454
472
  if (!token || token.length < MIN_TOKEN_LENGTH || token.length > MAX_TOKEN_LENGTH) return null;
455
473
  return token;
456
474
  }
457
- var extractToken = { extractBearerToken: extractBearerToken$4 };
475
+ var extractToken = { extractBearerToken: extractBearerToken$5 };
476
+ const TTL_MS = 60 * 1e3;
477
+ const MAX_ENTRIES = 1e4;
478
+ const cache = /* @__PURE__ */ new Map();
479
+ const prune$1 = () => {
480
+ const now = Date.now();
481
+ for (const [k2, v] of cache) {
482
+ if (v.expiresAt <= now) cache.delete(k2);
483
+ }
484
+ };
485
+ function setSessionRejectionReason$1(tokenHash, reason) {
486
+ if (!tokenHash || !reason) return;
487
+ if (cache.size >= MAX_ENTRIES) prune$1();
488
+ cache.set(tokenHash, { reason, expiresAt: Date.now() + TTL_MS });
489
+ }
490
+ function consumeSessionRejectionReason$2(tokenHash) {
491
+ if (!tokenHash) return null;
492
+ const entry = cache.get(tokenHash);
493
+ if (!entry) return null;
494
+ cache.delete(tokenHash);
495
+ if (entry.expiresAt <= Date.now()) return null;
496
+ return entry.reason;
497
+ }
498
+ var rejectionCache = {
499
+ setSessionRejectionReason: setSessionRejectionReason$1,
500
+ consumeSessionRejectionReason: consumeSessionRejectionReason$2
501
+ };
502
+ const { extractBearerToken: extractBearerToken$4 } = extractToken;
503
+ const { hashToken: hashToken$5 } = encryption;
504
+ const { consumeSessionRejectionReason: consumeSessionRejectionReason$1 } = rejectionCache;
505
+ const HEADER = "X-Session-Terminated-Reason";
506
+ const REASON_MESSAGES = {
507
+ manual: "Your session was terminated. Please log in again.",
508
+ idle: "Your session expired due to inactivity. Please log in again.",
509
+ expired: "Your session has reached its maximum age. Please log in again.",
510
+ blocked: "Your account has been blocked. Contact support."
511
+ };
512
+ const middleware = () => async (ctx, next) => {
513
+ await next();
514
+ if (ctx.status !== 401) return;
515
+ const token = extractBearerToken$4(ctx);
516
+ if (!token) return;
517
+ const reason = consumeSessionRejectionReason$1(hashToken$5(token));
518
+ if (!reason) return;
519
+ ctx.set(HEADER, reason);
520
+ const existing = ctx.body;
521
+ const friendlyMessage = REASON_MESSAGES[reason] || "Session invalid. Please log in again.";
522
+ if (existing && typeof existing === "object" && existing.error) {
523
+ existing.error.details = existing.error.details || {};
524
+ if (!existing.error.details.reason) {
525
+ existing.error.details.reason = reason;
526
+ }
527
+ if (!existing.error.message || existing.error.message === "Unauthorized") {
528
+ existing.error.message = friendlyMessage;
529
+ }
530
+ return;
531
+ }
532
+ ctx.body = {
533
+ data: null,
534
+ error: {
535
+ status: 401,
536
+ name: "UnauthorizedError",
537
+ message: friendlyMessage,
538
+ details: { reason }
539
+ }
540
+ };
541
+ };
542
+ var sessionRejectionHeaders = middleware;
458
543
  const { resolveUserDocumentId: resolveUserDocumentId$4 } = resolveUser;
459
- const { getPluginSettings: getPluginSettings$4 } = settingsLoader;
544
+ const { getPluginSettings: getPluginSettings$5 } = settingsLoader;
460
545
  const { extractBearerToken: extractBearerToken$3 } = extractToken;
461
546
  const { hashToken: hashToken$4 } = encryption;
462
547
  const SESSION_UID$5 = "plugin::magic-sessionmanager.session";
@@ -477,65 +562,75 @@ function isAuthEndpoint(path2) {
477
562
  var lastSeen = ({ strapi: strapi2, sessionService }) => {
478
563
  return async (ctx, next) => {
479
564
  if (isAuthEndpoint(ctx.path)) {
480
- await next();
481
- return;
565
+ return next();
482
566
  }
483
- if (ctx.state.user) {
567
+ if (!ctx.state.user) {
568
+ return next();
569
+ }
570
+ let userDocId = ctx.state.user.documentId;
571
+ if (!userDocId && ctx.state.user.id) {
484
572
  try {
485
- let userDocId2 = ctx.state.user.documentId;
486
- if (!userDocId2 && ctx.state.user.id) {
487
- userDocId2 = await resolveUserDocumentId$4(strapi2, ctx.state.user.id);
488
- }
489
- if (userDocId2) {
490
- const settings2 = await getPluginSettings$4(strapi2);
491
- const strictMode = settings2.strictSessionEnforcement === true;
492
- const token = extractBearerToken$3(ctx);
493
- const tokenHashValue = token ? hashToken$4(token) : null;
494
- const thisSession = tokenHashValue ? await strapi2.documents(SESSION_UID$5).findFirst({
495
- filters: { user: { documentId: userDocId2 }, tokenHash: tokenHashValue },
496
- fields: ["documentId", "isActive", "terminatedManually"]
497
- }) : null;
498
- if (thisSession) {
499
- if (thisSession.terminatedManually === true) {
500
- strapi2.log.info(`[magic-sessionmanager] [BLOCKED] Session was manually terminated (user: ${userDocId2.substring(0, 8)}...)`);
501
- return ctx.unauthorized("Session terminated. Please login again.");
502
- }
503
- ctx.state.userDocumentId = userDocId2;
504
- ctx.state.__magicSessionId = thisSession.documentId;
505
- await next();
506
- if (thisSession.isActive) {
507
- try {
508
- await sessionService.touch({
509
- userId: userDocId2,
510
- sessionId: thisSession.documentId
511
- });
512
- } catch (err) {
513
- strapi2.log.debug("[magic-sessionmanager] Error updating lastSeen:", err.message);
514
- }
515
- }
516
- return;
517
- }
518
- if (strictMode) {
519
- strapi2.log.info(`[magic-sessionmanager] [BLOCKED] No session matches this token (user: ${userDocId2.substring(0, 8)}..., strictMode)`);
520
- return ctx.unauthorized("No valid session. Please login again.");
521
- }
522
- strapi2.log.warn(`[magic-sessionmanager] [WARN] No session for token (user: ${userDocId2.substring(0, 8)}...) - allowing in non-strict mode`);
523
- ctx.state.userDocumentId = userDocId2;
524
- }
573
+ userDocId = await resolveUserDocumentId$4(strapi2, ctx.state.user.id);
525
574
  } catch (err) {
526
- strapi2.log.debug("[magic-sessionmanager] Error checking active sessions:", err.message);
575
+ strapi2.log.debug("[magic-sessionmanager] user doc-id lookup failed:", err.message);
576
+ return next();
527
577
  }
528
578
  }
529
- await next();
530
- const userDocId = ctx.state.userDocumentId || ctx.state.user?.documentId;
531
- const sessionId = ctx.state.__magicSessionId;
532
- if (userDocId && sessionId) {
579
+ if (!userDocId) {
580
+ return next();
581
+ }
582
+ const settings2 = await getPluginSettings$5(strapi2).catch(() => ({}));
583
+ const strictMode = settings2.strictSessionEnforcement === true;
584
+ const gracePeriodMs = Math.max(0, Number(settings2.sessionCreationGraceMs) || 5e3);
585
+ const token = extractBearerToken$3(ctx);
586
+ const tokenHashValue = token ? hashToken$4(token) : null;
587
+ let thisSession = null;
588
+ if (tokenHashValue) {
533
589
  try {
534
- await sessionService.touch({ userId: userDocId, sessionId });
590
+ thisSession = await strapi2.documents(SESSION_UID$5).findFirst({
591
+ filters: { user: { documentId: userDocId }, tokenHash: tokenHashValue },
592
+ fields: ["documentId", "isActive", "terminatedManually", "terminationReason"]
593
+ });
594
+ } catch (err) {
595
+ strapi2.log.debug("[magic-sessionmanager] session lookup failed:", err.message);
596
+ }
597
+ }
598
+ if (thisSession) {
599
+ if (thisSession.isActive === false) {
600
+ return ctx.unauthorized("Session terminated. Please login again.");
601
+ }
602
+ ctx.state.userDocumentId = userDocId;
603
+ ctx.state.__magicSessionId = thisSession.documentId;
604
+ await next();
605
+ try {
606
+ await sessionService.touch({
607
+ userId: userDocId,
608
+ sessionId: thisSession.documentId
609
+ });
535
610
  } catch (err) {
536
611
  strapi2.log.debug("[magic-sessionmanager] Error updating lastSeen:", err.message);
537
612
  }
613
+ return;
538
614
  }
615
+ if (strictMode) {
616
+ const iat = ctx.state.user?.iat;
617
+ if (gracePeriodMs > 0 && typeof iat === "number") {
618
+ const ageMs = Date.now() - iat * 1e3;
619
+ if (ageMs >= 0 && ageMs < gracePeriodMs) {
620
+ ctx.state.userDocumentId = userDocId;
621
+ return next();
622
+ }
623
+ }
624
+ strapi2.log.info(
625
+ `[magic-sessionmanager] [BLOCKED] No session matches this token (user: ${userDocId.substring(0, 8)}..., strictMode)`
626
+ );
627
+ return ctx.unauthorized("No valid session. Please login again.");
628
+ }
629
+ strapi2.log.debug(
630
+ `[magic-sessionmanager] [WARN] No session for token (user: ${userDocId.substring(0, 8)}...) - allowing in non-strict mode`
631
+ );
632
+ ctx.state.userDocumentId = userDocId;
633
+ return next();
539
634
  };
540
635
  };
541
636
  var jsonwebtoken = { exports: {} };
@@ -9502,8 +9597,12 @@ const getClientIp = getClientIp_1;
9502
9597
  const { encryptToken: encryptToken$1, hashToken: hashToken$3 } = encryption;
9503
9598
  const { createLogger: createLogger$3 } = logger;
9504
9599
  const { resolveUserDocumentId: resolveUserDocumentId$3 } = resolveUser;
9505
- const { getPluginSettings: getPluginSettings$3 } = settingsLoader;
9600
+ const { getPluginSettings: getPluginSettings$4 } = settingsLoader;
9506
9601
  const { extractBearerToken: extractBearerToken$2 } = extractToken;
9602
+ const {
9603
+ setSessionRejectionReason,
9604
+ consumeSessionRejectionReason
9605
+ } = rejectionCache;
9507
9606
  const SESSION_UID$4 = "plugin::magic-sessionmanager.session";
9508
9607
  const JWT_WRAPPED_FLAG = Symbol.for("magic-sessionmanager.jwt.wrapped");
9509
9608
  const LOGIN_PATHS = /* @__PURE__ */ new Set([
@@ -9571,37 +9670,71 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9571
9670
  }
9572
9671
  }, 3e3);
9573
9672
  const sessionService = strapi2.plugin("magic-sessionmanager").service("session");
9673
+ if (!strapi2.sessionManagerIntervals) {
9674
+ strapi2.sessionManagerIntervals = {};
9675
+ }
9574
9676
  log.info("Running initial session cleanup...");
9575
9677
  try {
9576
- const settings2 = await getPluginSettings$3(strapi2);
9678
+ const settings2 = await getPluginSettings$4(strapi2);
9577
9679
  await sessionService.cleanupInactiveSessions({
9578
9680
  useDbDirect: settings2.cleanupUseDbDirect === true
9579
9681
  });
9580
9682
  } catch (cleanupErr) {
9581
9683
  log.warn("Initial cleanup failed:", cleanupErr.message);
9582
9684
  }
9583
- const cleanupInterval = 30 * 60 * 1e3;
9584
- const cleanupIntervalHandle = setInterval(async () => {
9685
+ const scheduleIdleCleanup = async () => {
9686
+ let intervalMs = 30 * 60 * 1e3;
9687
+ let useDbDirect = false;
9585
9688
  try {
9586
- const service = strapi2.plugin("magic-sessionmanager").service("session");
9587
- const settings2 = await getPluginSettings$3(strapi2).catch(() => ({}));
9588
- await service.cleanupInactiveSessions({
9589
- useDbDirect: settings2.cleanupUseDbDirect === true
9590
- });
9591
- } catch (err) {
9592
- log.error("Periodic cleanup error:", err);
9689
+ const settings2 = await getPluginSettings$4(strapi2);
9690
+ intervalMs = Math.max(5 * 60 * 1e3, settings2.cleanupInterval || intervalMs);
9691
+ useDbDirect = settings2.cleanupUseDbDirect === true;
9692
+ } catch {
9593
9693
  }
9594
- }, cleanupInterval);
9595
- log.info("[TIME] Periodic cleanup scheduled (every 30 minutes)");
9596
- if (!strapi2.sessionManagerIntervals) {
9597
- strapi2.sessionManagerIntervals = {};
9598
- }
9599
- strapi2.sessionManagerIntervals.cleanup = cleanupIntervalHandle;
9694
+ const handle = setTimeout(async () => {
9695
+ try {
9696
+ const service = strapi2.plugin("magic-sessionmanager").service("session");
9697
+ await service.cleanupInactiveSessions({ useDbDirect });
9698
+ } catch (err) {
9699
+ log.error("Periodic cleanup error:", err);
9700
+ }
9701
+ scheduleIdleCleanup();
9702
+ }, intervalMs);
9703
+ strapi2.sessionManagerIntervals.cleanupTimeout = handle;
9704
+ };
9705
+ await scheduleIdleCleanup();
9706
+ const RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1e3;
9707
+ const scheduleRetention = async () => {
9708
+ let useDbDirect = false;
9709
+ try {
9710
+ const settings2 = await getPluginSettings$4(strapi2);
9711
+ useDbDirect = settings2.cleanupUseDbDirect === true;
9712
+ } catch {
9713
+ }
9714
+ const handle = setTimeout(async () => {
9715
+ try {
9716
+ const service = strapi2.plugin("magic-sessionmanager").service("session");
9717
+ await service.deleteOldSessions({ useDbDirect });
9718
+ } catch (err) {
9719
+ log.error("Retention cleanup error:", err);
9720
+ }
9721
+ scheduleRetention();
9722
+ }, RETENTION_INTERVAL_MS);
9723
+ strapi2.sessionManagerIntervals.retentionTimeout = handle;
9724
+ };
9725
+ strapi2.sessionManagerIntervals.retentionStartup = setTimeout(() => {
9726
+ scheduleRetention();
9727
+ }, 5 * 60 * 1e3);
9728
+ log.info("[TIME] Dynamic cleanup + retention scheduled");
9600
9729
  mountPreLoginGeoGuard({ strapi: strapi2, log });
9601
9730
  mountFailedLoginLockout({ strapi: strapi2, log });
9602
9731
  mountLogoutRoute({ strapi: strapi2, log, sessionService });
9603
9732
  mountLoginInterceptor({ strapi: strapi2, log, sessionService });
9604
9733
  mountRefreshTokenInterceptor({ strapi: strapi2, log });
9734
+ strapi2.server.use(
9735
+ sessionRejectionHeaders({}, { strapi: strapi2 })
9736
+ );
9737
+ log.info("[SUCCESS] Session-rejection-headers middleware mounted");
9605
9738
  strapi2.server.use(
9606
9739
  lastSeen({ strapi: strapi2, sessionService })
9607
9740
  );
@@ -9620,7 +9753,7 @@ function mountPreLoginGeoGuard({ strapi: strapi2, log }) {
9620
9753
  }
9621
9754
  let settings2 = {};
9622
9755
  try {
9623
- settings2 = await getPluginSettings$3(strapi2);
9756
+ settings2 = await getPluginSettings$4(strapi2);
9624
9757
  } catch {
9625
9758
  settings2 = {};
9626
9759
  }
@@ -9796,7 +9929,7 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9796
9929
  if (!isLoginPath(ctx.path, ctx.method)) return next();
9797
9930
  let maxFailed = 0;
9798
9931
  try {
9799
- const settings2 = await getPluginSettings$3(strapi2);
9932
+ const settings2 = await getPluginSettings$4(strapi2);
9800
9933
  maxFailed = Number(settings2.maxFailedLogins) || 0;
9801
9934
  } catch {
9802
9935
  maxFailed = 0;
@@ -9875,7 +10008,7 @@ function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9875
10008
  }
9876
10009
  log.info(`[SUCCESS] Session ${newSession.documentId} created for user ${userDocId} (IP: ${ip})`);
9877
10010
  try {
9878
- const settings2 = await getPluginSettings$3(strapi2);
10011
+ const settings2 = await getPluginSettings$4(strapi2);
9879
10012
  if (!geoData || !(settings2.enableEmailAlerts || settings2.enableWebhooks)) {
9880
10013
  return;
9881
10014
  }
@@ -10192,7 +10325,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10192
10325
  }
10193
10326
  let settings2;
10194
10327
  try {
10195
- settings2 = await getPluginSettings$3(strapi2);
10328
+ settings2 = await getPluginSettings$4(strapi2);
10196
10329
  } catch {
10197
10330
  settings2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
10198
10331
  }
@@ -10217,6 +10350,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10217
10350
  strapi2.log.info(
10218
10351
  `[magic-sessionmanager] [JWT-BLOCKED] User is blocked (user: ${userDocId.substring(0, 8)}...)`
10219
10352
  );
10353
+ setSessionRejectionReason(hashToken$3(token), "blocked");
10220
10354
  return null;
10221
10355
  }
10222
10356
  } catch {
@@ -10227,7 +10361,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10227
10361
  user: { documentId: userDocId },
10228
10362
  tokenHash: tokenHashValue
10229
10363
  },
10230
- fields: ["documentId", "isActive", "terminatedManually", "lastActive", "loginTime"]
10364
+ fields: ["documentId", "isActive", "terminatedManually", "terminationReason", "lastActive", "loginTime"]
10231
10365
  });
10232
10366
  if (thisSession) {
10233
10367
  if (isSessionExpired(thisSession, maxSessionAgeDays)) {
@@ -10236,15 +10370,25 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10236
10370
  );
10237
10371
  await strapi2.documents(SESSION_UID$4).update({
10238
10372
  documentId: thisSession.documentId,
10239
- data: { isActive: false, terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
10373
+ data: {
10374
+ isActive: false,
10375
+ terminatedManually: false,
10376
+ terminationReason: "expired",
10377
+ logoutTime: /* @__PURE__ */ new Date()
10378
+ }
10240
10379
  });
10380
+ setSessionRejectionReason(tokenHashValue, "expired");
10241
10381
  return null;
10242
10382
  }
10243
- if (thisSession.terminatedManually === true) {
10244
- strapi2.log.info(
10245
- `[magic-sessionmanager] [JWT-BLOCKED] Session was manually terminated (user: ${userDocId.substring(0, 8)}...)`
10246
- );
10247
- return null;
10383
+ if (thisSession.isActive === false) {
10384
+ const reason = thisSession.terminationReason || (thisSession.terminatedManually === true ? "manual" : null);
10385
+ if (reason) {
10386
+ strapi2.log.info(
10387
+ `[magic-sessionmanager] [JWT-REJECTED] Session inactive (reason: ${reason}) for user ${userDocId.substring(0, 8)}...`
10388
+ );
10389
+ setSessionRejectionReason(tokenHashValue, reason);
10390
+ return null;
10391
+ }
10248
10392
  }
10249
10393
  if (thisSession.isActive) {
10250
10394
  resetErrorCounter();
@@ -10259,8 +10403,13 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10259
10403
  );
10260
10404
  await strapi2.documents(SESSION_UID$4).update({
10261
10405
  documentId: thisSession.documentId,
10262
- data: { terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
10406
+ data: {
10407
+ terminatedManually: false,
10408
+ terminationReason: "idle",
10409
+ logoutTime: /* @__PURE__ */ new Date()
10410
+ }
10263
10411
  });
10412
+ setSessionRejectionReason(tokenHashValue, "idle");
10264
10413
  return null;
10265
10414
  }
10266
10415
  await strapi2.documents(SESSION_UID$4).update({
@@ -10328,9 +10477,10 @@ var destroy$1 = async ({ strapi: strapi2 }) => {
10328
10477
  if (!handle) continue;
10329
10478
  try {
10330
10479
  clearInterval(handle);
10331
- log.info(`[STOP] ${name} interval stopped`);
10480
+ clearTimeout(handle);
10481
+ log.info(`[STOP] ${name} timer stopped`);
10332
10482
  } catch (err) {
10333
- log.warn(`Failed to stop ${name} interval:`, err.message);
10483
+ log.warn(`Failed to stop ${name} timer:`, err.message);
10334
10484
  }
10335
10485
  }
10336
10486
  strapi2.sessionManagerIntervals = {};
@@ -10461,6 +10611,16 @@ const attributes = {
10461
10611
  "default": false,
10462
10612
  required: false
10463
10613
  },
10614
+ terminationReason: {
10615
+ type: "enumeration",
10616
+ "enum": [
10617
+ "manual",
10618
+ "idle",
10619
+ "expired",
10620
+ "blocked"
10621
+ ],
10622
+ required: false
10623
+ },
10464
10624
  geoLocation: {
10465
10625
  type: "json"
10466
10626
  },
@@ -10494,10 +10654,16 @@ var contentTypes$2 = {
10494
10654
  }
10495
10655
  };
10496
10656
  const writeRateLimit = [
10497
- { name: "plugin::magic-sessionmanager.rate-limit", config: { max: 10, window: 6e4 } }
10657
+ {
10658
+ name: "plugin::magic-sessionmanager.rate-limit",
10659
+ config: { profile: "write", max: 10, window: 6e4 }
10660
+ }
10498
10661
  ];
10499
10662
  const readRateLimit = [
10500
- { name: "plugin::magic-sessionmanager.rate-limit", config: { max: 120, window: 6e4 } }
10663
+ {
10664
+ name: "plugin::magic-sessionmanager.rate-limit",
10665
+ config: { profile: "read", max: 120, window: 6e4 }
10666
+ }
10501
10667
  ];
10502
10668
  var contentApi$1 = {
10503
10669
  type: "content-api",
@@ -10900,15 +11066,22 @@ var enhanceSession_1 = { enhanceSession: enhanceSession$1, enhanceSessions: enha
10900
11066
  const { hashToken: hashToken$2 } = encryption;
10901
11067
  const { enhanceSessions: enhanceSessions$1, enhanceSession } = enhanceSession_1;
10902
11068
  const { resolveUserDocumentId: resolveUserDocumentId$2 } = resolveUser;
10903
- const { getPluginSettings: getPluginSettings$2 } = settingsLoader;
11069
+ const { getPluginSettings: getPluginSettings$3 } = settingsLoader;
10904
11070
  const { extractBearerToken: extractBearerToken$1 } = extractToken;
10905
11071
  const SESSION_UID$2 = "plugin::magic-sessionmanager.session";
10906
11072
  const USER_UID = "plugin::users-permissions.user";
11073
+ const OWN_SESSIONS_LIMIT = 200;
11074
+ async function resolveAuthUserDocId(ctx) {
11075
+ const u2 = ctx.state.user;
11076
+ if (!u2) return null;
11077
+ if (u2.documentId) return u2.documentId;
11078
+ if (u2.id) return resolveUserDocumentId$2(strapi, u2.id);
11079
+ return null;
11080
+ }
10907
11081
  var session$3 = {
10908
11082
  /**
10909
11083
  * Lists all sessions (active + inactive) for admin overviews.
10910
11084
  * @route GET /magic-sessionmanager/sessions
10911
- * @returns {object} `{ data, meta }`
10912
11085
  */
10913
11086
  async getAllSessionsAdmin(ctx) {
10914
11087
  try {
@@ -10930,7 +11103,6 @@ var session$3 = {
10930
11103
  /**
10931
11104
  * Lists currently-active sessions only.
10932
11105
  * @route GET /magic-sessionmanager/sessions/active
10933
- * @returns {object} `{ data, meta }`
10934
11106
  */
10935
11107
  async getActiveSessions(ctx) {
10936
11108
  try {
@@ -10946,35 +11118,33 @@ var session$3 = {
10946
11118
  }
10947
11119
  },
10948
11120
  /**
10949
- * Returns the authenticated user's own sessions, with the current session
10950
- * flagged via `isCurrentSession`.
10951
- *
11121
+ * Returns the authenticated user's own sessions, current session flagged.
10952
11122
  * @route GET /api/magic-sessionmanager/my-sessions
10953
- * @returns {object} `{ data, meta }`
10954
- * @throws {UnauthorizedError} When user is not authenticated
10955
11123
  */
10956
11124
  async getOwnSessions(ctx) {
10957
11125
  try {
10958
- const userId = ctx.state.user?.documentId;
11126
+ const userDocId = await resolveAuthUserDocId(ctx);
11127
+ if (!userDocId) {
11128
+ return ctx.unauthorized("Authentication required");
11129
+ }
10959
11130
  const currentToken = extractBearerToken$1(ctx);
10960
11131
  const currentTokenHash = currentToken ? hashToken$2(currentToken) : null;
10961
- if (!userId) {
10962
- return ctx.throw(401, "Unauthorized");
10963
- }
10964
11132
  const allSessions = await strapi.documents(SESSION_UID$2).findMany({
10965
- filters: { user: { documentId: userId } },
11133
+ filters: { user: { documentId: userDocId } },
10966
11134
  sort: { loginTime: "desc" },
10967
- limit: 200
11135
+ limit: OWN_SESSIONS_LIMIT + 1
10968
11136
  });
10969
- const settings2 = await getPluginSettings$2(strapi);
11137
+ const hasMore = allSessions.length > OWN_SESSIONS_LIMIT;
11138
+ const paged = hasMore ? allSessions.slice(0, OWN_SESSIONS_LIMIT) : allSessions;
11139
+ const settings2 = await getPluginSettings$3(strapi);
10970
11140
  const enhanceOpts = {
10971
11141
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
10972
11142
  geolocationService: strapi.plugin("magic-sessionmanager").service("geolocation"),
10973
11143
  strapi
10974
11144
  };
10975
- const sessionsWithCurrent = await enhanceSessions$1(allSessions, enhanceOpts, 20);
11145
+ const sessionsWithCurrent = await enhanceSessions$1(paged, enhanceOpts, 20);
10976
11146
  for (const s3 of sessionsWithCurrent) {
10977
- s3.isCurrentSession = !!(currentTokenHash && allSessions.find(
11147
+ s3.isCurrentSession = !!(currentTokenHash && paged.find(
10978
11148
  (raw) => raw.documentId === s3.documentId && raw.tokenHash === currentTokenHash
10979
11149
  ));
10980
11150
  }
@@ -10987,7 +11157,9 @@ var session$3 = {
10987
11157
  data: sessionsWithCurrent,
10988
11158
  meta: {
10989
11159
  count: sessionsWithCurrent.length,
10990
- active: sessionsWithCurrent.filter((s3) => s3.isTrulyActive).length
11160
+ active: sessionsWithCurrent.filter((s3) => s3.isTrulyActive).length,
11161
+ hasMore,
11162
+ limit: OWN_SESSIONS_LIMIT
10991
11163
  }
10992
11164
  };
10993
11165
  } catch (err) {
@@ -10996,18 +11168,14 @@ var session$3 = {
10996
11168
  }
10997
11169
  },
10998
11170
  /**
10999
- * Get a specific user's sessions. Admins may query any user; Content-API
11171
+ * Get a specific user's sessions. Admins can query any user; content-api
11000
11172
  * users can only query themselves.
11001
- *
11002
- * @route GET /magic-sessionmanager/user/:userId/sessions (admin)
11003
- * @route GET /api/magic-sessionmanager/user/:userId/sessions (content-api)
11004
- * @throws {ForbiddenError} When a non-admin requests another user's sessions
11005
11173
  */
11006
11174
  async getUserSessions(ctx) {
11007
11175
  try {
11008
11176
  const { userId } = ctx.params;
11009
11177
  const isAdminRequest = !!(ctx.state.userAbility || ctx.state.admin);
11010
- const requestingUserDocId = ctx.state.user?.documentId;
11178
+ const requestingUserDocId = await resolveAuthUserDocId(ctx);
11011
11179
  if (!isAdminRequest) {
11012
11180
  if (!requestingUserDocId) {
11013
11181
  strapi.log.warn(`[magic-sessionmanager] Security: Request without documentId tried to access sessions of user ${userId}`);
@@ -11036,26 +11204,34 @@ var session$3 = {
11036
11204
  */
11037
11205
  async logout(ctx) {
11038
11206
  try {
11039
- const userId = ctx.state.user?.documentId;
11207
+ const userDocId = await resolveAuthUserDocId(ctx);
11040
11208
  const token = extractBearerToken$1(ctx);
11041
- if (!userId || !token) {
11042
- return ctx.throw(401, "Unauthorized");
11209
+ if (!userDocId || !token) {
11210
+ return ctx.unauthorized("Authentication required");
11043
11211
  }
11044
11212
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11045
11213
  const currentTokenHash = hashToken$2(token);
11046
11214
  const matchingSession = await strapi.documents(SESSION_UID$2).findFirst({
11047
11215
  filters: {
11048
- user: { documentId: userId },
11216
+ user: { documentId: userDocId },
11049
11217
  tokenHash: currentTokenHash,
11050
11218
  isActive: true
11051
11219
  },
11052
11220
  fields: ["documentId"]
11053
11221
  });
11222
+ let terminated = false;
11054
11223
  if (matchingSession) {
11055
- await sessionService.terminateSession({ sessionId: matchingSession.documentId });
11056
- strapi.log.info(`[magic-sessionmanager] User ${userId} logged out (session ${matchingSession.documentId})`);
11224
+ await sessionService.terminateSession({
11225
+ sessionId: matchingSession.documentId,
11226
+ reason: "manual"
11227
+ });
11228
+ terminated = true;
11229
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} logged out (session ${matchingSession.documentId})`);
11057
11230
  }
11058
- ctx.body = { message: "Logged out successfully" };
11231
+ ctx.body = {
11232
+ message: "Logged out successfully",
11233
+ terminated
11234
+ };
11059
11235
  } catch (err) {
11060
11236
  strapi.log.error("[magic-sessionmanager] Logout error:", err);
11061
11237
  ctx.throw(500, "Error during logout");
@@ -11067,13 +11243,13 @@ var session$3 = {
11067
11243
  */
11068
11244
  async logoutAll(ctx) {
11069
11245
  try {
11070
- const userId = ctx.state.user?.documentId;
11071
- if (!userId) {
11072
- return ctx.throw(401, "Unauthorized");
11246
+ const userDocId = await resolveAuthUserDocId(ctx);
11247
+ if (!userDocId) {
11248
+ return ctx.unauthorized("Authentication required");
11073
11249
  }
11074
11250
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11075
- await sessionService.terminateSession({ userId });
11076
- strapi.log.info(`[magic-sessionmanager] User ${userId} logged out from all devices`);
11251
+ await sessionService.terminateSession({ userId: userDocId, reason: "manual" });
11252
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} logged out from all devices`);
11077
11253
  ctx.body = { message: "Logged out from all devices successfully" };
11078
11254
  } catch (err) {
11079
11255
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
@@ -11082,27 +11258,48 @@ var session$3 = {
11082
11258
  },
11083
11259
  /**
11084
11260
  * Returns the session associated with the current JWT.
11261
+ *
11262
+ * During the post-login grace window the session-create write may not
11263
+ * yet be visible. In that case we return 202 Accepted with
11264
+ * `{ pending: true }` so the client knows to retry shortly instead of
11265
+ * interpreting a 404 as "no session at all".
11266
+ *
11085
11267
  * @route GET /api/magic-sessionmanager/current-session
11086
11268
  */
11087
11269
  async getCurrentSession(ctx) {
11088
11270
  try {
11089
- const userId = ctx.state.user?.documentId;
11271
+ const userDocId = await resolveAuthUserDocId(ctx);
11090
11272
  const token = extractBearerToken$1(ctx);
11091
- if (!userId || !token) {
11092
- return ctx.throw(401, "Unauthorized");
11273
+ if (!userDocId || !token) {
11274
+ return ctx.unauthorized("Authentication required");
11093
11275
  }
11094
11276
  const currentTokenHash = hashToken$2(token);
11095
11277
  const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11096
11278
  filters: {
11097
- user: { documentId: userId },
11279
+ user: { documentId: userDocId },
11098
11280
  tokenHash: currentTokenHash,
11099
11281
  isActive: true
11100
11282
  }
11101
11283
  });
11102
11284
  if (!currentSession) {
11285
+ const settings3 = await getPluginSettings$3(strapi);
11286
+ const gracePeriodMs = Math.max(0, Number(settings3.sessionCreationGraceMs) || 5e3);
11287
+ const iat = ctx.state.user?.iat || ctx.state.auth?.credentials?.iat || null;
11288
+ if (gracePeriodMs > 0 && typeof iat === "number") {
11289
+ const ageMs = Date.now() - iat * 1e3;
11290
+ if (ageMs >= 0 && ageMs < gracePeriodMs) {
11291
+ ctx.status = 202;
11292
+ ctx.body = {
11293
+ data: null,
11294
+ meta: { pending: true, retryAfterMs: gracePeriodMs - ageMs },
11295
+ message: "Session is still being created — please retry shortly."
11296
+ };
11297
+ return;
11298
+ }
11299
+ }
11103
11300
  return ctx.notFound("Current session not found");
11104
11301
  }
11105
- const settings2 = await getPluginSettings$2(strapi);
11302
+ const settings2 = await getPluginSettings$3(strapi);
11106
11303
  const enhanced = await enhanceSession(currentSession, {
11107
11304
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11108
11305
  geolocationService: strapi.plugin("magic-sessionmanager").service("geolocation"),
@@ -11118,17 +11315,17 @@ var session$3 = {
11118
11315
  }
11119
11316
  },
11120
11317
  /**
11121
- * Terminates one of the authenticated user's OWN sessions (not the current one).
11318
+ * Terminates one of the authenticated user's OWN sessions (not current).
11122
11319
  * @route DELETE /api/magic-sessionmanager/my-sessions/:sessionId
11123
11320
  */
11124
11321
  async terminateOwnSession(ctx) {
11125
11322
  try {
11126
- const userId = ctx.state.user?.documentId;
11323
+ const userDocId = await resolveAuthUserDocId(ctx);
11127
11324
  const { sessionId } = ctx.params;
11128
11325
  const currentToken = extractBearerToken$1(ctx);
11129
11326
  const currentTokenHash = currentToken ? hashToken$2(currentToken) : null;
11130
- if (!userId) {
11131
- return ctx.throw(401, "Unauthorized");
11327
+ if (!userDocId) {
11328
+ return ctx.unauthorized("Authentication required");
11132
11329
  }
11133
11330
  if (!sessionId) {
11134
11331
  return ctx.badRequest("Session ID is required");
@@ -11141,19 +11338,23 @@ var session$3 = {
11141
11338
  return ctx.notFound("Session not found");
11142
11339
  }
11143
11340
  const sessionUserId = sessionToTerminate.user?.documentId;
11144
- if (sessionUserId !== userId) {
11145
- strapi.log.warn(`[magic-sessionmanager] Security: User ${userId} tried to terminate session ${sessionId} of user ${sessionUserId}`);
11341
+ if (sessionUserId !== userDocId) {
11342
+ strapi.log.warn(`[magic-sessionmanager] Security: User ${userDocId} tried to terminate session ${sessionId} of user ${sessionUserId}`);
11146
11343
  return ctx.forbidden("You can only terminate your own sessions");
11147
11344
  }
11148
11345
  if (currentTokenHash && sessionToTerminate.tokenHash === currentTokenHash) {
11149
- return ctx.badRequest("Cannot terminate current session. Use /logout instead.");
11346
+ return ctx.badRequest("Cannot terminate the current session. Use /logout instead.");
11347
+ }
11348
+ const alreadyTerminated = sessionToTerminate.isActive === false;
11349
+ if (!alreadyTerminated) {
11350
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11351
+ await sessionService.terminateSession({ sessionId, reason: "manual" });
11352
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} terminated own session ${sessionId}`);
11150
11353
  }
11151
- const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11152
- await sessionService.terminateSession({ sessionId });
11153
- strapi.log.info(`[magic-sessionmanager] User ${userId} terminated own session ${sessionId}`);
11154
11354
  ctx.body = {
11155
- message: `Session ${sessionId} terminated successfully`,
11156
- success: true
11355
+ message: alreadyTerminated ? `Session ${sessionId} was already terminated` : `Session ${sessionId} terminated successfully`,
11356
+ success: true,
11357
+ alreadyTerminated
11157
11358
  };
11158
11359
  } catch (err) {
11159
11360
  strapi.log.error("[magic-sessionmanager] Error terminating own session:", err);
@@ -11161,9 +11362,7 @@ var session$3 = {
11161
11362
  }
11162
11363
  },
11163
11364
  /**
11164
- * Sets isActive:false + terminatedManually:false on a session, simulating
11165
- * a cleanup timeout. Available only outside of production/staging.
11166
- *
11365
+ * Simulates an inactivity timeout on a session. Dev-only.
11167
11366
  * @route POST /magic-sessionmanager/sessions/:sessionId/simulate-timeout
11168
11367
  */
11169
11368
  async simulateTimeout(ctx) {
@@ -11182,13 +11381,16 @@ var session$3 = {
11182
11381
  }
11183
11382
  await strapi.documents(SESSION_UID$2).update({
11184
11383
  documentId: sessionId,
11185
- data: { isActive: false, terminatedManually: false }
11384
+ data: {
11385
+ isActive: false,
11386
+ terminatedManually: false,
11387
+ terminationReason: "idle"
11388
+ }
11186
11389
  });
11187
- strapi.log.info(`[magic-sessionmanager] [TEST] Session ${sessionId} simulated timeout (terminatedManually: false)`);
11390
+ strapi.log.info(`[magic-sessionmanager] [TEST] Session ${sessionId} simulated timeout`);
11188
11391
  ctx.body = {
11189
- message: `Session ${sessionId} marked as timed out (reactivatable)`,
11190
- success: true,
11191
- terminatedManually: false
11392
+ message: `Session ${sessionId} marked as timed out`,
11393
+ success: true
11192
11394
  };
11193
11395
  } catch (err) {
11194
11396
  strapi.log.error("[magic-sessionmanager] Error simulating timeout:", err);
@@ -11197,13 +11399,12 @@ var session$3 = {
11197
11399
  },
11198
11400
  /**
11199
11401
  * Terminates a specific session (admin action).
11200
- * @route POST /magic-sessionmanager/sessions/:sessionId/terminate
11201
11402
  */
11202
11403
  async terminateSingleSession(ctx) {
11203
11404
  try {
11204
11405
  const { sessionId } = ctx.params;
11205
11406
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11206
- await sessionService.terminateSession({ sessionId });
11407
+ await sessionService.terminateSession({ sessionId, reason: "manual" });
11207
11408
  ctx.body = {
11208
11409
  message: `Session ${sessionId} terminated`,
11209
11410
  success: true
@@ -11215,13 +11416,12 @@ var session$3 = {
11215
11416
  },
11216
11417
  /**
11217
11418
  * Terminates ALL sessions for a specific user (admin action).
11218
- * @route POST /magic-sessionmanager/user/:userId/terminate-all
11219
11419
  */
11220
11420
  async terminateAllUserSessions(ctx) {
11221
11421
  try {
11222
11422
  const { userId } = ctx.params;
11223
11423
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11224
- await sessionService.terminateSession({ userId });
11424
+ await sessionService.terminateSession({ userId, reason: "manual" });
11225
11425
  ctx.body = {
11226
11426
  message: `All sessions terminated for user ${userId}`,
11227
11427
  success: true
@@ -11233,9 +11433,6 @@ var session$3 = {
11233
11433
  },
11234
11434
  /**
11235
11435
  * Returns geolocation data for a specific IP address (Premium feature).
11236
- *
11237
- * @route GET /magic-sessionmanager/geolocation/:ipAddress
11238
- * @throws {ForbiddenError} When no premium license is active
11239
11436
  */
11240
11437
  async getIpGeolocation(ctx) {
11241
11438
  try {
@@ -11262,10 +11459,7 @@ var session$3 = {
11262
11459
  return ctx.badRequest("Invalid IP address format");
11263
11460
  }
11264
11461
  const licenseGuard2 = strapi.plugin("magic-sessionmanager").service("license-guard");
11265
- const pluginStore = strapi.store({
11266
- type: "plugin",
11267
- name: "magic-sessionmanager"
11268
- });
11462
+ const pluginStore = strapi.store({ type: "plugin", name: "magic-sessionmanager" });
11269
11463
  const licenseKey = await pluginStore.get({ key: "licenseKey" });
11270
11464
  if (!licenseKey) {
11271
11465
  return ctx.forbidden("Premium license required for geolocation features");
@@ -11287,7 +11481,6 @@ var session$3 = {
11287
11481
  },
11288
11482
  /**
11289
11483
  * Permanently deletes a session (admin action).
11290
- * @route DELETE /magic-sessionmanager/sessions/:sessionId
11291
11484
  */
11292
11485
  async deleteSession(ctx) {
11293
11486
  try {
@@ -11305,7 +11498,6 @@ var session$3 = {
11305
11498
  },
11306
11499
  /**
11307
11500
  * Deletes all inactive sessions (admin action).
11308
- * @route POST /magic-sessionmanager/sessions/clean-inactive
11309
11501
  */
11310
11502
  async cleanInactiveSessions(ctx) {
11311
11503
  try {
@@ -11323,9 +11515,6 @@ var session$3 = {
11323
11515
  },
11324
11516
  /**
11325
11517
  * Toggles a user's blocked status and terminates their sessions on block.
11326
- *
11327
- * @route POST /magic-sessionmanager/user/:userId/toggle-block
11328
- * @throws {NotFoundError} When the user cannot be found
11329
11518
  */
11330
11519
  async toggleUserBlock(ctx) {
11331
11520
  try {
@@ -11341,11 +11530,11 @@ var session$3 = {
11341
11530
  }
11342
11531
  }
11343
11532
  if (!userDocumentId) {
11344
- return ctx.throw(404, "User not found");
11533
+ return ctx.notFound("User not found");
11345
11534
  }
11346
11535
  const user = await strapi.documents(USER_UID).findOne({ documentId: userDocumentId });
11347
11536
  if (!user) {
11348
- return ctx.throw(404, "User not found");
11537
+ return ctx.notFound("User not found");
11349
11538
  }
11350
11539
  const newBlockedStatus = !user.blocked;
11351
11540
  await strapi.documents(USER_UID).update({
@@ -11354,7 +11543,7 @@ var session$3 = {
11354
11543
  });
11355
11544
  if (newBlockedStatus) {
11356
11545
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11357
- await sessionService.terminateSession({ userId: userDocumentId });
11546
+ await sessionService.terminateSession({ userId: userDocumentId, reason: "blocked" });
11358
11547
  }
11359
11548
  ctx.body = {
11360
11549
  message: `User ${newBlockedStatus ? "blocked" : "unblocked"} successfully`,
@@ -11846,13 +12035,13 @@ const { createLogger: createLogger$1 } = logger;
11846
12035
  const { parseUserAgent } = userAgentParser;
11847
12036
  const { resolveUserDocumentId: resolveUserDocumentId$1 } = resolveUser;
11848
12037
  const { enhanceSessions } = enhanceSession_1;
11849
- const { getPluginSettings: getPluginSettings$1 } = settingsLoader;
12038
+ const { getPluginSettings: getPluginSettings$2 } = settingsLoader;
11850
12039
  const SESSION_UID$1 = "plugin::magic-sessionmanager.session";
11851
12040
  const MAX_SESSIONS_QUERY = 1e3;
11852
12041
  var session$1 = ({ strapi: strapi2 }) => {
11853
12042
  const log = createLogger$1(strapi2);
11854
12043
  async function getEnhanceOpts() {
11855
- const settings2 = await getPluginSettings$1(strapi2);
12044
+ const settings2 = await getPluginSettings$2(strapi2);
11856
12045
  return {
11857
12046
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11858
12047
  geolocationService: strapi2.plugin("magic-sessionmanager").service("geolocation"),
@@ -11921,15 +12110,38 @@ var session$1 = ({ strapi: strapi2 }) => {
11921
12110
  }
11922
12111
  },
11923
12112
  /**
11924
- * Terminates a single session or all sessions of a user.
12113
+ * Terminates a single session or all sessions of a user with a typed
12114
+ * reason so the JWT-verify wrapper and downstream middleware can
12115
+ * communicate the cause to the client (and we can show sensible UI).
12116
+ *
12117
+ * Supported reasons:
12118
+ * - 'manual': user clicked logout, or admin terminated a session
12119
+ * - 'idle': inactivity timeout cleanup
12120
+ * - 'expired': maxSessionAgeDays exceeded
12121
+ * - 'blocked': the owning user was marked blocked
12122
+ *
12123
+ * For backwards compatibility `terminatedManually` is still set true
12124
+ * only when reason === 'manual'; idle/expired/blocked paths set it
12125
+ * false so reporting dashboards that queried that boolean continue
12126
+ * to work, while new code relies on `terminationReason`.
12127
+ *
11925
12128
  * @param {Object} params
11926
12129
  * @param {string} [params.sessionId]
11927
12130
  * @param {string|number} [params.userId]
12131
+ * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
11928
12132
  * @returns {Promise<void>}
11929
12133
  */
11930
- async terminateSession({ sessionId, userId }) {
12134
+ async terminateSession({ sessionId, userId, reason = "manual" }) {
11931
12135
  try {
11932
12136
  const now = /* @__PURE__ */ new Date();
12137
+ const validReasons = ["manual", "idle", "expired", "blocked"];
12138
+ const finalReason = validReasons.includes(reason) ? reason : "manual";
12139
+ const updateData = {
12140
+ isActive: false,
12141
+ terminatedManually: finalReason === "manual",
12142
+ terminationReason: finalReason,
12143
+ logoutTime: now
12144
+ };
11933
12145
  if (sessionId) {
11934
12146
  const existing = await strapi2.documents(SESSION_UID$1).findOne({
11935
12147
  documentId: sessionId,
@@ -11941,9 +12153,9 @@ var session$1 = ({ strapi: strapi2 }) => {
11941
12153
  }
11942
12154
  await strapi2.documents(SESSION_UID$1).update({
11943
12155
  documentId: sessionId,
11944
- data: { isActive: false, terminatedManually: true, logoutTime: now }
12156
+ data: updateData
11945
12157
  });
11946
- log.info(`Session ${sessionId} terminated (manual)`);
12158
+ log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
11947
12159
  } else if (userId) {
11948
12160
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
11949
12161
  if (!userDocumentId) return;
@@ -11955,10 +12167,10 @@ var session$1 = ({ strapi: strapi2 }) => {
11955
12167
  for (const session2 of activeSessions) {
11956
12168
  await strapi2.documents(SESSION_UID$1).update({
11957
12169
  documentId: session2.documentId,
11958
- data: { isActive: false, terminatedManually: true, logoutTime: now }
12170
+ data: updateData
11959
12171
  });
11960
12172
  }
11961
- log.info(`All sessions terminated (manual) for user ${userDocumentId}`);
12173
+ log.info(`All sessions terminated for user ${userDocumentId} (reason: ${finalReason})`);
11962
12174
  }
11963
12175
  } catch (err) {
11964
12176
  log.error("Error terminating session:", err);
@@ -12043,7 +12255,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12043
12255
  async touch({ userId, sessionId, token }) {
12044
12256
  try {
12045
12257
  const now = /* @__PURE__ */ new Date();
12046
- const settings2 = await getPluginSettings$1(strapi2);
12258
+ const settings2 = await getPluginSettings$2(strapi2);
12047
12259
  const rateLimit2 = settings2.lastSeenRateLimit || 3e4;
12048
12260
  let session2 = null;
12049
12261
  let sessionDocId = sessionId;
@@ -12112,7 +12324,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12112
12324
  */
12113
12325
  async cleanupInactiveSessions({ useDbDirect = false } = {}) {
12114
12326
  try {
12115
- const settings2 = await getPluginSettings$1(strapi2);
12327
+ const settings2 = await getPluginSettings$2(strapi2);
12116
12328
  const inactivityTimeout = settings2.inactivityTimeout || 15 * 60 * 1e3;
12117
12329
  const now = /* @__PURE__ */ new Date();
12118
12330
  const cutoffTime = new Date(now.getTime() - inactivityTimeout);
@@ -12125,7 +12337,8 @@ var session$1 = ({ strapi: strapi2 }) => {
12125
12337
  });
12126
12338
  }).update({
12127
12339
  is_active: false,
12128
- terminated_manually: true,
12340
+ terminated_manually: false,
12341
+ termination_reason: "idle",
12129
12342
  logout_time: now
12130
12343
  });
12131
12344
  log.info(`[SUCCESS] Cleanup (db-direct) complete: ${deactivated} sessions deactivated`);
@@ -12166,7 +12379,8 @@ var session$1 = ({ strapi: strapi2 }) => {
12166
12379
  documentId,
12167
12380
  data: {
12168
12381
  isActive: false,
12169
- terminatedManually: true,
12382
+ terminatedManually: false,
12383
+ terminationReason: "idle",
12170
12384
  logoutTime: now
12171
12385
  }
12172
12386
  });
@@ -12197,6 +12411,81 @@ var session$1 = ({ strapi: strapi2 }) => {
12197
12411
  throw err;
12198
12412
  }
12199
12413
  },
12414
+ /**
12415
+ * Permanently deletes sessions that have been inactive past the
12416
+ * configured retention window. Distinct from `deleteInactiveSessions`
12417
+ * (which deletes ALL inactive sessions) — this only drops rows older
12418
+ * than `retentionDays`, so recently-terminated sessions stay queryable
12419
+ * for audits.
12420
+ *
12421
+ * @param {Object} [options]
12422
+ * @param {number} [options.retentionDays] Overrides the stored setting.
12423
+ * @param {boolean} [options.useDbDirect] Fast-path via single SQL
12424
+ * DELETE. Bypasses lifecycle hooks; use only when necessary.
12425
+ * @returns {Promise<number>} Number of sessions deleted
12426
+ */
12427
+ async deleteOldSessions({ retentionDays, useDbDirect } = {}) {
12428
+ try {
12429
+ const settings2 = await getPluginSettings$2(strapi2);
12430
+ const effectiveDays = Number.isFinite(retentionDays) ? retentionDays : settings2.retentionDays || 90;
12431
+ if (effectiveDays === -1) {
12432
+ log.debug("[RETENTION] retentionDays=-1 (forever) — skipping");
12433
+ return 0;
12434
+ }
12435
+ const cutoffDate = new Date(Date.now() - effectiveDays * 24 * 60 * 60 * 1e3);
12436
+ const wantDbDirect = useDbDirect ?? settings2.cleanupUseDbDirect === true;
12437
+ log.info(`[RETENTION] Deleting inactive sessions older than ${effectiveDays} days (before ${cutoffDate.toISOString()})`);
12438
+ if (wantDbDirect) {
12439
+ try {
12440
+ const deleted = await strapi2.db.connection("magic_sessions").where("is_active", false).andWhere(function whereOldEnough() {
12441
+ this.where("logout_time", "<", cutoffDate).orWhere(function whereNullLogout() {
12442
+ this.whereNull("logout_time").andWhere(function whereOldByActivity() {
12443
+ this.where("last_active", "<", cutoffDate).orWhere(function whereNullActivity() {
12444
+ this.whereNull("last_active").andWhere("login_time", "<", cutoffDate);
12445
+ });
12446
+ });
12447
+ });
12448
+ }).del();
12449
+ log.info(`[SUCCESS] Retention (db-direct) deleted ${deleted} old session(s)`);
12450
+ return deleted;
12451
+ } catch (err) {
12452
+ log.warn("[RETENTION] DB-direct delete failed, falling back to Document Service:", err.message);
12453
+ }
12454
+ }
12455
+ let deletedCount = 0;
12456
+ const BATCH = 200;
12457
+ while (true) {
12458
+ const batch = await strapi2.documents(SESSION_UID$1).findMany({
12459
+ filters: {
12460
+ isActive: false,
12461
+ $or: [
12462
+ { logoutTime: { $lt: cutoffDate } },
12463
+ { logoutTime: { $null: true }, lastActive: { $lt: cutoffDate } },
12464
+ { logoutTime: { $null: true }, lastActive: { $null: true }, loginTime: { $lt: cutoffDate } }
12465
+ ]
12466
+ },
12467
+ fields: ["documentId"],
12468
+ sort: { loginTime: "asc" },
12469
+ limit: BATCH
12470
+ });
12471
+ if (!batch || batch.length === 0) break;
12472
+ for (const session2 of batch) {
12473
+ try {
12474
+ await strapi2.documents(SESSION_UID$1).delete({ documentId: session2.documentId });
12475
+ deletedCount++;
12476
+ } catch (err) {
12477
+ log.debug(`[RETENTION] Failed to delete session ${session2.documentId}:`, err.message);
12478
+ }
12479
+ }
12480
+ if (batch.length < BATCH) break;
12481
+ }
12482
+ log.info(`[SUCCESS] Retention deleted ${deletedCount} old session(s)`);
12483
+ return deletedCount;
12484
+ } catch (err) {
12485
+ log.error("Error in retention cleanup:", err);
12486
+ return 0;
12487
+ }
12488
+ },
12200
12489
  /**
12201
12490
  * Permanently deletes all inactive sessions.
12202
12491
  * Uses an inner scan loop that tolerates partial failures.
@@ -12242,7 +12531,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12242
12531
  }
12243
12532
  };
12244
12533
  };
12245
- const version$1 = "4.5.0";
12534
+ const version$1 = "4.5.2";
12246
12535
  const require$$2 = {
12247
12536
  version: version$1
12248
12537
  };
@@ -13200,6 +13489,7 @@ var services$1 = {
13200
13489
  geolocation,
13201
13490
  notifications
13202
13491
  };
13492
+ const { getPluginSettings: getPluginSettings$1 } = settingsLoader;
13203
13493
  const buckets = /* @__PURE__ */ new Map();
13204
13494
  const prune = (now) => {
13205
13495
  for (const [key, entry] of buckets) {
@@ -13213,10 +13503,45 @@ const callerKey = (ctx) => {
13213
13503
  if (tokenId) return `t:${String(tokenId).slice(-16)}`;
13214
13504
  return `ip:${ctx.request.ip || ctx.ip || "unknown"}`;
13215
13505
  };
13506
+ const RESOLVED_TTL_MS = 3e4;
13507
+ let resolvedCache = null;
13508
+ let resolvedAt = 0;
13509
+ async function resolveLimits({ profile, routeMax, routeWindowMs, strapi: strapi2 }) {
13510
+ const now = Date.now();
13511
+ if (resolvedCache && now - resolvedAt < RESOLVED_TTL_MS) {
13512
+ const p = resolvedCache[profile];
13513
+ if (p) {
13514
+ return { max: p.max, windowMs: p.windowMs };
13515
+ }
13516
+ }
13517
+ let settings2 = {};
13518
+ try {
13519
+ settings2 = await getPluginSettings$1(strapi2);
13520
+ } catch {
13521
+ settings2 = {};
13522
+ }
13523
+ const windowSec = Number.isFinite(settings2.rateLimitWindowSeconds) ? settings2.rateLimitWindowSeconds : Math.round(routeWindowMs / 1e3);
13524
+ const windowMs = Math.max(1e4, windowSec * 1e3);
13525
+ const resolvedWrite = {
13526
+ max: Math.min(routeMax, Number.isFinite(settings2.rateLimitWriteMax) ? settings2.rateLimitWriteMax : routeMax),
13527
+ windowMs
13528
+ };
13529
+ const resolvedRead = {
13530
+ max: Math.max(routeMax, Number.isFinite(settings2.rateLimitReadMax) ? settings2.rateLimitReadMax : routeMax),
13531
+ windowMs
13532
+ };
13533
+ resolvedCache = { read: resolvedRead, write: resolvedWrite };
13534
+ resolvedAt = now;
13535
+ if (profile === "read") return resolvedRead;
13536
+ if (profile === "write") return resolvedWrite;
13537
+ return { max: routeMax, windowMs };
13538
+ }
13216
13539
  const rateLimit = (cfg = {}, { strapi: strapi2 }) => {
13217
- const max = Number.isFinite(cfg.max) ? cfg.max : 30;
13218
- const windowMs = Number.isFinite(cfg.window) ? cfg.window : 6e4;
13540
+ const routeMax = Number.isFinite(cfg.max) ? cfg.max : 30;
13541
+ const routeWindowMs = Number.isFinite(cfg.window) ? cfg.window : 6e4;
13542
+ const profile = cfg.profile === "read" || cfg.profile === "write" ? cfg.profile : null;
13219
13543
  return async (ctx, next) => {
13544
+ const { max, windowMs } = profile ? await resolveLimits({ profile, routeMax, routeWindowMs, strapi: strapi2 }) : { max: routeMax, windowMs: routeWindowMs };
13220
13545
  const key = `${ctx.path}::${callerKey(ctx)}`;
13221
13546
  const now = Date.now();
13222
13547
  if (buckets.size > 5e3) prune(now);
@@ -13250,7 +13575,8 @@ const rateLimit = (cfg = {}, { strapi: strapi2 }) => {
13250
13575
  var rateLimit_1 = rateLimit;
13251
13576
  var middlewares$1 = {
13252
13577
  "last-seen": lastSeen,
13253
- "rate-limit": rateLimit_1
13578
+ "rate-limit": rateLimit_1,
13579
+ "session-rejection-headers": sessionRejectionHeaders
13254
13580
  };
13255
13581
  var lodashExports = requireLodash();
13256
13582
  const ___default = /* @__PURE__ */ getDefaultExportFromCjs(lodashExports);
@@ -51331,18 +51657,18 @@ const CSP_DEFAULTS = {
51331
51657
  "blob:"
51332
51658
  ]
51333
51659
  };
51334
- const extendMiddlewareConfiguration = (middlewares2, middleware) => {
51660
+ const extendMiddlewareConfiguration = (middlewares2, middleware2) => {
51335
51661
  return middlewares2.map((currentMiddleware) => {
51336
- if (typeof currentMiddleware === "string" && currentMiddleware === middleware.name) {
51337
- return middleware;
51662
+ if (typeof currentMiddleware === "string" && currentMiddleware === middleware2.name) {
51663
+ return middleware2;
51338
51664
  }
51339
- if (typeof currentMiddleware === "object" && currentMiddleware.name === middleware.name) {
51665
+ if (typeof currentMiddleware === "object" && currentMiddleware.name === middleware2.name) {
51340
51666
  return fp.mergeWith((objValue, srcValue) => {
51341
51667
  if (Array.isArray(objValue)) {
51342
51668
  return Array.from(new Set(objValue.concat(srcValue)));
51343
51669
  }
51344
51670
  return void 0;
51345
- }, currentMiddleware, middleware);
51671
+ }, currentMiddleware, middleware2);
51346
51672
  }
51347
51673
  return currentMiddleware;
51348
51674
  });