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.
@@ -174,7 +174,16 @@ var register$1 = async ({ strapi: strapi2 }) => {
174
174
  try {
175
175
  await strapi2.documents(SESSION_UID$6).update({
176
176
  documentId: session2.documentId,
177
- data: { isActive: false, terminatedManually: true, logoutTime: now }
177
+ data: {
178
+ isActive: false,
179
+ // Blocked users are NOT a "manual" self-logout — mark
180
+ // this distinctly so the client receives a different
181
+ // error message ("account blocked") rather than
182
+ // "session terminated".
183
+ terminatedManually: false,
184
+ terminationReason: "blocked",
185
+ logoutTime: now
186
+ }
178
187
  });
179
188
  terminated++;
180
189
  } catch (updateErr) {
@@ -304,27 +313,27 @@ function generateSessionId$1(userId) {
304
313
  const userHash = crypto$1.createHash("sha256").update(userId.toString()).digest("hex").substring(0, 8);
305
314
  return `sess_${timestamp2}_${userHash}_${randomBytes}`;
306
315
  }
307
- function hashToken$5(token) {
316
+ function hashToken$6(token) {
308
317
  if (!token) return null;
309
318
  return crypto$1.createHash("sha256").update(token).digest("hex");
310
319
  }
311
320
  var encryption = {
312
321
  encryptToken: encryptToken$2,
313
322
  generateSessionId: generateSessionId$1,
314
- hashToken: hashToken$5
323
+ hashToken: hashToken$6
315
324
  };
316
325
  const USER_UID$1 = "plugin::users-permissions.user";
317
- const cache = /* @__PURE__ */ new Map();
326
+ const cache$1 = /* @__PURE__ */ new Map();
318
327
  const CACHE_TTL = 5 * 60 * 1e3;
319
328
  const CACHE_MAX_SIZE = 1e3;
320
329
  function evict() {
321
330
  const now = Date.now();
322
- for (const [key, value] of cache) {
323
- if (now - value.ts >= CACHE_TTL) cache.delete(key);
331
+ for (const [key, value] of cache$1) {
332
+ if (now - value.ts >= CACHE_TTL) cache$1.delete(key);
324
333
  }
325
- if (cache.size >= CACHE_MAX_SIZE) {
326
- const keysToDelete = [...cache.keys()].slice(0, Math.floor(CACHE_MAX_SIZE / 4));
327
- keysToDelete.forEach((k2) => cache.delete(k2));
334
+ if (cache$1.size >= CACHE_MAX_SIZE) {
335
+ const keysToDelete = [...cache$1.keys()].slice(0, Math.floor(CACHE_MAX_SIZE / 4));
336
+ keysToDelete.forEach((k2) => cache$1.delete(k2));
328
337
  }
329
338
  }
330
339
  async function resolveUserDocumentId$5(strapi2, userId) {
@@ -334,17 +343,17 @@ async function resolveUserDocumentId$5(strapi2, userId) {
334
343
  }
335
344
  const numericId = typeof userId === "number" ? userId : parseInt(userId, 10);
336
345
  const cacheKey = `u_${numericId}`;
337
- const cached2 = cache.get(cacheKey);
346
+ const cached2 = cache$1.get(cacheKey);
338
347
  if (cached2 && Date.now() - cached2.ts < CACHE_TTL) {
339
348
  return cached2.documentId;
340
349
  }
341
- if (cache.size >= CACHE_MAX_SIZE) evict();
350
+ if (cache$1.size >= CACHE_MAX_SIZE) evict();
342
351
  try {
343
352
  const user = await strapi2.entityService.findOne(USER_UID$1, numericId, {
344
353
  fields: ["documentId"]
345
354
  });
346
355
  if (user?.documentId) {
347
- cache.set(cacheKey, { documentId: user.documentId, ts: Date.now() });
356
+ cache$1.set(cacheKey, { documentId: user.documentId, ts: Date.now() });
348
357
  return user.documentId;
349
358
  }
350
359
  } catch {
@@ -405,6 +414,15 @@ function normalizeStoredSettings(stored) {
405
414
  if (stored.sessionCreationGraceMs !== void 0) {
406
415
  out.sessionCreationGraceMs = toIntInRange(stored.sessionCreationGraceMs, 5e3, 0, 3e4);
407
416
  }
417
+ if (stored.rateLimitWriteMax !== void 0) {
418
+ out.rateLimitWriteMax = toIntInRange(stored.rateLimitWriteMax, 10, 1, 1e3);
419
+ }
420
+ if (stored.rateLimitReadMax !== void 0) {
421
+ out.rateLimitReadMax = toIntInRange(stored.rateLimitReadMax, 120, 1, 1e4);
422
+ }
423
+ if (stored.rateLimitWindowSeconds !== void 0) {
424
+ out.rateLimitWindowSeconds = toIntInRange(stored.rateLimitWindowSeconds, 60, 10, 3600);
425
+ }
408
426
  for (const key of passthroughBooleans) {
409
427
  if (stored[key] !== void 0) out[key] = !!stored[key];
410
428
  }
@@ -428,7 +446,7 @@ function normalizeStoredSettings(stored) {
428
446
  }
429
447
  return out;
430
448
  }
431
- async function getPluginSettings$5(strapi2) {
449
+ async function getPluginSettings$6(strapi2) {
432
450
  const now = Date.now();
433
451
  if (cached$1 && now - cachedAt < CACHE_TTL_MS) {
434
452
  return cached$1;
@@ -452,12 +470,12 @@ function invalidateSettingsCache$1() {
452
470
  cachedAt = 0;
453
471
  }
454
472
  var settingsLoader = {
455
- getPluginSettings: getPluginSettings$5,
473
+ getPluginSettings: getPluginSettings$6,
456
474
  invalidateSettingsCache: invalidateSettingsCache$1
457
475
  };
458
476
  const MIN_TOKEN_LENGTH = 40;
459
477
  const MAX_TOKEN_LENGTH = 8192;
460
- function extractBearerToken$4(ctx) {
478
+ function extractBearerToken$5(ctx) {
461
479
  const headers = ctx?.request?.headers || ctx?.request?.header || {};
462
480
  const raw = headers.authorization || headers.Authorization;
463
481
  if (!raw || typeof raw !== "string") return null;
@@ -467,9 +485,76 @@ function extractBearerToken$4(ctx) {
467
485
  if (!token || token.length < MIN_TOKEN_LENGTH || token.length > MAX_TOKEN_LENGTH) return null;
468
486
  return token;
469
487
  }
470
- var extractToken = { extractBearerToken: extractBearerToken$4 };
488
+ var extractToken = { extractBearerToken: extractBearerToken$5 };
489
+ const TTL_MS = 60 * 1e3;
490
+ const MAX_ENTRIES = 1e4;
491
+ const cache = /* @__PURE__ */ new Map();
492
+ const prune$1 = () => {
493
+ const now = Date.now();
494
+ for (const [k2, v] of cache) {
495
+ if (v.expiresAt <= now) cache.delete(k2);
496
+ }
497
+ };
498
+ function setSessionRejectionReason$1(tokenHash, reason) {
499
+ if (!tokenHash || !reason) return;
500
+ if (cache.size >= MAX_ENTRIES) prune$1();
501
+ cache.set(tokenHash, { reason, expiresAt: Date.now() + TTL_MS });
502
+ }
503
+ function consumeSessionRejectionReason$2(tokenHash) {
504
+ if (!tokenHash) return null;
505
+ const entry = cache.get(tokenHash);
506
+ if (!entry) return null;
507
+ cache.delete(tokenHash);
508
+ if (entry.expiresAt <= Date.now()) return null;
509
+ return entry.reason;
510
+ }
511
+ var rejectionCache = {
512
+ setSessionRejectionReason: setSessionRejectionReason$1,
513
+ consumeSessionRejectionReason: consumeSessionRejectionReason$2
514
+ };
515
+ const { extractBearerToken: extractBearerToken$4 } = extractToken;
516
+ const { hashToken: hashToken$5 } = encryption;
517
+ const { consumeSessionRejectionReason: consumeSessionRejectionReason$1 } = rejectionCache;
518
+ const HEADER = "X-Session-Terminated-Reason";
519
+ const REASON_MESSAGES = {
520
+ manual: "Your session was terminated. Please log in again.",
521
+ idle: "Your session expired due to inactivity. Please log in again.",
522
+ expired: "Your session has reached its maximum age. Please log in again.",
523
+ blocked: "Your account has been blocked. Contact support."
524
+ };
525
+ const middleware = () => async (ctx, next) => {
526
+ await next();
527
+ if (ctx.status !== 401) return;
528
+ const token = extractBearerToken$4(ctx);
529
+ if (!token) return;
530
+ const reason = consumeSessionRejectionReason$1(hashToken$5(token));
531
+ if (!reason) return;
532
+ ctx.set(HEADER, reason);
533
+ const existing = ctx.body;
534
+ const friendlyMessage = REASON_MESSAGES[reason] || "Session invalid. Please log in again.";
535
+ if (existing && typeof existing === "object" && existing.error) {
536
+ existing.error.details = existing.error.details || {};
537
+ if (!existing.error.details.reason) {
538
+ existing.error.details.reason = reason;
539
+ }
540
+ if (!existing.error.message || existing.error.message === "Unauthorized") {
541
+ existing.error.message = friendlyMessage;
542
+ }
543
+ return;
544
+ }
545
+ ctx.body = {
546
+ data: null,
547
+ error: {
548
+ status: 401,
549
+ name: "UnauthorizedError",
550
+ message: friendlyMessage,
551
+ details: { reason }
552
+ }
553
+ };
554
+ };
555
+ var sessionRejectionHeaders = middleware;
471
556
  const { resolveUserDocumentId: resolveUserDocumentId$4 } = resolveUser;
472
- const { getPluginSettings: getPluginSettings$4 } = settingsLoader;
557
+ const { getPluginSettings: getPluginSettings$5 } = settingsLoader;
473
558
  const { extractBearerToken: extractBearerToken$3 } = extractToken;
474
559
  const { hashToken: hashToken$4 } = encryption;
475
560
  const SESSION_UID$5 = "plugin::magic-sessionmanager.session";
@@ -490,65 +575,75 @@ function isAuthEndpoint(path2) {
490
575
  var lastSeen = ({ strapi: strapi2, sessionService }) => {
491
576
  return async (ctx, next) => {
492
577
  if (isAuthEndpoint(ctx.path)) {
493
- await next();
494
- return;
578
+ return next();
579
+ }
580
+ if (!ctx.state.user) {
581
+ return next();
495
582
  }
496
- if (ctx.state.user) {
583
+ let userDocId = ctx.state.user.documentId;
584
+ if (!userDocId && ctx.state.user.id) {
497
585
  try {
498
- let userDocId2 = ctx.state.user.documentId;
499
- if (!userDocId2 && ctx.state.user.id) {
500
- userDocId2 = await resolveUserDocumentId$4(strapi2, ctx.state.user.id);
501
- }
502
- if (userDocId2) {
503
- const settings2 = await getPluginSettings$4(strapi2);
504
- const strictMode = settings2.strictSessionEnforcement === true;
505
- const token = extractBearerToken$3(ctx);
506
- const tokenHashValue = token ? hashToken$4(token) : null;
507
- const thisSession = tokenHashValue ? await strapi2.documents(SESSION_UID$5).findFirst({
508
- filters: { user: { documentId: userDocId2 }, tokenHash: tokenHashValue },
509
- fields: ["documentId", "isActive", "terminatedManually"]
510
- }) : null;
511
- if (thisSession) {
512
- if (thisSession.terminatedManually === true) {
513
- strapi2.log.info(`[magic-sessionmanager] [BLOCKED] Session was manually terminated (user: ${userDocId2.substring(0, 8)}...)`);
514
- return ctx.unauthorized("Session terminated. Please login again.");
515
- }
516
- ctx.state.userDocumentId = userDocId2;
517
- ctx.state.__magicSessionId = thisSession.documentId;
518
- await next();
519
- if (thisSession.isActive) {
520
- try {
521
- await sessionService.touch({
522
- userId: userDocId2,
523
- sessionId: thisSession.documentId
524
- });
525
- } catch (err) {
526
- strapi2.log.debug("[magic-sessionmanager] Error updating lastSeen:", err.message);
527
- }
528
- }
529
- return;
530
- }
531
- if (strictMode) {
532
- strapi2.log.info(`[magic-sessionmanager] [BLOCKED] No session matches this token (user: ${userDocId2.substring(0, 8)}..., strictMode)`);
533
- return ctx.unauthorized("No valid session. Please login again.");
534
- }
535
- strapi2.log.warn(`[magic-sessionmanager] [WARN] No session for token (user: ${userDocId2.substring(0, 8)}...) - allowing in non-strict mode`);
536
- ctx.state.userDocumentId = userDocId2;
537
- }
586
+ userDocId = await resolveUserDocumentId$4(strapi2, ctx.state.user.id);
538
587
  } catch (err) {
539
- strapi2.log.debug("[magic-sessionmanager] Error checking active sessions:", err.message);
588
+ strapi2.log.debug("[magic-sessionmanager] user doc-id lookup failed:", err.message);
589
+ return next();
540
590
  }
541
591
  }
542
- await next();
543
- const userDocId = ctx.state.userDocumentId || ctx.state.user?.documentId;
544
- const sessionId = ctx.state.__magicSessionId;
545
- if (userDocId && sessionId) {
592
+ if (!userDocId) {
593
+ return next();
594
+ }
595
+ const settings2 = await getPluginSettings$5(strapi2).catch(() => ({}));
596
+ const strictMode = settings2.strictSessionEnforcement === true;
597
+ const gracePeriodMs = Math.max(0, Number(settings2.sessionCreationGraceMs) || 5e3);
598
+ const token = extractBearerToken$3(ctx);
599
+ const tokenHashValue = token ? hashToken$4(token) : null;
600
+ let thisSession = null;
601
+ if (tokenHashValue) {
602
+ try {
603
+ thisSession = await strapi2.documents(SESSION_UID$5).findFirst({
604
+ filters: { user: { documentId: userDocId }, tokenHash: tokenHashValue },
605
+ fields: ["documentId", "isActive", "terminatedManually", "terminationReason"]
606
+ });
607
+ } catch (err) {
608
+ strapi2.log.debug("[magic-sessionmanager] session lookup failed:", err.message);
609
+ }
610
+ }
611
+ if (thisSession) {
612
+ if (thisSession.isActive === false) {
613
+ return ctx.unauthorized("Session terminated. Please login again.");
614
+ }
615
+ ctx.state.userDocumentId = userDocId;
616
+ ctx.state.__magicSessionId = thisSession.documentId;
617
+ await next();
546
618
  try {
547
- await sessionService.touch({ userId: userDocId, sessionId });
619
+ await sessionService.touch({
620
+ userId: userDocId,
621
+ sessionId: thisSession.documentId
622
+ });
548
623
  } catch (err) {
549
624
  strapi2.log.debug("[magic-sessionmanager] Error updating lastSeen:", err.message);
550
625
  }
626
+ return;
627
+ }
628
+ if (strictMode) {
629
+ const iat = ctx.state.user?.iat;
630
+ if (gracePeriodMs > 0 && typeof iat === "number") {
631
+ const ageMs = Date.now() - iat * 1e3;
632
+ if (ageMs >= 0 && ageMs < gracePeriodMs) {
633
+ ctx.state.userDocumentId = userDocId;
634
+ return next();
635
+ }
636
+ }
637
+ strapi2.log.info(
638
+ `[magic-sessionmanager] [BLOCKED] No session matches this token (user: ${userDocId.substring(0, 8)}..., strictMode)`
639
+ );
640
+ return ctx.unauthorized("No valid session. Please login again.");
551
641
  }
642
+ strapi2.log.debug(
643
+ `[magic-sessionmanager] [WARN] No session for token (user: ${userDocId.substring(0, 8)}...) - allowing in non-strict mode`
644
+ );
645
+ ctx.state.userDocumentId = userDocId;
646
+ return next();
552
647
  };
553
648
  };
554
649
  var jsonwebtoken = { exports: {} };
@@ -9515,8 +9610,12 @@ const getClientIp = getClientIp_1;
9515
9610
  const { encryptToken: encryptToken$1, hashToken: hashToken$3 } = encryption;
9516
9611
  const { createLogger: createLogger$3 } = logger;
9517
9612
  const { resolveUserDocumentId: resolveUserDocumentId$3 } = resolveUser;
9518
- const { getPluginSettings: getPluginSettings$3 } = settingsLoader;
9613
+ const { getPluginSettings: getPluginSettings$4 } = settingsLoader;
9519
9614
  const { extractBearerToken: extractBearerToken$2 } = extractToken;
9615
+ const {
9616
+ setSessionRejectionReason,
9617
+ consumeSessionRejectionReason
9618
+ } = rejectionCache;
9520
9619
  const SESSION_UID$4 = "plugin::magic-sessionmanager.session";
9521
9620
  const JWT_WRAPPED_FLAG = Symbol.for("magic-sessionmanager.jwt.wrapped");
9522
9621
  const LOGIN_PATHS = /* @__PURE__ */ new Set([
@@ -9589,7 +9688,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9589
9688
  }
9590
9689
  log.info("Running initial session cleanup...");
9591
9690
  try {
9592
- const settings2 = await getPluginSettings$3(strapi2);
9691
+ const settings2 = await getPluginSettings$4(strapi2);
9593
9692
  await sessionService.cleanupInactiveSessions({
9594
9693
  useDbDirect: settings2.cleanupUseDbDirect === true
9595
9694
  });
@@ -9600,7 +9699,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9600
9699
  let intervalMs = 30 * 60 * 1e3;
9601
9700
  let useDbDirect = false;
9602
9701
  try {
9603
- const settings2 = await getPluginSettings$3(strapi2);
9702
+ const settings2 = await getPluginSettings$4(strapi2);
9604
9703
  intervalMs = Math.max(5 * 60 * 1e3, settings2.cleanupInterval || intervalMs);
9605
9704
  useDbDirect = settings2.cleanupUseDbDirect === true;
9606
9705
  } catch {
@@ -9621,7 +9720,7 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9621
9720
  const scheduleRetention = async () => {
9622
9721
  let useDbDirect = false;
9623
9722
  try {
9624
- const settings2 = await getPluginSettings$3(strapi2);
9723
+ const settings2 = await getPluginSettings$4(strapi2);
9625
9724
  useDbDirect = settings2.cleanupUseDbDirect === true;
9626
9725
  } catch {
9627
9726
  }
@@ -9645,6 +9744,10 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
9645
9744
  mountLogoutRoute({ strapi: strapi2, log, sessionService });
9646
9745
  mountLoginInterceptor({ strapi: strapi2, log, sessionService });
9647
9746
  mountRefreshTokenInterceptor({ strapi: strapi2, log });
9747
+ strapi2.server.use(
9748
+ sessionRejectionHeaders({}, { strapi: strapi2 })
9749
+ );
9750
+ log.info("[SUCCESS] Session-rejection-headers middleware mounted");
9648
9751
  strapi2.server.use(
9649
9752
  lastSeen({ strapi: strapi2, sessionService })
9650
9753
  );
@@ -9663,7 +9766,7 @@ function mountPreLoginGeoGuard({ strapi: strapi2, log }) {
9663
9766
  }
9664
9767
  let settings2 = {};
9665
9768
  try {
9666
- settings2 = await getPluginSettings$3(strapi2);
9769
+ settings2 = await getPluginSettings$4(strapi2);
9667
9770
  } catch {
9668
9771
  settings2 = {};
9669
9772
  }
@@ -9839,7 +9942,7 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9839
9942
  if (!isLoginPath(ctx.path, ctx.method)) return next();
9840
9943
  let maxFailed = 0;
9841
9944
  try {
9842
- const settings2 = await getPluginSettings$3(strapi2);
9945
+ const settings2 = await getPluginSettings$4(strapi2);
9843
9946
  maxFailed = Number(settings2.maxFailedLogins) || 0;
9844
9947
  } catch {
9845
9948
  maxFailed = 0;
@@ -9878,17 +9981,28 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9878
9981
  });
9879
9982
  log.info("[SUCCESS] Failed-login lockout middleware mounted");
9880
9983
  }
9984
+ function isJwtIssuingPath(path2, method) {
9985
+ if (!path2) return false;
9986
+ const get2 = method === "GET";
9987
+ const post = method === "POST";
9988
+ if (post && path2 === "/api/auth/local") return true;
9989
+ if (post && path2 === "/api/auth/local/register") return true;
9990
+ if (post && path2 === "/api/auth/reset-password") return true;
9991
+ if ((get2 || post) && path2 === "/api/auth/email-confirmation") return true;
9992
+ if (get2 && /^\/api\/auth\/[a-z0-9-]+\/callback$/i.test(path2)) return true;
9993
+ if ((get2 || post) && path2.startsWith("/api/magic-link/login")) return true;
9994
+ if (post && path2 === "/api/magic-link/verify-mfa-totp") return true;
9995
+ if (post && path2 === "/api/magic-link/otp/verify") return true;
9996
+ if (post && path2 === "/api/magic-link/login-totp") return true;
9997
+ if ((get2 || post) && path2.startsWith("/api/passwordless/")) return true;
9998
+ return false;
9999
+ }
9881
10000
  function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9882
10001
  strapi2.server.use(async (ctx, next) => {
9883
10002
  await next();
9884
- const isAuthLocal = ctx.path === "/api/auth/local" && ctx.method === "POST";
9885
- const isMagicLinkLogin = ctx.path.includes("/magic-link/login") && (ctx.method === "GET" || ctx.method === "POST");
9886
- const isMagicLinkMFA = ctx.path.includes("/magic-link/verify-mfa-totp") && ctx.method === "POST";
9887
- const isMagicLinkOTP = ctx.path.includes("/magic-link/otp/verify") && ctx.method === "POST";
9888
- const isMagicLink = isMagicLinkLogin || isMagicLinkMFA || isMagicLinkOTP;
9889
- if (!((isAuthLocal || isMagicLink) && ctx.status === 200 && ctx.body && ctx.body.jwt && ctx.body.user)) {
9890
- return;
9891
- }
10003
+ if (!isJwtIssuingPath(ctx.path, ctx.method)) return;
10004
+ if (ctx.status !== 200) return;
10005
+ if (!ctx.body || !ctx.body.jwt || !ctx.body.user) return;
9892
10006
  try {
9893
10007
  const user = ctx.body.user;
9894
10008
  const ip = getClientIp(ctx);
@@ -9918,7 +10032,7 @@ function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9918
10032
  }
9919
10033
  log.info(`[SUCCESS] Session ${newSession.documentId} created for user ${userDocId} (IP: ${ip})`);
9920
10034
  try {
9921
- const settings2 = await getPluginSettings$3(strapi2);
10035
+ const settings2 = await getPluginSettings$4(strapi2);
9922
10036
  if (!geoData || !(settings2.enableEmailAlerts || settings2.enableWebhooks)) {
9923
10037
  return;
9924
10038
  }
@@ -10089,11 +10203,19 @@ function mountRefreshTokenInterceptor({ strapi: strapi2, log }) {
10089
10203
  log.info("[SUCCESS] Refresh token interceptor middleware mounted");
10090
10204
  }
10091
10205
  async function ensureContentApiPermissions(strapi2, log) {
10206
+ const PERMISSIONS_VERSION = 2;
10092
10207
  try {
10093
10208
  const pluginStore = strapi2.store({ type: "plugin", name: "magic-sessionmanager" });
10094
- const alreadyInitialized = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10095
- if (alreadyInitialized === true) {
10096
- log.debug("Content-API permissions already initialized (skipping auto-setup)");
10209
+ const storedVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10210
+ if (storedVersion === void 0) {
10211
+ const legacyFlag = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10212
+ if (legacyFlag === true) {
10213
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: 1 });
10214
+ }
10215
+ }
10216
+ const effectiveVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10217
+ if (effectiveVersion >= PERMISSIONS_VERSION) {
10218
+ log.debug("Content-API permissions already at current version (skipping auto-setup)");
10097
10219
  return;
10098
10220
  }
10099
10221
  const ROLE_UID = "plugin::users-permissions.role";
@@ -10111,6 +10233,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10111
10233
  const requiredActions = [
10112
10234
  "plugin::magic-sessionmanager.session.logout",
10113
10235
  "plugin::magic-sessionmanager.session.logoutAll",
10236
+ "plugin::magic-sessionmanager.session.logoutOthers",
10114
10237
  "plugin::magic-sessionmanager.session.getOwnSessions",
10115
10238
  "plugin::magic-sessionmanager.session.getUserSessions",
10116
10239
  "plugin::magic-sessionmanager.session.getCurrentSession",
@@ -10127,7 +10250,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10127
10250
  const existingActions = existingPermissions.map((p) => p.action);
10128
10251
  const missingActions = requiredActions.filter((action) => !existingActions.includes(action));
10129
10252
  if (missingActions.length === 0) {
10130
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10253
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10131
10254
  log.debug("Content-API permissions already configured");
10132
10255
  return;
10133
10256
  }
@@ -10137,7 +10260,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10137
10260
  });
10138
10261
  log.info(`[PERMISSION] Enabled ${action} for authenticated users`);
10139
10262
  }
10140
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10263
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10141
10264
  log.info("[SUCCESS] Content-API permissions configured for authenticated users");
10142
10265
  } catch (err) {
10143
10266
  log.warn("Could not auto-configure permissions:", err.message);
@@ -10235,7 +10358,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10235
10358
  }
10236
10359
  let settings2;
10237
10360
  try {
10238
- settings2 = await getPluginSettings$3(strapi2);
10361
+ settings2 = await getPluginSettings$4(strapi2);
10239
10362
  } catch {
10240
10363
  settings2 = strapi2.config.get("plugin::magic-sessionmanager") || {};
10241
10364
  }
@@ -10260,6 +10383,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10260
10383
  strapi2.log.info(
10261
10384
  `[magic-sessionmanager] [JWT-BLOCKED] User is blocked (user: ${userDocId.substring(0, 8)}...)`
10262
10385
  );
10386
+ setSessionRejectionReason(hashToken$3(token), "blocked");
10263
10387
  return null;
10264
10388
  }
10265
10389
  } catch {
@@ -10270,7 +10394,7 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10270
10394
  user: { documentId: userDocId },
10271
10395
  tokenHash: tokenHashValue
10272
10396
  },
10273
- fields: ["documentId", "isActive", "terminatedManually", "lastActive", "loginTime"]
10397
+ fields: ["documentId", "isActive", "terminatedManually", "terminationReason", "lastActive", "loginTime"]
10274
10398
  });
10275
10399
  if (thisSession) {
10276
10400
  if (isSessionExpired(thisSession, maxSessionAgeDays)) {
@@ -10279,15 +10403,25 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10279
10403
  );
10280
10404
  await strapi2.documents(SESSION_UID$4).update({
10281
10405
  documentId: thisSession.documentId,
10282
- data: { isActive: false, terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
10406
+ data: {
10407
+ isActive: false,
10408
+ terminatedManually: false,
10409
+ terminationReason: "expired",
10410
+ logoutTime: /* @__PURE__ */ new Date()
10411
+ }
10283
10412
  });
10413
+ setSessionRejectionReason(tokenHashValue, "expired");
10284
10414
  return null;
10285
10415
  }
10286
- if (thisSession.terminatedManually === true) {
10287
- strapi2.log.info(
10288
- `[magic-sessionmanager] [JWT-BLOCKED] Session was manually terminated (user: ${userDocId.substring(0, 8)}...)`
10289
- );
10290
- return null;
10416
+ if (thisSession.isActive === false) {
10417
+ const reason = thisSession.terminationReason || (thisSession.terminatedManually === true ? "manual" : null);
10418
+ if (reason) {
10419
+ strapi2.log.info(
10420
+ `[magic-sessionmanager] [JWT-REJECTED] Session inactive (reason: ${reason}) for user ${userDocId.substring(0, 8)}...`
10421
+ );
10422
+ setSessionRejectionReason(tokenHashValue, reason);
10423
+ return null;
10424
+ }
10291
10425
  }
10292
10426
  if (thisSession.isActive) {
10293
10427
  resetErrorCounter();
@@ -10302,8 +10436,13 @@ async function registerSessionAwareAuthStrategy(strapi2, log) {
10302
10436
  );
10303
10437
  await strapi2.documents(SESSION_UID$4).update({
10304
10438
  documentId: thisSession.documentId,
10305
- data: { terminatedManually: true, logoutTime: /* @__PURE__ */ new Date() }
10439
+ data: {
10440
+ terminatedManually: false,
10441
+ terminationReason: "idle",
10442
+ logoutTime: /* @__PURE__ */ new Date()
10443
+ }
10306
10444
  });
10445
+ setSessionRejectionReason(tokenHashValue, "idle");
10307
10446
  return null;
10308
10447
  }
10309
10448
  await strapi2.documents(SESSION_UID$4).update({
@@ -10505,6 +10644,16 @@ const attributes = {
10505
10644
  "default": false,
10506
10645
  required: false
10507
10646
  },
10647
+ terminationReason: {
10648
+ type: "enumeration",
10649
+ "enum": [
10650
+ "manual",
10651
+ "idle",
10652
+ "expired",
10653
+ "blocked"
10654
+ ],
10655
+ required: false
10656
+ },
10508
10657
  geoLocation: {
10509
10658
  type: "json"
10510
10659
  },
@@ -10538,10 +10687,16 @@ var contentTypes$2 = {
10538
10687
  }
10539
10688
  };
10540
10689
  const writeRateLimit = [
10541
- { name: "plugin::magic-sessionmanager.rate-limit", config: { max: 10, window: 6e4 } }
10690
+ {
10691
+ name: "plugin::magic-sessionmanager.rate-limit",
10692
+ config: { profile: "write", max: 10, window: 6e4 }
10693
+ }
10542
10694
  ];
10543
10695
  const readRateLimit = [
10544
- { name: "plugin::magic-sessionmanager.rate-limit", config: { max: 120, window: 6e4 } }
10696
+ {
10697
+ name: "plugin::magic-sessionmanager.rate-limit",
10698
+ config: { profile: "read", max: 120, window: 6e4 }
10699
+ }
10545
10700
  ];
10546
10701
  var contentApi$1 = {
10547
10702
  type: "content-api",
@@ -10564,7 +10719,17 @@ var contentApi$1 = {
10564
10719
  config: {
10565
10720
  auth: { strategies: ["users-permissions"] },
10566
10721
  middlewares: writeRateLimit,
10567
- description: "Logout from all devices (requires JWT)"
10722
+ description: "Logout from ALL devices including the current one (requires JWT)"
10723
+ }
10724
+ },
10725
+ {
10726
+ method: "POST",
10727
+ path: "/logout-others",
10728
+ handler: "session.logoutOthers",
10729
+ config: {
10730
+ auth: { strategies: ["users-permissions"] },
10731
+ middlewares: writeRateLimit,
10732
+ description: "Logout from all OTHER devices, keep current session alive (requires JWT)"
10568
10733
  }
10569
10734
  },
10570
10735
  // ================== SESSION QUERIES ==================
@@ -10944,15 +11109,22 @@ var enhanceSession_1 = { enhanceSession: enhanceSession$1, enhanceSessions: enha
10944
11109
  const { hashToken: hashToken$2 } = encryption;
10945
11110
  const { enhanceSessions: enhanceSessions$1, enhanceSession } = enhanceSession_1;
10946
11111
  const { resolveUserDocumentId: resolveUserDocumentId$2 } = resolveUser;
10947
- const { getPluginSettings: getPluginSettings$2 } = settingsLoader;
11112
+ const { getPluginSettings: getPluginSettings$3 } = settingsLoader;
10948
11113
  const { extractBearerToken: extractBearerToken$1 } = extractToken;
10949
11114
  const SESSION_UID$2 = "plugin::magic-sessionmanager.session";
10950
11115
  const USER_UID = "plugin::users-permissions.user";
11116
+ const OWN_SESSIONS_LIMIT = 200;
11117
+ async function resolveAuthUserDocId(ctx) {
11118
+ const u2 = ctx.state.user;
11119
+ if (!u2) return null;
11120
+ if (u2.documentId) return u2.documentId;
11121
+ if (u2.id) return resolveUserDocumentId$2(strapi, u2.id);
11122
+ return null;
11123
+ }
10951
11124
  var session$3 = {
10952
11125
  /**
10953
11126
  * Lists all sessions (active + inactive) for admin overviews.
10954
11127
  * @route GET /magic-sessionmanager/sessions
10955
- * @returns {object} `{ data, meta }`
10956
11128
  */
10957
11129
  async getAllSessionsAdmin(ctx) {
10958
11130
  try {
@@ -10974,7 +11146,6 @@ var session$3 = {
10974
11146
  /**
10975
11147
  * Lists currently-active sessions only.
10976
11148
  * @route GET /magic-sessionmanager/sessions/active
10977
- * @returns {object} `{ data, meta }`
10978
11149
  */
10979
11150
  async getActiveSessions(ctx) {
10980
11151
  try {
@@ -10990,35 +11161,33 @@ var session$3 = {
10990
11161
  }
10991
11162
  },
10992
11163
  /**
10993
- * Returns the authenticated user's own sessions, with the current session
10994
- * flagged via `isCurrentSession`.
10995
- *
11164
+ * Returns the authenticated user's own sessions, current session flagged.
10996
11165
  * @route GET /api/magic-sessionmanager/my-sessions
10997
- * @returns {object} `{ data, meta }`
10998
- * @throws {UnauthorizedError} When user is not authenticated
10999
11166
  */
11000
11167
  async getOwnSessions(ctx) {
11001
11168
  try {
11002
- const userId = ctx.state.user?.documentId;
11169
+ const userDocId = await resolveAuthUserDocId(ctx);
11170
+ if (!userDocId) {
11171
+ return ctx.unauthorized("Authentication required");
11172
+ }
11003
11173
  const currentToken = extractBearerToken$1(ctx);
11004
11174
  const currentTokenHash = currentToken ? hashToken$2(currentToken) : null;
11005
- if (!userId) {
11006
- return ctx.throw(401, "Unauthorized");
11007
- }
11008
11175
  const allSessions = await strapi.documents(SESSION_UID$2).findMany({
11009
- filters: { user: { documentId: userId } },
11176
+ filters: { user: { documentId: userDocId } },
11010
11177
  sort: { loginTime: "desc" },
11011
- limit: 200
11178
+ limit: OWN_SESSIONS_LIMIT + 1
11012
11179
  });
11013
- const settings2 = await getPluginSettings$2(strapi);
11180
+ const hasMore = allSessions.length > OWN_SESSIONS_LIMIT;
11181
+ const paged = hasMore ? allSessions.slice(0, OWN_SESSIONS_LIMIT) : allSessions;
11182
+ const settings2 = await getPluginSettings$3(strapi);
11014
11183
  const enhanceOpts = {
11015
11184
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11016
11185
  geolocationService: strapi.plugin("magic-sessionmanager").service("geolocation"),
11017
11186
  strapi
11018
11187
  };
11019
- const sessionsWithCurrent = await enhanceSessions$1(allSessions, enhanceOpts, 20);
11188
+ const sessionsWithCurrent = await enhanceSessions$1(paged, enhanceOpts, 20);
11020
11189
  for (const s3 of sessionsWithCurrent) {
11021
- s3.isCurrentSession = !!(currentTokenHash && allSessions.find(
11190
+ s3.isCurrentSession = !!(currentTokenHash && paged.find(
11022
11191
  (raw) => raw.documentId === s3.documentId && raw.tokenHash === currentTokenHash
11023
11192
  ));
11024
11193
  }
@@ -11031,7 +11200,9 @@ var session$3 = {
11031
11200
  data: sessionsWithCurrent,
11032
11201
  meta: {
11033
11202
  count: sessionsWithCurrent.length,
11034
- active: sessionsWithCurrent.filter((s3) => s3.isTrulyActive).length
11203
+ active: sessionsWithCurrent.filter((s3) => s3.isTrulyActive).length,
11204
+ hasMore,
11205
+ limit: OWN_SESSIONS_LIMIT
11035
11206
  }
11036
11207
  };
11037
11208
  } catch (err) {
@@ -11040,18 +11211,14 @@ var session$3 = {
11040
11211
  }
11041
11212
  },
11042
11213
  /**
11043
- * Get a specific user's sessions. Admins may query any user; Content-API
11214
+ * Get a specific user's sessions. Admins can query any user; content-api
11044
11215
  * users can only query themselves.
11045
- *
11046
- * @route GET /magic-sessionmanager/user/:userId/sessions (admin)
11047
- * @route GET /api/magic-sessionmanager/user/:userId/sessions (content-api)
11048
- * @throws {ForbiddenError} When a non-admin requests another user's sessions
11049
11216
  */
11050
11217
  async getUserSessions(ctx) {
11051
11218
  try {
11052
11219
  const { userId } = ctx.params;
11053
11220
  const isAdminRequest = !!(ctx.state.userAbility || ctx.state.admin);
11054
- const requestingUserDocId = ctx.state.user?.documentId;
11221
+ const requestingUserDocId = await resolveAuthUserDocId(ctx);
11055
11222
  if (!isAdminRequest) {
11056
11223
  if (!requestingUserDocId) {
11057
11224
  strapi.log.warn(`[magic-sessionmanager] Security: Request without documentId tried to access sessions of user ${userId}`);
@@ -11080,73 +11247,177 @@ var session$3 = {
11080
11247
  */
11081
11248
  async logout(ctx) {
11082
11249
  try {
11083
- const userId = ctx.state.user?.documentId;
11250
+ const userDocId = await resolveAuthUserDocId(ctx);
11084
11251
  const token = extractBearerToken$1(ctx);
11085
- if (!userId || !token) {
11086
- return ctx.throw(401, "Unauthorized");
11252
+ if (!userDocId || !token) {
11253
+ return ctx.unauthorized("Authentication required");
11087
11254
  }
11088
11255
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11089
11256
  const currentTokenHash = hashToken$2(token);
11090
11257
  const matchingSession = await strapi.documents(SESSION_UID$2).findFirst({
11091
11258
  filters: {
11092
- user: { documentId: userId },
11259
+ user: { documentId: userDocId },
11093
11260
  tokenHash: currentTokenHash,
11094
11261
  isActive: true
11095
11262
  },
11096
11263
  fields: ["documentId"]
11097
11264
  });
11265
+ let terminated = false;
11098
11266
  if (matchingSession) {
11099
- await sessionService.terminateSession({ sessionId: matchingSession.documentId });
11100
- strapi.log.info(`[magic-sessionmanager] User ${userId} logged out (session ${matchingSession.documentId})`);
11267
+ await sessionService.terminateSession({
11268
+ sessionId: matchingSession.documentId,
11269
+ reason: "manual"
11270
+ });
11271
+ terminated = true;
11272
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} logged out (session ${matchingSession.documentId})`);
11101
11273
  }
11102
- ctx.body = { message: "Logged out successfully" };
11274
+ ctx.body = {
11275
+ message: "Logged out successfully",
11276
+ terminated
11277
+ };
11103
11278
  } catch (err) {
11104
11279
  strapi.log.error("[magic-sessionmanager] Logout error:", err);
11105
11280
  ctx.throw(500, "Error during logout");
11106
11281
  }
11107
11282
  },
11108
11283
  /**
11109
- * Terminates all sessions of the authenticated user.
11284
+ * Terminates EVERY session of the authenticated user — including the
11285
+ * current one. After this call the caller will also be logged out on
11286
+ * the next request (their own JWT is rejected by the JWT-verify wrapper
11287
+ * with reason=manual).
11288
+ *
11289
+ * For the "log me out everywhere ELSE but keep me here" flow, use
11290
+ * `/logout-others` below.
11291
+ *
11110
11292
  * @route POST /api/magic-sessionmanager/logout-all
11111
11293
  */
11112
11294
  async logoutAll(ctx) {
11113
11295
  try {
11114
- const userId = ctx.state.user?.documentId;
11115
- if (!userId) {
11116
- return ctx.throw(401, "Unauthorized");
11296
+ const userDocId = await resolveAuthUserDocId(ctx);
11297
+ if (!userDocId) {
11298
+ return ctx.unauthorized("Authentication required");
11117
11299
  }
11118
11300
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11119
- await sessionService.terminateSession({ userId });
11120
- strapi.log.info(`[magic-sessionmanager] User ${userId} logged out from all devices`);
11121
- ctx.body = { message: "Logged out from all devices successfully" };
11301
+ const { terminatedCount } = await sessionService.terminateSession({
11302
+ userId: userDocId,
11303
+ reason: "manual"
11304
+ });
11305
+ strapi.log.info(
11306
+ `[magic-sessionmanager] User ${userDocId} logged out from all devices (${terminatedCount})`
11307
+ );
11308
+ ctx.body = {
11309
+ message: "Logged out from all devices successfully",
11310
+ terminatedCount
11311
+ };
11122
11312
  } catch (err) {
11123
11313
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
11124
11314
  ctx.throw(500, "Error during logout");
11125
11315
  }
11126
11316
  },
11317
+ /**
11318
+ * Terminates every session of the authenticated user EXCEPT the current
11319
+ * one. This is the "kick everyone else off my account" flow — the
11320
+ * caller stays logged in on the device they are using.
11321
+ *
11322
+ * If no "current session" record can be located for the caller's JWT
11323
+ * (edge case: user logged in before the session-manager was installed,
11324
+ * or right at the end of the grace window) we fall back to terminating
11325
+ * ALL sessions so the user still gets the safety effect they asked for,
11326
+ * and we report `fellBackToLogoutAll: true` so the client can adjust
11327
+ * its success message.
11328
+ *
11329
+ * @route POST /api/magic-sessionmanager/logout-others
11330
+ */
11331
+ async logoutOthers(ctx) {
11332
+ try {
11333
+ const userDocId = await resolveAuthUserDocId(ctx);
11334
+ const token = extractBearerToken$1(ctx);
11335
+ if (!userDocId || !token) {
11336
+ return ctx.unauthorized("Authentication required");
11337
+ }
11338
+ const currentTokenHash = hashToken$2(token);
11339
+ const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11340
+ filters: { user: { documentId: userDocId }, tokenHash: currentTokenHash },
11341
+ fields: ["documentId"]
11342
+ });
11343
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11344
+ if (!currentSession) {
11345
+ const { terminatedCount: terminatedCount2 } = await sessionService.terminateSession({
11346
+ userId: userDocId,
11347
+ reason: "manual"
11348
+ });
11349
+ strapi.log.warn(
11350
+ `[magic-sessionmanager] logoutOthers fell back to logoutAll for user ${userDocId} (no current session match)`
11351
+ );
11352
+ ctx.body = {
11353
+ message: "All sessions terminated (could not preserve current session)",
11354
+ terminatedCount: terminatedCount2,
11355
+ fellBackToLogoutAll: true
11356
+ };
11357
+ return;
11358
+ }
11359
+ const { terminatedCount } = await sessionService.terminateSession({
11360
+ userId: userDocId,
11361
+ exceptSessionId: currentSession.documentId,
11362
+ reason: "manual"
11363
+ });
11364
+ strapi.log.info(
11365
+ `[magic-sessionmanager] User ${userDocId} logged out ${terminatedCount} other device(s)`
11366
+ );
11367
+ ctx.body = {
11368
+ message: terminatedCount === 0 ? "No other active sessions to terminate" : `${terminatedCount} other session(s) terminated`,
11369
+ terminatedCount,
11370
+ currentSessionPreserved: true
11371
+ };
11372
+ } catch (err) {
11373
+ strapi.log.error("[magic-sessionmanager] Logout-others error:", err);
11374
+ ctx.throw(500, "Error terminating other sessions");
11375
+ }
11376
+ },
11127
11377
  /**
11128
11378
  * Returns the session associated with the current JWT.
11379
+ *
11380
+ * During the post-login grace window the session-create write may not
11381
+ * yet be visible. In that case we return 202 Accepted with
11382
+ * `{ pending: true }` so the client knows to retry shortly instead of
11383
+ * interpreting a 404 as "no session at all".
11384
+ *
11129
11385
  * @route GET /api/magic-sessionmanager/current-session
11130
11386
  */
11131
11387
  async getCurrentSession(ctx) {
11132
11388
  try {
11133
- const userId = ctx.state.user?.documentId;
11389
+ const userDocId = await resolveAuthUserDocId(ctx);
11134
11390
  const token = extractBearerToken$1(ctx);
11135
- if (!userId || !token) {
11136
- return ctx.throw(401, "Unauthorized");
11391
+ if (!userDocId || !token) {
11392
+ return ctx.unauthorized("Authentication required");
11137
11393
  }
11138
11394
  const currentTokenHash = hashToken$2(token);
11139
11395
  const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11140
11396
  filters: {
11141
- user: { documentId: userId },
11397
+ user: { documentId: userDocId },
11142
11398
  tokenHash: currentTokenHash,
11143
11399
  isActive: true
11144
11400
  }
11145
11401
  });
11146
11402
  if (!currentSession) {
11403
+ const settings3 = await getPluginSettings$3(strapi);
11404
+ const gracePeriodMs = Math.max(0, Number(settings3.sessionCreationGraceMs) || 5e3);
11405
+ const iat = ctx.state.user?.iat || ctx.state.auth?.credentials?.iat || null;
11406
+ if (gracePeriodMs > 0 && typeof iat === "number") {
11407
+ const ageMs = Date.now() - iat * 1e3;
11408
+ if (ageMs >= 0 && ageMs < gracePeriodMs) {
11409
+ ctx.status = 202;
11410
+ ctx.body = {
11411
+ data: null,
11412
+ meta: { pending: true, retryAfterMs: gracePeriodMs - ageMs },
11413
+ message: "Session is still being created — please retry shortly."
11414
+ };
11415
+ return;
11416
+ }
11417
+ }
11147
11418
  return ctx.notFound("Current session not found");
11148
11419
  }
11149
- const settings2 = await getPluginSettings$2(strapi);
11420
+ const settings2 = await getPluginSettings$3(strapi);
11150
11421
  const enhanced = await enhanceSession(currentSession, {
11151
11422
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11152
11423
  geolocationService: strapi.plugin("magic-sessionmanager").service("geolocation"),
@@ -11162,17 +11433,17 @@ var session$3 = {
11162
11433
  }
11163
11434
  },
11164
11435
  /**
11165
- * Terminates one of the authenticated user's OWN sessions (not the current one).
11436
+ * Terminates one of the authenticated user's OWN sessions (not current).
11166
11437
  * @route DELETE /api/magic-sessionmanager/my-sessions/:sessionId
11167
11438
  */
11168
11439
  async terminateOwnSession(ctx) {
11169
11440
  try {
11170
- const userId = ctx.state.user?.documentId;
11441
+ const userDocId = await resolveAuthUserDocId(ctx);
11171
11442
  const { sessionId } = ctx.params;
11172
11443
  const currentToken = extractBearerToken$1(ctx);
11173
11444
  const currentTokenHash = currentToken ? hashToken$2(currentToken) : null;
11174
- if (!userId) {
11175
- return ctx.throw(401, "Unauthorized");
11445
+ if (!userDocId) {
11446
+ return ctx.unauthorized("Authentication required");
11176
11447
  }
11177
11448
  if (!sessionId) {
11178
11449
  return ctx.badRequest("Session ID is required");
@@ -11185,19 +11456,23 @@ var session$3 = {
11185
11456
  return ctx.notFound("Session not found");
11186
11457
  }
11187
11458
  const sessionUserId = sessionToTerminate.user?.documentId;
11188
- if (sessionUserId !== userId) {
11189
- strapi.log.warn(`[magic-sessionmanager] Security: User ${userId} tried to terminate session ${sessionId} of user ${sessionUserId}`);
11459
+ if (sessionUserId !== userDocId) {
11460
+ strapi.log.warn(`[magic-sessionmanager] Security: User ${userDocId} tried to terminate session ${sessionId} of user ${sessionUserId}`);
11190
11461
  return ctx.forbidden("You can only terminate your own sessions");
11191
11462
  }
11192
11463
  if (currentTokenHash && sessionToTerminate.tokenHash === currentTokenHash) {
11193
- return ctx.badRequest("Cannot terminate current session. Use /logout instead.");
11464
+ return ctx.badRequest("Cannot terminate the current session. Use /logout instead.");
11465
+ }
11466
+ const alreadyTerminated = sessionToTerminate.isActive === false;
11467
+ if (!alreadyTerminated) {
11468
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11469
+ await sessionService.terminateSession({ sessionId, reason: "manual" });
11470
+ strapi.log.info(`[magic-sessionmanager] User ${userDocId} terminated own session ${sessionId}`);
11194
11471
  }
11195
- const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11196
- await sessionService.terminateSession({ sessionId });
11197
- strapi.log.info(`[magic-sessionmanager] User ${userId} terminated own session ${sessionId}`);
11198
11472
  ctx.body = {
11199
- message: `Session ${sessionId} terminated successfully`,
11200
- success: true
11473
+ message: alreadyTerminated ? `Session ${sessionId} was already terminated` : `Session ${sessionId} terminated successfully`,
11474
+ success: true,
11475
+ alreadyTerminated
11201
11476
  };
11202
11477
  } catch (err) {
11203
11478
  strapi.log.error("[magic-sessionmanager] Error terminating own session:", err);
@@ -11205,9 +11480,7 @@ var session$3 = {
11205
11480
  }
11206
11481
  },
11207
11482
  /**
11208
- * Sets isActive:false + terminatedManually:false on a session, simulating
11209
- * a cleanup timeout. Available only outside of production/staging.
11210
- *
11483
+ * Simulates an inactivity timeout on a session. Dev-only.
11211
11484
  * @route POST /magic-sessionmanager/sessions/:sessionId/simulate-timeout
11212
11485
  */
11213
11486
  async simulateTimeout(ctx) {
@@ -11226,13 +11499,16 @@ var session$3 = {
11226
11499
  }
11227
11500
  await strapi.documents(SESSION_UID$2).update({
11228
11501
  documentId: sessionId,
11229
- data: { isActive: false, terminatedManually: false }
11502
+ data: {
11503
+ isActive: false,
11504
+ terminatedManually: false,
11505
+ terminationReason: "idle"
11506
+ }
11230
11507
  });
11231
- strapi.log.info(`[magic-sessionmanager] [TEST] Session ${sessionId} simulated timeout (terminatedManually: false)`);
11508
+ strapi.log.info(`[magic-sessionmanager] [TEST] Session ${sessionId} simulated timeout`);
11232
11509
  ctx.body = {
11233
- message: `Session ${sessionId} marked as timed out (reactivatable)`,
11234
- success: true,
11235
- terminatedManually: false
11510
+ message: `Session ${sessionId} marked as timed out`,
11511
+ success: true
11236
11512
  };
11237
11513
  } catch (err) {
11238
11514
  strapi.log.error("[magic-sessionmanager] Error simulating timeout:", err);
@@ -11241,13 +11517,12 @@ var session$3 = {
11241
11517
  },
11242
11518
  /**
11243
11519
  * Terminates a specific session (admin action).
11244
- * @route POST /magic-sessionmanager/sessions/:sessionId/terminate
11245
11520
  */
11246
11521
  async terminateSingleSession(ctx) {
11247
11522
  try {
11248
11523
  const { sessionId } = ctx.params;
11249
11524
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11250
- await sessionService.terminateSession({ sessionId });
11525
+ await sessionService.terminateSession({ sessionId, reason: "manual" });
11251
11526
  ctx.body = {
11252
11527
  message: `Session ${sessionId} terminated`,
11253
11528
  success: true
@@ -11259,13 +11534,12 @@ var session$3 = {
11259
11534
  },
11260
11535
  /**
11261
11536
  * Terminates ALL sessions for a specific user (admin action).
11262
- * @route POST /magic-sessionmanager/user/:userId/terminate-all
11263
11537
  */
11264
11538
  async terminateAllUserSessions(ctx) {
11265
11539
  try {
11266
11540
  const { userId } = ctx.params;
11267
11541
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11268
- await sessionService.terminateSession({ userId });
11542
+ await sessionService.terminateSession({ userId, reason: "manual" });
11269
11543
  ctx.body = {
11270
11544
  message: `All sessions terminated for user ${userId}`,
11271
11545
  success: true
@@ -11277,9 +11551,6 @@ var session$3 = {
11277
11551
  },
11278
11552
  /**
11279
11553
  * Returns geolocation data for a specific IP address (Premium feature).
11280
- *
11281
- * @route GET /magic-sessionmanager/geolocation/:ipAddress
11282
- * @throws {ForbiddenError} When no premium license is active
11283
11554
  */
11284
11555
  async getIpGeolocation(ctx) {
11285
11556
  try {
@@ -11306,10 +11577,7 @@ var session$3 = {
11306
11577
  return ctx.badRequest("Invalid IP address format");
11307
11578
  }
11308
11579
  const licenseGuard2 = strapi.plugin("magic-sessionmanager").service("license-guard");
11309
- const pluginStore = strapi.store({
11310
- type: "plugin",
11311
- name: "magic-sessionmanager"
11312
- });
11580
+ const pluginStore = strapi.store({ type: "plugin", name: "magic-sessionmanager" });
11313
11581
  const licenseKey = await pluginStore.get({ key: "licenseKey" });
11314
11582
  if (!licenseKey) {
11315
11583
  return ctx.forbidden("Premium license required for geolocation features");
@@ -11331,7 +11599,6 @@ var session$3 = {
11331
11599
  },
11332
11600
  /**
11333
11601
  * Permanently deletes a session (admin action).
11334
- * @route DELETE /magic-sessionmanager/sessions/:sessionId
11335
11602
  */
11336
11603
  async deleteSession(ctx) {
11337
11604
  try {
@@ -11349,7 +11616,6 @@ var session$3 = {
11349
11616
  },
11350
11617
  /**
11351
11618
  * Deletes all inactive sessions (admin action).
11352
- * @route POST /magic-sessionmanager/sessions/clean-inactive
11353
11619
  */
11354
11620
  async cleanInactiveSessions(ctx) {
11355
11621
  try {
@@ -11367,9 +11633,6 @@ var session$3 = {
11367
11633
  },
11368
11634
  /**
11369
11635
  * Toggles a user's blocked status and terminates their sessions on block.
11370
- *
11371
- * @route POST /magic-sessionmanager/user/:userId/toggle-block
11372
- * @throws {NotFoundError} When the user cannot be found
11373
11636
  */
11374
11637
  async toggleUserBlock(ctx) {
11375
11638
  try {
@@ -11385,11 +11648,11 @@ var session$3 = {
11385
11648
  }
11386
11649
  }
11387
11650
  if (!userDocumentId) {
11388
- return ctx.throw(404, "User not found");
11651
+ return ctx.notFound("User not found");
11389
11652
  }
11390
11653
  const user = await strapi.documents(USER_UID).findOne({ documentId: userDocumentId });
11391
11654
  if (!user) {
11392
- return ctx.throw(404, "User not found");
11655
+ return ctx.notFound("User not found");
11393
11656
  }
11394
11657
  const newBlockedStatus = !user.blocked;
11395
11658
  await strapi.documents(USER_UID).update({
@@ -11398,7 +11661,7 @@ var session$3 = {
11398
11661
  });
11399
11662
  if (newBlockedStatus) {
11400
11663
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11401
- await sessionService.terminateSession({ userId: userDocumentId });
11664
+ await sessionService.terminateSession({ userId: userDocumentId, reason: "blocked" });
11402
11665
  }
11403
11666
  ctx.body = {
11404
11667
  message: `User ${newBlockedStatus ? "blocked" : "unblocked"} successfully`,
@@ -11890,13 +12153,13 @@ const { createLogger: createLogger$1 } = logger;
11890
12153
  const { parseUserAgent } = userAgentParser;
11891
12154
  const { resolveUserDocumentId: resolveUserDocumentId$1 } = resolveUser;
11892
12155
  const { enhanceSessions } = enhanceSession_1;
11893
- const { getPluginSettings: getPluginSettings$1 } = settingsLoader;
12156
+ const { getPluginSettings: getPluginSettings$2 } = settingsLoader;
11894
12157
  const SESSION_UID$1 = "plugin::magic-sessionmanager.session";
11895
12158
  const MAX_SESSIONS_QUERY = 1e3;
11896
12159
  var session$1 = ({ strapi: strapi2 }) => {
11897
12160
  const log = createLogger$1(strapi2);
11898
12161
  async function getEnhanceOpts() {
11899
- const settings2 = await getPluginSettings$1(strapi2);
12162
+ const settings2 = await getPluginSettings$2(strapi2);
11900
12163
  return {
11901
12164
  inactivityTimeout: settings2.inactivityTimeout || 15 * 60 * 1e3,
11902
12165
  geolocationService: strapi2.plugin("magic-sessionmanager").service("geolocation"),
@@ -11965,15 +12228,44 @@ var session$1 = ({ strapi: strapi2 }) => {
11965
12228
  }
11966
12229
  },
11967
12230
  /**
11968
- * Terminates a single session or all sessions of a user.
12231
+ * Terminates a single session, all sessions of a user, or all sessions
12232
+ * of a user EXCEPT one with a typed reason so the JWT-verify wrapper
12233
+ * can communicate the cause to the client.
12234
+ *
12235
+ * Supported reasons:
12236
+ * - 'manual': user clicked logout, or admin terminated a session
12237
+ * - 'idle': inactivity timeout cleanup
12238
+ * - 'expired': maxSessionAgeDays exceeded
12239
+ * - 'blocked': the owning user was marked blocked
12240
+ *
12241
+ * For backwards compatibility `terminatedManually` is still set true
12242
+ * only when reason === 'manual'; idle/expired/blocked paths set it
12243
+ * false so reporting dashboards that queried that boolean continue
12244
+ * to work, while new code relies on `terminationReason`.
12245
+ *
11969
12246
  * @param {Object} params
11970
- * @param {string} [params.sessionId]
11971
- * @param {string|number} [params.userId]
11972
- * @returns {Promise<void>}
12247
+ * @param {string} [params.sessionId] Terminate exactly this session
12248
+ * @param {string|number} [params.userId] Terminate every active session
12249
+ * of this user …
12250
+ * @param {string} [params.exceptSessionId] … except for this one. Only
12251
+ * meaningful together with
12252
+ * `userId`. Used by
12253
+ * /logout-other-devices so
12254
+ * the caller stays logged in.
12255
+ * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
12256
+ * @returns {Promise<{terminatedCount: number}>}
11973
12257
  */
11974
- async terminateSession({ sessionId, userId }) {
12258
+ async terminateSession({ sessionId, userId, exceptSessionId = null, reason = "manual" }) {
11975
12259
  try {
11976
12260
  const now = /* @__PURE__ */ new Date();
12261
+ const validReasons = ["manual", "idle", "expired", "blocked"];
12262
+ const finalReason = validReasons.includes(reason) ? reason : "manual";
12263
+ const updateData = {
12264
+ isActive: false,
12265
+ terminatedManually: finalReason === "manual",
12266
+ terminationReason: finalReason,
12267
+ logoutTime: now
12268
+ };
11977
12269
  if (sessionId) {
11978
12270
  const existing = await strapi2.documents(SESSION_UID$1).findOne({
11979
12271
  documentId: sessionId,
@@ -11981,29 +12273,46 @@ var session$1 = ({ strapi: strapi2 }) => {
11981
12273
  });
11982
12274
  if (!existing) {
11983
12275
  log.warn(`Session ${sessionId} not found for termination`);
11984
- return;
12276
+ return { terminatedCount: 0 };
11985
12277
  }
11986
12278
  await strapi2.documents(SESSION_UID$1).update({
11987
12279
  documentId: sessionId,
11988
- data: { isActive: false, terminatedManually: true, logoutTime: now }
12280
+ data: updateData
11989
12281
  });
11990
- log.info(`Session ${sessionId} terminated (manual)`);
11991
- } else if (userId) {
12282
+ log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
12283
+ return { terminatedCount: 1 };
12284
+ }
12285
+ if (userId) {
11992
12286
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
11993
- if (!userDocumentId) return;
12287
+ if (!userDocumentId) return { terminatedCount: 0 };
12288
+ const filters2 = { user: { documentId: userDocumentId }, isActive: true };
12289
+ if (exceptSessionId) {
12290
+ filters2.documentId = { $ne: exceptSessionId };
12291
+ }
11994
12292
  const activeSessions = await strapi2.documents(SESSION_UID$1).findMany({
11995
- filters: { user: { documentId: userDocumentId }, isActive: true },
12293
+ filters: filters2,
11996
12294
  fields: ["documentId"],
11997
12295
  limit: MAX_SESSIONS_QUERY
11998
12296
  });
12297
+ let terminatedCount = 0;
11999
12298
  for (const session2 of activeSessions) {
12000
- await strapi2.documents(SESSION_UID$1).update({
12001
- documentId: session2.documentId,
12002
- data: { isActive: false, terminatedManually: true, logoutTime: now }
12003
- });
12299
+ try {
12300
+ await strapi2.documents(SESSION_UID$1).update({
12301
+ documentId: session2.documentId,
12302
+ data: updateData
12303
+ });
12304
+ terminatedCount++;
12305
+ } catch (err) {
12306
+ log.debug(`Failed to terminate session ${session2.documentId}:`, err.message);
12307
+ }
12004
12308
  }
12005
- log.info(`All sessions terminated (manual) for user ${userDocumentId}`);
12309
+ const label = exceptSessionId ? "OTHER sessions" : "ALL sessions";
12310
+ log.info(
12311
+ `${label} terminated for user ${userDocumentId} (reason: ${finalReason}, count: ${terminatedCount})`
12312
+ );
12313
+ return { terminatedCount };
12006
12314
  }
12315
+ return { terminatedCount: 0 };
12007
12316
  } catch (err) {
12008
12317
  log.error("Error terminating session:", err);
12009
12318
  throw err;
@@ -12087,7 +12396,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12087
12396
  async touch({ userId, sessionId, token }) {
12088
12397
  try {
12089
12398
  const now = /* @__PURE__ */ new Date();
12090
- const settings2 = await getPluginSettings$1(strapi2);
12399
+ const settings2 = await getPluginSettings$2(strapi2);
12091
12400
  const rateLimit2 = settings2.lastSeenRateLimit || 3e4;
12092
12401
  let session2 = null;
12093
12402
  let sessionDocId = sessionId;
@@ -12156,7 +12465,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12156
12465
  */
12157
12466
  async cleanupInactiveSessions({ useDbDirect = false } = {}) {
12158
12467
  try {
12159
- const settings2 = await getPluginSettings$1(strapi2);
12468
+ const settings2 = await getPluginSettings$2(strapi2);
12160
12469
  const inactivityTimeout = settings2.inactivityTimeout || 15 * 60 * 1e3;
12161
12470
  const now = /* @__PURE__ */ new Date();
12162
12471
  const cutoffTime = new Date(now.getTime() - inactivityTimeout);
@@ -12169,7 +12478,8 @@ var session$1 = ({ strapi: strapi2 }) => {
12169
12478
  });
12170
12479
  }).update({
12171
12480
  is_active: false,
12172
- terminated_manually: true,
12481
+ terminated_manually: false,
12482
+ termination_reason: "idle",
12173
12483
  logout_time: now
12174
12484
  });
12175
12485
  log.info(`[SUCCESS] Cleanup (db-direct) complete: ${deactivated} sessions deactivated`);
@@ -12210,7 +12520,8 @@ var session$1 = ({ strapi: strapi2 }) => {
12210
12520
  documentId,
12211
12521
  data: {
12212
12522
  isActive: false,
12213
- terminatedManually: true,
12523
+ terminatedManually: false,
12524
+ terminationReason: "idle",
12214
12525
  logoutTime: now
12215
12526
  }
12216
12527
  });
@@ -12256,7 +12567,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12256
12567
  */
12257
12568
  async deleteOldSessions({ retentionDays, useDbDirect } = {}) {
12258
12569
  try {
12259
- const settings2 = await getPluginSettings$1(strapi2);
12570
+ const settings2 = await getPluginSettings$2(strapi2);
12260
12571
  const effectiveDays = Number.isFinite(retentionDays) ? retentionDays : settings2.retentionDays || 90;
12261
12572
  if (effectiveDays === -1) {
12262
12573
  log.debug("[RETENTION] retentionDays=-1 (forever) — skipping");
@@ -12361,7 +12672,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12361
12672
  }
12362
12673
  };
12363
12674
  };
12364
- const version$1 = "4.5.1";
12675
+ const version$1 = "4.5.3";
12365
12676
  const require$$2 = {
12366
12677
  version: version$1
12367
12678
  };
@@ -13319,6 +13630,7 @@ var services$1 = {
13319
13630
  geolocation,
13320
13631
  notifications
13321
13632
  };
13633
+ const { getPluginSettings: getPluginSettings$1 } = settingsLoader;
13322
13634
  const buckets = /* @__PURE__ */ new Map();
13323
13635
  const prune = (now) => {
13324
13636
  for (const [key, entry] of buckets) {
@@ -13332,10 +13644,45 @@ const callerKey = (ctx) => {
13332
13644
  if (tokenId) return `t:${String(tokenId).slice(-16)}`;
13333
13645
  return `ip:${ctx.request.ip || ctx.ip || "unknown"}`;
13334
13646
  };
13647
+ const RESOLVED_TTL_MS = 3e4;
13648
+ let resolvedCache = null;
13649
+ let resolvedAt = 0;
13650
+ async function resolveLimits({ profile, routeMax, routeWindowMs, strapi: strapi2 }) {
13651
+ const now = Date.now();
13652
+ if (resolvedCache && now - resolvedAt < RESOLVED_TTL_MS) {
13653
+ const p = resolvedCache[profile];
13654
+ if (p) {
13655
+ return { max: p.max, windowMs: p.windowMs };
13656
+ }
13657
+ }
13658
+ let settings2 = {};
13659
+ try {
13660
+ settings2 = await getPluginSettings$1(strapi2);
13661
+ } catch {
13662
+ settings2 = {};
13663
+ }
13664
+ const windowSec = Number.isFinite(settings2.rateLimitWindowSeconds) ? settings2.rateLimitWindowSeconds : Math.round(routeWindowMs / 1e3);
13665
+ const windowMs = Math.max(1e4, windowSec * 1e3);
13666
+ const resolvedWrite = {
13667
+ max: Math.min(routeMax, Number.isFinite(settings2.rateLimitWriteMax) ? settings2.rateLimitWriteMax : routeMax),
13668
+ windowMs
13669
+ };
13670
+ const resolvedRead = {
13671
+ max: Math.max(routeMax, Number.isFinite(settings2.rateLimitReadMax) ? settings2.rateLimitReadMax : routeMax),
13672
+ windowMs
13673
+ };
13674
+ resolvedCache = { read: resolvedRead, write: resolvedWrite };
13675
+ resolvedAt = now;
13676
+ if (profile === "read") return resolvedRead;
13677
+ if (profile === "write") return resolvedWrite;
13678
+ return { max: routeMax, windowMs };
13679
+ }
13335
13680
  const rateLimit = (cfg = {}, { strapi: strapi2 }) => {
13336
- const max = Number.isFinite(cfg.max) ? cfg.max : 30;
13337
- const windowMs = Number.isFinite(cfg.window) ? cfg.window : 6e4;
13681
+ const routeMax = Number.isFinite(cfg.max) ? cfg.max : 30;
13682
+ const routeWindowMs = Number.isFinite(cfg.window) ? cfg.window : 6e4;
13683
+ const profile = cfg.profile === "read" || cfg.profile === "write" ? cfg.profile : null;
13338
13684
  return async (ctx, next) => {
13685
+ const { max, windowMs } = profile ? await resolveLimits({ profile, routeMax, routeWindowMs, strapi: strapi2 }) : { max: routeMax, windowMs: routeWindowMs };
13339
13686
  const key = `${ctx.path}::${callerKey(ctx)}`;
13340
13687
  const now = Date.now();
13341
13688
  if (buckets.size > 5e3) prune(now);
@@ -13369,7 +13716,8 @@ const rateLimit = (cfg = {}, { strapi: strapi2 }) => {
13369
13716
  var rateLimit_1 = rateLimit;
13370
13717
  var middlewares$1 = {
13371
13718
  "last-seen": lastSeen,
13372
- "rate-limit": rateLimit_1
13719
+ "rate-limit": rateLimit_1,
13720
+ "session-rejection-headers": sessionRejectionHeaders
13373
13721
  };
13374
13722
  var lodashExports = requireLodash();
13375
13723
  const ___default = /* @__PURE__ */ getDefaultExportFromCjs(lodashExports);
@@ -51450,18 +51798,18 @@ const CSP_DEFAULTS = {
51450
51798
  "blob:"
51451
51799
  ]
51452
51800
  };
51453
- const extendMiddlewareConfiguration = (middlewares2, middleware) => {
51801
+ const extendMiddlewareConfiguration = (middlewares2, middleware2) => {
51454
51802
  return middlewares2.map((currentMiddleware) => {
51455
- if (typeof currentMiddleware === "string" && currentMiddleware === middleware.name) {
51456
- return middleware;
51803
+ if (typeof currentMiddleware === "string" && currentMiddleware === middleware2.name) {
51804
+ return middleware2;
51457
51805
  }
51458
- if (typeof currentMiddleware === "object" && currentMiddleware.name === middleware.name) {
51806
+ if (typeof currentMiddleware === "object" && currentMiddleware.name === middleware2.name) {
51459
51807
  return fp.mergeWith((objValue, srcValue) => {
51460
51808
  if (Array.isArray(objValue)) {
51461
51809
  return Array.from(new Set(objValue.concat(srcValue)));
51462
51810
  }
51463
51811
  return void 0;
51464
- }, currentMiddleware, middleware);
51812
+ }, currentMiddleware, middleware2);
51465
51813
  }
51466
51814
  return currentMiddleware;
51467
51815
  });