strapi-plugin-magic-sessionmanager 4.5.2 → 4.5.4

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();
566
+ }
567
+ if (!ctx.state.user) {
568
+ return next();
482
569
  }
483
- if (ctx.state.user) {
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) {
589
+ try {
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();
533
605
  try {
534
- await sessionService.touch({ userId: userDocId, sessionId });
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;
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.");
538
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([
@@ -9576,7 +9675,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9576
9675
  }
9577
9676
  log.info("Running initial session cleanup...");
9578
9677
  try {
9579
- const settings2 = await getPluginSettings$3(strapi2);
9678
+ const settings2 = await getPluginSettings$4(strapi2);
9580
9679
  await sessionService.cleanupInactiveSessions({
9581
9680
  useDbDirect: settings2.cleanupUseDbDirect === true
9582
9681
  });
@@ -9587,7 +9686,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9587
9686
  let intervalMs = 30 * 60 * 1e3;
9588
9687
  let useDbDirect = false;
9589
9688
  try {
9590
- const settings2 = await getPluginSettings$3(strapi2);
9689
+ const settings2 = await getPluginSettings$4(strapi2);
9591
9690
  intervalMs = Math.max(5 * 60 * 1e3, settings2.cleanupInterval || intervalMs);
9592
9691
  useDbDirect = settings2.cleanupUseDbDirect === true;
9593
9692
  } catch {
@@ -9608,7 +9707,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9608
9707
  const scheduleRetention = async () => {
9609
9708
  let useDbDirect = false;
9610
9709
  try {
9611
- const settings2 = await getPluginSettings$3(strapi2);
9710
+ const settings2 = await getPluginSettings$4(strapi2);
9612
9711
  useDbDirect = settings2.cleanupUseDbDirect === true;
9613
9712
  } catch {
9614
9713
  }
@@ -9632,6 +9731,10 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9632
9731
  mountLogoutRoute({ strapi: strapi2, log, sessionService });
9633
9732
  mountLoginInterceptor({ strapi: strapi2, log, sessionService });
9634
9733
  mountRefreshTokenInterceptor({ strapi: strapi2, log });
9734
+ strapi2.server.use(
9735
+ sessionRejectionHeaders({}, { strapi: strapi2 })
9736
+ );
9737
+ log.info("[SUCCESS] Session-rejection-headers middleware mounted");
9635
9738
  strapi2.server.use(
9636
9739
  lastSeen({ strapi: strapi2, sessionService })
9637
9740
  );
@@ -9650,7 +9753,7 @@ function mountPreLoginGeoGuard({ strapi: strapi2, log }) {
9650
9753
  }
9651
9754
  let settings2 = {};
9652
9755
  try {
9653
- settings2 = await getPluginSettings$3(strapi2);
9756
+ settings2 = await getPluginSettings$4(strapi2);
9654
9757
  } catch {
9655
9758
  settings2 = {};
9656
9759
  }
@@ -9826,7 +9929,7 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9826
9929
  if (!isLoginPath(ctx.path, ctx.method)) return next();
9827
9930
  let maxFailed = 0;
9828
9931
  try {
9829
- const settings2 = await getPluginSettings$3(strapi2);
9932
+ const settings2 = await getPluginSettings$4(strapi2);
9830
9933
  maxFailed = Number(settings2.maxFailedLogins) || 0;
9831
9934
  } catch {
9832
9935
  maxFailed = 0;
@@ -9865,17 +9968,28 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9865
9968
  });
9866
9969
  log.info("[SUCCESS] Failed-login lockout middleware mounted");
9867
9970
  }
9971
+ function isJwtIssuingPath(path2, method) {
9972
+ if (!path2) return false;
9973
+ const get2 = method === "GET";
9974
+ const post = method === "POST";
9975
+ if (post && path2 === "/api/auth/local") return true;
9976
+ if (post && path2 === "/api/auth/local/register") return true;
9977
+ if (post && path2 === "/api/auth/reset-password") return true;
9978
+ if ((get2 || post) && path2 === "/api/auth/email-confirmation") return true;
9979
+ if (get2 && /^\/api\/auth\/[a-z0-9-]+\/callback$/i.test(path2)) return true;
9980
+ if ((get2 || post) && path2.startsWith("/api/magic-link/login")) return true;
9981
+ if (post && path2 === "/api/magic-link/verify-mfa-totp") return true;
9982
+ if (post && path2 === "/api/magic-link/otp/verify") return true;
9983
+ if (post && path2 === "/api/magic-link/login-totp") return true;
9984
+ if ((get2 || post) && path2.startsWith("/api/passwordless/")) return true;
9985
+ return false;
9986
+ }
9868
9987
  function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9869
9988
  strapi2.server.use(async (ctx, next) => {
9870
9989
  await next();
9871
- const isAuthLocal = ctx.path === "/api/auth/local" && ctx.method === "POST";
9872
- const isMagicLinkLogin = ctx.path.includes("/magic-link/login") && (ctx.method === "GET" || ctx.method === "POST");
9873
- const isMagicLinkMFA = ctx.path.includes("/magic-link/verify-mfa-totp") && ctx.method === "POST";
9874
- const isMagicLinkOTP = ctx.path.includes("/magic-link/otp/verify") && ctx.method === "POST";
9875
- const isMagicLink = isMagicLinkLogin || isMagicLinkMFA || isMagicLinkOTP;
9876
- if (!((isAuthLocal || isMagicLink) && ctx.status === 200 && ctx.body && ctx.body.jwt && ctx.body.user)) {
9877
- return;
9878
- }
9990
+ if (!isJwtIssuingPath(ctx.path, ctx.method)) return;
9991
+ if (ctx.status !== 200) return;
9992
+ if (!ctx.body || !ctx.body.jwt || !ctx.body.user) return;
9879
9993
  try {
9880
9994
  const user = ctx.body.user;
9881
9995
  const ip = getClientIp(ctx);
@@ -9905,7 +10019,7 @@ function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9905
10019
  }
9906
10020
  log.info(`[SUCCESS] Session ${newSession.documentId} created for user ${userDocId} (IP: ${ip})`);
9907
10021
  try {
9908
- const settings2 = await getPluginSettings$3(strapi2);
10022
+ const settings2 = await getPluginSettings$4(strapi2);
9909
10023
  if (!geoData || !(settings2.enableEmailAlerts || settings2.enableWebhooks)) {
9910
10024
  return;
9911
10025
  }
@@ -10076,11 +10190,19 @@ function mountRefreshTokenInterceptor({ strapi: strapi2, log }) {
10076
10190
  log.info("[SUCCESS] Refresh token interceptor middleware mounted");
10077
10191
  }
10078
10192
  async function ensureContentApiPermissions(strapi2, log) {
10193
+ const PERMISSIONS_VERSION = 2;
10079
10194
  try {
10080
10195
  const pluginStore = strapi2.store({ type: "plugin", name: "magic-sessionmanager" });
10081
- const alreadyInitialized = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10082
- if (alreadyInitialized === true) {
10083
- log.debug("Content-API permissions already initialized (skipping auto-setup)");
10196
+ const storedVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10197
+ if (storedVersion === void 0) {
10198
+ const legacyFlag = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10199
+ if (legacyFlag === true) {
10200
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: 1 });
10201
+ }
10202
+ }
10203
+ const effectiveVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10204
+ if (effectiveVersion >= PERMISSIONS_VERSION) {
10205
+ log.debug("Content-API permissions already at current version (skipping auto-setup)");
10084
10206
  return;
10085
10207
  }
10086
10208
  const ROLE_UID = "plugin::users-permissions.role";
@@ -10098,6 +10220,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10098
10220
  const requiredActions = [
10099
10221
  "plugin::magic-sessionmanager.session.logout",
10100
10222
  "plugin::magic-sessionmanager.session.logoutAll",
10223
+ "plugin::magic-sessionmanager.session.logoutOthers",
10101
10224
  "plugin::magic-sessionmanager.session.getOwnSessions",
10102
10225
  "plugin::magic-sessionmanager.session.getUserSessions",
10103
10226
  "plugin::magic-sessionmanager.session.getCurrentSession",
@@ -10114,7 +10237,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10114
10237
  const existingActions = existingPermissions.map((p) => p.action);
10115
10238
  const missingActions = requiredActions.filter((action) => !existingActions.includes(action));
10116
10239
  if (missingActions.length === 0) {
10117
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10240
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10118
10241
  log.debug("Content-API permissions already configured");
10119
10242
  return;
10120
10243
  }
@@ -10124,7 +10247,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10124
10247
  });
10125
10248
  log.info(`[PERMISSION] Enabled ${action} for authenticated users`);
10126
10249
  }
10127
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10250
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10128
10251
  log.info("[SUCCESS] Content-API permissions configured for authenticated users");
10129
10252
  } catch (err) {
10130
10253
  log.warn("Could not auto-configure permissions:", err.message);
@@ -10222,7 +10345,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10222
10345
  }
10223
10346
  let settings2;
10224
10347
  try {
10225
- settings2 = await getPluginSettings$3(strapi2);
10348
+ settings2 = await getPluginSettings$4(strapi2);
10226
10349
  } catch {
10227
10350
  settings2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
10228
10351
  }
@@ -10247,6 +10370,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10247
10370
  strapi2.log.info(
10248
10371
  `[magic-sessionmanager] [JWT-BLOCKED] User is blocked (user: ${userDocId.substring(0, 8)}...)`
10249
10372
  );
10373
+ setSessionRejectionReason(hashToken$3(token), "blocked");
10250
10374
  return null;
10251
10375
  }
10252
10376
  } catch {
@@ -10257,7 +10381,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10257
10381
  user: { documentId: userDocId },
10258
10382
  tokenHash: tokenHashValue
10259
10383
  },
10260
- fields: ["documentId", "isActive", "terminatedManually", "lastActive", "loginTime"]
10384
+ fields: ["documentId", "isActive", "terminatedManually", "terminationReason", "lastActive", "loginTime"]
10261
10385
  });
10262
10386
  if (thisSession) {
10263
10387
  if (isSessionExpired(thisSession, maxSessionAgeDays)) {
@@ -10266,15 +10390,25 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10266
10390
  );
10267
10391
  await strapi2.documents(SESSION_UID$4).update({
10268
10392
  documentId: thisSession.documentId,
10269
- data: { isActive: false, terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
10393
+ data: {
10394
+ isActive: false,
10395
+ terminatedManually: false,
10396
+ terminationReason: "expired",
10397
+ logoutTime: /* @__PURE__ */ new Date()
10398
+ }
10270
10399
  });
10400
+ setSessionRejectionReason(tokenHashValue, "expired");
10271
10401
  return null;
10272
10402
  }
10273
- if (thisSession.terminatedManually === true) {
10274
- strapi2.log.info(
10275
- `[magic-sessionmanager] [JWT-BLOCKED] Session was manually terminated (user: ${userDocId.substring(0, 8)}...)`
10276
- );
10277
- return null;
10403
+ if (thisSession.isActive === false) {
10404
+ const reason = thisSession.terminationReason || (thisSession.terminatedManually === true ? "manual" : null);
10405
+ if (reason) {
10406
+ strapi2.log.info(
10407
+ `[magic-sessionmanager] [JWT-REJECTED] Session inactive (reason: ${reason}) for user ${userDocId.substring(0, 8)}...`
10408
+ );
10409
+ setSessionRejectionReason(tokenHashValue, reason);
10410
+ return null;
10411
+ }
10278
10412
  }
10279
10413
  if (thisSession.isActive) {
10280
10414
  resetErrorCounter();
@@ -10289,8 +10423,13 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10289
10423
  );
10290
10424
  await strapi2.documents(SESSION_UID$4).update({
10291
10425
  documentId: thisSession.documentId,
10292
- data: { terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
10426
+ data: {
10427
+ terminatedManually: false,
10428
+ terminationReason: "idle",
10429
+ logoutTime: /* @__PURE__ */ new Date()
10430
+ }
10293
10431
  });
10432
+ setSessionRejectionReason(tokenHashValue, "idle");
10294
10433
  return null;
10295
10434
  }
10296
10435
  await strapi2.documents(SESSION_UID$4).update({
@@ -10492,6 +10631,16 @@ const attributes = {
10492
10631
  "default": false,
10493
10632
  required: false
10494
10633
  },
10634
+ terminationReason: {
10635
+ type: "enumeration",
10636
+ "enum": [
10637
+ "manual",
10638
+ "idle",
10639
+ "expired",
10640
+ "blocked"
10641
+ ],
10642
+ required: false
10643
+ },
10495
10644
  geoLocation: {
10496
10645
  type: "json"
10497
10646
  },
@@ -10525,10 +10674,16 @@ var contentTypes$2 = {
10525
10674
  }
10526
10675
  };
10527
10676
  const writeRateLimit = [
10528
- { name: "plugin::magic-sessionmanager.rate-limit", config: { max: 10, window: 6e4 } }
10677
+ {
10678
+ name: "plugin::magic-sessionmanager.rate-limit",
10679
+ config: { profile: "write", max: 10, window: 6e4 }
10680
+ }
10529
10681
  ];
10530
10682
  const readRateLimit = [
10531
- { name: "plugin::magic-sessionmanager.rate-limit", config: { max: 120, window: 6e4 } }
10683
+ {
10684
+ name: "plugin::magic-sessionmanager.rate-limit",
10685
+ config: { profile: "read", max: 120, window: 6e4 }
10686
+ }
10532
10687
  ];
10533
10688
  var contentApi$1 = {
10534
10689
  type: "content-api",
@@ -10551,7 +10706,17 @@ var contentApi$1 = {
10551
10706
  config: {
10552
10707
  auth: { strategies: ["users-permissions"] },
10553
10708
  middlewares: writeRateLimit,
10554
- description: "Logout from all devices (requires JWT)"
10709
+ description: "Logout from ALL devices including the current one (requires JWT)"
10710
+ }
10711
+ },
10712
+ {
10713
+ method: "POST",
10714
+ path: "/logout-others",
10715
+ handler: "session.logoutOthers",
10716
+ config: {
10717
+ auth: { strategies: ["users-permissions"] },
10718
+ middlewares: writeRateLimit,
10719
+ description: "Logout from all OTHER devices, keep current session alive (requires JWT)"
10555
10720
  }
10556
10721
  },
10557
10722
  // ================== SESSION QUERIES ==================
@@ -10931,15 +11096,22 @@ var enhanceSession_1 = { enhanceSession: enhanceSession$1, enhanceSessions: enha
10931
11096
  const { hashToken: hashToken$2 } = encryption;
10932
11097
  const { enhanceSessions: enhanceSessions$1, enhanceSession } = enhanceSession_1;
10933
11098
  const { resolveUserDocumentId: resolveUserDocumentId$2 } = resolveUser;
10934
- const { getPluginSettings: getPluginSettings$2 } = settingsLoader;
11099
+ const { getPluginSettings: getPluginSettings$3 } = settingsLoader;
10935
11100
  const { extractBearerToken: extractBearerToken$1 } = extractToken;
10936
11101
  const SESSION_UID$2 = "plugin::magic-sessionmanager.session";
10937
11102
  const USER_UID = "plugin::users-permissions.user";
11103
+ const OWN_SESSIONS_LIMIT = 200;
11104
+ async function resolveAuthUserDocId(ctx) {
11105
+ const u2 = ctx.state.user;
11106
+ if (!u2) return null;
11107
+ if (u2.documentId) return u2.documentId;
11108
+ if (u2.id) return resolveUserDocumentId$2(strapi, u2.id);
11109
+ return null;
11110
+ }
10938
11111
  var session$3 = {
10939
11112
  /**
10940
11113
  * Lists all sessions (active + inactive) for admin overviews.
10941
11114
  * @route GET /magic-sessionmanager/sessions
10942
- * @returns {object} `{ data, meta }`
10943
11115
  */
10944
11116
  async getAllSessionsAdmin(ctx) {
10945
11117
  try {
@@ -10961,7 +11133,6 @@ var session$3 = {
10961
11133
  /**
10962
11134
  * Lists currently-active sessions only.
10963
11135
  * @route GET /magic-sessionmanager/sessions/active
10964
- * @returns {object} `{ data, meta }`
10965
11136
  */
10966
11137
  async getActiveSessions(ctx) {
10967
11138
  try {
@@ -10977,35 +11148,33 @@ var session$3 = {
10977
11148
  }
10978
11149
  },
10979
11150
  /**
10980
- * Returns the authenticated user's own sessions, with the current session
10981
- * flagged via `isCurrentSession`.
10982
- *
11151
+ * Returns the authenticated user's own sessions, current session flagged.
10983
11152
  * @route GET /api/magic-sessionmanager/my-sessions
10984
- * @returns {object} `{ data, meta }`
10985
- * @throws {UnauthorizedError} When user is not authenticated
10986
11153
  */
10987
11154
  async getOwnSessions(ctx) {
10988
11155
  try {
10989
- const userId = ctx.state.user?.documentId;
11156
+ const userDocId = await resolveAuthUserDocId(ctx);
11157
+ if (!userDocId) {
11158
+ return ctx.unauthorized("Authentication required");
11159
+ }
10990
11160
  const currentToken = extractBearerToken$1(ctx);
10991
11161
  const currentTokenHash = currentToken ? hashToken$2(currentToken) : null;
10992
- if (!userId) {
10993
- return ctx.throw(401, "Unauthorized");
10994
- }
10995
11162
  const allSessions = await strapi.documents(SESSION_UID$2).findMany({
10996
- filters: { user: { documentId: userId } },
11163
+ filters: { user: { documentId: userDocId } },
10997
11164
  sort: { loginTime: "desc" },
10998
- limit: 200
11165
+ limit: OWN_SESSIONS_LIMIT + 1
10999
11166
  });
11000
- const settings2 = await getPluginSettings$2(strapi);
11167
+ const hasMore = allSessions.length > OWN_SESSIONS_LIMIT;
11168
+ const paged = hasMore ? allSessions.slice(0, OWN_SESSIONS_LIMIT) : allSessions;
11169
+ const settings2 = await getPluginSettings$3(strapi);
11001
11170
  const enhanceOpts = {
11002
11171
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11003
11172
  geolocationService: strapi.plugin("magic-sessionmanager").service("geolocation"),
11004
11173
  strapi
11005
11174
  };
11006
- const sessionsWithCurrent = await enhanceSessions$1(allSessions, enhanceOpts, 20);
11175
+ const sessionsWithCurrent = await enhanceSessions$1(paged, enhanceOpts, 20);
11007
11176
  for (const s3 of sessionsWithCurrent) {
11008
- s3.isCurrentSession = !!(currentTokenHash && allSessions.find(
11177
+ s3.isCurrentSession = !!(currentTokenHash && paged.find(
11009
11178
  (raw) => raw.documentId === s3.documentId && raw.tokenHash === currentTokenHash
11010
11179
  ));
11011
11180
  }
@@ -11018,7 +11187,9 @@ var session$3 = {
11018
11187
  data: sessionsWithCurrent,
11019
11188
  meta: {
11020
11189
  count: sessionsWithCurrent.length,
11021
- active: sessionsWithCurrent.filter((s3) => s3.isTrulyActive).length
11190
+ active: sessionsWithCurrent.filter((s3) => s3.isTrulyActive).length,
11191
+ hasMore,
11192
+ limit: OWN_SESSIONS_LIMIT
11022
11193
  }
11023
11194
  };
11024
11195
  } catch (err) {
@@ -11027,18 +11198,14 @@ var session$3 = {
11027
11198
  }
11028
11199
  },
11029
11200
  /**
11030
- * Get a specific user's sessions. Admins may query any user; Content-API
11201
+ * Get a specific user's sessions. Admins can query any user; content-api
11031
11202
  * users can only query themselves.
11032
- *
11033
- * @route GET /magic-sessionmanager/user/:userId/sessions (admin)
11034
- * @route GET /api/magic-sessionmanager/user/:userId/sessions (content-api)
11035
- * @throws {ForbiddenError} When a non-admin requests another user's sessions
11036
11203
  */
11037
11204
  async getUserSessions(ctx) {
11038
11205
  try {
11039
11206
  const { userId } = ctx.params;
11040
11207
  const isAdminRequest = !!(ctx.state.userAbility || ctx.state.admin);
11041
- const requestingUserDocId = ctx.state.user?.documentId;
11208
+ const requestingUserDocId = await resolveAuthUserDocId(ctx);
11042
11209
  if (!isAdminRequest) {
11043
11210
  if (!requestingUserDocId) {
11044
11211
  strapi.log.warn(`[magic-sessionmanager] Security: Request without documentId tried to access sessions of user ${userId}`);
@@ -11067,73 +11234,177 @@ var session$3 = {
11067
11234
  */
11068
11235
  async logout(ctx) {
11069
11236
  try {
11070
- const userId = ctx.state.user?.documentId;
11237
+ const userDocId = await resolveAuthUserDocId(ctx);
11071
11238
  const token = extractBearerToken$1(ctx);
11072
- if (!userId || !token) {
11073
- return ctx.throw(401, "Unauthorized");
11239
+ if (!userDocId || !token) {
11240
+ return ctx.unauthorized("Authentication required");
11074
11241
  }
11075
11242
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11076
11243
  const currentTokenHash = hashToken$2(token);
11077
11244
  const matchingSession = await strapi.documents(SESSION_UID$2).findFirst({
11078
11245
  filters: {
11079
- user: { documentId: userId },
11246
+ user: { documentId: userDocId },
11080
11247
  tokenHash: currentTokenHash,
11081
11248
  isActive: true
11082
11249
  },
11083
11250
  fields: ["documentId"]
11084
11251
  });
11252
+ let terminated = false;
11085
11253
  if (matchingSession) {
11086
- await sessionService.terminateSession({ sessionId: matchingSession.documentId });
11087
- strapi.log.info(`[magic-sessionmanager] User ${userId} logged out (session ${matchingSession.documentId})`);
11254
+ await sessionService.terminateSession({
11255
+ sessionId: matchingSession.documentId,
11256
+ reason: "manual"
11257
+ });
11258
+ terminated = true;
11259
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} logged out (session ${matchingSession.documentId})`);
11088
11260
  }
11089
- ctx.body = { message: "Logged out successfully" };
11261
+ ctx.body = {
11262
+ message: "Logged out successfully",
11263
+ terminated
11264
+ };
11090
11265
  } catch (err) {
11091
11266
  strapi.log.error("[magic-sessionmanager] Logout error:", err);
11092
11267
  ctx.throw(500, "Error during logout");
11093
11268
  }
11094
11269
  },
11095
11270
  /**
11096
- * Terminates all sessions of the authenticated user.
11271
+ * Terminates EVERY session of the authenticated user — including the
11272
+ * current one. After this call the caller will also be logged out on
11273
+ * the next request (their own JWT is rejected by the JWT-verify wrapper
11274
+ * with reason=manual).
11275
+ *
11276
+ * For the "log me out everywhere ELSE but keep me here" flow, use
11277
+ * `/logout-others` below.
11278
+ *
11097
11279
  * @route POST /api/magic-sessionmanager/logout-all
11098
11280
  */
11099
11281
  async logoutAll(ctx) {
11100
11282
  try {
11101
- const userId = ctx.state.user?.documentId;
11102
- if (!userId) {
11103
- return ctx.throw(401, "Unauthorized");
11283
+ const userDocId = await resolveAuthUserDocId(ctx);
11284
+ if (!userDocId) {
11285
+ return ctx.unauthorized("Authentication required");
11104
11286
  }
11105
11287
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11106
- await sessionService.terminateSession({ userId });
11107
- strapi.log.info(`[magic-sessionmanager] User ${userId} logged out from all devices`);
11108
- ctx.body = { message: "Logged out from all devices successfully" };
11288
+ const { terminatedCount } = await sessionService.terminateSession({
11289
+ userId: userDocId,
11290
+ reason: "manual"
11291
+ });
11292
+ strapi.log.info(
11293
+ `[magic-sessionmanager] User ${userDocId} logged out from all devices (${terminatedCount})`
11294
+ );
11295
+ ctx.body = {
11296
+ message: "Logged out from all devices successfully",
11297
+ terminatedCount
11298
+ };
11109
11299
  } catch (err) {
11110
11300
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
11111
11301
  ctx.throw(500, "Error during logout");
11112
11302
  }
11113
11303
  },
11304
+ /**
11305
+ * Terminates every session of the authenticated user EXCEPT the current
11306
+ * one. This is the "kick everyone else off my account" flow — the
11307
+ * caller stays logged in on the device they are using.
11308
+ *
11309
+ * If no "current session" record can be located for the caller's JWT
11310
+ * (edge case: user logged in before the session-manager was installed,
11311
+ * or right at the end of the grace window) we fall back to terminating
11312
+ * ALL sessions so the user still gets the safety effect they asked for,
11313
+ * and we report `fellBackToLogoutAll: true` so the client can adjust
11314
+ * its success message.
11315
+ *
11316
+ * @route POST /api/magic-sessionmanager/logout-others
11317
+ */
11318
+ async logoutOthers(ctx) {
11319
+ try {
11320
+ const userDocId = await resolveAuthUserDocId(ctx);
11321
+ const token = extractBearerToken$1(ctx);
11322
+ if (!userDocId || !token) {
11323
+ return ctx.unauthorized("Authentication required");
11324
+ }
11325
+ const currentTokenHash = hashToken$2(token);
11326
+ const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11327
+ filters: { user: { documentId: userDocId }, tokenHash: currentTokenHash },
11328
+ fields: ["documentId"]
11329
+ });
11330
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11331
+ if (!currentSession) {
11332
+ const { terminatedCount: terminatedCount2 } = await sessionService.terminateSession({
11333
+ userId: userDocId,
11334
+ reason: "manual"
11335
+ });
11336
+ strapi.log.warn(
11337
+ `[magic-sessionmanager] logoutOthers fell back to logoutAll for user ${userDocId} (no current session match)`
11338
+ );
11339
+ ctx.body = {
11340
+ message: "All sessions terminated (could not preserve current session)",
11341
+ terminatedCount: terminatedCount2,
11342
+ fellBackToLogoutAll: true
11343
+ };
11344
+ return;
11345
+ }
11346
+ const { terminatedCount } = await sessionService.terminateSession({
11347
+ userId: userDocId,
11348
+ exceptSessionId: currentSession.documentId,
11349
+ reason: "manual"
11350
+ });
11351
+ strapi.log.info(
11352
+ `[magic-sessionmanager] User ${userDocId} logged out ${terminatedCount} other device(s)`
11353
+ );
11354
+ ctx.body = {
11355
+ message: terminatedCount === 0 ? "No other active sessions to terminate" : `${terminatedCount} other session(s) terminated`,
11356
+ terminatedCount,
11357
+ currentSessionPreserved: true
11358
+ };
11359
+ } catch (err) {
11360
+ strapi.log.error("[magic-sessionmanager] Logout-others error:", err);
11361
+ ctx.throw(500, "Error terminating other sessions");
11362
+ }
11363
+ },
11114
11364
  /**
11115
11365
  * Returns the session associated with the current JWT.
11366
+ *
11367
+ * During the post-login grace window the session-create write may not
11368
+ * yet be visible. In that case we return 202 Accepted with
11369
+ * `{ pending: true }` so the client knows to retry shortly instead of
11370
+ * interpreting a 404 as "no session at all".
11371
+ *
11116
11372
  * @route GET /api/magic-sessionmanager/current-session
11117
11373
  */
11118
11374
  async getCurrentSession(ctx) {
11119
11375
  try {
11120
- const userId = ctx.state.user?.documentId;
11376
+ const userDocId = await resolveAuthUserDocId(ctx);
11121
11377
  const token = extractBearerToken$1(ctx);
11122
- if (!userId || !token) {
11123
- return ctx.throw(401, "Unauthorized");
11378
+ if (!userDocId || !token) {
11379
+ return ctx.unauthorized("Authentication required");
11124
11380
  }
11125
11381
  const currentTokenHash = hashToken$2(token);
11126
11382
  const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11127
11383
  filters: {
11128
- user: { documentId: userId },
11384
+ user: { documentId: userDocId },
11129
11385
  tokenHash: currentTokenHash,
11130
11386
  isActive: true
11131
11387
  }
11132
11388
  });
11133
11389
  if (!currentSession) {
11390
+ const settings3 = await getPluginSettings$3(strapi);
11391
+ const gracePeriodMs = Math.max(0, Number(settings3.sessionCreationGraceMs) || 5e3);
11392
+ const iat = ctx.state.user?.iat || ctx.state.auth?.credentials?.iat || null;
11393
+ if (gracePeriodMs > 0 && typeof iat === "number") {
11394
+ const ageMs = Date.now() - iat * 1e3;
11395
+ if (ageMs >= 0 && ageMs < gracePeriodMs) {
11396
+ ctx.status = 202;
11397
+ ctx.body = {
11398
+ data: null,
11399
+ meta: { pending: true, retryAfterMs: gracePeriodMs - ageMs },
11400
+ message: "Session is still being created — please retry shortly."
11401
+ };
11402
+ return;
11403
+ }
11404
+ }
11134
11405
  return ctx.notFound("Current session not found");
11135
11406
  }
11136
- const settings2 = await getPluginSettings$2(strapi);
11407
+ const settings2 = await getPluginSettings$3(strapi);
11137
11408
  const enhanced = await enhanceSession(currentSession, {
11138
11409
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11139
11410
  geolocationService: strapi.plugin("magic-sessionmanager").service("geolocation"),
@@ -11149,17 +11420,17 @@ var session$3 = {
11149
11420
  }
11150
11421
  },
11151
11422
  /**
11152
- * Terminates one of the authenticated user's OWN sessions (not the current one).
11423
+ * Terminates one of the authenticated user's OWN sessions (not current).
11153
11424
  * @route DELETE /api/magic-sessionmanager/my-sessions/:sessionId
11154
11425
  */
11155
11426
  async terminateOwnSession(ctx) {
11156
11427
  try {
11157
- const userId = ctx.state.user?.documentId;
11428
+ const userDocId = await resolveAuthUserDocId(ctx);
11158
11429
  const { sessionId } = ctx.params;
11159
11430
  const currentToken = extractBearerToken$1(ctx);
11160
11431
  const currentTokenHash = currentToken ? hashToken$2(currentToken) : null;
11161
- if (!userId) {
11162
- return ctx.throw(401, "Unauthorized");
11432
+ if (!userDocId) {
11433
+ return ctx.unauthorized("Authentication required");
11163
11434
  }
11164
11435
  if (!sessionId) {
11165
11436
  return ctx.badRequest("Session ID is required");
@@ -11172,19 +11443,23 @@ var session$3 = {
11172
11443
  return ctx.notFound("Session not found");
11173
11444
  }
11174
11445
  const sessionUserId = sessionToTerminate.user?.documentId;
11175
- if (sessionUserId !== userId) {
11176
- strapi.log.warn(`[magic-sessionmanager] Security: User ${userId} tried to terminate session ${sessionId} of user ${sessionUserId}`);
11446
+ if (sessionUserId !== userDocId) {
11447
+ strapi.log.warn(`[magic-sessionmanager] Security: User ${userDocId} tried to terminate session ${sessionId} of user ${sessionUserId}`);
11177
11448
  return ctx.forbidden("You can only terminate your own sessions");
11178
11449
  }
11179
11450
  if (currentTokenHash && sessionToTerminate.tokenHash === currentTokenHash) {
11180
- return ctx.badRequest("Cannot terminate current session. Use /logout instead.");
11451
+ return ctx.badRequest("Cannot terminate the current session. Use /logout instead.");
11452
+ }
11453
+ const alreadyTerminated = sessionToTerminate.isActive === false;
11454
+ if (!alreadyTerminated) {
11455
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11456
+ await sessionService.terminateSession({ sessionId, reason: "manual" });
11457
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} terminated own session ${sessionId}`);
11181
11458
  }
11182
- const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11183
- await sessionService.terminateSession({ sessionId });
11184
- strapi.log.info(`[magic-sessionmanager] User ${userId} terminated own session ${sessionId}`);
11185
11459
  ctx.body = {
11186
- message: `Session ${sessionId} terminated successfully`,
11187
- success: true
11460
+ message: alreadyTerminated ? `Session ${sessionId} was already terminated` : `Session ${sessionId} terminated successfully`,
11461
+ success: true,
11462
+ alreadyTerminated
11188
11463
  };
11189
11464
  } catch (err) {
11190
11465
  strapi.log.error("[magic-sessionmanager] Error terminating own session:", err);
@@ -11192,9 +11467,7 @@ var session$3 = {
11192
11467
  }
11193
11468
  },
11194
11469
  /**
11195
- * Sets isActive:false + terminatedManually:false on a session, simulating
11196
- * a cleanup timeout. Available only outside of production/staging.
11197
- *
11470
+ * Simulates an inactivity timeout on a session. Dev-only.
11198
11471
  * @route POST /magic-sessionmanager/sessions/:sessionId/simulate-timeout
11199
11472
  */
11200
11473
  async simulateTimeout(ctx) {
@@ -11213,13 +11486,16 @@ var session$3 = {
11213
11486
  }
11214
11487
  await strapi.documents(SESSION_UID$2).update({
11215
11488
  documentId: sessionId,
11216
- data: { isActive: false, terminatedManually: false }
11489
+ data: {
11490
+ isActive: false,
11491
+ terminatedManually: false,
11492
+ terminationReason: "idle"
11493
+ }
11217
11494
  });
11218
- strapi.log.info(`[magic-sessionmanager] [TEST] Session ${sessionId} simulated timeout (terminatedManually: false)`);
11495
+ strapi.log.info(`[magic-sessionmanager] [TEST] Session ${sessionId} simulated timeout`);
11219
11496
  ctx.body = {
11220
- message: `Session ${sessionId} marked as timed out (reactivatable)`,
11221
- success: true,
11222
- terminatedManually: false
11497
+ message: `Session ${sessionId} marked as timed out`,
11498
+ success: true
11223
11499
  };
11224
11500
  } catch (err) {
11225
11501
  strapi.log.error("[magic-sessionmanager] Error simulating timeout:", err);
@@ -11228,13 +11504,12 @@ var session$3 = {
11228
11504
  },
11229
11505
  /**
11230
11506
  * Terminates a specific session (admin action).
11231
- * @route POST /magic-sessionmanager/sessions/:sessionId/terminate
11232
11507
  */
11233
11508
  async terminateSingleSession(ctx) {
11234
11509
  try {
11235
11510
  const { sessionId } = ctx.params;
11236
11511
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11237
- await sessionService.terminateSession({ sessionId });
11512
+ await sessionService.terminateSession({ sessionId, reason: "manual" });
11238
11513
  ctx.body = {
11239
11514
  message: `Session ${sessionId} terminated`,
11240
11515
  success: true
@@ -11246,13 +11521,12 @@ var session$3 = {
11246
11521
  },
11247
11522
  /**
11248
11523
  * Terminates ALL sessions for a specific user (admin action).
11249
- * @route POST /magic-sessionmanager/user/:userId/terminate-all
11250
11524
  */
11251
11525
  async terminateAllUserSessions(ctx) {
11252
11526
  try {
11253
11527
  const { userId } = ctx.params;
11254
11528
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11255
- await sessionService.terminateSession({ userId });
11529
+ await sessionService.terminateSession({ userId, reason: "manual" });
11256
11530
  ctx.body = {
11257
11531
  message: `All sessions terminated for user ${userId}`,
11258
11532
  success: true
@@ -11264,9 +11538,6 @@ var session$3 = {
11264
11538
  },
11265
11539
  /**
11266
11540
  * Returns geolocation data for a specific IP address (Premium feature).
11267
- *
11268
- * @route GET /magic-sessionmanager/geolocation/:ipAddress
11269
- * @throws {ForbiddenError} When no premium license is active
11270
11541
  */
11271
11542
  async getIpGeolocation(ctx) {
11272
11543
  try {
@@ -11293,10 +11564,7 @@ var session$3 = {
11293
11564
  return ctx.badRequest("Invalid IP address format");
11294
11565
  }
11295
11566
  const licenseGuard2 = strapi.plugin("magic-sessionmanager").service("license-guard");
11296
- const pluginStore = strapi.store({
11297
- type: "plugin",
11298
- name: "magic-sessionmanager"
11299
- });
11567
+ const pluginStore = strapi.store({ type: "plugin", name: "magic-sessionmanager" });
11300
11568
  const licenseKey = await pluginStore.get({ key: "licenseKey" });
11301
11569
  if (!licenseKey) {
11302
11570
  return ctx.forbidden("Premium license required for geolocation features");
@@ -11318,7 +11586,6 @@ var session$3 = {
11318
11586
  },
11319
11587
  /**
11320
11588
  * Permanently deletes a session (admin action).
11321
- * @route DELETE /magic-sessionmanager/sessions/:sessionId
11322
11589
  */
11323
11590
  async deleteSession(ctx) {
11324
11591
  try {
@@ -11336,7 +11603,6 @@ var session$3 = {
11336
11603
  },
11337
11604
  /**
11338
11605
  * Deletes all inactive sessions (admin action).
11339
- * @route POST /magic-sessionmanager/sessions/clean-inactive
11340
11606
  */
11341
11607
  async cleanInactiveSessions(ctx) {
11342
11608
  try {
@@ -11354,9 +11620,6 @@ var session$3 = {
11354
11620
  },
11355
11621
  /**
11356
11622
  * Toggles a user's blocked status and terminates their sessions on block.
11357
- *
11358
- * @route POST /magic-sessionmanager/user/:userId/toggle-block
11359
- * @throws {NotFoundError} When the user cannot be found
11360
11623
  */
11361
11624
  async toggleUserBlock(ctx) {
11362
11625
  try {
@@ -11372,11 +11635,11 @@ var session$3 = {
11372
11635
  }
11373
11636
  }
11374
11637
  if (!userDocumentId) {
11375
- return ctx.throw(404, "User not found");
11638
+ return ctx.notFound("User not found");
11376
11639
  }
11377
11640
  const user = await strapi.documents(USER_UID).findOne({ documentId: userDocumentId });
11378
11641
  if (!user) {
11379
- return ctx.throw(404, "User not found");
11642
+ return ctx.notFound("User not found");
11380
11643
  }
11381
11644
  const newBlockedStatus = !user.blocked;
11382
11645
  await strapi.documents(USER_UID).update({
@@ -11385,7 +11648,7 @@ var session$3 = {
11385
11648
  });
11386
11649
  if (newBlockedStatus) {
11387
11650
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11388
- await sessionService.terminateSession({ userId: userDocumentId });
11651
+ await sessionService.terminateSession({ userId: userDocumentId, reason: "blocked" });
11389
11652
  }
11390
11653
  ctx.body = {
11391
11654
  message: `User ${newBlockedStatus ? "blocked" : "unblocked"} successfully`,
@@ -11877,13 +12140,13 @@ const { createLogger: createLogger$1 } = logger;
11877
12140
  const { parseUserAgent } = userAgentParser;
11878
12141
  const { resolveUserDocumentId: resolveUserDocumentId$1 } = resolveUser;
11879
12142
  const { enhanceSessions } = enhanceSession_1;
11880
- const { getPluginSettings: getPluginSettings$1 } = settingsLoader;
12143
+ const { getPluginSettings: getPluginSettings$2 } = settingsLoader;
11881
12144
  const SESSION_UID$1 = "plugin::magic-sessionmanager.session";
11882
12145
  const MAX_SESSIONS_QUERY = 1e3;
11883
12146
  var session$1 = ({ strapi: strapi2 }) => {
11884
12147
  const log = createLogger$1(strapi2);
11885
12148
  async function getEnhanceOpts() {
11886
- const settings2 = await getPluginSettings$1(strapi2);
12149
+ const settings2 = await getPluginSettings$2(strapi2);
11887
12150
  return {
11888
12151
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11889
12152
  geolocationService: strapi2.plugin("magic-sessionmanager").service("geolocation"),
@@ -11952,15 +12215,44 @@ var session$1 = ({ strapi: strapi2 }) => {
11952
12215
  }
11953
12216
  },
11954
12217
  /**
11955
- * Terminates a single session or all sessions of a user.
12218
+ * Terminates a single session, all sessions of a user, or all sessions
12219
+ * of a user EXCEPT one with a typed reason so the JWT-verify wrapper
12220
+ * can communicate the cause to the client.
12221
+ *
12222
+ * Supported reasons:
12223
+ * - 'manual': user clicked logout, or admin terminated a session
12224
+ * - 'idle': inactivity timeout cleanup
12225
+ * - 'expired': maxSessionAgeDays exceeded
12226
+ * - 'blocked': the owning user was marked blocked
12227
+ *
12228
+ * For backwards compatibility `terminatedManually` is still set true
12229
+ * only when reason === 'manual'; idle/expired/blocked paths set it
12230
+ * false so reporting dashboards that queried that boolean continue
12231
+ * to work, while new code relies on `terminationReason`.
12232
+ *
11956
12233
  * @param {Object} params
11957
- * @param {string} [params.sessionId]
11958
- * @param {string|number} [params.userId]
11959
- * @returns {Promise<void>}
12234
+ * @param {string} [params.sessionId] Terminate exactly this session
12235
+ * @param {string|number} [params.userId] Terminate every active session
12236
+ * of this user …
12237
+ * @param {string} [params.exceptSessionId] … except for this one. Only
12238
+ * meaningful together with
12239
+ * `userId`. Used by
12240
+ * /logout-other-devices so
12241
+ * the caller stays logged in.
12242
+ * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
12243
+ * @returns {Promise<{terminatedCount: number}>}
11960
12244
  */
11961
- async terminateSession({ sessionId, userId }) {
12245
+ async terminateSession({ sessionId, userId, exceptSessionId = null, reason = "manual" }) {
11962
12246
  try {
11963
12247
  const now = /* @__PURE__ */ new Date();
12248
+ const validReasons = ["manual", "idle", "expired", "blocked"];
12249
+ const finalReason = validReasons.includes(reason) ? reason : "manual";
12250
+ const updateData = {
12251
+ isActive: false,
12252
+ terminatedManually: finalReason === "manual",
12253
+ terminationReason: finalReason,
12254
+ logoutTime: now
12255
+ };
11964
12256
  if (sessionId) {
11965
12257
  const existing = await strapi2.documents(SESSION_UID$1).findOne({
11966
12258
  documentId: sessionId,
@@ -11968,29 +12260,46 @@ var session$1 = ({ strapi: strapi2 }) => {
11968
12260
  });
11969
12261
  if (!existing) {
11970
12262
  log.warn(`Session ${sessionId} not found for termination`);
11971
- return;
12263
+ return { terminatedCount: 0 };
11972
12264
  }
11973
12265
  await strapi2.documents(SESSION_UID$1).update({
11974
12266
  documentId: sessionId,
11975
- data: { isActive: false, terminatedManually: true, logoutTime: now }
12267
+ data: updateData
11976
12268
  });
11977
- log.info(`Session ${sessionId} terminated (manual)`);
11978
- } else if (userId) {
12269
+ log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
12270
+ return { terminatedCount: 1 };
12271
+ }
12272
+ if (userId) {
11979
12273
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
11980
- if (!userDocumentId) return;
12274
+ if (!userDocumentId) return { terminatedCount: 0 };
12275
+ const filters2 = { user: { documentId: userDocumentId }, isActive: true };
12276
+ if (exceptSessionId) {
12277
+ filters2.documentId = { $ne: exceptSessionId };
12278
+ }
11981
12279
  const activeSessions = await strapi2.documents(SESSION_UID$1).findMany({
11982
- filters: { user: { documentId: userDocumentId }, isActive: true },
12280
+ filters: filters2,
11983
12281
  fields: ["documentId"],
11984
12282
  limit: MAX_SESSIONS_QUERY
11985
12283
  });
12284
+ let terminatedCount = 0;
11986
12285
  for (const session2 of activeSessions) {
11987
- await strapi2.documents(SESSION_UID$1).update({
11988
- documentId: session2.documentId,
11989
- data: { isActive: false, terminatedManually: true, logoutTime: now }
11990
- });
12286
+ try {
12287
+ await strapi2.documents(SESSION_UID$1).update({
12288
+ documentId: session2.documentId,
12289
+ data: updateData
12290
+ });
12291
+ terminatedCount++;
12292
+ } catch (err) {
12293
+ log.debug(`Failed to terminate session ${session2.documentId}:`, err.message);
12294
+ }
11991
12295
  }
11992
- log.info(`All sessions terminated (manual) for user ${userDocumentId}`);
12296
+ const label = exceptSessionId ? "OTHER sessions" : "ALL sessions";
12297
+ log.info(
12298
+ `${label} terminated for user ${userDocumentId} (reason: ${finalReason}, count: ${terminatedCount})`
12299
+ );
12300
+ return { terminatedCount };
11993
12301
  }
12302
+ return { terminatedCount: 0 };
11994
12303
  } catch (err) {
11995
12304
  log.error("Error terminating session:", err);
11996
12305
  throw err;
@@ -12074,7 +12383,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12074
12383
  async touch({ userId, sessionId, token }) {
12075
12384
  try {
12076
12385
  const now = /* @__PURE__ */ new Date();
12077
- const settings2 = await getPluginSettings$1(strapi2);
12386
+ const settings2 = await getPluginSettings$2(strapi2);
12078
12387
  const rateLimit2 = settings2.lastSeenRateLimit || 3e4;
12079
12388
  let session2 = null;
12080
12389
  let sessionDocId = sessionId;
@@ -12143,7 +12452,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12143
12452
  */
12144
12453
  async cleanupInactiveSessions({ useDbDirect = false } = {}) {
12145
12454
  try {
12146
- const settings2 = await getPluginSettings$1(strapi2);
12455
+ const settings2 = await getPluginSettings$2(strapi2);
12147
12456
  const inactivityTimeout = settings2.inactivityTimeout || 15 * 60 * 1e3;
12148
12457
  const now = /* @__PURE__ */ new Date();
12149
12458
  const cutoffTime = new Date(now.getTime() - inactivityTimeout);
@@ -12156,7 +12465,8 @@ var session$1 = ({ strapi: strapi2 }) => {
12156
12465
  });
12157
12466
  }).update({
12158
12467
  is_active: false,
12159
- terminated_manually: true,
12468
+ terminated_manually: false,
12469
+ termination_reason: "idle",
12160
12470
  logout_time: now
12161
12471
  });
12162
12472
  log.info(`[SUCCESS] Cleanup (db-direct) complete: ${deactivated} sessions deactivated`);
@@ -12197,7 +12507,8 @@ var session$1 = ({ strapi: strapi2 }) => {
12197
12507
  documentId,
12198
12508
  data: {
12199
12509
  isActive: false,
12200
- terminatedManually: true,
12510
+ terminatedManually: false,
12511
+ terminationReason: "idle",
12201
12512
  logoutTime: now
12202
12513
  }
12203
12514
  });
@@ -12243,7 +12554,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12243
12554
  */
12244
12555
  async deleteOldSessions({ retentionDays, useDbDirect } = {}) {
12245
12556
  try {
12246
- const settings2 = await getPluginSettings$1(strapi2);
12557
+ const settings2 = await getPluginSettings$2(strapi2);
12247
12558
  const effectiveDays = Number.isFinite(retentionDays) ? retentionDays : settings2.retentionDays || 90;
12248
12559
  if (effectiveDays === -1) {
12249
12560
  log.debug("[RETENTION] retentionDays=-1 (forever) — skipping");
@@ -12348,7 +12659,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12348
12659
  }
12349
12660
  };
12350
12661
  };
12351
- const version$1 = "4.5.1";
12662
+ const version$1 = "4.5.3";
12352
12663
  const require$$2 = {
12353
12664
  version: version$1
12354
12665
  };
@@ -13306,6 +13617,7 @@ var services$1 = {
13306
13617
  geolocation,
13307
13618
  notifications
13308
13619
  };
13620
+ const { getPluginSettings: getPluginSettings$1 } = settingsLoader;
13309
13621
  const buckets = /* @__PURE__ */ new Map();
13310
13622
  const prune = (now) => {
13311
13623
  for (const [key, entry] of buckets) {
@@ -13319,10 +13631,45 @@ const callerKey = (ctx) => {
13319
13631
  if (tokenId) return `t:${String(tokenId).slice(-16)}`;
13320
13632
  return `ip:${ctx.request.ip || ctx.ip || "unknown"}`;
13321
13633
  };
13634
+ const RESOLVED_TTL_MS = 3e4;
13635
+ let resolvedCache = null;
13636
+ let resolvedAt = 0;
13637
+ async function resolveLimits({ profile, routeMax, routeWindowMs, strapi: strapi2 }) {
13638
+ const now = Date.now();
13639
+ if (resolvedCache && now - resolvedAt < RESOLVED_TTL_MS) {
13640
+ const p = resolvedCache[profile];
13641
+ if (p) {
13642
+ return { max: p.max, windowMs: p.windowMs };
13643
+ }
13644
+ }
13645
+ let settings2 = {};
13646
+ try {
13647
+ settings2 = await getPluginSettings$1(strapi2);
13648
+ } catch {
13649
+ settings2 = {};
13650
+ }
13651
+ const windowSec = Number.isFinite(settings2.rateLimitWindowSeconds) ? settings2.rateLimitWindowSeconds : Math.round(routeWindowMs / 1e3);
13652
+ const windowMs = Math.max(1e4, windowSec * 1e3);
13653
+ const resolvedWrite = {
13654
+ max: Math.min(routeMax, Number.isFinite(settings2.rateLimitWriteMax) ? settings2.rateLimitWriteMax : routeMax),
13655
+ windowMs
13656
+ };
13657
+ const resolvedRead = {
13658
+ max: Math.max(routeMax, Number.isFinite(settings2.rateLimitReadMax) ? settings2.rateLimitReadMax : routeMax),
13659
+ windowMs
13660
+ };
13661
+ resolvedCache = { read: resolvedRead, write: resolvedWrite };
13662
+ resolvedAt = now;
13663
+ if (profile === "read") return resolvedRead;
13664
+ if (profile === "write") return resolvedWrite;
13665
+ return { max: routeMax, windowMs };
13666
+ }
13322
13667
  const rateLimit = (cfg = {}, { strapi: strapi2 }) => {
13323
- const max = Number.isFinite(cfg.max) ? cfg.max : 30;
13324
- const windowMs = Number.isFinite(cfg.window) ? cfg.window : 6e4;
13668
+ const routeMax = Number.isFinite(cfg.max) ? cfg.max : 30;
13669
+ const routeWindowMs = Number.isFinite(cfg.window) ? cfg.window : 6e4;
13670
+ const profile = cfg.profile === "read" || cfg.profile === "write" ? cfg.profile : null;
13325
13671
  return async (ctx, next) => {
13672
+ const { max, windowMs } = profile ? await resolveLimits({ profile, routeMax, routeWindowMs, strapi: strapi2 }) : { max: routeMax, windowMs: routeWindowMs };
13326
13673
  const key = `${ctx.path}::${callerKey(ctx)}`;
13327
13674
  const now = Date.now();
13328
13675
  if (buckets.size > 5e3) prune(now);
@@ -13356,7 +13703,8 @@ const rateLimit = (cfg = {}, { strapi: strapi2 }) => {
13356
13703
  var rateLimit_1 = rateLimit;
13357
13704
  var middlewares$1 = {
13358
13705
  "last-seen": lastSeen,
13359
- "rate-limit": rateLimit_1
13706
+ "rate-limit": rateLimit_1,
13707
+ "session-rejection-headers": sessionRejectionHeaders
13360
13708
  };
13361
13709
  var lodashExports = requireLodash();
13362
13710
  const ___default = /* @__PURE__ */ getDefaultExportFromCjs(lodashExports);
@@ -51437,18 +51785,18 @@ const CSP_DEFAULTS = {
51437
51785
  "blob:"
51438
51786
  ]
51439
51787
  };
51440
- const extendMiddlewareConfiguration = (middlewares2, middleware) => {
51788
+ const extendMiddlewareConfiguration = (middlewares2, middleware2) => {
51441
51789
  return middlewares2.map((currentMiddleware) => {
51442
- if (typeof currentMiddleware === "string" && currentMiddleware === middleware.name) {
51443
- return middleware;
51790
+ if (typeof currentMiddleware === "string" && currentMiddleware === middleware2.name) {
51791
+ return middleware2;
51444
51792
  }
51445
- if (typeof currentMiddleware === "object" && currentMiddleware.name === middleware.name) {
51793
+ if (typeof currentMiddleware === "object" && currentMiddleware.name === middleware2.name) {
51446
51794
  return fp.mergeWith((objValue, srcValue) => {
51447
51795
  if (Array.isArray(objValue)) {
51448
51796
  return Array.from(new Set(objValue.concat(srcValue)));
51449
51797
  }
51450
51798
  return void 0;
51451
- }, currentMiddleware, middleware);
51799
+ }, currentMiddleware, middleware2);
51452
51800
  }
51453
51801
  return currentMiddleware;
51454
51802
  });