docs(quick-261022-vfz): user-plugin /login throttle via surf.ClientIP and whole-second tokens_valid_after writers

This commit is contained in:
Jakub Zych
2026-10-22 23:08:04 +02:00
parent c309a1abe9
commit e9ebf93d36
6 changed files with 560 additions and 7 deletions

View File

@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-10-22)
Phase: 19.7 (Generic MCP module (INSERTED)) — EXECUTING
Plan: 2 of 5
Status: Ready to execute
Last activity: 2026-10-22 — Completed quick task 261022-usi: user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase (logout revokes under serve)
Last activity: 2026-10-22 — Completed quick task 261022-vfz: user-plugin /login throttle via surf.ClientIP and whole-second tokens_valid_after writers
Progress: [████████░░] 82%
@@ -1047,6 +1047,7 @@ Recent decisions affecting current work:
| 261022-tb4 | Password /login refuses a not-activated account while require_activation is on (sm-user-plugin 15ff979, unreleased: hold until the escalation batch lands) | 2026-10-22 | 15ff979 (sm-user-plugin) | [261022-tb4-password-login-must-refuse-is-activated-](./quick/261022-tb4-password-login-must-refuse-is-activated-/) |
| 261022-tto | User plugin /fetch, /refresh, /update, /change-password accept token/auth_token session cookies (sm-user-plugin 8e4b863, ships v0.5.0) | 2026-10-22 | 8e4b863 (sm-user-plugin) | [261022-tto-fetch-refresh-update-change-password-mus](./quick/261022-tto-fetch-refresh-update-change-password-mus/) |
| 261022-usi | User plugin jwt blacklist and GORM hooks wired via lagoon.OnDatabase so logout revokes under serve; jwt guard uses SessionCookieNames (sm-user-plugin 4bf44fc, ships v0.5.0) | 2026-10-22 | 4bf44fc (sm-user-plugin) | [261022-usi-user-plugin-jwt-blacklist-and-hooks-wire](./quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/) |
| 261022-vfz | User plugin /login throttle keyed on surf.ClientIP (trusted proxies); reset-password and admin password set stamp tokens_valid_after in whole seconds (sm-user-plugin b6f8055, a484d0e, ships v0.5.0; verified) | 2026-10-22 | b6f8055, a484d0e (sm-user-plugin) | [261022-vfz-user-plugin-login-throttle-via-surf-clie](./quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) |
## Deferred Items

View File

@@ -27,8 +27,8 @@ Reviewed 2026-10-22. The list is `golem15.user` (sm-user-plugin), not cabana adm
| 6 | G8 API token guard ignores `tokens_valid_after` | Decline | — | — | declined | n/a |
| 7 | Cookie option on `/login` | Feature | [2026-10-22-user-login-opt-in-session-cookie.md](../todos/pending/2026-10-22-user-login-opt-in-session-cookie.md) | strnsccy | open | |
| 8 | Password-routes 404 switch | Feature | [2026-10-22-user-password-routes-404-switch.md](../todos/pending/2026-10-22-user-password-routes-404-switch.md) | h7yrafgd | open | |
| 9 | Lockout keyed on `RemoteAddr` | Fix | with 10: [2026-10-22-user-login-lockout-and-same-second-cutoff.md](../todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | open | |
| 10 | Same-second token refusal | Fix | with 9, same todo | sniewiif | open | |
| 9 | Lockout keyed on `RemoteAddr` | Fix | [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | done (unreleased) | `b6f8055` → v0.5.0 |
| 10 | Same-second token refusal | Fix | [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | done (unreleased) | `a484d0e` → v0.5.0 |
Status values: `open` · `done` · `declined`. Shipped: local commit now; replace with the v0.5.0 plugin/framework tags at release. Do not write a mid-wave plugin tag.
@@ -60,8 +60,8 @@ Suggested order if you care about risk: G4 (serve blacklist), then G3, G2, 9+10,
- **G8.** Personal tokens die via `revoked_at`. `RevokeUserAccess` already sets that on deactivate/ban. Do not teach the API-token guard a JWT cutoff. Password reset killing API tokens would be a separate, explicit revoke.
- **7.** Sociallogin already has `token_cookie` for OAuth. Password `/login` does not set a cookie. `--discuss` must decide cookie rotation on `/refresh` (G3 left the body-only rotate) and keep SameSite=Lax (no new CSRF token).
- **8.** Plugin config; no framework wrap.
- **9.** `/login` uses `RemoteAddr`; `/register` already uses `surf.ClientIP`.
- **10.** `RevokeUserAccess` already truncates. Reset-password and the admin password set still stamp `time.Now()`.
- **9.** Done locally (`b6f8055`). /login keys the throttle on surf.ClientIP with http.trusted_proxies (the /register expression); RemoteIP stays the RemoteAddr helper; without trusted proxies X-Forwarded-For is ignored.
- **10.** Done locally (`a484d0e`). /reset-password and the admin password set truncate the cutoff to whole seconds like RevokeUserAccess; change-password and bouncer unchanged.
## Reply to send
@@ -101,9 +101,9 @@ TODO: ships in SummerCMS v0.5.0 — config key <name>.
Login lockout keyed on RemoteAddr
Agreed. /login used RemoteAddr; /register already uses the trusted-proxy client address. Behind a reverse proxy five failures locked everyone.
TODO: ships in SummerCMS v0.5.0 — /login throttle uses surf.ClientIP.
Ships in SummerCMS v0.5.0 (sm-user-plugin b6f8055, tagged with that wave): /login counts failed logins per account and client address, and the client address is now the one /register and the user-api rate limit use: behind a proxy listed in http.trusted_proxies, the X-Forwarded-For client; otherwise the connection address. An X-Forwarded-For header from an address not in http.trusted_proxies is ignored, so it cannot dodge or move a lockout. List your nginx/Caddy address in http.trusted_proxies; without it every visitor still counts as the proxy.
Same-second token refusal
Agreed. JWT iat is whole seconds; some tokens_valid_after writers stamped sub-second time. RevokeUserAccess already truncates. Reset-password and the admin password set will too.
TODO: ships in SummerCMS v0.5.0 — a token minted in the same second as a reset is accepted.
Ships in SummerCMS v0.5.0 (sm-user-plugin a484d0e, tagged with that wave): POST /reset-password and an administrator's password set on the Users form stamp tokens_valid_after with the current whole second, as deactivate and ban already do, so a token minted in the same second as the reset (the login right after it) is accepted and every token from an earlier second is still refused. As with deactivate and ban, a token issued earlier within that same second also stays valid, because iat has no finer resolution. /change-password and the guard's comparison are unchanged.
```

View File

@@ -0,0 +1,327 @@
---
phase: quick-261022-vfz
plan: 01
quick_id: 261022-vfz
slug: user-plugin-login-throttle-via-surf-clie
type: execute
mode: quick
wave: 1
depends_on: []
autonomous: true
requirements:
- vfz-1 # item 9: POST /login keys its failed-login throttle on surf.ClientIP with http.trusted_proxies (as /register does); exported RemoteIP stays the RemoteAddr host helper with a corrected doc comment
- vfz-2 # item 10: /reset-password and the admin Users password set stamp tokens_valid_after truncated to whole seconds (as RevokeUserAccess does); /change-password and bouncer's comparison unchanged
- vfz-3 # escalation note items 9 and 10 (tracker, verdicts, reply) and the todo's Outcome filled; left uncommitted for the orchestrator
files_modified:
# sm-user-plugin (module git.golem15.com/golem15/sm-user-plugin), worked in the sm-demo-app submodule checkout. Commit there only.
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/login_client_ip_test.go
- ../sm-demo-app/plugins/golem15/user/same_second_cutoff_test.go
- ../sm-demo-app/plugins/golem15/user/README.md
# summercms.go planning docs, edited by Task 3 and NOT committed (the orchestrator commits docs)
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md
estimate:
tokens: 85000
raw_tokens: 85000
tasks: 3
confidence: low # estimate-calibration: factor 1, sample_count 0
must_haves:
truths:
- "With http.trusted_proxies listing the proxy, five wrong passwords sent through that proxy with X-Forwarded-For 203.0.113.10 leave exactly one user_throttle row for the account, keyed 203.0.113.10, with 5 attempts and suspended; the correct password from 203.0.113.10 then gets the opaque 401, while the correct password from 203.0.113.20 through the same proxy gets 200 and a token."
- "Without http.trusted_proxies (the default), X-Forwarded-For is ignored for the /login throttle: failures from one connection address with a different X-Forwarded-For each time all count on one row keyed on the connection address, and a further forged X-Forwarded-For does not escape that suspension."
- "controllers.RemoteIP keeps its signature and behaviour (the host of r.RemoteAddr); its doc comment no longer says /login uses it."
- "After POST /reset-password and after an administrator sets a password on the Users form, users.tokens_valid_after is a whole second. A session token whose iat is that second is accepted on a jwt.auth route, at /fetch and at /refresh; a token issued one second earlier is refused at all three; and a login right after the reset yields a token that works. email_verified_at is still written by the reset."
- "/change-password still writes iat minus one second, and summercms.go modules/bouncer is unmodified."
- "go vet and the full Docker-backed go test of sm-user-plugin pass at each of two local fix commits on master (throttle first, cutoff second), neither pushed nor tagged; go.mod/go.sum unchanged; the sm-demo-app pointer is not bumped."
- "Escalation note rows 9 and 10 read done (unreleased) with their commit SHA and v0.5.0, their verdicts and reply paragraphs are filled with no TODO left for them, and the todo carries an Outcome section and stays in pending/, all uncommitted."
artifacts:
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
provides: "Login throttle IP from surf.ClientIP with the trusted proxies; reset-password cutoff truncated to the second; RemoteIP doc comment corrected"
contains: "now.Truncate(time.Second)"
- path: ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
provides: "FormBeforeUpdate stamps TokensValidAfter truncated to the second when the password changes"
contains: "now := time.Now().Truncate(time.Second)"
- path: ../sm-demo-app/plugins/golem15/user/login_client_ip_test.go
provides: "TestLoginThrottleClientIP (Docker, skips under -short) and the adminEnv.loginFrom helper"
contains: "func TestLoginThrottleClientIP"
- path: ../sm-demo-app/plugins/golem15/user/same_second_cutoff_test.go
provides: "TestPasswordCutoffSameSecond (Docker, skips under -short): reset-password and admin password set subtests"
contains: "func TestPasswordCutoffSameSecond"
- path: ../sm-demo-app/plugins/golem15/user/README.md
provides: "Login throttling bullet names the client-address rule and http.trusted_proxies; RemoteIP described as the RemoteAddr host with no proxy handling; Testing names TestLoginThrottleClientIP"
contains: "http.trusted_proxies"
- path: .planning/notes/2026-10-22-external-user-plugin-escalations.md
provides: "rows 9 and 10 done (unreleased) with SHAs, verdicts and reply paragraphs filled"
contains: "done (unreleased)"
- path: .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md
provides: "## Outcome (2026-10-22) section before ## Invoke"
contains: "## Outcome"
key_links:
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Login)
to: summercms.go modules/surf/clientip.go (ClientIP, TrustedProxies)
via: "ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app))), read per request, feeds RejectIfThrottled, refuseLogin and CheckAndRecordLogin"
pattern: "surf\\.ClientIP\\(r, surf\\.TrustedProxies\\(appConfig\\(app\\)\\)\\)"
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (ResetPassword) and controllers/users_admin_controller.go (FormBeforeUpdate)
to: summercms.go modules/bouncer/jwt.go (issuedBeforeCutoff), controllers/api_controller.go refresh check (:286) and classes/session_user.go RequestUser (:83)
via: "all three compare iat.Before(tokens_valid_after); iat is whole seconds (bouncer.Mint uses jwt.NewNumericDate), so a whole-second cutoff accepts a token minted in the same second"
pattern: "Truncate\\(time\\.Second\\)"
- from: Task 1 and Task 2 commit SHAs (sm-user-plugin master)
to: .planning/notes/2026-10-22-external-user-plugin-escalations.md rows 9 and 10 and reply paragraphs
via: "Task 3 reads each SHA with git log --format=%h --grep on the commit subject"
pattern: "→ v0\\.5\\.0"
---
<objective>
Close external escalation items 9 and 10 in sm-user-plugin.
Item 9: POST /login keys its failed-login throttle on the host of r.RemoteAddr. Behind nginx or Caddy every visitor has the proxy's address, so five wrong passwords lock the account for everyone for 15 minutes. /register and the user-api rate limit already use surf.ClientIP with http.trusted_proxies. /login will do the same. The same `ip` variable also feeds RejectIfThrottled and refuseLogin (G2's activation refusal), so one assignment fixes all three. Exported controllers.RemoteIP stays as the RemoteAddr host helper (social_hooks_test uses it, and it is public API of a core plugin). Only its doc comment changes.
Item 10: JWT iat is whole seconds. Every cutoff check is iat.Before(tokens_valid_after). RevokeUserAccess already stamps the cutoff truncated to the second. /reset-password and the admin Users password set stamp sub-second time, so a token minted later in the same second is refused, intermittently. Both writers will truncate down. /change-password (iat minus one second) and bouncer's comparison stay as they are.
Purpose: one attacker behind a shared proxy cannot lock everyone else out of an account, and a login right after a password reset no longer gets 401 at random.
Output: two local fix commits on sm-user-plugin master (one logical change each, per CLAUDE.md), made in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user with git -C. Then the escalation note and the todo in summercms.go are filled and left uncommitted. No push, no tag and no sm-demo-app pointer bump, because of the SummerCMS v0.5.0 release hold. summercms.go code (modules/bouncer, modules/surf) is not modified.
Planner's choices (quick task, no CONTEXT.md; the executor does not revisit them):
- QD-1 Two commits, not one. CLAUDE.md says one logical change per commit, and the throttle key and the cutoff precision are separate fixes. Each escalation row and reply paragraph then names its own SHA.
- QD-2 /login uses exactly /register's expression, surf.ClientIP(r, surf.TrustedProxies(appConfig(app))), read per request, so a config value set after Boot (as the harness's configure functions do) applies. The unexported remoteIP stays because RemoteIP still calls it.
- QD-3 In /reset-password only the tokens_valid_after value is truncated (now.Truncate(time.Second), inline in the Updates map). email_verified_at keeps `now` (D-27 address proof, sub-second precision is harmless there). In FormBeforeUpdate, `now` feeds only TokensValidAfter, so the assignment itself becomes time.Now().Truncate(time.Second).
- QD-4 The tests go through the real router (newAdminEnv: surf.Assemble with the jwt guard, the user-api limiter and the admin API), so the throttle test sees the request as nginx would deliver it and the cutoff test covers all three comparison sites (bouncer's jwt guard on GET /tokens, /fetch, /refresh). The same-second proof is deterministic: the stored cutoff is checked to be a whole second, and tokens are crafted with iat equal to the cutoff (accepted) and one second earlier (refused) using userAudienceToken. A real login right after the reset is also checked, after aligning to a fresh second, but whether it falls in the same second is only logged, never asserted, because a slow run would make that flaky.
- QD-5 README: only behaviour that names the /login throttle key changes: the Login throttling bullet, the RemoteIP clause, and one Testing paragraph in the throttle commit. The cutoff commit touches no README text. No documented behaviour states the reset cutoff's precision; the RevokeUserAccess entry already says "before the current second".
Source coverage audit:
| Source | Item | Covered by |
|--------|------|------------|
| GOAL / todo Solution | /login throttle IP = surf.ClientIP with trusted proxies, same as /register | Task 1 |
| todo Solution | refuseLogin records the same ip (one assignment) | Task 1 (same `ip` variable; TestLoginThrottleClientIP counts refusals on the XFF row) |
| todo Solution | exported RemoteIP stays; doc comment no longer claims /login | Task 1 |
| todo Solution | /reset-password and admin password set truncate the cutoff; change-password and bouncer untouched | Task 2 (code + grep gates + bouncer diff check) |
| todo Tests | XFF behind a trusted proxy is the /login throttle key | Task 1 TestLoginThrottleClientIP |
| todo Tests | reset (and admin password set) then a same-second token succeeds | Task 2 TestPasswordCutoffSameSecond |
| todo / orchestrator | hold push/tag (v0.5.0); base 4bf44fc | Tasks 1-2 commit gates |
| orchestrator | fill items 9 and 10 (tracker, reply), todo Outcome, stays in pending, not committed | Task 3 |
| orchestrator | README only if the throttle key is documented | Task 1 (it is: README :23 and :183) |
| REQ / RESEARCH / CONTEXT | none (quick task, no research, no discuss) | n/a |
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/STATE.md
@.planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md
@.planning/notes/2026-10-22-external-user-plugin-escalations.md
@../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
@modules/surf/clientip.go
Working copies (observed at planning time, 2026-10-22):
- sm-user-plugin is at ../sm-demo-app/plugins/golem15/user (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user). Branch master at 4bf44fc (G4), 3 ahead of origin/master, working tree clean, no tag on HEAD. Do not fetch, merge, rebase or reset. If master has moved past 4bf44fc, build on top and record `git log --oneline 4bf44fc..master` in the SUMMARY. If the tree is dirty, stop: another session is working there.
- Go: sm-demo-app's go.work does not list the plugin under `use`, so every go command in the plugin dir runs with GOWORK=off. go.mod:176 replaces summercms with ../../../../summercms.go. sm-demo-app's go.work replaces sm-user-plugin with this checkout for the sociallogin and oauthserver consumers.
- Docker: docker info answered (server 29.9.1). If the socket is denied, run go test through sudo -u golem (CLAUDE.md).
- sm-demo-app (../sm-demo-app) has another session's uncommitted edits (main.go and the plugins/golem15/user pointer). It is read-only here: run tests, never stage or commit.
- summercms.go has other sessions' uncommitted edits (.planning/state.json, untracked .msd/ and phase .gitkeep files). Leave them alone. STATE.md, g15office and every commit in summercms.go belong to the orchestrator.
Source facts (plugin checkout at 4bf44fc):
- controllers/api_controller.go:
- Login doc comment :46-57 and handler :58-140. `ip := remoteIP(r)` :77 feeds RejectIfThrottled :79, refuseLogin :89 and :96 (the G2 activation gate), and CheckAndRecordLogin :107.
- Register :746 already has `ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))`. surf is already imported; appConfig :883 is nil-safe.
- ResetPassword :373-430. `now := time.Now()` :418 feeds both `"tokens_valid_after": now` :422 and `"email_verified_at": now` :423 in one Updates map. gofmt aligns those keys; "tokens_valid_after" is the longest, so it takes one space after its colon.
- ChangePassword cutoff `cutoff := iat.Add(-time.Second)` :674, written at :679 and :686. Leave it alone.
- Refresh-side cutoff check `iat.Time.Before(*user.TokensValidAfter)` :286.
- RemoteIP doc comment :1162-1164 (it claims /login's throttle uses it), RemoteIP :1165-1167, remoteIP :1169-1178 (returns raw RemoteAddr when it has no port).
- controllers/users_admin_controller.go: FormBeforeUpdate :216-249. When storePassword reports a change, :243-246 runs `now := time.Now()` and `user.TokensValidAfter = &now`; cabana's Save writes it.
- classes/admin_actions.go RevokeUserAccess :106-120, `cutoff := time.Now().Truncate(time.Second)` :113 (the reference). classes/session_user.go :83 RequestUser cutoff check.
- classes/throttle.go: rows are per (user_id, ip_address). RejectIfThrottled and CheckAndRecordLogin select `user_id = ? AND (ip_address = ? OR ip_address IS NULL)`. A missing row is created with IPAddress &ip on the first recorded attempt, success included.
- summercms.go (read-only): surf.ClientIP uses RemoteAddr unless it lies inside a trusted prefix; then it takes the rightmost X-Forwarded-For hop outside the trusted prefixes. It returns "" for an unparseable RemoteAddr and unmaps IPv4-mapped IPv6. surf.TrustedProxies reads http.trusted_proxies ([]string or []any of CIDRs). bouncer.Mint sets IssuedAt with jwt.NewNumericDate (whole seconds). issuedBeforeCutoff is `iat.Before(user.TokensValidAfter)` (jwt.go:172-174).
- Plugin.Buckets (plugin.go:146-166): the user-api limiter (120/min) keys on the verified bearer sub or on surf.ClientIP with the trusted proxies read at Assemble.
- Tests (package user, plugin root):
- admin_harness_test.go: adminHarnessDB :91 (skips under -short); newAdminEnv(t, configure...) :165. Configure functions run after party.Activate and before Assemble, and each calls cfg.Set and t.Fatal on error (see admin_groups_edge_test.go:120).
- adminEnv: call/send :287/:301. send sets Host example.com, Accept-Language en and Content-Type, and gives every request its own RemoteAddr 198.51.100.N:4000, so it cannot model a fixed proxy. Also expect :416, seedUser :513 (password correct-horse, unique email), reload :554, admin(t, perms...) :242, userLogin :682, userPath(id, suffix) :507, adminStamp :85.
- logout_test.go: guarded(t, token, cookie) :220 (GET /_user/api/v1/tokens, jwt.auth) and userAudienceToken(t, sub, jti, iat, exp) :291 (signs like bouncer.Mint).
- offboarding_test.go: fetchCode :45, refresh :50, assertRefreshRefused :55.
- session_test.go: invalidLoginBody, parseToken :644. session_cookie_test.go: userSub :237.
- Existing coverage that must stay green: TestLogin (seeds a suspended row at 192.0.2.1, httptest's default address, and expects the block), TestLoginSixthAttempt, TestLoginRequiresActivation, TestRegistrationExports (RemoteIP with and without a port; X-Forwarded-For ignored), TestResetPassword, TestAdminUserPassword and TestAdminPrivilegedMember T-12.1-21 (both sleep 1.1s and check an older token is refused), TestRevokeUserAccess in classes (checks the whole-second cutoff).
- README.md: the Login throttling bullet :23 ("Failed logins are counted per user and IP in `user_throttle` ..."). The `RefreshTTL`/`RemoteIP` clause :183 says /login keys its throttle on RemoteIP. The social-login helpers list :43 names RemoteIP (keep it). Testing section :247-272; the TestLoginRequiresActivation paragraph is :268. README does not mention http.trusted_proxies today.
- The names TestLoginThrottleClientIP, TestPasswordCutoffSameSecond, loginFrom and waitForFreshSecond, and the files login_client_ip_test.go and same_second_cutoff_test.go, are all unused today.
- Escalation note (summercms.go): tracker rows 9 and 10 at :30-31 (status open, Shipped empty). Rows 1-3 :22-24 are the format. Verdicts **9.** :63 and **10.** :64; G3/G4 :56-57 are the "Done locally (`sha`). ..." format. Reply TODO lines :104 (under "Login lockout keyed on RemoteAddr") and :108 (under "Same-second token refusal"). G2-G4 "Ships in SummerCMS v0.5.0 (sm-user-plugin <sha>, tagged with that wave): ..." lines :75, :79, :83 are the format. The note has four lines starting with TODO today; the two for items 7 and 8 stay.
- Todo: the Outcome section goes between "## Solution" and "## Invoke", as in .planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md:33-35.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (tracer): /login throttles per client address behind a trusted proxy, end to end through the router on Postgres, committed in sm-user-plugin</name>
<files>../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/login_client_ip_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
<precondition>git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user status --short is empty, its branch is master, and 4bf44fc is an ancestor of HEAD. Docker is reachable for testcontainers, directly or through sudo -u golem.</precondition>
<behavior>
- TestLoginThrottleClientIP (login_client_ip_test.go, package user, Docker through newAdminEnv; skips under -short). Two subtests, each with its own env and a fresh seedUser account (password correct-horse).
- Subtest "behind a trusted proxy": newAdminEnv configured with http.trusted_proxies = []string{"10.0.0.0/8"}. All requests come from RemoteAddr 10.0.0.2:5000.
- Five wrong-password POSTs to /_user/api/v1/login with X-Forwarded-For 203.0.113.10 each answer 401 with invalidLoginBody.
- The account then has exactly one user_throttle row: ip_address 203.0.113.10, attempts 5, is_suspended true.
- The correct password with X-Forwarded-For 203.0.113.10 answers 401 invalidLoginBody.
- The correct password with X-Forwarded-For 203.0.113.20 through the same proxy answers 200 with a non-empty token.
- Subtest "untrusted X-Forwarded-For is ignored": newAdminEnv with no http.trusted_proxies. All requests come from RemoteAddr 192.0.2.50:4000.
- Five wrong-password POSTs, carrying X-Forwarded-For 203.0.113.1 through 203.0.113.5 in turn, each answer 401.
- Exactly one row exists, keyed 192.0.2.50, with attempts 5 and suspended.
- The correct password with X-Forwarded-For 203.0.113.99 from 192.0.2.50 answers 401 invalidLoginBody.
- The correct password from RemoteAddr 192.0.2.51:4000 answers 200: the throttle is still per address without proxies.
- RED at 4bf44fc: the trusted-proxy subtest fails at the row assertion, because the row is keyed 10.0.0.2. The untrusted subtest already passes at 4bf44fc and pins the anti-spoofing property against a future blind trust of X-Forwarded-For.
</behavior>
<action>
RED. Write login_client_ip_test.go (package user) with TestLoginThrottleClientIP as the behavior block describes. Its doc comment says it pins the /login throttle key: the trusted-proxy client address, as /register and the user-api limit use, and never an X-Forwarded-For header from an untrusted peer.
Add the helper (e *adminEnv) loginFrom(t *testing.T, remoteAddr, forwardedFor, email, password string) *httptest.ResponseRecorder in the same file. It builds a POST /_user/api/v1/login request the way send does: JSON body {"email","password"}, Host example.com, Accept-Language en, Content-Type application/json. It sets req.RemoteAddr = remoteAddr, sets X-Forwarded-For only when forwardedFor is non-empty, and serves it through e.h. A short doc comment explains why send cannot be used: send gives every request its own address.
The trusted-proxy subtest's configure function calls cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}) and calls t.Fatal on error, the same way admin_groups_edge_test.go:120 does. Read the rows with e.gdb.Where("user_id = ?", user.ID).Find into []models.Throttle, and compare *IPAddress (it is a pointer). Check the rows before the 203.0.113.20 login, because a successful login creates that client's own row.
Run the test. It must fail against the unchanged handler; record the failure line for the SUMMARY. Do not commit a red test.
GREEN, per QD-2. In controllers/api_controller.go Login, replace the :77 assignment with ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app))), the expression Register uses at :746. Leave RejectIfThrottled, refuseLogin and CheckAndRecordLogin as they are; they already take `ip`.
Extend the Login doc comment by one sentence: the throttle counts failed attempts per user and client address, which is surf.ClientIP with http.trusted_proxies, as for /register.
Rewrite the RemoteIP doc comment. It should say that RemoteIP returns the host part of r.RemoteAddr (r.RemoteAddr itself when it has no port, "" when it is empty), with no proxy handling. It should also say that /login and /register key their throttles on surf.ClientIP with the trusted proxies, and that RemoteIP stays for callers that want the connection address. Keep the RemoteIP and remoteIP code unchanged. gofmt the file.
README.md, in the same commit (per QD-5):
- Rewrite the Login throttling bullet (:23) so it says what follows. Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit. Behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
- At :183, change the RemoteIP half of the sentence to: `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
- After the TestLoginRequiresActivation paragraph (:268), add a paragraph. `TestLoginThrottleClientIP` sends failed logins through one trusted proxy (`http.trusted_proxies`). It checks that the throttle row carries the `X-Forwarded-For` client, and that this client is suspended while another client behind the same proxy still signs in. It also checks that without trusted proxies a changing `X-Forwarded-For` neither moves the counter nor escapes the suspension.
Run go vet, the targeted tests, then the full Docker-backed suite of the plugin, so the commit is green (CLAUDE.md). If an existing test fails because it relied on the raw RemoteAddr key, stop and report it; planning found none. A failure unrelated to this change is not patched here: stop and report it.
Commit in the plugin repo only, staging by path. Run git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add controllers/api_controller.go login_client_ip_test.go README.md, then git -C with the same path and commit with this subject:
fix: /login throttles failed logins per client address behind trusted proxies (surf.ClientIP), like /register
The body is one short paragraph: behind a reverse proxy every visitor had the proxy's RemoteAddr, so one client's failures locked the account for all; the key is now surf.ClientIP with http.trusted_proxies; RemoteIP is unchanged. Add no trailer of any kind; the user's global CLAUDE.md forbids co-author tags and overrides any harness attribution reminder. Do not push or tag (v0.5.0 release hold). Record the short SHA (git log -1 --format=%h) and the RED line in the SUMMARY.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l controllers/api_controller.go login_client_ip_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestLoginThrottleClientIP|TestLogin|TestLoginSixthAttempt|TestLoginRequiresActivation|TestRegistrationExports)$' . && L=$(awk '/^func Login\(/,/^}/' controllers/api_controller.go) && grep -qF 'ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))' <<<"$L" && ! grep -qF 'remoteIP(r)' <<<"$L" && G=$(awk '/^func Register\(/,/^}/' controllers/api_controller.go) && grep -qF 'ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))' <<<"$G" && R=$(awk '/^func RemoteIP\(/,/^}/' controllers/api_controller.go) && grep -qF 'return remoteIP(r)' <<<"$R" && grep -qF 'func RemoteIP(r *http.Request) string' controllers/api_controller.go && ! grep -qF 'address /login uses' controllers/api_controller.go && grep -qF 'with no proxy handling' controllers/api_controller.go && ! grep -qF 'uses for its throttle' README.md && grep -qF 'http.trusted_proxies' README.md && grep -qF 'TestLoginThrottleClientIP' README.md && grep -qF 'func TestLoginThrottleClientIP' login_client_ip_test.go && grep -qF 'func (e *adminEnv) loginFrom' login_client_ip_test.go && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='per client address behind trusted proxies' master) && test -n "$C" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md controllers/api_controller.go login_client_ip_test.go " && TG=$(git tag --points-at "$C") && test -z "$TG" && RB=$(git branch -r --contains "$C") && test -z "$RB"</automated>
</verify>
<done>TestLoginThrottleClientIP failed at 4bf44fc (failure line recorded) and now passes. Behind a trusted proxy the X-Forwarded-For client is the throttle key, so one client's lockout does not lock another client behind the same proxy, and without trusted proxies X-Forwarded-For is ignored. RemoteIP is unchanged in code, with a corrected doc comment. The README describes the client-address rule, http.trusted_proxies and the new test. go vet and the full plugin suite are green. One fix commit (api_controller.go, login_client_ip_test.go, README.md) is on sm-user-plugin master: no trailer, go.mod and go.sum unchanged, untagged, not on any remote branch. The tree is clean.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: reset-password and the admin password set stamp a whole-second cutoff, so a same-second token is accepted; full suites, consumer cross-check, commit</name>
<files>../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go, ../sm-demo-app/plugins/golem15/user/same_second_cutoff_test.go</files>
<precondition>Task 1's verify passed: its commit is HEAD of sm-user-plugin master and the plugin tree is clean.</precondition>
<behavior>
- TestPasswordCutoffSameSecond (same_second_cutoff_test.go, package user, Docker through one newAdminEnv; skips under -short) has two subtests, each with a fresh seedUser account.
- Subtest "reset-password":
- Issue a code with classes.IssueResetCode(t.Context(), e.gdb, user), call waitForFreshSecond(t), then POST /_user/api/v1/reset-password through e.call with code "<id>!<code>" and a new password plus its confirmation. It answers 200 {"message":"Password has been reset"}.
- e.reload(t, user.ID): TokensValidAfter is non-nil and Equal to its own Truncate(time.Second), and EmailVerifiedAt is non-nil.
- Subtest "admin password set": an admin token from e.admin(t, permAccessUsers); waitForFreshSecond(t); then PUT userPath(user.ID, "") with name, email, password and password_confirmation, which answers 200 through e.expect. TokensValidAfter after reload is non-nil and a whole second.
- Both subtests then run the same checks with cutoff := the reloaded TokensValidAfter:
- a token from userAudienceToken(t, userSub(user), a fresh jti, cutoff, cutoff plus one hour) gets 200 from e.guarded(t, tok, "") (bouncer's jwt guard), 200 from e.fetchCode, and 200 from e.refresh (refresh last, because it revokes);
- a token with iat cutoff minus one second gets 401 from guarded and fetchCode, and assertRefreshRefused passes on e.refresh;
- e.userLogin with the new password answers 200, and that token gets 200 from guarded and fetchCode. Whether its iat (parseToken) equals the cutoff second is only logged with t.Logf (QD-4).
- RED at the Task 1 commit: both subtests fail at the whole-second assertion, because the stored cutoff carries microseconds.
</behavior>
<action>
RED. Write same_second_cutoff_test.go (package user) with TestPasswordCutoffSameSecond as the behavior block describes. Its doc comment says it pins the whole-second tokens_valid_after cutoff of POST /reset-password and of the admin Users password set: JWT iat has one-second resolution, so a token minted in the second of the reset is accepted on every check site and an earlier second is refused.
Add waitForFreshSecond(t *testing.T) in the same file. It sleeps until 10ms after the next whole second, so that the reset, the reload and the login usually fall within one second. Its comment says the deterministic proof is the crafted-token pair, not this alignment.
Use fresh jtis built from adminStamp(). Reuse the existing helpers; add no new harness code outside this file. Run the test. It must fail against the unchanged writers; record the failure line for the SUMMARY. Do not commit a red test.
GREEN, per QD-3. In controllers/api_controller.go ResetPassword:
- keep now := time.Now();
- change only the map entry to "tokens_valid_after": now.Truncate(time.Second), and leave "email_verified_at" as now;
- extend the comment above the write by one sentence: the session cutoff is truncated to whole seconds, as in RevokeUserAccess, because JWT iat has whole-second precision, so the login that follows the reset is not refused.
In controllers/users_admin_controller.go FormBeforeUpdate, change the assignment to now := time.Now().Truncate(time.Second) and add the same reason to the comment above it.
Do not touch ChangePassword's cutoff := iat.Add(-time.Second), RevokeUserAccess, or anything in summercms.go modules/bouncer. gofmt both files.
Run go vet, the targeted tests, TestRevokeUserAccess in ./classes, go test -short and the full Docker-backed suite of the plugin.
Consumer cross-check, read-only: from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app run go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/... (go.work points them at this checkout, which carries both fix commits). Record the result in the SUMMARY. If a failure names /login, the throttle, RemoteIP, tokens_valid_after or the reset/admin password path of the user plugin, stop and fix it in this task. Record any other failure as unrelated; do not patch those repos. Do not stage anything in sm-demo-app.
Commit in the plugin repo only, staging by path. Run git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add controllers/api_controller.go controllers/users_admin_controller.go same_second_cutoff_test.go, then git -C with the same path and commit with this subject:
fix: reset-password and the admin password set stamp tokens_valid_after in whole seconds, so a token minted in the same second is accepted
The body is one short paragraph: iat is whole seconds and the checks are iat before cutoff, so a sub-second cutoff refused the next login in that second; both writers now truncate like RevokeUserAccess; change-password and bouncer are unchanged. No trailer, no push, no tag, no pointer bump. Record the short SHA and the test and cross-check results in the SUMMARY.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l controllers/api_controller.go controllers/users_admin_controller.go same_second_cutoff_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestPasswordCutoffSameSecond|TestResetPassword|TestAdminUserPassword|TestAdminPrivilegedMember|TestSessionRoutesAcceptCookies|TestOffboarding|TestRefreshSubjectChecks)$' . && GOWORK=off go test -count=1 -run '^TestRevokeUserAccess$' ./classes && grep -qF '"tokens_valid_after": now.Truncate(time.Second),' controllers/api_controller.go && grep -qE '"email_verified_at": +now,' controllers/api_controller.go && grep -qF 'cutoff := iat.Add(-time.Second)' controllers/api_controller.go && grep -qF 'now := time.Now().Truncate(time.Second)' controllers/users_admin_controller.go && grep -qF 'func TestPasswordCutoffSameSecond' same_second_cutoff_test.go && grep -qF 'func waitForFreshSecond' same_second_cutoff_test.go && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='stamp tokens_valid_after in whole seconds' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && test "$(git rev-parse "$C^")" = "$(git log -1 --format=%H --fixed-strings --grep='per client address behind trusted proxies' master)" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum README.md && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "controllers/api_controller.go controllers/users_admin_controller.go same_second_cutoff_test.go " && TG=$(git tag --points-at "$C") && test -z "$TG" && RB=$(git branch -r --contains "$C") && test -z "$RB" && git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet HEAD -- modules/bouncer modules/surf</automated>
</verify>
<done>TestPasswordCutoffSameSecond failed at the Task 1 commit (failure line recorded) and now passes. After a reset-password and after an admin password set, tokens_valid_after is a whole second. A token issued in that second is accepted by the jwt guard, /fetch and /refresh; one second earlier is refused at all three; the login right after works; email_verified_at is still written. Change-password, RevokeUserAccess and bouncer are untouched. The plugin's go vet, go test -short and full suite are green, and the sociallogin/oauthserver cross-check result is recorded. A second fix commit (api_controller.go, users_admin_controller.go, same_second_cutoff_test.go) sits on top of Task 1's: no trailer, go.mod, go.sum and README unchanged, untagged, unpushed. The tree is clean.</done>
</task>
<task type="auto">
<name>Task 3: fill items 9 and 10 in the escalation note and the todo's Outcome (summercms.go, left uncommitted)</name>
<files>.planning/notes/2026-10-22-external-user-plugin-escalations.md, .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md</files>
<precondition>Task 2's verify passed: sm-user-plugin master carries both fix commits.</precondition>
<action>
Get the two short SHAs from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user:
- S9 = git log -1 --format=%h --fixed-strings --grep='per client address behind trusted proxies' master
- S10 = git log -1 --format=%h --fixed-strings --grep='stamp tokens_valid_after in whole seconds' master
Edit .planning/notes/2026-10-22-external-user-plugin-escalations.md with Edit (scoped replacements only, never a whole-file Write):
- Tracker row 9, using rows 1-3 as the format. The Quick / todo cell becomes [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md). Status becomes done (unreleased). Shipped becomes `S9` → v0.5.0. Label, Verdict and g15office stay.
- Tracker row 10: the same cell links, done (unreleased), and `S10` → v0.5.0.
- Verdicts, in the G3/G4 format:
- **9.** becomes: Done locally (`S9`). /login keys the throttle on surf.ClientIP with http.trusted_proxies (the /register expression); RemoteIP stays the RemoteAddr helper; without trusted proxies X-Forwarded-For is ignored.
- **10.** becomes: Done locally (`S10`). /reset-password and the admin password set truncate the cutoff to whole seconds like RevokeUserAccess; change-password and bouncer unchanged.
- Reply to send, "Login lockout keyed on RemoteAddr": keep the "Agreed. ..." line. Replace the TODO line under it with one line in the G2-G4 format: Ships in SummerCMS v0.5.0 (sm-user-plugin S9, tagged with that wave): /login counts failed logins per account and client address, and the client address is now the one /register and the user-api rate limit use: behind a proxy listed in http.trusted_proxies, the X-Forwarded-For client; otherwise the connection address. An X-Forwarded-For header from an address not in http.trusted_proxies is ignored, so it cannot dodge or move a lockout. List your nginx/Caddy address in http.trusted_proxies; without it every visitor still counts as the proxy.
- Reply to send, "Same-second token refusal": keep the "Agreed. ..." line and replace the TODO line under it with: Ships in SummerCMS v0.5.0 (sm-user-plugin S10, tagged with that wave): POST /reset-password and an administrator's password set on the Users form stamp tokens_valid_after with the current whole second, as deactivate and ban already do, so a token minted in the same second as the reset (the login right after it) is accepted and every token from an earlier second is still refused. As with deactivate and ban, a token issued earlier within that same second also stays valid, because iat has no finer resolution. /change-password and the guard's comparison are unchanged.
- In both reply lines, substitute the real S9 and S10, unquoted, as G2-G4 do. Leave the two TODO lines for items 7 and 8, and everything else in the note, unchanged.
Edit the todo .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md: insert an "## Outcome (2026-10-22)" section between "## Solution" (after its last paragraph) and "## Invoke", in the format of 2026-10-22-user-logout-blacklist-missing-under-serve.md. One paragraph, which:
- names quick `261022-vfz`, the two fix commits `S9` and `S10` on sm-user-plugin master and their base `4bf44fc`;
- describes S9: /login uses surf.ClientIP(r, surf.TrustedProxies(appConfig(app))) as /register does; the same ip feeds RejectIfThrottled, refuseLogin and CheckAndRecordLogin; exported RemoteIP is unchanged with a corrected doc comment; README updated; TestLoginThrottleClientIP (Docker) covers the trusted proxy and an ignored untrusted X-Forwarded-For;
- describes S10: /reset-password (tokens_valid_after only; email_verified_at unchanged) and the admin password set truncate to the second; change-password and bouncer untouched; TestPasswordCutoffSameSecond (Docker) covers iat equal to the cutoff (accepted on jwt.auth, /fetch and /refresh) and one second earlier (refused);
- ends with: Reply note items 9 and 10 filled. Not tagged, not pushed: ships with the v0.5.0 wave.
Leave the todo in pending/.
Do not commit, do not stage and do not touch STATE.md, state.json or any other file: the orchestrator commits planning docs and handles STATE and g15office.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && N=.planning/notes/2026-10-22-external-user-plugin-escalations.md && T=.planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md && P=/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && S9=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='per client address behind trusted proxies' master) && S10=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='stamp tokens_valid_after in whole seconds' master) && test -n "$S9" && test -n "$S10" && grep -qE '^\| 9 \| Lockout keyed on `RemoteAddr` \| Fix \| \[261022-vfz\].*\| sniewiif \| done \(unreleased\) \| `[0-9a-f]{7,}` → v0\.5\.0 \|$' "$N" && grep -qE '^\| 10 \| Same-second token refusal \| Fix \| \[261022-vfz\].*\| sniewiif \| done \(unreleased\) \| `[0-9a-f]{7,}` → v0\.5\.0 \|$' "$N" && grep -qF "\`$S9\` → v0.5.0" "$N" && grep -qF "\`$S10\` → v0.5.0" "$N" && grep -qF "Done locally (\`$S9\`)" "$N" && grep -qF "Done locally (\`$S10\`)" "$N" && grep -qF "Ships in SummerCMS v0.5.0 (sm-user-plugin $S9, tagged with that wave)" "$N" && grep -qF "Ships in SummerCMS v0.5.0 (sm-user-plugin $S10, tagged with that wave)" "$N" && awk '/^Login lockout keyed on RemoteAddr$/{f=1;next} f&&(/^$/||/^```/){f=0} f&&/^TODO/{bad=1} END{exit bad}' "$N" && awk '/^Same-second token refusal$/{f=1;next} f&&(/^$/||/^```/){f=0} f&&/^TODO/{bad=1} END{exit bad}' "$N" && grep -qF 'TODO: ships in SummerCMS v0.5.0 — config key <name>, HttpOnly' "$N" && grep -qF 'TODO: ships in SummerCMS v0.5.0 — config key <name>.' "$N" && grep -qF 'Ships in SummerCMS v0.5.0 (sm-user-plugin 4bf44fc, tagged with that wave)' "$N" && grep -q '^## Outcome' "$T" && awk '/^## Outcome/{o=NR} /^## Invoke/{i=NR} END{exit !(o && i && o<i)}' "$T" && grep -qF "$S9" "$T" && grep -qF "$S10" "$T" && test ! -e .planning/todos/done/2026-10-22-user-login-lockout-and-same-second-cutoff.md && D=$(git diff --name-only -- "$N" "$T") && grep -qxF "$N" <<<"$D" && grep -qxF "$T" <<<"$D" && ST=$(git diff --cached --name-only) && test -z "$ST"</automated>
</verify>
<done>Escalation note rows 9 and 10 read done (unreleased) with `S9` → v0.5.0 and `S10` → v0.5.0 and link the quick and the todo. Verdicts 9 and 10 say "Done locally" with their SHAs. The two reply paragraphs carry "Ships in SummerCMS v0.5.0 (sm-user-plugin <sha>, tagged with that wave): ..." lines, and only the TODO lines for items 7 and 8 remain. The todo has an Outcome section before Invoke naming both commits and tests, and it is still in pending/. Both files are modified, unstaged and uncommitted; nothing else in summercms.go was touched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client or reverse proxy to POST /login | untrusted credentials and an untrusted X-Forwarded-For header; only RemoteAddr is a network fact |
| session token to jwt.auth, /fetch and /refresh | an untrusted token's iat is compared with the user's tokens_valid_after |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-vfz-01 | Denial of service | /login throttle behind a reverse proxy (one client's five failures suspend the account for every visitor) | high | mitigate | Task 1 keys the throttle on surf.ClientIP with http.trusted_proxies; TestLoginThrottleClientIP proves a second client behind the same proxy still signs in while the failing client is suspended. |
| T-vfz-02 | Spoofing | X-Forwarded-For forged to dodge a lockout or to suspend another client's address | high | mitigate | surf.ClientIP honours X-Forwarded-For only when RemoteAddr is inside http.trusted_proxies and takes the rightmost untrusted hop; TestLoginThrottleClientIP's untrusted subtest proves a changing forged header neither moves the counter nor escapes the suspension. |
| T-vfz-03 | Elevation of privilege | whole-second cutoff after a password reset (a token issued earlier within the reset's second stays valid) | low | accept | Inherent to whole-second iat; identical to RevokeUserAccess's documented trade-off. The alternative, refusing the user's own same-second login, is the reported bug. Tokens from any earlier second are refused (TestPasswordCutoffSameSecond). |
| T-vfz-04 | Denial of service | deployments that never set http.trusted_proxies keep keying on the proxy address | medium | transfer | Operator configuration. The README bullet and the reply paragraph tell operators to list their proxy; the default stays RemoteAddr-only, so no deployment starts trusting forged headers. |
| T-vfz-05 | Tampering | key format change for IPv4-mapped IPv6 and unparseable RemoteAddr (ClientIP unmaps and returns ""), which orphans throttle rows written before the upgrade | low | accept | At most one suspension window (15 minutes by default) is forgotten for such clients at upgrade. Counting resumes on the new key. |
| T-vfz-SC | Tampering | npm/pip/cargo/Go module installs | low | accept | No dependency is added; go.mod and go.sum are unchanged (both commit gates check it). No package-legitimacy checkpoint is needed. |
</threat_model>
<verification>
- sm-user-plugin: GOWORK=off go vet ./..., GOWORK=off go test -short -count=1 ./... and GOWORK=off go test -count=1 ./... are green at both fix commits.
- TestLoginThrottleClientIP was red at 4bf44fc and is green at the first commit. TestPasswordCutoffSameSecond was red at the first commit and is green at the second.
- The sociallogin and oauthserver suites, run through sm-demo-app's go.work against this checkout, have their result recorded in the SUMMARY.
- Two local commits on sm-user-plugin master: no trailer, not pushed, not tagged. The sm-demo-app pointer is unchanged; summercms.go modules/bouncer and modules/surf are unmodified.
- The escalation note and the todo in summercms.go are filled and uncommitted.
</verification>
<success_criteria>
- Behind a configured trusted proxy, the /login lockout of one client does not lock out other clients of the same account; without trusted proxies, X-Forwarded-For has no effect on the throttle.
- A login (or any token) issued in the same second as a reset-password or an admin password set is accepted everywhere; earlier seconds are refused.
- Exported RemoteIP, change-password's cutoff and bouncer's comparison are unchanged.
- Items 9 and 10 are recorded as done (unreleased) with their SHAs, and the reply is ready apart from items 7 and 8.
</success_criteria>
<output>
Create `.planning/quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/261022-vfz-SUMMARY.md` in summercms.go when done. Include both plugin commits' short SHAs and the base, both RED failure lines, the suite results, the consumer cross-check result, the logged same-second observation from TestPasswordCutoffSameSecond, and a confirmation that nothing was pushed, tagged or pointer-bumped and that the note and todo are left uncommitted. Do not commit the SUMMARY; the orchestrator commits planning docs.
</output>

View File

@@ -0,0 +1,121 @@
---
phase: quick-261022-vfz
plan: 01
quick_id: 261022-vfz
subsystem: sm-user-plugin (login throttle, session cutoff)
tags: [user-plugin, throttle, trusted-proxies, jwt, tokens_valid_after, escalation]
status: complete
requires:
- sm-user-plugin 4bf44fc (G4)
- summercms.go modules/surf ClientIP / TrustedProxies (unchanged)
provides:
- /login throttle keyed on surf.ClientIP with http.trusted_proxies
- whole-second tokens_valid_after from /reset-password and the admin Users password set
affects:
- golem15.sociallogin, golem15.oauthserver (consumers through sm-demo-app go.work; cross-checked)
tech-stack:
added: []
patterns:
- "per-request surf.ClientIP(r, surf.TrustedProxies(appConfig(app))) for any client-address key"
- "every tokens_valid_after writer truncates to the second (JWT iat resolution)"
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/login_client_ip_test.go
- ../sm-demo-app/plugins/golem15/user/same_second_cutoff_test.go
modified:
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/README.md
- .planning/notes/2026-10-22-external-user-plugin-escalations.md (uncommitted)
- .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md (uncommitted)
decisions:
- "/login throttle key is surf.ClientIP with http.trusted_proxies, read per request (the /register expression); exported RemoteIP stays the RemoteAddr host helper with a corrected doc comment"
- "/reset-password and the admin Users password set truncate tokens_valid_after to whole seconds like RevokeUserAccess; email_verified_at keeps full precision; change-password and bouncer unchanged"
metrics:
duration: ~11min
completed: 2026-10-22
actuals:
tokens: 5500
tasks: 3
commits: 2
plan_head_before: 4bf44fc6eaa3a1ca53bc6861716aecd8ed93b940
plan_head_after: a484d0e9a2f29db8cee338370ba961f6c2c14f75
---
# Quick 261022-vfz: user-plugin login throttle via surf.ClientIP, and whole-second password cutoffs (Summary)
POST /login now throttles failed logins per account and per client address. The address comes from `surf.ClientIP` with `http.trusted_proxies`, so one client behind nginx or Caddy can no longer lock everyone out of an account. `/reset-password` and the admin password set now stamp `tokens_valid_after` in whole seconds, so a login in the same second as the reset is accepted.
## Commits (sm-user-plugin master, base `4bf44fc`, local only)
| Task | SHA | Subject | Files |
|------|-----|---------|-------|
| 1 (item 9) | `b6f8055` | fix: /login throttles failed logins per client address behind trusted proxies (surf.ClientIP), like /register | controllers/api_controller.go, login_client_ip_test.go, README.md |
| 2 (item 10) | `a484d0e` | fix: reset-password and the admin password set stamp tokens_valid_after in whole seconds, so a token minted in the same second is accepted | controllers/api_controller.go, controllers/users_admin_controller.go, same_second_cutoff_test.go |
Master was at `4bf44fc` with a clean tree when work started (it had not moved). Neither commit has a trailer. go.mod and go.sum are unchanged. Nothing was pushed or tagged. The sm-demo-app submodule pointer was not bumped and nothing was staged in sm-demo-app; its `plugins/golem15/user` already showed as modified from another session before this task. summercms.go `modules/bouncer` and `modules/surf` are unmodified.
## Task 1: /login throttle key
- `Login`: `ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))` replaces `remoteIP(r)`. This one `ip` feeds `RejectIfThrottled`, both `refuseLogin` calls (including the G2 activation gate) and `CheckAndRecordLogin`. The doc comment gained a sentence about the throttle key.
- `RemoteIP`: the code is unchanged. Its doc comment now says it returns the RemoteAddr host with no proxy handling, and that /login and /register use surf.ClientIP.
- README: the Login throttling bullet now explains the client-address rule and `http.trusted_proxies`. The RemoteIP clause was corrected, and a Testing paragraph for `TestLoginThrottleClientIP` was added.
- `TestLoginThrottleClientIP` has two subtests:
- **Trusted proxy 10.0.0.0/8:** five failures with XFF 203.0.113.10 leave one row, keyed 203.0.113.10, with 5 attempts and suspended. That client is then refused, while 203.0.113.20 behind the same proxy gets 200 and a token.
- **No trusted proxies:** a changing XFF counts on one row keyed 192.0.2.50, a forged XFF does not escape the suspension, and 192.0.2.51 still signs in.
- The test also adds the helper `(e *adminEnv) loginFrom`.
- **RED at 4bf44fc:** `login_client_ip_test.go:33: throttle row ip=10.0.0.2 attempts=5 suspended=true, want ip=203.0.113.10 attempts=5 suspended`. The untrusted subtest already passed at 4bf44fc, as planned.
## Task 2: whole-second cutoff
- `ResetPassword`: the Updates map entry is now `"tokens_valid_after": now.Truncate(time.Second)`. `"email_verified_at": now` is unchanged. The comment explains the truncation.
- `FormBeforeUpdate`: `now := time.Now().Truncate(time.Second)` when the password changes, with a comment.
- `ChangePassword` (`cutoff := iat.Add(-time.Second)`), `RevokeUserAccess` and bouncer are untouched.
- `TestPasswordCutoffSameSecond` has subtests for reset-password and the admin password set. Each one checks:
- the stored cutoff is a whole second;
- a crafted token with iat equal to the cutoff gets 200 on jwt.auth (GET /tokens), /fetch and /refresh;
- a token with iat one second earlier gets 401 on jwt.auth and /fetch, and the refused-refresh body on /refresh;
- a real login with the new password works on jwt.auth and /fetch;
- the reset still writes email_verified_at.
- The file also adds `waitForFreshSecond`.
- **RED at b6f8055:**
- reset-password: `same_second_cutoff_test.go:41: tokens_valid_after 2026-10-22T22:54:14.075686+02:00 is not a whole second`
- admin password set: `same_second_cutoff_test.go:52: tokens_valid_after 2026-10-22T22:54:15.089852+02:00 is not a whole second`
- **Same-second observation (logged only):** `login iat 1792702472, cutoff 1792702472, same second: true` (reset-password) and `login iat 1792702473, cutoff 1792702473, same second: true` (admin password set). Both real logins fell in the cutoff's second and were accepted.
## Test results
All runs used `GOWORK=off` in the plugin directory. Docker answered directly; `sudo -u golem` was not needed.
- At `b6f8055`: `go vet ./...` clean. The targeted run of TestLoginThrottleClientIP, TestLogin, TestLoginSixthAttempt, TestLoginRequiresActivation and TestRegistrationExports passed. The full `go test -count=1 ./...` passed (root 50.2s, classes 43.3s, models, updates).
- At `a484d0e`: `go vet ./...` clean. The targeted run of TestPasswordCutoffSameSecond, TestResetPassword, TestAdminUserPassword, TestAdminPrivilegedMember, TestSessionRoutesAcceptCookies, TestOffboarding and TestRefreshSubjectChecks passed, and so did `TestRevokeUserAccess` in ./classes. `go test -short -count=1 ./...` and the full `go test -count=1 ./...` both passed.
- All three verify blocks in the plan passed (VERIFY1, VERIFY2, VERIFY3).
## Consumer cross-check (sm-demo-app go.work, read-only, both fix commits present)
The cross-check ran `go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/...` from sm-demo-app.
- Every test except the ones below passes: re-run with `-skip '^(TestPHPMethodMap|TestClientRegistry)$'`, sociallogin and oauthserver are both ok.
- Three tests fail for a reason unrelated to this change. They scan source files through relative paths that do not exist in the sm-demo-app checkout layout:
- sociallogin `TestPHPMethodMap`: `lstat ../fonoteka.go/plugins/golem15/user: no such file or directory`
- oauthserver `TestPHPMethodMap`: `lstat ../summercms.go/modules/wristband: no such file or directory`
- oauthserver `TestClientRegistry/verify_secret_uses_constant_time_comparison`: `open ../summercms.go/modules/wristband/oidc_token.go: no such file or directory`
- None of the failures involves /login, the throttle, RemoteIP, tokens_valid_after or the reset/admin password path. Per the plan, those repos were not patched.
## Task 3: planning docs (uncommitted)
- **Escalation note:** rows 9 and 10 now read done (unreleased) and link the quick and the todo; their Shipped cells are `b6f8055` → v0.5.0 and `a484d0e` → v0.5.0. Verdicts 9 and 10 now read "Done locally". Both reply paragraphs carry "Ships in SummerCMS v0.5.0 (sm-user-plugin <sha>, tagged with that wave): ..." lines. Only the TODO lines for items 7 and 8 remain.
- **Todo:** a `## Outcome (2026-10-22)` section was added before `## Invoke`. The todo stays in pending/.
- Neither file is staged or committed. STATE.md, state.json and ROADMAP.md were not touched, and nothing was committed in summercms.go.
## Deviations from Plan
None. The plan was executed as written. The only extra was a small unexported test helper, `assertThrottleRow`, in login_client_ip_test.go, which shares the row assertion between the two subtests.
## Threat Flags
None. No new endpoints or trust surface: T-vfz-01 and T-vfz-02 are mitigated as planned and covered by TestLoginThrottleClientIP. T-vfz-03 (the whole-second window) is accepted, as for RevokeUserAccess.
## Self-Check: PASSED
- FOUND: login_client_ip_test.go, same_second_cutoff_test.go (plugin root)
- FOUND: b6f8055, a484d0e on sm-user-plugin master; `git rev-list --count 4bf44fc..HEAD` = 2
- Plugin tree clean; no tag on either commit; no remote branch contains them

View File

@@ -0,0 +1,100 @@
---
phase: quick-261022-vfz
verified: 2026-10-22T21:10:00Z
status: passed
score: 7/7 must-haves verified
covered_files:
- .planning/quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/261022-vfz-PLAN.md
- .planning/quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/261022-vfz-SUMMARY.md
covered_digest: "v2:sha256:61944e45e0b64a9619cbc2aa21ec65fb3b0cfa4d796e83a12835c39673b0c1b0"
# The implementation is in sm-user-plugin, outside this project root, so the fingerprint tool
# cannot cover it. It is pinned by commit instead: sm-user-plugin master b6f8055 (throttle),
# a484d0e (cutoff), base 4bf44fc.
implementation_commits: [b6f8055, a484d0e]
behavior_unverified: 0
overrides_applied: 0
---
# Quick 261022-vfz: Verification Report
**Goal:** The user plugin's /login throttle uses surf.ClientIP with the trusted proxies instead of RemoteAddr. Exported RemoteIP stays the RemoteAddr helper. The remaining tokens_valid_after writers (reset-password and the admin password set) truncate to the second, as RevokeUserAccess does. change-password and bouncer's comparison stay as they are.
**Verified:** 2026-10-22
**Status:** passed
**Re-verification:** No, this is the first verification.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Trusted proxy plus XFF 203.0.113.10: five failures leave one row keyed 203.0.113.10 with 5 attempts and suspended. That client is refused, and 203.0.113.20 gets 200 and a token. | VERIFIED | `api_controller.go:79` `ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))`. This single `ip` feeds RejectIfThrottled (:81), both refuseLogin calls (:91, :98) and CheckAndRecordLogin (:109). `TestLoginThrottleClientIP/behind_a_trusted_proxy` PASS, run by the verifier. |
| 2 | With no trusted proxies, XFF is ignored: the attempts count on the connection-address row, and a forged XFF does not escape. | VERIFIED | surf.ClientIP returns RemoteAddr when `trusted` is empty (`modules/surf/clientip.go:21`). `TestLoginThrottleClientIP/untrusted_X-Forwarded-For_is_ignored` PASS. |
| 3 | controllers.RemoteIP keeps its signature and behaviour. Its doc comment no longer claims /login uses it. | VERIFIED | `RemoteIP(r *http.Request) string { return remoteIP(r) }` and `remoteIP` are unchanged in the diff; only the comment changed. `social_hooks_test.go:327` still exercises it, and the suite passes. |
| 4 | After reset-password or an admin password set, tokens_valid_after is a whole second. A token whose iat equals the cutoff is accepted at jwt.auth, /fetch and /refresh, and one from a second earlier is refused at all three. A fresh login works, and email_verified_at is still written. | VERIFIED | `api_controller.go:427` `"tokens_valid_after": now.Truncate(time.Second)`, with `email_verified_at: now` kept. `users_admin_controller.go:247` `now := time.Now().Truncate(time.Second)`. `TestPasswordCutoffSameSecond` (both subtests) PASS; the logged real logins fell in the same second as the cutoff and were accepted. |
| 5 | /change-password still writes iat minus one second, and summercms.go modules/bouncer is unmodified. | VERIFIED | `api_controller.go:679` `cutoff := iat.Add(-time.Second)` is not in the diff. The summercms.go modules/ working tree is clean, and the last bouncer/surf commits are from phase 19.7. |
| 6 | go vet and the full Docker-backed go test pass at both commits (throttle first, then cutoff). Nothing is pushed or tagged, go.mod and go.sum are unchanged, and the pointer is not bumped. | VERIFIED | At a484d0e (the checkout): vet clean and the full `go test -count=1 ./...` ok in all packages. At b6f8055 (`git archive` into the scratchpad, replace pointed at an absolute path): vet clean, all packages ok. The one failure there, TestRegisterCORSPath, comes from the copy's location (it reads `config/http.yaml` from the parent app) and passes in the real layout. No tags; `git branch -r --contains b6f8055` is empty; master is ahead of origin by 5. go.mod and go.sum are not in the 4bf44fc..a484d0e diff. sm-demo-app has nothing staged, and its last pointer commit is still e587980. |
| 7 | Escalation rows 9 and 10 read done (unreleased) with their SHA and v0.5.0. Their verdicts and replies are filled with no TODO left for them, and the todo has an Outcome section, stays in pending/, and is uncommitted. | VERIFIED | Note lines 30-31 (rows), 63-64 (verdicts), and the reply paragraphs carry b6f8055 and a484d0e. The only TODOs left belong to the cookie-on-/login and password-routes items. The todo has `## Outcome (2026-10-22)` before `## Invoke`. Both files show as ` M` (uncommitted). |
**Score:** 7/7 truths verified (0 present but behavior-unverified).
### Required Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `sm-user-plugin/controllers/api_controller.go` | VERIFIED | Contains `now.Truncate(time.Second)` and the ClientIP expression; RemoteIP comment corrected |
| `sm-user-plugin/controllers/users_admin_controller.go` | VERIFIED | Contains `now := time.Now().Truncate(time.Second)` |
| `sm-user-plugin/login_client_ip_test.go` | VERIFIED | `TestLoginThrottleClientIP` and `loginFrom` go through the real router (`e.h`) |
| `sm-user-plugin/same_second_cutoff_test.go` | VERIFIED | `TestPasswordCutoffSameSecond`, crafted iat tokens on all three check sites |
| `sm-user-plugin/README.md` | VERIFIED | Login throttling bullet names `http.trusted_proxies`; RemoteIP clause corrected; Testing paragraph added |
| escalation note / todo | VERIFIED | See truth 7 |
### Key Link Verification
| From | To | Via | Status |
|------|----|-----|--------|
| Login | surf.ClientIP / TrustedProxies | read per request, one `ip` feeds all 4 throttle call sites | WIRED |
| ResetPassword, FormBeforeUpdate | bouncer issuedBeforeCutoff, refresh check (:288), RequestUser (:83) | whole-second cutoff vs whole-second iat; exercised at all three sites by the test | WIRED |
| commit SHAs | escalation note rows and reply | `b6f8055` → v0.5.0 and `a484d0e` → v0.5.0 | WIRED |
Every writer of tokens_valid_after was checked: RevokeUserAccess truncates, ResetPassword truncates, FormBeforeUpdate truncates, and ChangePassword uses iat minus 1s as intended. No other writer exists.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| vet at HEAD | `GOWORK=off go vet ./...` | clean | PASS |
| full suite at HEAD | `GOWORK=off go test -count=1 ./...` | root 48.4s, classes 40.6s, models, updates ok | PASS |
| new tests run, not skipped | `go test -v -run '^(TestLoginThrottleClientIP\|TestPasswordCutoffSameSecond)$' .` | 4/4 subtests PASS | PASS |
| intermediate commit b6f8055 | vet plus full suite in an archive copy | all ok, apart from the layout-only TestRegisterCORSPath | PASS |
### Probe Execution
None declared for this quick task.
### Requirements Coverage
| Requirement | Status | Evidence |
|-------------|--------|----------|
| vfz-1 (login throttle via surf.ClientIP; RemoteIP kept) | SATISFIED | Truths 1-3 |
| vfz-2 (whole-second cutoff at reset and admin set; change-password and bouncer unchanged) | SATISFIED | Truths 4-5 |
| vfz-3 (escalation note and todo, uncommitted) | SATISFIED | Truth 7 |
### Anti-Patterns Found
No TBD, FIXME, XXX, TODO, HACK or placeholder markers were added in 4bf44fc..a484d0e.
Info only: for a RemoteAddr that does not parse as an IP, surf.ClientIP returns "" where the old remoteIP returned the raw string. Real listeners always give a parsable host:port, so this has no practical effect, and it matches /register.
### Human Verification Required
None.
### Gaps Summary
None. The phase goal is met in the code, and the behaviour is backed by passing tests that go through the real router.
---
_Verified: 2026-10-22_
_Verifier: Claude (msd-verifier)_

View File

@@ -33,6 +33,10 @@ Tests: X-Forwarded-For behind a trusted proxy is the `/login` throttle key; a re
Base is sm-user-plugin `4bf44fc` (G4). Hold push/tag for SummerCMS v0.5.0. After the commit, fill items 9 and 10 in `.planning/notes/2026-10-22-external-user-plugin-escalations.md` like G2–G4 (`done (unreleased)`, ships in v0.5.0). Leave the todo in pending with an Outcome.
## Outcome (2026-10-22)
Quick `261022-vfz`. Two fix commits on sm-user-plugin master, `b6f8055` and `a484d0e` (base `4bf44fc`). `b6f8055`: `/login` uses `surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))` as `/register` does; the same `ip` feeds `RejectIfThrottled`, `refuseLogin` and `CheckAndRecordLogin`; exported `RemoteIP` is unchanged with a corrected doc comment; README updated; `TestLoginThrottleClientIP` (Docker) covers the trusted proxy and an ignored untrusted `X-Forwarded-For`. `a484d0e`: `/reset-password` (`tokens_valid_after` only; `email_verified_at` unchanged) and the admin password set truncate to the second; change-password and bouncer untouched; `TestPasswordCutoffSameSecond` (Docker) covers `iat` equal to the cutoff (accepted on `jwt.auth`, `/fetch` and `/refresh`) and one second earlier (refused). Reply note items 9 and 10 filled. Not tagged, not pushed: ships with the v0.5.0 wave.
## Invoke
```