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 CHANGED
@@ -1,43 +1,48 @@
1
1
  # 🛡️ Strapi Security Suite
2
2
 
3
- ### The admin security plugin that takes your sessions _personally_.
3
+ ### The admin security plugin that takes your sessions _personally_ — and now scales horizontally.
4
4
 
5
- > **One plugin. Auto-logout. Single-session enforcement. Token revocation. Password policy.**
6
- > Built for **Strapi v5**. Fueled by in-memory Maps and zero tolerance for stale tokens.
5
+ > **One plugin. Auto-logout. Single-session enforcement. Token revocation. Heartbeat. Multi-pod-safe.**
6
+ > Built for **Strapi v5**. Backed by your existing database. Zero new infrastructure.
7
7
 
8
8
  ---
9
9
 
10
10
  ## 🤔 What Is This?
11
11
 
12
- Imagine a bouncer at a nightclub. But the nightclub is your **Strapi admin panel**, and the bouncer has a _perfect memory_, never sleeps, and will physically escort your idle admins out the door after 30 minutes of doing nothing.
12
+ Imagine a bouncer at a nightclub. But the nightclub is your **Strapi admin panel**, the bouncer has a _perfect memory_, never sleeps, and will physically escort your idle admins out the door after 30 minutes of doing nothing.
13
13
 
14
- **That's this plugin.**
14
+ **That's this plugin.** And in v0.4, the bouncer works across every door of every venue in the franchise — not just the one he's standing at.
15
15
 
16
16
  ```
17
- 🔐 Admin logs in
17
+ 🔐 Admin logs in (Pod A)
18
18
  |
19
- | 👀 Every request tracked (timestamp saved in memory)
19
+ | 👀 Activity tracked → DB (visible to all pods)
20
+ | 🫀 Client heartbeat fires every 30s on mouse/keyboard
20
21
  |
21
- | 😴 Admin goes idle...
22
+ | 😴 Admin walks away from desk...
22
23
  |
23
24
  | ⏰ 30 minutes pass...
24
25
  |
25
- 🚪 BOOM. Logged out. Cookies cleared. Token dead.
26
- No arguments. No appeals. Just security.
26
+ | 👑 Watcher leader (could be Pod C) marks session revoked → DB
27
+ |
28
+ 🚪 Next request to ANY pod → BOOM. Logged out. Cookies cleared. Token dead.
29
+ No coin-flip. No "depends which pod you hit." Just security.
27
30
  ```
28
31
 
29
32
  ---
30
33
 
31
34
  ## ✨ Features at a Glance
32
35
 
33
- | Feature | What It Does | Vibe |
34
- | -------------------------- | ---------------------------------------------- | -------------------- |
35
- | ⏰ **Auto-Logout** | Kicks idle admins after configurable minutes | "Use it or lose it" |
36
- | 🚫 **Single-Session Lock** | One admin = one session. Period. | "No shadow clones" |
37
- | 💀 **Token Revocation** | Dead tokens stay dead. Instantly. | "Ghosts get ghosted" |
38
- | 🔑 **Password Policy** | Expiry + non-reusable passwords (configurable) | "Rotate or regret" |
39
- | ⚙️ **Admin UI** | Settings panel right inside Strapi | "Click, don't code" |
40
- | 🛡️ **Input Validation** | Server-side validation on every settings save | "Trust nobody" |
36
+ | Feature | What It Does | Vibe |
37
+ | -------------------------- | ---------------------------------------------------------- | -------------------- |
38
+ | ⏰ **Auto-Logout** | Kicks idle admins after configurable minutes | "Use it or lose it" |
39
+ | 🫀 **Activity Heartbeat** | Form-filling counts as activity (no spurious idle-logouts) | "We see you typing" |
40
+ | 🚫 **Single-Session Lock** | One admin = one session. Across every pod. | "No shadow clones" |
41
+ | 💀 **Session Revocation** | Per-`sessionId`. Cluster-wide. Instant. | "Ghosts get ghosted" |
42
+ | 🌐 **Multi-Pod Safe** | DB-backed state, leader-elected watcher | "OpenShift-ready" |
43
+ | 🔑 **Password Policy** | Expiry + non-reusable passwords (configurable) | "Rotate or regret" |
44
+ | ⚙️ **Admin UI** | Settings panel right inside Strapi | "Click, don't code" |
45
+ | 🛡️ **Input Validation** | Server-side validation on every settings save | "Trust nobody" |
41
46
 
42
47
  ---
43
48
 
@@ -67,6 +72,8 @@ module.exports = ({ env }) => ({
67
72
  yarn develop
68
73
  ```
69
74
 
75
+ Three new tables — `sss_admin_sessions`, `sss_login_locks`, `sss_watcher_leases` — are auto-created by Strapi on first boot. No manual migration. No new dependencies. No config required.
76
+
70
77
  ### Step 4: Find it
71
78
 
72
79
  Go to **Settings** → **Global** → **Security Suite**
@@ -75,6 +82,19 @@ That's it. You're done. Go get a coffee. ☕
75
82
 
76
83
  ---
77
84
 
85
+ ## 🌐 Why Multi-Pod-Safe Matters
86
+
87
+ In v0.3 the plugin kept its session state — last-active timestamps, revoked emails, login locks — in **per-pod in-memory `Map`s and `Set`s**. On a single-pod deployment, fine. On a horizontally-scaled OpenShift / Kubernetes deployment with multiple replicas behind a load balancer, those data structures **lived independently per pod** and that broke every guarantee the plugin made:
88
+
89
+ - An admin's requests round-robined across N pods → each pod saw only ~1/N of their activity → some pod's watcher decided they'd been idle 30 min and revoked them while they were actively typing.
90
+ - Pod A revoked a session. The next request landed on Pod B → no entry → no force-reload signal → revocation became a 1/N-probability event.
91
+ - A logged-out admin's bearer kept working on every pod that hadn't seen the logout, until the JWT expired.
92
+ - Two concurrent logins for the same email hit different pods → both pods saw empty maps → both succeeded. Single-session enforcement only worked on a single pod, which is exactly when you don't need it.
93
+
94
+ **v0.4 moves all of that state into the database.** Revocation issued on any pod is visible to every other pod on the next request. The watcher is leader-elected so only one pod cluster-wide actually runs the 5-second tick. Login locks are atomic across pods via `SELECT … FOR UPDATE`. No Redis. No new infra. Your existing Postgres / MySQL / SQLite handles it.
95
+
96
+ ---
97
+
78
98
  ## 🖼️ The Admin Panel
79
99
 
80
100
  Once installed, you get a beautiful settings page with two panels:
@@ -86,8 +106,8 @@ Once installed, you get a beautiful settings page with two panels:
86
106
  │ │ │
87
107
  │ 🕐 SESSION MANAGEMENT │ 🔑 PASSWORD MANAGEMENT │
88
108
  │ │ │
89
- │ Auto Logout Time: [30] │ Password Control: [ON] │
90
- │ (minutes) │ │
109
+ │ Auto Logout Time: [30] │ Password Control: [ON] │
110
+ │ (minutes) │ │
91
111
  │ │ Expiry Days: [30] │
92
112
  │ Multi-Session │ │
93
113
  │ Control: [ON] │ Non-Reusable: [ON] │
@@ -97,14 +117,12 @@ Once installed, you get a beautiful settings page with two panels:
97
117
  └───────────────────────────────────────────────────────┘
98
118
  ```
99
119
 
100
- **Everything is stored in the database.** Change a value, hit save, it takes effect immediately. No restarts. No config files. No drama.
120
+ Settings are stored in a single-type DB record. Change a value, hit save, it takes effect immediately. No restarts. No config files.
101
121
 
102
122
  ---
103
123
 
104
124
  ## 🧠 How It Actually Works
105
125
 
106
- Here's the whole flow, explained like you're five (but a very smart five):
107
-
108
126
  ### 🔗 The Middleware Pipeline
109
127
 
110
128
  When any request hits your Strapi server, it passes through **5 security checkpoints** (middlewares), in this exact order:
@@ -114,47 +132,65 @@ When any request hits your Strapi server, it passes through **5 security checkpo
114
132
 
115
133
 
116
134
  1. 🐣 seedUserInfos
117
- │ "Who are you? Let me check your JWT and load your profile"
135
+ │ "Decode the JWT. Pull userId AND sessionId. Hydrate ctx.state."
118
136
 
119
137
 
120
138
  2. 🔍 interceptRenewToken
121
- │ "Trying to renew your token? Let me make sure you're still welcome"
139
+ │ "Logging out? Mark this sessionId revoked in the DB. Cluster-wide."
122
140
 
123
141
 
124
142
  3. 👣 trackActivity
125
- │ "OK you're legit. I'm writing down the time. Don't be idle."
143
+ │ "If this sessionId is revoked 403 + clear cookies. Else stamp lastActiveAt
144
+ │ to the DB (write-coalesced to once per 30s)."
126
145
 
127
146
 
128
147
  4. ☠️ rejectRevokedTokens
129
- │ "Wait... is your session revoked? GET OUT. Cookies deleted. Token dead."
148
+ │ "Belt-and-suspenders revocation check. Sets app.admin.tk header so the
149
+ │ frontend force-reloads. Calls sessionManager.invalidateRefreshToken."
130
150
 
131
151
 
132
152
  5. 🚫 preventMultipleSessions (on login only)
133
- "Already logged in somewhere else? 409 Conflict. One session only."
153
+ "Acquire cross-pod login lock. Refuse with 409 if another active session
154
+ for this email exists anywhere in the cluster."
134
155
  ```
135
156
 
136
- ### ⏱️ The Auto-Logout Watcher
157
+ ### ⏱️ The Auto-Logout Watcher (Leader-Elected)
137
158
 
138
- Running in the background, checking every 5 seconds:
159
+ Every pod runs a `setInterval` every 5 seconds. Inside the tick:
139
160
 
140
161
  ```
141
- 🔄 Every 5 seconds:
162
+ 🔄 Every 5 seconds, every pod:
142
163
 
143
- 🔍 Check each active session in memory
144
- │ 🕐 Compare last activity timestamp vs. configured timeout
145
-
146
- │ 😴 Idle too long?
147
- │ │
148
- │ YES → Add email to revoked set
149
- │ │ → Delete session from activity map
150
- │ │ → Log it: "Auto-logged out admin X after Y seconds"
164
+ ├─→ acquireWatcherLease() (atomic UPDATE on sss_watcher_leases)
151
165
  │ │
152
- NO → Carry on, you're fine 👍
166
+ ├─→ Got it? I'm the leader. Continue.
167
+ │ └─→ Someone else has it? Skip the rest. (1 cheap DB query, done.)
168
+
169
+ │ (Only the leader runs the body below)
170
+
171
+ ├─→ pruneExpiredLocks() (clean up sss_login_locks where lockedUntil < now)
172
+
173
+ ├─→ listIdleSessions({ idleThresholdMs })
174
+ │ SELECT FROM sss_admin_sessions
175
+ │ WHERE revoked_at IS NULL AND last_active_at < (now - threshold)
176
+
177
+ └─→ For each idle session:
178
+ • UPDATE sss_admin_sessions SET revoked_at = NOW() WHERE id = ?
179
+ • sessionManager('admin').invalidateRefreshToken(userId)
180
+ • Log it
153
181
  ```
154
182
 
183
+ If the leader pod dies, its lease (15s TTL) expires and another pod claims it on the next tick. Worst-case revocation lag during failover: 15 seconds.
184
+
185
+ ### 🫀 The Activity Heartbeat
186
+
187
+ A new admin-side hook listens for `mousemove`, `keydown`, `scroll`, `click`, `touchstart` (passive). On any event, **throttled to once per 30 seconds**, it fires `POST /strapi-security-suite/heartbeat`. The middleware chain treats it like any other authenticated request, so `trackActivity` updates `lastActiveAt`.
188
+
189
+ This means a user filling a long form for 25 minutes — generating zero other HTTP traffic — is **not** auto-logged-out. Form-filling is correctly recognized as activity.
190
+
155
191
  ### 🖥️ The Frontend Interceptor
156
192
 
157
- On the admin panel side, `window.fetch` is patched to watch for a special header:
193
+ `window.fetch` is patched to watch for the `app.admin.tk` response header:
158
194
 
159
195
  ```
160
196
  🌐 Admin makes any API call
@@ -162,79 +198,89 @@ On the admin panel side, `window.fetch` is patched to watch for a special header
162
198
 
163
199
  👀 Check response headers for 'app.admin.tk'
164
200
 
165
- YES → 🚨 FORCED LOGOUT 🚨
166
- │ window.stop()
167
- │ window.location.reload()
168
- │ (Session is over. Go home.)
201
+ YES → 🚨 FORCED LOGOUT 🚨 window.location.reload()
169
202
 
170
203
  NO → ✅ Normal response. Continue working.
171
204
  ```
172
205
 
173
206
  ---
174
207
 
208
+ ## 🗃️ DB Schema (auto-created on boot)
209
+
210
+ | Table | Purpose | Key columns |
211
+ | -------------------- | ----------------------------- | -------------------------------------------------------------- |
212
+ | `sss_admin_sessions` | One row per admin session | `session_id` (unique), `email`, `last_active_at`, `revoked_at` |
213
+ | `sss_login_locks` | Cross-pod login lock | `email` (unique), `locked_until` |
214
+ | `sss_watcher_leases` | Watcher leader-election lease | `name` (unique), `holder`, `expires_at` |
215
+
216
+ Hidden from the content-manager and content-type-builder via `pluginOptions`. Strapi creates them on first boot the same way the existing `security-settings` singleType is created — no manual migration step.
217
+
218
+ ---
219
+
175
220
  ## 📂 Project Structure
176
221
 
177
222
  ```
178
223
  strapi-security-suite/
179
224
  📁 admin/src/ ← Admin panel (React)
180
- │ 📄 index.js Plugin entry + fetch interceptor
181
- │ 📄 constants.js API paths, header names
225
+ │ 📄 index.js Plugin entry + fetch interceptor + heartbeat install
226
+ │ 📄 heartbeat.js Throttled activity-heartbeat client hook
227
+ │ 📄 constants.js API paths, header names, heartbeat throttle
182
228
  │ 📄 pluginId.js Plugin ID constant
183
- │ 📁 components/
184
- │ │ 📄 Initializer.jsx Plugin lifecycle init
229
+ │ 📁 components/Initializer.jsx Plugin lifecycle init
185
230
  │ 📁 pages/
186
231
  │ │ 📄 App.jsx Router
187
- │ │ 📄 HomePage.jsx Settings UI (the pretty one)
188
- │ 📁 translations/
189
- │ 📄 en.json i18n strings
232
+ │ │ 📄 HomePage.jsx Settings UI
233
+ │ 📁 translations/en.json i18n strings
190
234
 
191
235
  📁 server/src/ ← Server-side (Node.js)
192
236
  │ 📄 index.js Plugin entry point
193
237
  │ 📄 register.js Middleware registration phase
194
- │ 📄 bootstrap.js Permissions + settings seeding
195
- │ 📄 destroy.js Cleanup on shutdown
238
+ │ 📄 bootstrap.js Permissions + settings seeding + watcher start
239
+ │ 📄 destroy.js Releases watcher lease, stops interval
196
240
  │ 📄 constants.js ⭐ ALL magic values live here
197
241
  │ │
198
242
  │ 📁 controllers/
199
- │ │ 📄 adminSecurityController.js GET/POST settings (with validation!)
243
+ │ │ 📄 adminSecurityController.js GET/POST settings + POST heartbeat
200
244
  │ │
201
245
  │ 📁 services/
202
- │ │ 📄 autoLogoutChecker.js Background watcher (setInterval)
246
+ │ │ 📄 state.js The DB-backed state core (replaces the in-memory globals)
247
+ │ │ 📄 autoLogoutChecker.js Leader-elected background watcher
203
248
  │ │
204
249
  │ 📁 middlewares/
205
- │ │ 📄 seedUserInfos.js Hydrate session from JWT
206
- │ │ 📄 interceptRenewToken.js Block renewal for dead sessions
207
- │ │ 📄 trackActivity.js Record last activity timestamp
208
- │ │ 📄 rejectRevokedTokens.js Nuke revoked sessions
209
- │ │ 📄 preventMultipleSessions.js One-session-per-admin gate
250
+ │ │ 📄 seedUserInfos.js Decode JWT, extract userId + sessionId
251
+ │ │ 📄 interceptRenewToken.js Revoke session on logout (DB-backed)
252
+ │ │ 📄 trackActivity.js Persist lastActiveAt (write-coalesced)
253
+ │ │ 📄 rejectRevokedTokens.js Force-reload signal + cookie clear
254
+ │ │ 📄 preventMultipleSessions.js Cross-pod login lock + active-session check
210
255
  │ │
211
- │ 📁 policies/
212
- │ │ 📄 has-admin-permission.js Route-level permission check
213
- │ │
214
- │ 📁 globals/ ← In-memory state (the "brain")
215
- │ │ 📄 sessionActivityMap.js Map<"id:email", timestamp>
216
- │ │ 📄 revokedTokenSet.js Set<email> of revoked sessions
217
- │ │ 📄 loginLocks.js Set<email> login race-condition guard
256
+ │ 📁 policies/has-admin-permission.js
218
257
  │ │
219
258
  │ 📁 utils/
220
259
  │ │ 📄 errors.js PluginError, ValidationError, AuthorizationError
221
- │ │ 📄 force-expire-admin.js Issue a 1-second JWT to kill client token
260
+ │ │ 📄 clearSessionCookies.js Clears koa.sess, koa.sess.sig, refresh + JWT cookies
222
261
  │ │
223
262
  │ 📁 content-types/
224
- │ │ 📁 security-settings/
225
- │ │ 📄 schema.json DB schema (singleType)
226
- │ │
227
- │ 📁 routes/
228
- │ │ 📄 index.js Admin-typed routes with policies
263
+ │ │ 📁 security-settings/ Plugin config (singleType)
264
+ │ │ 📁 admin-session/ Per-session activity + revocation
265
+ │ │ 📁 login-lock/ Cross-pod login lock
266
+ 📁 watcher-lease/ Watcher leader-election lease
229
267
  │ │
230
- │ 📁 types/
231
- │ 📄 typedefs.js JSDoc type definitions
268
+ │ 📁 routes/index.js Admin-typed routes with policies
232
269
 
233
- 📄 eslint.config.mjs ESLint v9 flat config
234
- 📄 .prettierrc Prettier config
235
- 📄 package.json Scripts, deps, lint-staged
236
- 📁 .husky/
237
- 📄 pre-commit Runs lint-staged before every commit
270
+ 📁 tests/ ← Vitest test suite (66 tests)
271
+ │ 📁 helpers/
272
+ │ │ 📄 strapi-fake.js sqlite :memory: + Knex harness
273
+ │ │ 📄 mock-strapi.js Mock-based ctx + state helpers
274
+ │ 📁 server/
275
+ │ │ 📄 state.test.js 15 tests — touch, revocation, listIdle, hasActiveSession
276
+ │ │ 📄 state.concurrency.test.js 9 tests — multi-pod login lock + watcher lease
277
+ │ │ 📄 seedUserInfos.test.js 6 tests — JWT decode, ctx hydration
278
+ │ │ 📄 trackActivity.test.js 4 tests — touch, revocation rejection
279
+ │ │ 📄 rejectRevokedTokens.test.js 4 tests — header signal, sessionManager
280
+ │ │ 📄 preventMultipleSessions.test.js 8 tests — login lock flow
281
+ │ │ 📄 interceptRenewToken.test.js 3 tests — logout revocation
282
+ │ │ 📄 autoLogoutChecker.test.js 8 tests — leader election + idle revocation
283
+ │ │ 📄 adminSecurityController.test.js 9 tests — heartbeat + settings validation
238
284
  ```
239
285
 
240
286
  ---
@@ -267,16 +313,14 @@ All settings live in a **single-type** content-type in the database:
267
313
 
268
314
  All routes are **admin-typed** (Strapi handles auth automatically):
269
315
 
270
- | Method | Path | Auth | Permission | Description |
271
- | ------ | --------------------------------------- | -------- | ---------------- | --------------- |
272
- | `GET` | `/strapi-security-suite/health` | 🔓 None | — | Health check |
273
- | `GET` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `view-configs` | Read settings |
274
- | `POST` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `manage-configs` | Update settings |
316
+ | Method | Path | Auth | Permission | Description |
317
+ | ------ | --------------------------------------- | -------- | ---------------- | --------------------------------- |
318
+ | `POST` | `/strapi-security-suite/heartbeat` | 🔒 Admin | — | Activity keep-alive (returns 204) |
319
+ | `GET` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `view-configs` | Read security settings |
320
+ | `POST` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `manage-configs` | Update security settings |
275
321
 
276
322
  ### 🔐 Permissions
277
323
 
278
- The plugin registers three permission actions:
279
-
280
324
  | Permission | What It Allows |
281
325
  | ---------------------------------------------- | ------------------------ |
282
326
  | `plugin::strapi-security-suite.access` | Access the settings page |
@@ -285,69 +329,61 @@ The plugin registers three permission actions:
285
329
 
286
330
  ---
287
331
 
288
- ## Engineering Standards Compliance
332
+ ## 💡 Recommended Host-App Configuration
289
333
 
290
- This plugin follows the **STANDARDS.md** operating protocol:
334
+ The plugin works out of the box with Strapi defaults, but for **tighter revocation latency** consider lowering the access-token TTL in your host app's `config/admin.js`:
291
335
 
292
- | Standard | Status | Details |
293
- | ----------------------------- | ------ | --------------------------------------------------------------------- |
294
- | 💬 **ES6+ JavaScript** | ✅ | Arrow functions, destructuring, template literals, native ESM |
295
- | 📄 **Full JSDoc** | ✅ | Every export documented with `@param`, `@returns`, `@module` |
296
- | 🔢 **No Magic Values** | ✅ | All values in `constants.js` (status codes, cookies, headers, timing) |
297
- | 🚨 **Custom Error Hierarchy** | ✅ | `PluginError` `ValidationError`, `AuthorizationError` |
298
- | 🛡️ **Input Validation** | ✅ | `saveSettings` validates keys, types, and shape |
299
- | 🔍 **ESLint** | ✅ | v9 flat config with jsdoc plugin + Prettier compat |
300
- | 🎨 **Prettier** | ✅ | Single quotes, 100 print width, trailing commas |
301
- | 🪝 **Husky + lint-staged** | ✅ | Pre-commit: ESLint fix + Prettier on staged files |
302
- | 🏗️ **Architecture** | ✅ | Routes → Controllers → Services → Data (proper layering) |
303
- | 🔐 **Security-First** | ✅ | Auth/AuthZ separated, input validated, no secrets in logs |
336
+ ```javascript
337
+ module.exports = ({ env }) => ({
338
+ auth: {
339
+ secret: env('ADMIN_JWT_SECRET'),
340
+ options: {
341
+ expiresIn: '2m', // short access tokens, refreshed transparently by the admin frontend
342
+ },
343
+ },
344
+ });
345
+ ```
346
+
347
+ With a 2-minute access-token TTL, a revoked admin loses access within ~2 minutes even if no other request is made (next refresh attempt fails because the refresh token is also invalidated). With the default 30-minute TTL, revocation is enforced on the next request the admin makes (via the `app.admin.tk` force-reload signal) — instant for active admins, up to 30 min for an idle one whose tab is open.
304
348
 
305
349
  ---
306
350
 
307
351
  ## 🛠️ Development
308
352
 
309
353
  ```bash
310
- # Install dependencies
311
- yarn install
312
-
313
- # Build the plugin
314
- yarn build
315
-
316
- # Watch mode (auto-rebuild on changes)
317
- yarn watch
318
-
319
- # Lint everything
320
- yarn lint
321
-
322
- # Lint + auto-fix
323
- yarn lint:fix
324
-
325
- # Check formatting
326
- yarn format:check
327
-
328
- # Auto-format everything
329
- yarn format
330
-
331
- # Verify plugin exports
332
- yarn verify
354
+ yarn install # Install dependencies
355
+ yarn build # Build the plugin
356
+ yarn watch # Auto-rebuild on changes
357
+ yarn lint # ESLint
358
+ yarn lint:fix # ESLint --fix
359
+ yarn format # Prettier
360
+ yarn format:check # Check formatting
361
+ yarn verify # Verify plugin exports
362
+ yarn test # Run the full test suite (66 tests)
363
+ yarn test:watch # Vitest in watch mode
364
+ yarn test:coverage # Coverage report
333
365
  ```
334
366
 
367
+ The state-service tests run against a real sqlite `:memory:` DB via Knex — they exercise the actual SQL the plugin issues, including the `SELECT … FOR UPDATE` paths and `ON CONFLICT` behaviors. Two simulated pods cover the cross-pod concurrency cases.
368
+
335
369
  ---
336
370
 
337
371
  ## 🔮 Roadmap
338
372
 
339
373
  | Feature | Status |
340
374
  | ----------------------------- | ----------------- |
341
- | ⏰ Auto-Logout | ✅ Shipped |
342
- | 🚫 Single-Session Enforcement | ✅ Shipped |
343
- | 💀 Token Revocation Pipeline | ✅ Shipped |
344
- | ⚙️ Admin Settings UI | ✅ Shipped |
375
+ | ⏰ Auto-Logout | ✅ Shipped (v0.1) |
376
+ | 🚫 Single-Session Enforcement | ✅ Shipped (v0.1) |
377
+ | 💀 Session Revocation | ✅ Shipped (v0.1) |
378
+ | ⚙️ Admin Settings UI | ✅ Shipped (v0.1) |
379
+ | 🌐 **Multi-Pod-Safe State** | ✅ Shipped (v0.4) |
380
+ | 🫀 **Activity Heartbeat** | ✅ Shipped (v0.4) |
381
+ | 🧪 **Test Suite (66 tests)** | ✅ Shipped (v0.4) |
345
382
  | 🔑 Password Expiry | 🚧 In Development |
346
383
  | 🔄 Non-Reusable Passwords | 🚧 In Development |
347
384
  | 📝 Admin Activity Logs | 🔜 Planned |
348
385
  | 📊 Security Dashboard | 🔜 Planned |
349
386
  | 👊 Brute Force Detection | 🔜 Planned |
350
- | 👁️ Real-time Session Viewer | 🔜 Planned |
351
387
 
352
388
  ---
353
389
 
@@ -362,6 +398,9 @@ yarn verify
362
398
  > "I left my desk for coffee and came back logged out. Respect."
363
399
  > — Someone who now understands security
364
400
 
401
+ > "We scaled to 8 pods on OpenShift and the plugin… just kept working. Sessions, revocations, locks — all consistent."
402
+ > — A platform engineer in v0.4
403
+
365
404
  ---
366
405
 
367
406
  ## 👥 Author
@@ -380,9 +419,9 @@ yarn verify
380
419
 
381
420
  Security should be:
382
421
 
383
- - **Fast** — In-memory. No database lookups on every request.
422
+ - **Correct under load** — Multi-pod deployments shouldn't degrade the security model into a coin flip.
423
+ - **Cheap to operate** — DB-backed state with write-coalescing. No Redis. No new infra.
384
424
  - **Unforgiving** — Idle? Gone. Revoked? Dead. Duplicated? Blocked.
385
- - **Elegant** — Clean constants, typed errors, layered architecture.
386
425
  - **Mildly judgmental** — This plugin _will_ side-eye your stale sessions.
387
426
 
388
427
  > _"The meta-principle: make the right thing the default thing. Discipline compounds. Shortcuts compound too, just in the wrong direction."_
@@ -5,7 +5,7 @@ const admin = require("@strapi/strapi/admin");
5
5
  const reactRouterDom = require("react-router-dom");
6
6
  const react = require("react");
7
7
  const designSystem = require("@strapi/design-system");
8
- const index = require("./index-BGBd43He.js");
8
+ const index = require("./index-BVsx1rse.js");
9
9
  const HomePage = () => {
10
10
  const client = admin.useFetchClient();
11
11
  const [config, setConfig] = react.useState({
@@ -3,7 +3,7 @@ import { useFetchClient, Page } from "@strapi/strapi/admin";
3
3
  import { Routes, Route } from "react-router-dom";
4
4
  import { useState, useEffect } from "react";
5
5
  import { Box, Typography, Alert, Flex, Divider, NumberInput, Switch, Button } from "@strapi/design-system";
6
- import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-ZKJuPZEH.mjs";
6
+ import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-CrITOuMT.mjs";
7
7
  const HomePage = () => {
8
8
  const client = useFetchClient();
9
9
  const [config, setConfig] = useState({
@@ -28,11 +28,42 @@ const SERVER_PLUGIN_NAME = "strapi-security-suite";
28
28
  const API_BASE_PATH = `/${SERVER_PLUGIN_NAME}`;
29
29
  const ADMIN_TOKEN_HEADER = "app.admin.tk";
30
30
  const SUCCESS_ALERT_DURATION = 3e3;
31
+ const HEARTBEAT_INTERVAL_MS = 3e4;
31
32
  const getTrad = (id) => `${PLUGIN_ID}.${id}`;
33
+ const HEARTBEAT_PATH = `${API_BASE_PATH}/heartbeat`;
34
+ const ACTIVITY_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
35
+ const installHeartbeat = () => {
36
+ if (typeof window === "undefined" || window.__sssHeartbeatInstalled) return;
37
+ window.__sssHeartbeatInstalled = true;
38
+ let lastFiredAt = 0;
39
+ let inFlight = false;
40
+ const fire = async () => {
41
+ const now = Date.now();
42
+ if (inFlight) return;
43
+ if (now - lastFiredAt < HEARTBEAT_INTERVAL_MS) return;
44
+ if (typeof document !== "undefined" && document.hidden) return;
45
+ lastFiredAt = now;
46
+ inFlight = true;
47
+ try {
48
+ await window.fetch(HEARTBEAT_PATH, {
49
+ method: "POST",
50
+ credentials: "include",
51
+ headers: { "Content-Type": "application/json" }
52
+ });
53
+ } catch {
54
+ } finally {
55
+ inFlight = false;
56
+ }
57
+ };
58
+ for (const evt of ACTIVITY_EVENTS) {
59
+ window.addEventListener(evt, fire, { passive: true });
60
+ }
61
+ };
32
62
  const index = {
33
63
  /**
34
- * Registers the plugin with the Strapi admin app and patches
35
- * `window.fetch` to intercept forced-logout signal headers.
64
+ * Registers the plugin with the Strapi admin app, patches `window.fetch`
65
+ * to intercept forced-logout signal headers, and installs the activity
66
+ * heartbeat listener.
36
67
  *
37
68
  * @param {Object} app - Strapi admin application instance
38
69
  */
@@ -56,6 +87,7 @@ const index = {
56
87
  };
57
88
  window.__secureFetchPatched = true;
58
89
  }
90
+ installHeartbeat();
59
91
  },
60
92
  /**
61
93
  * Adds the Security Suite settings link under Settings > Global.
@@ -70,7 +102,7 @@ const index = {
70
102
  id: getTrad("settings.title"),
71
103
  defaultMessage: "Security Suite"
72
104
  },
73
- Component: () => Promise.resolve().then(() => require("./App-ConqHB2Q.js")),
105
+ Component: () => Promise.resolve().then(() => require("./App-B-CRozv4.js")),
74
106
  permissions: [
75
107
  {
76
108
  action: "plugin::strapi-security-suite.access",
@@ -27,11 +27,42 @@ const SERVER_PLUGIN_NAME = "strapi-security-suite";
27
27
  const API_BASE_PATH = `/${SERVER_PLUGIN_NAME}`;
28
28
  const ADMIN_TOKEN_HEADER = "app.admin.tk";
29
29
  const SUCCESS_ALERT_DURATION = 3e3;
30
+ const HEARTBEAT_INTERVAL_MS = 3e4;
30
31
  const getTrad = (id) => `${PLUGIN_ID}.${id}`;
32
+ const HEARTBEAT_PATH = `${API_BASE_PATH}/heartbeat`;
33
+ const ACTIVITY_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
34
+ const installHeartbeat = () => {
35
+ if (typeof window === "undefined" || window.__sssHeartbeatInstalled) return;
36
+ window.__sssHeartbeatInstalled = true;
37
+ let lastFiredAt = 0;
38
+ let inFlight = false;
39
+ const fire = async () => {
40
+ const now = Date.now();
41
+ if (inFlight) return;
42
+ if (now - lastFiredAt < HEARTBEAT_INTERVAL_MS) return;
43
+ if (typeof document !== "undefined" && document.hidden) return;
44
+ lastFiredAt = now;
45
+ inFlight = true;
46
+ try {
47
+ await window.fetch(HEARTBEAT_PATH, {
48
+ method: "POST",
49
+ credentials: "include",
50
+ headers: { "Content-Type": "application/json" }
51
+ });
52
+ } catch {
53
+ } finally {
54
+ inFlight = false;
55
+ }
56
+ };
57
+ for (const evt of ACTIVITY_EVENTS) {
58
+ window.addEventListener(evt, fire, { passive: true });
59
+ }
60
+ };
31
61
  const index = {
32
62
  /**
33
- * Registers the plugin with the Strapi admin app and patches
34
- * `window.fetch` to intercept forced-logout signal headers.
63
+ * Registers the plugin with the Strapi admin app, patches `window.fetch`
64
+ * to intercept forced-logout signal headers, and installs the activity
65
+ * heartbeat listener.
35
66
  *
36
67
  * @param {Object} app - Strapi admin application instance
37
68
  */
@@ -55,6 +86,7 @@ const index = {
55
86
  };
56
87
  window.__secureFetchPatched = true;
57
88
  }
89
+ installHeartbeat();
58
90
  },
59
91
  /**
60
92
  * Adds the Security Suite settings link under Settings > Global.
@@ -69,7 +101,7 @@ const index = {
69
101
  id: getTrad("settings.title"),
70
102
  defaultMessage: "Security Suite"
71
103
  },
72
- Component: () => import("./App-CBOxzfqu.mjs"),
104
+ Component: () => import("./App-CM1kp54o.mjs"),
73
105
  permissions: [
74
106
  {
75
107
  action: "plugin::strapi-security-suite.access",
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-BGBd43He.js");
2
+ const index = require("../_chunks/index-BVsx1rse.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-ZKJuPZEH.mjs";
1
+ import { i } from "../_chunks/index-CrITOuMT.mjs";
2
2
  export {
3
3
  i as default
4
4
  };