strapi-security-suite 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -132
- package/dist/_chunks/{App-ConqHB2Q.js → App-B-CRozv4.js} +1 -1
- package/dist/_chunks/{App-CBOxzfqu.mjs → App-CM1kp54o.mjs} +1 -1
- package/dist/_chunks/{index-BGBd43He.js → index-BVsx1rse.js} +35 -3
- package/dist/_chunks/{index-ZKJuPZEH.mjs → index-CrITOuMT.mjs} +35 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +589 -138
- package/dist/server/index.mjs +589 -138
- package/package.json +9 -2
package/dist/server/index.js
CHANGED
|
@@ -1,36 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const jwt = require("jsonwebtoken");
|
|
3
|
+
const node_crypto = require("node:crypto");
|
|
3
4
|
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
4
5
|
const jwt__default = /* @__PURE__ */ _interopDefault(jwt);
|
|
5
|
-
const revokedTokenSet = /* @__PURE__ */ new Set();
|
|
6
|
-
const revokedConnectionTokens = /* @__PURE__ */ new Map();
|
|
7
|
-
const TOKEN_TTL = 30 * 60 * 1e3;
|
|
8
|
-
function pruneExpiredTokens() {
|
|
9
|
-
const cutoff = Date.now() - TOKEN_TTL;
|
|
10
|
-
for (const [token, revokedAt] of revokedConnectionTokens) {
|
|
11
|
-
if (revokedAt < cutoff) {
|
|
12
|
-
revokedConnectionTokens.delete(token);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
const sessionActivityMap = /* @__PURE__ */ new Map();
|
|
17
6
|
const PLUGIN_ID = "strapi-security-suite";
|
|
18
7
|
const CONTENT_TYPES = {
|
|
19
|
-
SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings
|
|
8
|
+
SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings`,
|
|
9
|
+
ADMIN_SESSION: `plugin::${PLUGIN_ID}.admin-session`,
|
|
10
|
+
LOGIN_LOCK: `plugin::${PLUGIN_ID}.login-lock`,
|
|
11
|
+
WATCHER_LEASE: `plugin::${PLUGIN_ID}.watcher-lease`
|
|
20
12
|
};
|
|
21
13
|
const SERVICES = {
|
|
22
|
-
AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
|
|
14
|
+
AUTO_LOGOUT_CHECKER: "autoLogoutChecker",
|
|
15
|
+
STATE: "state"
|
|
23
16
|
};
|
|
24
17
|
const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
|
|
18
|
+
const WATCHER_LEASE_NAME = "autologout-watcher";
|
|
25
19
|
const CHECK_INTERVAL = 5e3;
|
|
26
20
|
const DEFAULT_AUTOLOGOUT_TIME = 30;
|
|
27
21
|
const MS_PER_MINUTE = 6e4;
|
|
28
22
|
const MS_PER_SECOND = 1e3;
|
|
23
|
+
const ACTIVITY_FLUSH_INTERVAL_MS = 30 * MS_PER_SECOND;
|
|
24
|
+
const LOGIN_LOCK_TTL_MS = 10 * MS_PER_SECOND;
|
|
25
|
+
const WATCHER_LEASE_TTL_MS = 15 * MS_PER_SECOND;
|
|
29
26
|
const LOGIN_PATH = "/admin/login";
|
|
30
27
|
const LOGOUT_PATH = "/admin/logout";
|
|
31
28
|
const ACCESS_TOKEN_PATH = "/access-token";
|
|
32
29
|
const CONTENT_PATH = "/content";
|
|
33
30
|
const HTTP_STATUS = {
|
|
31
|
+
NO_CONTENT: 204,
|
|
34
32
|
BAD_REQUEST: 400,
|
|
35
33
|
FORBIDDEN: 403,
|
|
36
34
|
CONFLICT: 409
|
|
@@ -106,17 +104,22 @@ function clearSessionCookies(ctx) {
|
|
|
106
104
|
path: "/admin",
|
|
107
105
|
httpOnly: true
|
|
108
106
|
});
|
|
109
|
-
|
|
107
|
+
const configuredSecure = strapi.config.get("admin.auth.cookie.secure");
|
|
108
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
109
|
+
const jwtClearOpts = {
|
|
110
110
|
expires: /* @__PURE__ */ new Date(0),
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
httpOnly: false,
|
|
112
|
+
secure: typeof configuredSecure === "boolean" ? configuredSecure : isProduction,
|
|
113
|
+
domain: strapi.config.get("admin.auth.domain"),
|
|
114
|
+
overwrite: true
|
|
115
|
+
};
|
|
116
|
+
ctx.cookies.set(COOKIES.JWT_TOKEN, "", jwtClearOpts);
|
|
114
117
|
}
|
|
115
118
|
async function trackActivity(ctx, next) {
|
|
116
119
|
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
121
|
+
if (adminUser?.sessionId && await state2.isRevoked(adminUser.sessionId)) {
|
|
122
|
+
clearSessionCookies(ctx);
|
|
120
123
|
ctx.status = HTTP_STATUS.FORBIDDEN;
|
|
121
124
|
ctx.body = {
|
|
122
125
|
error: {
|
|
@@ -129,21 +132,23 @@ async function trackActivity(ctx, next) {
|
|
|
129
132
|
}
|
|
130
133
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
131
134
|
clearSessionCookies(ctx);
|
|
132
|
-
|
|
135
|
+
return await next();
|
|
133
136
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
if (adminUser?.sessionId && adminUser?.id && adminUser?.email) {
|
|
138
|
+
await state2.touch({
|
|
139
|
+
sessionId: adminUser.sessionId,
|
|
140
|
+
userId: adminUser.id,
|
|
141
|
+
email: adminUser.email
|
|
142
|
+
});
|
|
143
|
+
strapi.log.debug(`[${PLUGIN_ID}] Activity touched: ${adminUser.id}:${adminUser.email}`);
|
|
137
144
|
}
|
|
138
145
|
await next();
|
|
139
146
|
}
|
|
140
|
-
|
|
141
|
-
function cleanupLoginState(ctx) {
|
|
147
|
+
async function cleanupLoginState(ctx) {
|
|
142
148
|
const email = ctx.request.body?.email;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
149
|
+
if (!email) return;
|
|
150
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
151
|
+
await state2.releaseLoginLock({ email });
|
|
147
152
|
}
|
|
148
153
|
async function preventMultipleSessions(ctx, next) {
|
|
149
154
|
const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
|
|
@@ -156,21 +161,32 @@ async function preventMultipleSessions(ctx, next) {
|
|
|
156
161
|
);
|
|
157
162
|
return await next();
|
|
158
163
|
}
|
|
164
|
+
const { email } = ctx.request.body ?? {};
|
|
165
|
+
if (!email) {
|
|
166
|
+
strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
|
|
167
|
+
return await next();
|
|
168
|
+
}
|
|
169
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
170
|
+
let lockAcquired = false;
|
|
159
171
|
try {
|
|
160
|
-
const { email } = ctx.request.body ?? {};
|
|
161
|
-
if (!email) {
|
|
162
|
-
strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
|
|
163
|
-
return await next();
|
|
164
|
-
}
|
|
165
172
|
const settings = await strapi.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
|
|
166
173
|
if (!settings?.multipleSessionsControl) return await next();
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
|
|
175
|
+
const hasActive = await state2.hasActiveSession({ email, idleThresholdMs });
|
|
176
|
+
if (hasActive) {
|
|
177
|
+
strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: already logged in.`);
|
|
178
|
+
ctx.status = HTTP_STATUS.CONFLICT;
|
|
179
|
+
ctx.body = {
|
|
180
|
+
error: {
|
|
181
|
+
status: HTTP_STATUS.CONFLICT,
|
|
182
|
+
message: ERROR_MESSAGES.MULTIPLE_SESSIONS
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
lockAcquired = await state2.acquireLoginLock({ email });
|
|
188
|
+
if (!lockAcquired) {
|
|
189
|
+
strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: another login is in flight.`);
|
|
174
190
|
ctx.status = HTTP_STATUS.CONFLICT;
|
|
175
191
|
ctx.body = {
|
|
176
192
|
error: {
|
|
@@ -180,44 +196,40 @@ async function preventMultipleSessions(ctx, next) {
|
|
|
180
196
|
};
|
|
181
197
|
return;
|
|
182
198
|
}
|
|
183
|
-
loginLocks.add(email);
|
|
184
199
|
} catch (err) {
|
|
185
200
|
strapi.log.error(`[${PLUGIN_ID}] Error in preventMultipleSessions:`, err);
|
|
186
201
|
}
|
|
187
202
|
try {
|
|
188
203
|
await next();
|
|
189
204
|
} finally {
|
|
190
|
-
|
|
205
|
+
if (lockAcquired) {
|
|
206
|
+
await cleanupLoginState(ctx);
|
|
207
|
+
}
|
|
191
208
|
}
|
|
192
209
|
}
|
|
193
210
|
async function rejectRevokedTokens(ctx, next) {
|
|
194
211
|
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
195
|
-
if (!adminUser?.
|
|
196
|
-
const
|
|
197
|
-
const key = id && adminEmail ? `${id}:${adminEmail}` : null;
|
|
212
|
+
if (!adminUser?.sessionId) return await next();
|
|
213
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
198
214
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
sessionActivityMap.delete(key);
|
|
208
|
-
revokedTokenSet.delete(adminEmail);
|
|
209
|
-
strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(id, adminEmail);
|
|
210
|
-
try {
|
|
211
|
-
if (strapi.sessionManager) {
|
|
212
|
-
await strapi.sessionManager("admin").invalidateRefreshToken(String(id));
|
|
213
|
-
}
|
|
214
|
-
} catch (err) {
|
|
215
|
-
strapi.log.error(`[${PLUGIN_ID}] Failed to invalidate DB session for ${adminEmail}:`, err);
|
|
215
|
+
const revoked = await state2.isRevoked(adminUser.sessionId);
|
|
216
|
+
if (!revoked) return await next();
|
|
217
|
+
ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminUser.email || "");
|
|
218
|
+
ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
|
|
219
|
+
clearSessionCookies(ctx);
|
|
220
|
+
try {
|
|
221
|
+
if (strapi.sessionManager && adminUser.id) {
|
|
222
|
+
await strapi.sessionManager("admin").invalidateRefreshToken(String(adminUser.id));
|
|
216
223
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
} catch (err) {
|
|
225
|
+
strapi.log.error(
|
|
226
|
+
`[${PLUGIN_ID}] Failed to invalidate DB session for ${adminUser.email}:`,
|
|
227
|
+
err
|
|
228
|
+
);
|
|
220
229
|
}
|
|
230
|
+
strapi.log.info(
|
|
231
|
+
`[${PLUGIN_ID}] Session revoked: ${adminUser.email} (session ${adminUser.sessionId})`
|
|
232
|
+
);
|
|
221
233
|
} catch (err) {
|
|
222
234
|
strapi.log.error(`[${PLUGIN_ID}] Error in rejectRevokedTokens middleware:`, err);
|
|
223
235
|
}
|
|
@@ -227,15 +239,11 @@ async function interceptRenewToken(ctx, next) {
|
|
|
227
239
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
228
240
|
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
229
241
|
strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
|
|
230
|
-
if (adminUser?.
|
|
231
|
-
strapi.plugin(PLUGIN_ID).service(SERVICES.
|
|
232
|
-
|
|
233
|
-
if (bearerToken) {
|
|
234
|
-
revokedConnectionTokens.set(bearerToken, Date.now());
|
|
235
|
-
}
|
|
236
|
-
clearSessionCookies(ctx);
|
|
237
|
-
sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
|
|
242
|
+
if (adminUser?.sessionId) {
|
|
243
|
+
const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
244
|
+
await state2.revokeSession({ sessionId: adminUser.sessionId });
|
|
238
245
|
}
|
|
246
|
+
clearSessionCookies(ctx);
|
|
239
247
|
await next();
|
|
240
248
|
return;
|
|
241
249
|
}
|
|
@@ -257,6 +265,7 @@ async function seedUserInfos(ctx, next) {
|
|
|
257
265
|
if (!token) return await next();
|
|
258
266
|
const decodedToken = jwt__default.default.decode(token);
|
|
259
267
|
const adminId = decodedToken?.userId;
|
|
268
|
+
const sessionId = decodedToken?.sessionId;
|
|
260
269
|
if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
|
|
261
270
|
return await next();
|
|
262
271
|
}
|
|
@@ -270,16 +279,13 @@ async function seedUserInfos(ctx, next) {
|
|
|
270
279
|
}
|
|
271
280
|
ctx.state[CTX_ADMIN_USER] = {
|
|
272
281
|
id: adminUser.id,
|
|
282
|
+
sessionId,
|
|
273
283
|
email: adminUser.email,
|
|
274
284
|
firstname: adminUser.firstname,
|
|
275
285
|
lastname: adminUser.lastname,
|
|
276
286
|
roles: adminUser.roles
|
|
277
287
|
};
|
|
278
|
-
|
|
279
|
-
if (!sessionActivityMap.has(key)) {
|
|
280
|
-
sessionActivityMap.set(key, Date.now());
|
|
281
|
-
}
|
|
282
|
-
strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
|
|
288
|
+
strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email} (session ${sessionId})`);
|
|
283
289
|
return await next();
|
|
284
290
|
} catch (error) {
|
|
285
291
|
strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
|
|
@@ -336,8 +342,8 @@ async function ensureDefaultSecuritySettings(strapi2) {
|
|
|
336
342
|
strapi2.log.error(`[${PLUGIN_ID}] Failed to ensure default security settings:`, error);
|
|
337
343
|
}
|
|
338
344
|
}
|
|
339
|
-
const destroy = ({ strapi: strapi2 }) => {
|
|
340
|
-
strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
|
|
345
|
+
const destroy = async ({ strapi: strapi2 }) => {
|
|
346
|
+
await strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
|
|
341
347
|
};
|
|
342
348
|
const register = ({ strapi: strapi2 }) => {
|
|
343
349
|
strapi2.server.use(middlewares.seedUserInfos);
|
|
@@ -358,17 +364,17 @@ const config = {
|
|
|
358
364
|
validator(_config) {
|
|
359
365
|
}
|
|
360
366
|
};
|
|
361
|
-
const kind = "singleType";
|
|
362
|
-
const info = {
|
|
367
|
+
const kind$3 = "singleType";
|
|
368
|
+
const info$3 = {
|
|
363
369
|
singularName: "security-settings",
|
|
364
370
|
pluralName: "security-settings",
|
|
365
371
|
displayName: "Security Settings",
|
|
366
372
|
description: "Stores security and session settings"
|
|
367
373
|
};
|
|
368
|
-
const options = {
|
|
374
|
+
const options$3 = {
|
|
369
375
|
draftAndPublish: false
|
|
370
376
|
};
|
|
371
|
-
const pluginOptions = {
|
|
377
|
+
const pluginOptions$3 = {
|
|
372
378
|
"content-manager": {
|
|
373
379
|
visible: false
|
|
374
380
|
},
|
|
@@ -376,7 +382,7 @@ const pluginOptions = {
|
|
|
376
382
|
visible: false
|
|
377
383
|
}
|
|
378
384
|
};
|
|
379
|
-
const attributes = {
|
|
385
|
+
const attributes$3 = {
|
|
380
386
|
autoLogoutTime: {
|
|
381
387
|
type: "integer",
|
|
382
388
|
required: true,
|
|
@@ -400,18 +406,157 @@ const attributes = {
|
|
|
400
406
|
"default": true
|
|
401
407
|
}
|
|
402
408
|
};
|
|
409
|
+
const schema$3 = {
|
|
410
|
+
kind: kind$3,
|
|
411
|
+
info: info$3,
|
|
412
|
+
options: options$3,
|
|
413
|
+
pluginOptions: pluginOptions$3,
|
|
414
|
+
attributes: attributes$3
|
|
415
|
+
};
|
|
416
|
+
const securitySettings = {
|
|
417
|
+
schema: schema$3
|
|
418
|
+
};
|
|
419
|
+
const kind$2 = "collectionType";
|
|
420
|
+
const collectionName$2 = "sss_admin_sessions";
|
|
421
|
+
const info$2 = {
|
|
422
|
+
singularName: "admin-session",
|
|
423
|
+
pluralName: "admin-sessions",
|
|
424
|
+
displayName: "Admin Session",
|
|
425
|
+
description: "Per-session activity and revocation state for admin users (managed by strapi-security-suite)."
|
|
426
|
+
};
|
|
427
|
+
const options$2 = {
|
|
428
|
+
draftAndPublish: false
|
|
429
|
+
};
|
|
430
|
+
const pluginOptions$2 = {
|
|
431
|
+
"content-manager": {
|
|
432
|
+
visible: false
|
|
433
|
+
},
|
|
434
|
+
"content-type-builder": {
|
|
435
|
+
visible: false
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const attributes$2 = {
|
|
439
|
+
sessionId: {
|
|
440
|
+
type: "string",
|
|
441
|
+
required: true,
|
|
442
|
+
unique: true
|
|
443
|
+
},
|
|
444
|
+
userId: {
|
|
445
|
+
type: "integer",
|
|
446
|
+
required: true
|
|
447
|
+
},
|
|
448
|
+
email: {
|
|
449
|
+
type: "string",
|
|
450
|
+
required: true
|
|
451
|
+
},
|
|
452
|
+
lastActiveAt: {
|
|
453
|
+
type: "datetime",
|
|
454
|
+
required: true
|
|
455
|
+
},
|
|
456
|
+
revokedAt: {
|
|
457
|
+
type: "datetime"
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const schema$2 = {
|
|
461
|
+
kind: kind$2,
|
|
462
|
+
collectionName: collectionName$2,
|
|
463
|
+
info: info$2,
|
|
464
|
+
options: options$2,
|
|
465
|
+
pluginOptions: pluginOptions$2,
|
|
466
|
+
attributes: attributes$2
|
|
467
|
+
};
|
|
468
|
+
const adminSession = {
|
|
469
|
+
schema: schema$2
|
|
470
|
+
};
|
|
471
|
+
const kind$1 = "collectionType";
|
|
472
|
+
const collectionName$1 = "sss_login_locks";
|
|
473
|
+
const info$1 = {
|
|
474
|
+
singularName: "login-lock",
|
|
475
|
+
pluralName: "login-locks",
|
|
476
|
+
displayName: "Login Lock",
|
|
477
|
+
description: "Cross-pod login lock keyed by email (managed by strapi-security-suite)."
|
|
478
|
+
};
|
|
479
|
+
const options$1 = {
|
|
480
|
+
draftAndPublish: false
|
|
481
|
+
};
|
|
482
|
+
const pluginOptions$1 = {
|
|
483
|
+
"content-manager": {
|
|
484
|
+
visible: false
|
|
485
|
+
},
|
|
486
|
+
"content-type-builder": {
|
|
487
|
+
visible: false
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const attributes$1 = {
|
|
491
|
+
email: {
|
|
492
|
+
type: "string",
|
|
493
|
+
required: true,
|
|
494
|
+
unique: true
|
|
495
|
+
},
|
|
496
|
+
lockedUntil: {
|
|
497
|
+
type: "datetime",
|
|
498
|
+
required: true
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const schema$1 = {
|
|
502
|
+
kind: kind$1,
|
|
503
|
+
collectionName: collectionName$1,
|
|
504
|
+
info: info$1,
|
|
505
|
+
options: options$1,
|
|
506
|
+
pluginOptions: pluginOptions$1,
|
|
507
|
+
attributes: attributes$1
|
|
508
|
+
};
|
|
509
|
+
const loginLock = {
|
|
510
|
+
schema: schema$1
|
|
511
|
+
};
|
|
512
|
+
const kind = "collectionType";
|
|
513
|
+
const collectionName = "sss_watcher_leases";
|
|
514
|
+
const info = {
|
|
515
|
+
singularName: "watcher-lease",
|
|
516
|
+
pluralName: "watcher-leases",
|
|
517
|
+
displayName: "Watcher Lease",
|
|
518
|
+
description: "Cross-pod leader-election lease for the auto-logout watcher (managed by strapi-security-suite)."
|
|
519
|
+
};
|
|
520
|
+
const options = {
|
|
521
|
+
draftAndPublish: false
|
|
522
|
+
};
|
|
523
|
+
const pluginOptions = {
|
|
524
|
+
"content-manager": {
|
|
525
|
+
visible: false
|
|
526
|
+
},
|
|
527
|
+
"content-type-builder": {
|
|
528
|
+
visible: false
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const attributes = {
|
|
532
|
+
name: {
|
|
533
|
+
type: "string",
|
|
534
|
+
required: true,
|
|
535
|
+
unique: true
|
|
536
|
+
},
|
|
537
|
+
holder: {
|
|
538
|
+
type: "string"
|
|
539
|
+
},
|
|
540
|
+
expiresAt: {
|
|
541
|
+
type: "datetime"
|
|
542
|
+
}
|
|
543
|
+
};
|
|
403
544
|
const schema = {
|
|
404
545
|
kind,
|
|
546
|
+
collectionName,
|
|
405
547
|
info,
|
|
406
548
|
options,
|
|
407
549
|
pluginOptions,
|
|
408
550
|
attributes
|
|
409
551
|
};
|
|
410
|
-
const
|
|
552
|
+
const watcherLease = {
|
|
411
553
|
schema
|
|
412
554
|
};
|
|
413
555
|
const contentTypes = {
|
|
414
|
-
"security-settings": securitySettings
|
|
556
|
+
"security-settings": securitySettings,
|
|
557
|
+
"admin-session": adminSession,
|
|
558
|
+
"login-lock": loginLock,
|
|
559
|
+
"watcher-lease": watcherLease
|
|
415
560
|
};
|
|
416
561
|
const validateSettingsPayload = (body) => {
|
|
417
562
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -490,6 +635,18 @@ const adminSecurityController = ({ strapi: strapi2 }) => ({
|
|
|
490
635
|
err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR
|
|
491
636
|
);
|
|
492
637
|
}
|
|
638
|
+
},
|
|
639
|
+
/**
|
|
640
|
+
* Lightweight keep-alive endpoint hit by the admin client while the user
|
|
641
|
+
* is actively interacting with the UI (mouse / keyboard). The middleware
|
|
642
|
+
* chain (`seedUserInfos` → `trackActivity`) does the actual work of
|
|
643
|
+
* persisting `lastActiveAt` for the current session, so the handler
|
|
644
|
+
* itself only needs to return 204.
|
|
645
|
+
*
|
|
646
|
+
* @param {import('koa').Context} ctx - Koa context
|
|
647
|
+
*/
|
|
648
|
+
heartbeat(ctx) {
|
|
649
|
+
ctx.status = HTTP_STATUS.NO_CONTENT;
|
|
493
650
|
}
|
|
494
651
|
});
|
|
495
652
|
const controllers = {
|
|
@@ -518,11 +675,11 @@ const policies = {
|
|
|
518
675
|
};
|
|
519
676
|
const admin = [
|
|
520
677
|
{
|
|
521
|
-
method: "
|
|
522
|
-
path: "/
|
|
523
|
-
handler: "adminSecurityController.
|
|
678
|
+
method: "POST",
|
|
679
|
+
path: "/heartbeat",
|
|
680
|
+
handler: "adminSecurityController.heartbeat",
|
|
524
681
|
config: {
|
|
525
|
-
|
|
682
|
+
// No policy: any authenticated admin may keep their session alive
|
|
526
683
|
}
|
|
527
684
|
},
|
|
528
685
|
{
|
|
@@ -561,74 +718,368 @@ const routes = {
|
|
|
561
718
|
let interval = null;
|
|
562
719
|
const autoLogoutChecker = ({ strapi: strapi2 }) => ({
|
|
563
720
|
/**
|
|
564
|
-
* Starts the auto-logout watcher interval.
|
|
565
|
-
*
|
|
566
|
-
* Runs every {@link CHECK_INTERVAL} ms, reads the configured `autoLogoutTime`
|
|
567
|
-
* from the database, and revokes tokens for sessions that have been idle
|
|
568
|
-
* beyond the threshold.
|
|
721
|
+
* Starts the auto-logout watcher interval. Idempotent — calling twice
|
|
722
|
+
* leaves the existing interval intact.
|
|
569
723
|
*/
|
|
570
724
|
startAutoLogoutWatcher() {
|
|
571
725
|
if (interval) {
|
|
572
726
|
strapi2.log.warn(`[${PLUGIN_ID}] AutoLogoutWatcher already running.`);
|
|
573
727
|
return;
|
|
574
728
|
}
|
|
729
|
+
const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
575
730
|
interval = setInterval(async () => {
|
|
576
731
|
try {
|
|
577
|
-
|
|
732
|
+
const isLeader = await state2.acquireWatcherLease();
|
|
733
|
+
if (!isLeader) return;
|
|
734
|
+
await state2.pruneExpiredLocks();
|
|
578
735
|
const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
736
|
+
const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
|
|
737
|
+
const idleSessions = await state2.listIdleSessions({ idleThresholdMs });
|
|
738
|
+
if (idleSessions.length === 0) return;
|
|
739
|
+
for (const session of idleSessions) {
|
|
740
|
+
await state2.revokeSession({ sessionId: session.sessionId });
|
|
741
|
+
if (strapi2.sessionManager && session.userId) {
|
|
742
|
+
await strapi2.sessionManager("admin").invalidateRefreshToken(String(session.userId)).catch(
|
|
743
|
+
(e) => strapi2.log.error(
|
|
744
|
+
`[${PLUGIN_ID}] Failed to invalidate DB session for ${session.email}:`,
|
|
745
|
+
e
|
|
746
|
+
)
|
|
590
747
|
);
|
|
591
748
|
}
|
|
749
|
+
const idleSeconds = Math.floor(
|
|
750
|
+
(Date.now() - new Date(session.lastActiveAt).getTime()) / MS_PER_SECOND
|
|
751
|
+
);
|
|
592
752
|
strapi2.log.info(
|
|
593
|
-
`[${PLUGIN_ID}] Auto-logged out admin "${email}" after ${
|
|
753
|
+
`[${PLUGIN_ID}] Auto-logged out admin "${session.email}" after ${idleSeconds}s of inactivity.`
|
|
594
754
|
);
|
|
595
755
|
}
|
|
596
756
|
} catch (err) {
|
|
597
|
-
strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher failed:`, err);
|
|
757
|
+
strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher tick failed:`, err);
|
|
598
758
|
}
|
|
599
759
|
}, CHECK_INTERVAL);
|
|
600
760
|
},
|
|
601
761
|
/**
|
|
602
|
-
* Stops the auto-logout watcher interval and releases the
|
|
603
|
-
|
|
604
|
-
stopAutoLogoutWatcher() {
|
|
605
|
-
if (interval) {
|
|
606
|
-
clearInterval(interval);
|
|
607
|
-
interval = null;
|
|
608
|
-
strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
|
|
609
|
-
}
|
|
610
|
-
},
|
|
611
|
-
/**
|
|
612
|
-
* Manually clears session activity and revoked state for a user.
|
|
762
|
+
* Stops the auto-logout watcher interval and releases the lease so another
|
|
763
|
+
* pod can pick it up immediately rather than waiting for TTL expiry.
|
|
613
764
|
*
|
|
614
|
-
* @
|
|
615
|
-
* @param {string} email - Admin email address
|
|
616
|
-
* @param {string} [reason='manual'] - Reason for clearing (used in log messages)
|
|
765
|
+
* @returns {Promise<void>}
|
|
617
766
|
*/
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
revokedTokenSet.delete(email);
|
|
626
|
-
strapi2.log.info(`[${PLUGIN_ID}] Revoked token cleared for ${email}`);
|
|
627
|
-
}
|
|
767
|
+
async stopAutoLogoutWatcher() {
|
|
768
|
+
if (!interval) return;
|
|
769
|
+
clearInterval(interval);
|
|
770
|
+
interval = null;
|
|
771
|
+
const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE);
|
|
772
|
+
await state2.releaseWatcherLease();
|
|
773
|
+
strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
|
|
628
774
|
}
|
|
629
775
|
});
|
|
776
|
+
const lastDbWriteAt = /* @__PURE__ */ new Map();
|
|
777
|
+
const POD_ID = `${process.env.HOSTNAME ?? "pod"}-${node_crypto.randomUUID()}`;
|
|
778
|
+
const isUniqueConstraintError = (err) => {
|
|
779
|
+
if (!err) return false;
|
|
780
|
+
const msg = String(err.message ?? "");
|
|
781
|
+
return err.code === "23505" || err.code === "ER_DUP_ENTRY" || err.errno === 1062 || err.errno === 19 || /unique/i.test(msg) || /duplicate/i.test(msg);
|
|
782
|
+
};
|
|
783
|
+
const columnFor = (strapi2, uid, attribute) => {
|
|
784
|
+
try {
|
|
785
|
+
const metadata = strapi2.db.metadata.get(uid);
|
|
786
|
+
const attr = metadata?.attributes?.[attribute];
|
|
787
|
+
if (attr?.columnName) return attr.columnName;
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
return attribute.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
791
|
+
};
|
|
792
|
+
const state = ({ strapi: strapi2 }) => {
|
|
793
|
+
const sessionUid = CONTENT_TYPES.ADMIN_SESSION;
|
|
794
|
+
const lockUid = CONTENT_TYPES.LOGIN_LOCK;
|
|
795
|
+
const leaseUid = CONTENT_TYPES.WATCHER_LEASE;
|
|
796
|
+
const tableOf = (uid) => strapi2.db.metadata.get(uid).tableName;
|
|
797
|
+
return {
|
|
798
|
+
/** Stable per-pod identifier; exposed for diagnostics. */
|
|
799
|
+
POD_ID,
|
|
800
|
+
/**
|
|
801
|
+
* Persists the user's last-active timestamp, write-coalesced to once
|
|
802
|
+
* per {@link ACTIVITY_FLUSH_INTERVAL_MS} per session.
|
|
803
|
+
*
|
|
804
|
+
* On first call for a session, performs an INSERT; subsequent calls
|
|
805
|
+
* UPDATE the existing row. A unique-constraint conflict (two pods
|
|
806
|
+
* inserting concurrently) falls back to UPDATE.
|
|
807
|
+
*
|
|
808
|
+
* @param {object} params
|
|
809
|
+
* @param {string} params.sessionId - JWT sessionId
|
|
810
|
+
* @param {number|string} params.userId - Admin user ID
|
|
811
|
+
* @param {string} params.email - Admin email
|
|
812
|
+
* @returns {Promise<void>}
|
|
813
|
+
*/
|
|
814
|
+
async touch({ sessionId, userId, email }) {
|
|
815
|
+
if (!sessionId || !userId || !email) return;
|
|
816
|
+
const now = Date.now();
|
|
817
|
+
const lastWrite = lastDbWriteAt.get(sessionId) ?? 0;
|
|
818
|
+
if (now - lastWrite < ACTIVITY_FLUSH_INTERVAL_MS) return;
|
|
819
|
+
lastDbWriteAt.set(sessionId, now);
|
|
820
|
+
const lastActiveAt = new Date(now);
|
|
821
|
+
try {
|
|
822
|
+
const existing = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["id"] });
|
|
823
|
+
if (existing) {
|
|
824
|
+
await strapi2.db.query(sessionUid).update({
|
|
825
|
+
where: { id: existing.id },
|
|
826
|
+
data: { lastActiveAt }
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
await strapi2.db.query(sessionUid).create({
|
|
831
|
+
data: { sessionId, userId, email, lastActiveAt }
|
|
832
|
+
});
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (isUniqueConstraintError(err)) {
|
|
835
|
+
await strapi2.db.query(sessionUid).update({ where: { sessionId }, data: { lastActiveAt } }).catch((e) => strapi2.log.error(`[${PLUGIN_ID}] touch fallback update failed:`, e));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
strapi2.log.error(`[${PLUGIN_ID}] touch failed for ${email}:`, err);
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
/**
|
|
842
|
+
* Returns true if the given session has been revoked.
|
|
843
|
+
*
|
|
844
|
+
* @param {string} sessionId - JWT sessionId
|
|
845
|
+
* @returns {Promise<boolean>}
|
|
846
|
+
*/
|
|
847
|
+
async isRevoked(sessionId) {
|
|
848
|
+
if (!sessionId) return false;
|
|
849
|
+
const row = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["revokedAt"] });
|
|
850
|
+
return Boolean(row?.revokedAt);
|
|
851
|
+
},
|
|
852
|
+
/**
|
|
853
|
+
* Marks a session as revoked.
|
|
854
|
+
*
|
|
855
|
+
* @param {object} params
|
|
856
|
+
* @param {string} params.sessionId - JWT sessionId
|
|
857
|
+
* @returns {Promise<void>}
|
|
858
|
+
*/
|
|
859
|
+
async revokeSession({ sessionId }) {
|
|
860
|
+
if (!sessionId) return;
|
|
861
|
+
lastDbWriteAt.delete(sessionId);
|
|
862
|
+
await strapi2.db.query(sessionUid).updateMany({ where: { sessionId }, data: { revokedAt: /* @__PURE__ */ new Date() } });
|
|
863
|
+
},
|
|
864
|
+
/**
|
|
865
|
+
* Marks all sessions belonging to the given email as revoked.
|
|
866
|
+
* Used by the auto-logout watcher and email-level revocation paths.
|
|
867
|
+
*
|
|
868
|
+
* @param {object} params
|
|
869
|
+
* @param {string} params.email - Admin email
|
|
870
|
+
* @returns {Promise<number>} Number of sessions revoked
|
|
871
|
+
*/
|
|
872
|
+
async revokeAllForEmail({ email }) {
|
|
873
|
+
if (!email) return 0;
|
|
874
|
+
const result = await strapi2.db.query(sessionUid).updateMany({
|
|
875
|
+
where: { email, revokedAt: { $null: true } },
|
|
876
|
+
data: { revokedAt: /* @__PURE__ */ new Date() }
|
|
877
|
+
});
|
|
878
|
+
return result?.count ?? 0;
|
|
879
|
+
},
|
|
880
|
+
/**
|
|
881
|
+
* Returns true if the email has an active (non-revoked, recent) session.
|
|
882
|
+
* Used by preventMultipleSessions.
|
|
883
|
+
*
|
|
884
|
+
* @param {object} params
|
|
885
|
+
* @param {string} params.email - Admin email
|
|
886
|
+
* @param {number} params.idleThresholdMs - Idle threshold in milliseconds
|
|
887
|
+
* @returns {Promise<boolean>}
|
|
888
|
+
*/
|
|
889
|
+
async hasActiveSession({ email, idleThresholdMs }) {
|
|
890
|
+
if (!email) return false;
|
|
891
|
+
const cutoff = new Date(Date.now() - idleThresholdMs);
|
|
892
|
+
const row = await strapi2.db.query(sessionUid).findOne({
|
|
893
|
+
where: {
|
|
894
|
+
email,
|
|
895
|
+
revokedAt: { $null: true },
|
|
896
|
+
lastActiveAt: { $gte: cutoff }
|
|
897
|
+
},
|
|
898
|
+
select: ["id"]
|
|
899
|
+
});
|
|
900
|
+
return Boolean(row);
|
|
901
|
+
},
|
|
902
|
+
/**
|
|
903
|
+
* Lists session rows that have been idle past the given threshold and
|
|
904
|
+
* are not yet revoked. Returns a small projection only.
|
|
905
|
+
*
|
|
906
|
+
* @param {object} params
|
|
907
|
+
* @param {number} params.idleThresholdMs - Idle threshold in milliseconds
|
|
908
|
+
* @returns {Promise<Array<{ id: number, sessionId: string, userId: number, email: string, lastActiveAt: Date }>>}
|
|
909
|
+
*/
|
|
910
|
+
async listIdleSessions({ idleThresholdMs }) {
|
|
911
|
+
const cutoff = new Date(Date.now() - idleThresholdMs);
|
|
912
|
+
return strapi2.db.query(sessionUid).findMany({
|
|
913
|
+
where: {
|
|
914
|
+
revokedAt: { $null: true },
|
|
915
|
+
lastActiveAt: { $lt: cutoff }
|
|
916
|
+
},
|
|
917
|
+
select: ["id", "sessionId", "userId", "email", "lastActiveAt"]
|
|
918
|
+
});
|
|
919
|
+
},
|
|
920
|
+
/**
|
|
921
|
+
* Atomically attempts to acquire a login lock for the given email.
|
|
922
|
+
* Returns true on success, false if another pod holds an unexpired lock.
|
|
923
|
+
*
|
|
924
|
+
* Uses `SELECT … FOR UPDATE` inside a transaction for cross-DB
|
|
925
|
+
* mutual exclusion (Postgres / MySQL / SQLite).
|
|
926
|
+
*
|
|
927
|
+
* @param {object} params
|
|
928
|
+
* @param {string} params.email - Admin email
|
|
929
|
+
* @returns {Promise<boolean>} True if the lock was acquired
|
|
930
|
+
*/
|
|
931
|
+
async acquireLoginLock({ email }) {
|
|
932
|
+
if (!email) return true;
|
|
933
|
+
const tableName = tableOf(lockUid);
|
|
934
|
+
const emailCol = columnFor(strapi2, lockUid, "email");
|
|
935
|
+
const lockedUntilCol = columnFor(strapi2, lockUid, "lockedUntil");
|
|
936
|
+
const documentIdCol = "document_id";
|
|
937
|
+
const createdAtCol = "created_at";
|
|
938
|
+
const updatedAtCol = "updated_at";
|
|
939
|
+
const now = /* @__PURE__ */ new Date();
|
|
940
|
+
const lockedUntil = new Date(now.getTime() + LOGIN_LOCK_TTL_MS);
|
|
941
|
+
const knex = strapi2.db.connection;
|
|
942
|
+
try {
|
|
943
|
+
return await knex.transaction(async (trx) => {
|
|
944
|
+
const row = await trx(tableName).where({ [emailCol]: email }).first().forUpdate();
|
|
945
|
+
if (!row) {
|
|
946
|
+
try {
|
|
947
|
+
await trx(tableName).insert({
|
|
948
|
+
[documentIdCol]: node_crypto.randomUUID(),
|
|
949
|
+
[emailCol]: email,
|
|
950
|
+
[lockedUntilCol]: lockedUntil,
|
|
951
|
+
[createdAtCol]: now,
|
|
952
|
+
[updatedAtCol]: now
|
|
953
|
+
});
|
|
954
|
+
return true;
|
|
955
|
+
} catch (err) {
|
|
956
|
+
if (isUniqueConstraintError(err)) return false;
|
|
957
|
+
throw err;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const heldUntil = row[lockedUntilCol] ? new Date(row[lockedUntilCol]) : null;
|
|
961
|
+
if (heldUntil && heldUntil > now) return false;
|
|
962
|
+
await trx(tableName).where({ id: row.id }).update({ [lockedUntilCol]: lockedUntil, [updatedAtCol]: now });
|
|
963
|
+
return true;
|
|
964
|
+
});
|
|
965
|
+
} catch (err) {
|
|
966
|
+
strapi2.log.error(`[${PLUGIN_ID}] acquireLoginLock failed for ${email}:`, err);
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
/**
|
|
971
|
+
* Releases the login lock for the given email. Idempotent.
|
|
972
|
+
*
|
|
973
|
+
* @param {object} params
|
|
974
|
+
* @param {string} params.email - Admin email
|
|
975
|
+
* @returns {Promise<void>}
|
|
976
|
+
*/
|
|
977
|
+
async releaseLoginLock({ email }) {
|
|
978
|
+
if (!email) return;
|
|
979
|
+
try {
|
|
980
|
+
await strapi2.db.query(lockUid).deleteMany({ where: { email } });
|
|
981
|
+
} catch (err) {
|
|
982
|
+
strapi2.log.error(`[${PLUGIN_ID}] releaseLoginLock failed for ${email}:`, err);
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
/**
|
|
986
|
+
* Removes login-lock rows whose `lockedUntil` has passed.
|
|
987
|
+
*
|
|
988
|
+
* @returns {Promise<number>} Number of rows deleted
|
|
989
|
+
*/
|
|
990
|
+
async pruneExpiredLocks() {
|
|
991
|
+
const result = await strapi2.db.query(lockUid).deleteMany({
|
|
992
|
+
where: { lockedUntil: { $lt: /* @__PURE__ */ new Date() } }
|
|
993
|
+
});
|
|
994
|
+
return result?.count ?? 0;
|
|
995
|
+
},
|
|
996
|
+
/**
|
|
997
|
+
* Atomically attempts to acquire (or renew) the auto-logout watcher lease.
|
|
998
|
+
* Only the holding pod runs the periodic watcher body cluster-wide.
|
|
999
|
+
*
|
|
1000
|
+
* @returns {Promise<boolean>} True if this pod now holds the lease
|
|
1001
|
+
*/
|
|
1002
|
+
async acquireWatcherLease() {
|
|
1003
|
+
const tableName = tableOf(leaseUid);
|
|
1004
|
+
const nameCol = columnFor(strapi2, leaseUid, "name");
|
|
1005
|
+
const holderCol = columnFor(strapi2, leaseUid, "holder");
|
|
1006
|
+
const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt");
|
|
1007
|
+
const documentIdCol = "document_id";
|
|
1008
|
+
const createdAtCol = "created_at";
|
|
1009
|
+
const updatedAtCol = "updated_at";
|
|
1010
|
+
const now = /* @__PURE__ */ new Date();
|
|
1011
|
+
const expiresAt = new Date(now.getTime() + WATCHER_LEASE_TTL_MS);
|
|
1012
|
+
const knex = strapi2.db.connection;
|
|
1013
|
+
try {
|
|
1014
|
+
return await knex.transaction(async (trx) => {
|
|
1015
|
+
const row = await trx(tableName).where({ [nameCol]: WATCHER_LEASE_NAME }).first().forUpdate();
|
|
1016
|
+
if (!row) {
|
|
1017
|
+
try {
|
|
1018
|
+
await trx(tableName).insert({
|
|
1019
|
+
[documentIdCol]: node_crypto.randomUUID(),
|
|
1020
|
+
[nameCol]: WATCHER_LEASE_NAME,
|
|
1021
|
+
[holderCol]: POD_ID,
|
|
1022
|
+
[expiresAtCol]: expiresAt,
|
|
1023
|
+
[createdAtCol]: now,
|
|
1024
|
+
[updatedAtCol]: now
|
|
1025
|
+
});
|
|
1026
|
+
return true;
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
if (isUniqueConstraintError(err)) return false;
|
|
1029
|
+
throw err;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const heldUntil = row[expiresAtCol] ? new Date(row[expiresAtCol]) : null;
|
|
1033
|
+
const isOwner = row[holderCol] === POD_ID;
|
|
1034
|
+
const expired = !heldUntil || heldUntil <= now;
|
|
1035
|
+
if (!isOwner && !expired) return false;
|
|
1036
|
+
await trx(tableName).where({ id: row.id }).update({
|
|
1037
|
+
[holderCol]: POD_ID,
|
|
1038
|
+
[expiresAtCol]: expiresAt,
|
|
1039
|
+
[updatedAtCol]: now
|
|
1040
|
+
});
|
|
1041
|
+
return true;
|
|
1042
|
+
});
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
strapi2.log.error(`[${PLUGIN_ID}] acquireWatcherLease failed:`, err);
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
},
|
|
1048
|
+
/**
|
|
1049
|
+
* Releases the watcher lease, but only if this pod currently holds it.
|
|
1050
|
+
*
|
|
1051
|
+
* @returns {Promise<void>}
|
|
1052
|
+
*/
|
|
1053
|
+
async releaseWatcherLease() {
|
|
1054
|
+
const tableName = tableOf(leaseUid);
|
|
1055
|
+
const nameCol = columnFor(strapi2, leaseUid, "name");
|
|
1056
|
+
const holderCol = columnFor(strapi2, leaseUid, "holder");
|
|
1057
|
+
const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt");
|
|
1058
|
+
const updatedAtCol = "updated_at";
|
|
1059
|
+
try {
|
|
1060
|
+
await strapi2.db.connection(tableName).where({ [nameCol]: WATCHER_LEASE_NAME, [holderCol]: POD_ID }).update({
|
|
1061
|
+
[holderCol]: "",
|
|
1062
|
+
[expiresAtCol]: /* @__PURE__ */ new Date(0),
|
|
1063
|
+
[updatedAtCol]: /* @__PURE__ */ new Date()
|
|
1064
|
+
});
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
strapi2.log.error(`[${PLUGIN_ID}] releaseWatcherLease failed:`, err);
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
/**
|
|
1070
|
+
* Test-only: clears the per-pod write-coalescing cache.
|
|
1071
|
+
* Exposed for unit tests so each test starts from a clean slate.
|
|
1072
|
+
*
|
|
1073
|
+
* @returns {void}
|
|
1074
|
+
*/
|
|
1075
|
+
_resetActivityCache() {
|
|
1076
|
+
lastDbWriteAt.clear();
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
};
|
|
630
1080
|
const services = {
|
|
631
|
-
autoLogoutChecker
|
|
1081
|
+
autoLogoutChecker,
|
|
1082
|
+
state
|
|
632
1083
|
};
|
|
633
1084
|
const index = {
|
|
634
1085
|
bootstrap,
|