docs(quick-261022-wah): user-plugin opt-in token_cookie session cookie and cookie-carried /refresh rotation

This commit is contained in:
Jakub Zych
2026-10-22 23:51:57 +02:00
parent 6a1974c0d2
commit cd90be9de3
6 changed files with 749 additions and 5 deletions

View File

@@ -5,10 +5,10 @@ current_phase: "19.7"
current_phase_name: Generic MCP module (INSERTED)
status: executing
stopped_at: Completed 19.7-01-PLAN.md
last_updated: "2026-10-22T20:36:03.317Z"
last_updated: "2026-10-22T21:51:48.670Z"
last_activity: 2026-10-22
last_activity_desc: "Completed quick task 261022-usi: user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase (logout revokes under serve)"
state_head: b8bf71c5dda8021ada897b4e8a759d7e9474a0b4
last_activity_desc: "Completed quick task 261022-wah: user-plugin opt-in token_cookie session cookie and cookie-carried /refresh rotation"
state_head: 6a1974c0d2ee89ead533ee8f887d7be2f7549f10
progress:
total_phases: 45
completed_phases: 37
@@ -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-vfz: user-plugin /login throttle via surf.ClientIP and whole-second tokens_valid_after writers
Last activity: 2026-10-22 — Completed quick task 261022-wah: user-plugin opt-in token_cookie session cookie and cookie-carried /refresh rotation
Progress: [████████░░] 82%
@@ -1048,6 +1048,7 @@ Recent decisions affecting current work:
| 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/) |
| 261022-wah | User plugin opt-in golem15.user.jwt.token_cookie (default off) sets the HttpOnly auth.cookie session cookie on /login, /register and /activate-by-code instead of the body token; a /refresh carried by the auth.cookie cookie rewrites it (sm-user-plugin 846087d, 8bdf2c7, ships v0.5.0; verified) | 2026-10-22 | 846087d, 8bdf2c7 (sm-user-plugin) | [261022-wah-opt-in-httponly-session-cookie-on-user-p](./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) |
## Deferred Items

View File

@@ -48,7 +48,7 @@ Status values: `open` · `done` · `declined`. Shipped: local commit now; replac
/msd-quick --validate User-plugin config switch that does not register POST /login /forgot-password /reset-password so they 404. Default keeps today’s routes. No framework route-wrap seam. See .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
```
Suggested order if you care about risk: G4 (serve blacklist), then G3, G2, 9+10, then the two features. Bugs are done locally; remaining are item 7 (cookie) then item 8 (password-routes 404). Do not overlap on the same plugin files.
Suggested order if you care about risk: G4 (serve blacklist), then G3, G2, 9+10, then the two features. Bugs and item 7 (cookie) are done locally; item 8 (password-routes 404) remains. Do not overlap on the same plugin files.
## Verdicts (kept for the sessions)

View File

@@ -0,0 +1,62 @@
# Quick Task 261022-wah: Opt-in HttpOnly session cookie on user-plugin POST /login and cookie-carried /refresh rotation - Context
**Gathered:** 2026-10-22
**Status:** Ready for planning
<domain>
## Task Boundary
External escalation item 7. In sm-user-plugin (mounted at `../sm-demo-app/plugins/golem15/user`, base `a484d0e`), add the opt-in config key `golem15.user.jwt.token_cookie` (default `false`). When it is on, the token-minting routes set the HttpOnly session cookie instead of returning the token in the body. A cookie-carried `POST /refresh` rewrites that cookie. Code changes are in the plugin repo. Framework repo changes are limited to the escalations note and todo outcome.
</domain>
<decisions>
## Implementation Decisions
### Locked from the todo (not reopened)
- Key `golem15.user.jwt.token_cookie`, default `false`. It is a twin of `golem15.sociallogin.token_cookie`. Cookie attributes come only from `auth.cookie.*` via `bouncer.LoadCookieConfig`; no second attributes block.
- When on: `http.SetCookie(w, cc.TokenCookie(token, RefreshTTL(app)))` and the `token` key is **omitted** from the body; `user` (and any other keys) stay. When off: today's bodies byte-for-byte and no `Set-Cookie`.
- A `LoadCookieConfig` error is logged and answers an opaque 500, like sociallogin `writeSession`.
- SameSite stays Lax (the auth.cookie default). No CSRF token. Do not switch to SameSite=None.
### Routes covered by token_cookie
- **All three token-minting session routes:** `POST /login`, `POST /register` (auto-activation branch that returns `{token, user}`), and activate-by-code (`ActivateByCode`, `{message, token, user}`). The same flag and the same helper apply to each: cookie set, body `token` omitted.
- Personal API token creation (`api_tokens.go`) is NOT a session and is untouched.
### Cookie-carried /refresh rotation
- The trigger is **the auth.cookie name only**. Rewrite the cookie when the token `/refresh` used came from the cookie named `auth.cookie.name` (`LoadCookieConfig(...).Name`, default `token`), i.e. there is no bearer header and that cookie supplied the token. This applies **regardless of `token_cookie`**, so a sociallogin-set cookie rotates too.
- A refresh carried by a bearer header stays body-only, even when the cookie is also present (bearer wins, as in SessionToken).
- A refresh carried by another session cookie (e.g. an app-written `auth_token`) stays body-only. No second cookie is written beside it.
- When the cookie is rewritten, the rotated token is **omitted from the body** (the HttpOnly token never reaches JS). Pick a body shape that stays valid JSON (e.g. `{}` or a non-token key); planner decides and documents it.
- Failure paths (401s) do not write or forget the cookie (unchanged behaviour).
### Claude's Discretion
- Helper placement: a shared `writeSessionToken`-style helper in controllers (or classes) used by the three routes and refresh.
- Exact empty-body shape on cookie refresh.
- Test layout: follow the existing api_controller tests.
</decisions>
<specifics>
## Specific Ideas
- Mirror `sociallogin/controllers/helpers.go` `writeSession`.
- `bouncer.CookieConfig.TokenCookie` already sets HttpOnly, Max-Age = refresh TTL, Secure and SameSite from config.
- Detecting the carrying cookie: SessionToken returns bearer-first. Compare against `r.Cookie(cc.Name)` only when the Authorization bearer is absent.
- After the plugin commit (local only; release hold for v0.5.0, no push or tag):
- Update the plugin README: the config table row, the JWT sessions paragraph (the line "A cookie-carried POST /refresh answers the rotated token in the body and writes no cookie" changes), and config.yaml with a commented key.
- Fill item 7 in `.planning/notes/2026-10-22-external-user-plugin-escalations.md` (tracker row `done (unreleased)` + commit → v0.5.0, the Verdicts line, and the "Cookie on /login" reply paragraph naming `golem15.user.jwt.token_cookie`). Also reword the G3 reply sentence about cookie-carried /refresh not rewriting the cookie, since that is now false for the auth.cookie cookie.
- Add an Outcome to the todo; leave it in pending.
</specifics>
<canonical_refs>
## Canonical References
- .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- ../sm-demo-app/plugins/golem15/sociallogin/controllers/helpers.go (writeSession)
- modules/bouncer/cookie.go (LoadCookieConfig, TokenCookie)
- ../sm-demo-app/plugins/golem15/user/classes/session_user.go (SessionToken, SessionCookieNames, EndSession)
</canonical_refs>

View File

@@ -0,0 +1,445 @@
---
phase: quick-261022-wah
plan: 01
quick_id: 261022-wah
slug: opt-in-httponly-session-cookie-on-user-p
type: execute
mode: quick
wave: 1
depends_on: []
autonomous: true
requirements:
- wah-1 # cookie-carried POST /refresh: when the token came from the auth.cookie cookie (no bearer), rewrite that cookie with the rotated token and leave the token out of the body, whatever token_cookie says; bearer and other-cookie refreshes stay body-only; refusals write and forget no cookie
- wah-2 # golem15.user.jwt.token_cookie (default false): POST /login, POST /register (token branch) and POST /activate-by-code set the auth.cookie HttpOnly cookie and omit body token; off = today's bodies byte-for-byte and no Set-Cookie; a LoadCookieConfig error is logged and answers the opaque 500
- wah-3 # escalation note item 7 (tracker, verdict, reply), the G3 verdict and reply sentence about /refresh reworded, the todo's Outcome; a separate docs commit in summercms.go
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/classes/session_user.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-demo-app/plugins/golem15/user/refresh_cookie_test.go
- ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
- ../sm-demo-app/plugins/golem15/user/token_cookie_test.go
# summercms.go planning docs, edited and committed by Task 3 (pathspec commit of exactly these two files)
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low # estimate-calibration: factor 1, sample_count 0
must_haves:
truths:
- "With golem15.user.jwt.token_cookie unset (the default) or false, POST /login and POST /register (auto mode) answer 200 with exactly the keys token and user, POST /activate-by-code with exactly message, token and user, and none of them sets a cookie; TestLogin, TestRegister and TestActivateByCode stay green unchanged."
- "With golem15.user.jwt.token_cookie true, those three routes answer 200 without a token key (user, and message for activate-by-code, stay) and set exactly one cookie: named auth.cookie.name, with the path, domain, SameSite (Lax by default) and Secure of bouncer.LoadCookieConfig, HttpOnly, Max-Age golem15.user.jwt.refresh_ttl in seconds; that cookie alone authenticates GET /fetch as the signed-in user."
- "A POST /refresh whose token came from the auth.cookie cookie with no bearer header answers 200 with the body {} and one Set-Cookie of that name carrying the rotated token, whether token_cookie is on or off; the rotated cookie authenticates /fetch and jwt.auth, and the old token is refused afterwards."
- "A POST /refresh carried by the bearer header (also when the auth.cookie cookie is sent too), or by the other session cookie, answers 200 with exactly the key token and sets no cookie, as at a484d0e."
- "A refused POST /refresh (401) sets and forgets no cookie. When bouncer.LoadCookieConfig refuses the auth.cookie block, a cookie-carried refresh answers the opaque 500 before rotating (the same token still refreshes by bearer), and with token_cookie on the three token routes answer the opaque 500 before they sign in, create or activate anything; a bearer refresh never reads auth.cookie."
- "POST /tokens (personal API tokens) is unchanged and sets no cookie when token_cookie is on."
- "Two local commits on sm-user-plugin master on top of a484d0e (the refresh rotation first, then token_cookie), each green on go vet, go test -short and the full Docker-backed suite; no trailer, not pushed, not tagged, go.mod and go.sum unchanged; the sm-demo-app pointer is neither committed nor staged."
- "Escalation note row 7 reads done (unreleased) with both short SHAs and v0.5.0; verdict 7, the G3 verdict and the G3 and Cookie-on-/login reply paragraphs describe the shipped behaviour and name golem15.user.jwt.token_cookie; only item 8's TODO line remains; the todo has an Outcome and stays in pending/; both files are committed in summercms.go by a commit touching only them."
artifacts:
- path: ../sm-demo-app/plugins/golem15/user/classes/session_user.go
provides: "SessionTokenCookie(r): the name of the session cookie that supplied SessionToken's token, \"\" when the bearer header did or there is none; bearer parsing shared with sessionTokens"
contains: "func SessionTokenCookie(r *http.Request) string"
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
provides: "tokenCookieConfig, writeSession (cookie-or-body 200 writer) and sessionCookie (token_cookie flag); Refresh rewrites the auth.cookie cookie; Login, Register and ActivateByCode write through writeSession"
contains: "func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any)"
- path: ../sm-demo-app/plugins/golem15/user/config/config.yaml
provides: "jwt.token_cookie: false with a comment"
contains: "token_cookie: false"
- path: ../sm-demo-app/plugins/golem15/user/refresh_cookie_test.go
provides: "TestRefreshRewritesAuthCookie (Docker) and TestSessionTokenCookie (-short); assertSessionCookie and assertNoCookie helpers"
contains: "func TestRefreshRewritesAuthCookie"
- path: ../sm-demo-app/plugins/golem15/user/token_cookie_test.go
provides: "TestTokenCookie (Docker): off by default, on, configured auth.cookie, broken auth.cookie"
contains: "func TestTokenCookie"
- path: ../sm-demo-app/plugins/golem15/user/README.md
provides: "JWT sessions bullet (cookie refresh and token_cookie), SessionTokenCookie in the API reference, config table row, Testing paragraphs"
contains: "golem15.user.jwt.token_cookie"
- path: .planning/notes/2026-10-22-external-user-plugin-escalations.md
provides: "row 7 done (unreleased) with both SHAs; verdicts 7 and G3; G3 and Cookie-on-/login reply paragraphs filled"
contains: "golem15.user.jwt.token_cookie"
- path: .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
provides: "## Outcome (2026-10-22) section before ## Invoke"
contains: "## Outcome"
key_links:
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Refresh)
to: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (SessionTokenCookie, SessionToken)
via: "Refresh rotates the token SessionToken returns and rewrites the cookie only when SessionTokenCookie names the same cookie as auth.cookie.name"
pattern: "classes\\.SessionTokenCookie\\(r\\)"
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (writeSession, tokenCookieConfig)
to: summercms.go modules/bouncer/cookie.go (LoadCookieConfig, CookieConfig.TokenCookie)
via: "auth.cookie attributes come only from bouncer.LoadCookieConfig(app.Config); the cookie is cc.TokenCookie(token, refreshTTL(app)), HttpOnly with Max-Age refresh_ttl"
pattern: "TokenCookie\\(token, refreshTTL\\(app\\)\\)"
- from: the cookie writeSession sets
to: "the plugin's jwt guard, authenticate and SessionToken (classes.SessionCookieNames, which lists token and auth_token)"
via: "the guard reads only the token and auth_token cookies, so the written cookie authenticates while auth.cookie.name is one of them (the default token)"
pattern: "SessionCookieNames"
- from: Task 1 and Task 2 commit SHAs (sm-user-plugin master)
to: .planning/notes/2026-10-22-external-user-plugin-escalations.md row 7, verdict 7 and the reply
via: "Task 3 reads each SHA with git log --format=%h --fixed-strings --grep on the commit subject"
pattern: "→ v0\\.5\\.0"
---
<objective>
Close external escalation item 7 in sm-user-plugin: an opt-in HttpOnly session cookie on the password session routes, and a cookie-carried POST /refresh that rewrites that cookie.
Today POST /login, POST /register (when it signs the new account in) and POST /activate-by-code return the session JWT only in the body, so the external app runs a Nuxt bridge to put it in a cookie. golem15.sociallogin already has the twin flag (golem15.sociallogin.token_cookie, its writeSession). G3 (8e4b863) made /refresh read the token and auth_token cookies but answer the rotated token in the body only while blacklisting the old jti, so an HttpOnly cookie (which JavaScript cannot rewrite) keeps a dead token after one refresh. Both halves land here.
Purpose: an application can keep the session in an HttpOnly cookie end to end (sign in, refresh, fetch, logout) without a bridge, and without any change for the many applications that use body tokens.
Output: two local 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 filled and committed in summercms.go as a separate docs commit. No push, no tag and no sm-demo-app pointer commit (SummerCMS v0.5.0 release hold). summercms.go code (modules/bouncer) is not modified.
Locked decisions from 261022-wah-CONTEXT.md (labelled here for traceability; the executor does not revisit them):
- L-1 Key golem15.user.jwt.token_cookie, default false, a twin of golem15.sociallogin.token_cookie. Cookie attributes come only from auth.cookie.* through bouncer.LoadCookieConfig; no second attributes block.
- L-2 When on: http.SetCookie(w, cc.TokenCookie(token, RefreshTTL(app))) and the token key is omitted from the body; user and any other keys stay. When off: today's bodies byte-for-byte and no Set-Cookie.
- L-3 A LoadCookieConfig error is logged and answers an opaque 500, like sociallogin writeSession.
- L-4 SameSite stays Lax (the auth.cookie default). No CSRF token. No switch to SameSite=None.
- L-5 All three token-minting session routes (/login, /register's {token, user} branch, /activate-by-code) get the same flag and helper. Personal API token creation (api_tokens.go) is untouched.
- L-6 /refresh rewrites the cookie when its token came from the cookie named auth.cookie.name (LoadCookieConfig(...).Name, default token) with no bearer header, regardless of token_cookie (a sociallogin-set cookie rotates too).
- L-7 A bearer-carried refresh stays body-only, also when the cookie is present (bearer wins, as in SessionToken).
- L-8 A refresh carried by another session cookie (e.g. an app-written auth_token) stays body-only; no second cookie is written.
- L-9 When the cookie is rewritten, the rotated token is omitted from the body; the body stays valid JSON.
- L-10 Failure paths (401) neither write nor forget the cookie.
Planner's choices (Claude's Discretion in CONTEXT, plus planning calls; the executor does not revisit them):
- QD-1 Two plugin commits, refresh first. Commit 1 (fix): the cookie-carried /refresh rewrite. It is coherent on its own, because a golem15.sociallogin token_cookie session now survives a refresh. Commit 2 (feat): golem15.user.jwt.token_cookie on the three routes. Each commit carries its own README and doc-comment changes (CLAUDE.md docs rule). Tracker row 7 lists both SHAs.
- QD-2 Helpers live in controllers/api_controller.go: tokenCookieConfig (a nil-safe bouncer.LoadCookieConfig), writeSession (the 200 writer: cookie and no body token, or body token and no cookie), and sessionCookie (the token_cookie flag plus the config, added in Task 2). Refresh and the three routes all answer through writeSession. Carrier detection is a new exported classes.SessionTokenCookie(r), next to SessionToken in classes/session_user.go, sharing one bearer parser with sessionTokens so the bearer-then-cookies order lives in one place. This is an additive export of a core plugin, documented in the README API reference.
- QD-3 The cookie-refresh body is {} (map[string]any{} through writeJSON), the plugin's existing token-less 200 shape (/register in admin mode, api_controller.go:816).
- QD-4 Config-error timing. The three routes call sessionCookie right after sessionDeps, before reading the body and before any side effect. A broken auth.cookie block therefore never restores or records a login, creates an account or consumes an activation code and then answers 500 (VerifyActivationCode activates the user and clears the code). Refresh loads auth.cookie only for a cookie-carried request, before bouncer.Refresh, so a 500 rotates and blacklists nothing; a bearer refresh never reads auth.cookie, so bearer clients keep working on a broken block. The log line goes through pluginLog.Error and the answer is writeOpaque500 (the plugin's opaque 500, {"error":true,"message":"Internal server error"}).
- QD-5 The guard reads only the token and auth_token cookies (classes.SessionCookieNames). An auth.cookie.name outside that list would set a cookie that no route reads, and SessionTokenCookie never names it, so /refresh never rewrites it. SessionCookieNames is not changed; the README and the config.yaml comment say to keep auth.cookie.name at token (the default) or auth_token.
- QD-6 Compatibility. Cookie-carried /refresh was never released: the last plugin tag v0.1.8 does not contain 8e4b863, and released versions read /refresh's token from the Authorization header only. Changing the token-cookie refresh answer changes no released contract. The existing TestSessionRoutesAcceptCookies subtest that pinned the unreleased body-only answer for the token cookie is updated, not deleted.
- QD-7 Tests go through the real router (newAdminEnv: surf.Assemble with the jwt guard and the user-api limiter, Postgres from testcontainers), so cookies, the guard and the route answers are seen as a browser sees them. The classes helper gets a pure table test that runs under -short. The harness app.url is https://app.example.test, so LoadCookieConfig gives Secure=true; cookie attributes are compared against e.cookieConfig(t).TokenCookie, as assertForgets does, never against hard-coded flags.
- QD-8 Docs commit. The orchestrator asked for the framework-repo planning docs as a separate docs task and commit, staged by path. Task 3 commits exactly the note and the todo with a pathspec commit. The orchestrator's final quick commit (PLAN, SUMMARY, STATE) stays separate.
Source coverage audit:
| Source | Item | Covered by |
|--------|------|------------|
| GOAL / todo Solution 1-2, L-1, L-2, L-5 | token_cookie key (default false) on /login, /register, /activate-by-code; cookie from LoadCookieConfig + TokenCookie(RefreshTTL); body token omitted; off unchanged; api_tokens untouched | Task 2 (code, config.yaml, TestTokenCookie off/on/settings, POST /tokens check, file-list gate) |
| L-3 | LoadCookieConfig error logged, opaque 500 | Task 1 (refresh), Task 2 (three routes), both tested with a broken auth.cookie.same_site |
| todo Solution 3, L-6, L-9 | cookie-carried /refresh rewrites the auth.cookie cookie, regardless of token_cookie; rotated token not in the body | Task 1 (Refresh + writeSession, TestRefreshRewritesAuthCookie, updated TestSessionRoutesAcceptCookies) |
| L-7, L-8 | bearer-carried (with or without the cookie) and other-cookie refreshes stay body-only, no second cookie | Task 1 (SessionTokenCookie, bearer-wins, auth_token and custom-name subtests) |
| L-10 | 401 refresh writes and forgets no cookie | Task 1 (banned and blacklisted subtests assert no Set-Cookie) |
| todo Solution 4, L-4 | SameSite=Lax, no CSRF, no SameSite=None | Tasks 1-2: no SameSite code; attributes only from LoadCookieConfig; README sentence; threat T-wah-02 |
| CONTEXT specifics | README config row, JWT sessions paragraph (the cookie-refresh sentence changes), config.yaml commented key | Task 1 (refresh sentence, API reference, Testing) and Task 2 (config row, token_cookie sentences, config.yaml, Testing) |
| CONTEXT specifics | note item 7 (tracker, verdict, reply), G3 reply sentence reworded, todo Outcome, todo stays pending | Task 3 |
| orchestrator | base a484d0e; local commits only; no push/tag; no sm-demo-app pointer bump; no co-author tags; docs as a separate commit staged by path | Task 1-3 commit gates |
| REQ / RESEARCH | none (quick task, no research) | n/a |
| CONTEXT Deferred Ideas | none listed | 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/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-CONTEXT.md
@.planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
@.planning/notes/2026-10-22-external-user-plugin-escalations.md
@../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
@../sm-demo-app/plugins/golem15/user/classes/session_user.go
@../sm-demo-app/plugins/golem15/sociallogin/controllers/helpers.go
@modules/bouncer/cookie.go
Working copies (observed at planning time, 2026-10-22):
- sm-user-plugin is at /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user. Branch master at a484d0e, 5 commits ahead of origin/master (930b158), working tree clean, no tag on HEAD; the last plugin tag is v0.1.8, which contains none of the five. Do not fetch, merge, rebase or reset. If master has moved past a484d0e, build on top and record git log --oneline a484d0e..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 (HEAD 18597e5) has another session's uncommitted edits (main.go, and the plugins/golem15/user pointer already shows modified because the checkout is ahead of the recorded commit). It is read-only here: run tests, never stage or commit there.
- summercms.go (HEAD ec69f54) has other sessions' uncommitted edits (.planning/state.json, untracked .msd/ and two phase .gitkeep files) and nothing staged. Leave them alone. STATE.md, g15office and the final quick commit belong to the orchestrator; Task 3 commits only the note and the todo.
Source facts (plugin checkout at a484d0e):
- controllers/api_controller.go:
- Login: doc comment :46-59, handler :60-141. sessionDeps :62, readFields :66, CheckAndRecordLogin success :108, TouchLastSeen :126, mintFor :129, the final 200 write with the token and user keys :139.
- Refresh: doc comment :191-198 (it says the rotated token is answered in the body only and no cookie is written). Handler :199-247: sessionDeps :201, raw := classes.SessionToken(r) :205, the "Token not found" 401 :206-209, bouncer.Refresh :214 (blacklists the old jti on success), the two 401 bodies :216-219 and :232-236, TouchLastSeen, then the final 200 write with the token key :245.
- ActivateByCode: doc comment :472, handler :473-531. sessionDeps :475, readFields :479, VerifyActivationCode :509 (activates the user and clears the code), mintFor :514, final write :524-528 with message "Account activated", the token and user.
- Register: doc comment :743, handler :744-818. sessionDeps :746, RegisterUser :768, user mode answers {"message":"Activation email sent"} :799, the token branch (mode auto or require_activation off) IssueToken :803 and its write :813, admin mode answers {} :816.
- appConfig :888 (nil-safe), sessionDeps :930 (guarantees a non-nil app), refreshTTL :996 (RefreshTTL exported at :992), writeJSON :1186 (Cache-Control no-cache, private, then wire.WriteJSON), writeOpaque500 :1253. pluginLog is diary.For("user") in controllers/logger.go. bouncer, net/http and strings are already imported.
- The only session-token mint sites are :129, :514 and :803 (IssueToken in registration.go:185 wraps mintFor). api_tokens.go:61 answers 201 {"token": secret, "meta": …} for personal API tokens; not a session, untouched.
- classes/session_user.go: sessionCookieNames = {"token", "auth_token"} :21, SessionCookieNames :27. forgetSessionCookies :159 already loads LoadCookieConfig with a fallback, for logout. SessionToken :225 returns sessionTokens(r)[0]. sessionTokens :235 parses the Authorization header as strings.TrimSpace(header) then strings.CutPrefix(h, "Bearer "), then each cookie of sessionCookieNames in order, trimmed, empty values skipped, duplicates dropped. A header of "Bearer " with nothing after it trims to "Bearer", so it supplies no token and the cookies are read.
- summercms.go modules/bouncer/cookie.go (read-only): LoadCookieConfig(src) returns defaults (token, "/", Lax) for a nil interface, but a nil *compass.Config wrapped in the interface is not nil, so never pass one. It errors on an invalid name, path, domain or same_site (lax, strict and none only), on Secure false in production, and on none without Secure. Secure defaults to app.url being https. CookieConfig.TokenCookie(token, ttl) is HttpOnly with MaxAge int(ttl/time.Second). No boot step calls LoadCookieConfig, so a broken block surfaces only at request time.
- golem15.sociallogin writeSession (../sm-demo-app/plugins/golem15/sociallogin/controllers/helpers.go:148): loads cfg.TokenCookie, then bouncer.LoadCookieConfig(app.Config); on error it logs and answers 500; then it either calls http.SetCookie(w, cc.TokenCookie(token, usercontrollers.RefreshTTL(app))) or sets body["token"]. Its TestTokenCookieMode (complete_test.go:116) is the assertion style to mirror.
- Tests (package user, plugin root; Docker ones skip under -short through adminHarnessDB):
- admin_harness_test.go: adminEnvConfig :131 (app.url https://app.example.test, SUMMER_ENV development), newAdminEnv(t, configure...) :165. Configure functions run after party.Activate and before Assemble; each calls cfg.Set and t.Fatal on error, as logout_test.go:167-178 does. Handlers read config per request, so e.app.Config.Set mid-test also applies. call :287, send :301 (Host example.com, a fresh RemoteAddr 198.51.100.N per request, bearer only when token is non-empty, extra headers), seedUser :513 (password correct-horse, IsActivated true, unique email; mutate adjusts the row), reload :554, seedThrottle :612, userLogin :682 (reads the token from the body).
- logout_test.go: userRefreshPath :22, userTokensPath :20, blacklistedBody :19, sessionLogin :208 (fails when the body has no token, so it only works with token_cookie off), guarded(t, token, cookie) :220, cookieConfig :255, assertForgets :273 (the attribute comparison to copy), userAudienceToken :291.
- session_cookie_test.go: TestSessionRoutesAcceptCookies :36, whose doc comment :27-35 ends with the cookie-refresh clause, and the "refresh via the <name> cookie" loop :92-119 (asserts body token, no cookies, then the old token refused through the same cookie). cookieSend :207, login :220, userSub :237, fetchedID :242.
- offboarding_test.go: fetchCode :45, refresh :50 (bearer), assertRefreshRefused :55 with refusedRefreshBody.
- session_test.go: TestLogin :54, TestRefresh :420, invalidLoginBody, parseToken :644. account_test.go TestActivateByCode :98 (classes.IssueActivationCode, packed id!code). register_test.go TestRegister :23 (email, password and name are enough in auto mode).
- The names TestRefreshRewritesAuthCookie, TestSessionTokenCookie, TestTokenCookie, SessionTokenCookie, tokenCookieConfig, writeSession, sessionCookie, assertSessionCookie and assertNoCookie, and the files refresh_cookie_test.go and token_cookie_test.go, are all unused in the plugin today.
- README.md: JWT sessions bullet :20 (its cookie-refresh sentence begins "A cookie-carried `POST /refresh`" and ends "stores the new token in it."; the SameSite sentence after it stays). The route note :111. The classes API bullet with SessionToken(r) and SessionCookieNames() :192. Config table :205-226, where the jwt rows end with blacklist_sweep_interval :212. Testing :246-274: TestSessionRoutesAcceptCookies paragraph :266 and TestLoginThrottleClientIP paragraph :270.
- config/config.yaml: the jwt block :1-7 ends with blacklist_sweep_interval: 10m; two-space indent; comments use #.
- Escalation note (summercms.go): tracker row 7 :28 (status open, Shipped empty); rows 1-3 :22-24 are the format. Verdict **G3.** :56 (its third sentence says /refresh leaves the cookie alone); verdict **7.** :61; **9.** and **10.** :63-64 are the "Done locally (`sha`). ..." format. Reply: the G3 "Ships in" line :79 ends with a sentence that begins "A cookie-carried /refresh returns"; "Cookie on /login" :94-96 with its TODO line :96; item 8's TODO line :100 stays. The note has exactly two lines starting with TODO today.
- Todo: sections ## Problem :17, ## Solution :25, ## Note from G3 (2026-10-22) :38, ## Invoke :42. The Outcome goes between Note from G3 and Invoke, in the format of .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): a POST /refresh carried by the auth.cookie cookie rewrites that cookie, end to end through the router on Postgres, committed in sm-user-plugin</name>
<files>../sm-demo-app/plugins/golem15/user/classes/session_user.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/refresh_cookie_test.go, ../sm-demo-app/plugins/golem15/user/session_cookie_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 a484d0e is an ancestor of HEAD. Docker is reachable for testcontainers, directly or through sudo -u golem.</precondition>
<behavior>
- TestRefreshRewritesAuthCookie (refresh_cookie_test.go, package user, Docker through newAdminEnv; skips under -short). golem15.user.jwt.token_cookie is never set in this test (L-6: the rewrite does not depend on it).
- "token cookie rotates" (default env): T from e.sessionLogin. cookieSend POST /refresh with only the token cookie = T answers 200 and the body is exactly {}. Exactly one cookie is set: name token, attributes equal to e.cookieConfig(t).TokenCookie (path, domain, SameSite, Secure, HttpOnly), MaxAge 20160*60, value N non-empty and different from T, and parseToken(N) sub equals T's sub. cookieSend GET /fetch with token = N answers 200 and fetchedID is that sub; e.guarded(t, N, "token") is 200; cookieSend GET /fetch with token = T answers 401.
- "auth_token cookie stays body-only" (L-8): cookieSend POST /refresh with only auth_token = T' answers 200 with exactly the key token (a value other than T'), and no cookie is set.
- "bearer wins over the cookie" (L-7): A and B from sessionLogin. e.send POST /refresh with bearer A and the header Cookie token=B answers 200 with exactly the key token, whose sub is A's; no cookie is set; cookieSend GET /fetch with token = B still answers 200 (B was not rotated).
- "bearer only": e.refresh(t, A2) answers 200 with exactly the key token and sets no cookie.
- "configured auth.cookie name": its own env with auth.cookie.name auth_token and golem15.user.jwt.refresh_ttl 60. A refresh carried by auth_token answers {} and sets exactly one cookie, auth_token, with MaxAge 3600 (no token cookie); that cookie value authenticates GET /fetch through the auth_token cookie. A refresh carried only by the token cookie answers exactly the key token and sets no cookie.
- "refusals set no cookie" (L-10): a banned user's token (seedUser, seedThrottle banned, userAudienceToken) sent through the token cookie gets assertRefreshRefused and no cookie. A token already rotated away through the token cookie, refreshed again through the token cookie, answers 401 with blacklistedBody and no cookie.
- "broken auth.cookie" (L-3): its own env with auth.cookie.same_site "sideways". T5 from sessionLogin (login does not read auth.cookie). A refresh carried by the token cookie answers 500 with exactly {"error":true,"message":"Internal server error"} and no cookie; then e.refresh(t, T5) by bearer answers 200 with a token (T5 was not rotated, and the bearer path never reads auth.cookie).
- TestSessionTokenCookie (same file, no database, runs under -short), on httptest.NewRequest requests:
- bearer A plus a token cookie: "".
- Authorization "Bearer " (nothing after it) plus a token cookie: "token".
- Authorization "Basic xyz" plus a token cookie: "token".
- only auth_token: "auth_token".
- token and auth_token: "token".
- an empty token value (Cookie: token=; auth_token=X): "auth_token".
- no credentials: "".
- For every case, a non-empty name means the trimmed value of r.Cookie(name) equals classes.SessionToken(r), and "" with a non-empty SessionToken means the bearer header supplied it.
- session_cookie_test.go: the "refresh via the <name> cookie" loop branches on the name. For token it asserts the rewrite: a {} body, a token cookie with a new value that serves e.fetchCode. For auth_token it keeps today's assertions. For both, the old token is refused afterwards through the same cookie. The TestSessionRoutesAcceptCookies doc comment's last clause says that a refresh through the token cookie (the auth.cookie default) gets the cookie back with the rotated token and an empty body, and one through auth_token gets it in the body and no cookie.
- RED at a484d0e: "token cookie rotates" fails at the cookie assertion (no Set-Cookie, and the body carries the token).
</behavior>
<action>
RED. Create refresh_cookie_test.go (package user) with TestRefreshRewritesAuthCookie as the behavior block describes. Its doc comment says it pins the cookie-carried refresh: the auth.cookie cookie is rewritten with the rotated token and the body is empty, whoever wrote the cookie; bearer and other-cookie refreshes stay body-only; refusals touch no cookie; a broken auth.cookie block fails a cookie refresh before it rotates.
Add these helpers to the same file, each with a one-line doc comment:
- assertSessionCookie(t, rec, cfg bouncer.CookieConfig, maxAge int) string. It requires exactly one cookie in rec.Result().Cookies(), named cfg.Name, with a non-empty value. Its Path, Domain, SameSite, Secure and HttpOnly must equal those of cfg.TokenCookie("x", time.Hour), and its MaxAge must equal maxAge. It returns the value.
- assertNoCookie(t, rec). It requires rec.Result().Cookies() to be empty and no Set-Cookie header.
- A small decoder that returns the body as map[string]any, so subtests compare the exact key set.
Configure functions call c.Set and t.Fatal on error, as logout_test.go:167-178 does.
Edit the "refresh via the <name> cookie" loop of session_cookie_test.go, and the TestSessionRoutesAcceptCookies doc comment, as the behavior block says. Do not add any reference to the not-yet-written classes function in this step, so the package still compiles.
Run GOWORK=off go test -count=1 -run '^TestRefreshRewritesAuthCookie$' . It must fail against the unchanged handler. Record the failure line for the SUMMARY. Do not commit a red test.
GREEN, classes/session_user.go (per QD-2):
- Extract the Authorization parsing of sessionTokens into an unexported bearerToken(r *http.Request) string. It trims the header, cuts the "Bearer " prefix, trims the rest, and returns "" when there is none. sessionTokens then calls it, so its behaviour is unchanged.
- Add exported SessionTokenCookie(r *http.Request) string right after SessionToken. It returns "" for a nil r, "" when bearerToken(r) is non-empty, and otherwise the name of the first cookie of sessionCookieNames whose trimmed value is non-empty ("" when there is none).
- Its doc comment says it names the session cookie that supplied the token SessionToken returns, for a handler that writes that cookie back (POST /refresh), and that it is "" when the bearer header supplied the token or the request carries none.
- Add TestSessionTokenCookie to refresh_cookie_test.go now.
GREEN, controllers/api_controller.go:
- Add tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error). It returns bouncer.LoadCookieConfig(app.Config) when app and app.Config are non-nil, and bouncer.LoadCookieConfig(nil) (the defaults) otherwise, so a nil *compass.Config never reaches the interface. Doc comment: the auth.cookie block the plugin writes the session cookie with.
- Add writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any). With cc nil it sets body["token"] = token. With cc set it calls http.SetCookie(w, cc.TokenCookie(token, refreshTTL(app))) and leaves the body without a token. Either way it then calls writeJSON(w, http.StatusOK, body).
- writeSession's doc comment calls it the user plugin's twin of golem15.sociallogin's writeSession, and says the HttpOnly token never reaches JavaScript when the cookie is written. With cc nil the body gets the same key set the routes answer today, so the bytes are unchanged.
- Refresh, after the "Token not found" check and before bouncer.Refresh: declare rotate as a *bouncer.CookieConfig, initially nil. When name := classes.SessionTokenCookie(r) is non-empty:
- Load cc, err := tokenCookieConfig(app).
- On error, log pluginLog.Error("golem15.user: refresh: auth.cookie config", "err", err), call writeOpaque500(w) and return. Nothing has been rotated at that point.
- When cc.Name equals name, set rotate = &cc.
- Replace the final 200 write of the token body with writeSession(w, app, rotate, next, map[string]any{}). The 401 paths stay exactly as they are and write no cookie.
- Rewrite the Refresh doc comment (it keeps beginning with "// Refresh "). It should say:
- the token is read as the jwt guard reads it;
- when it came from the cookie named auth.cookie.name with no bearer header, that cookie is rewritten with the rotated token (bouncer.LoadCookieConfig attributes, HttpOnly, Max-Age refresh_ttl) and the body is {}, whoever wrote the cookie;
- a bearer-carried or other-cookie refresh answers the token in the body and writes no cookie;
- refusals write and forget no cookie;
- a broken auth.cookie block answers a cookie-carried refresh with the opaque 500 before rotating;
- the existing subject-check sentences stay.
- gofmt both files.
README.md, in the same commit:
- In the JWT sessions bullet (:20), replace the cookie-refresh sentence (it begins "A cookie-carried `POST /refresh`" and ends "stores the new token in it.") with sentences that say the following:
- A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token. The cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript.
- This holds whoever wrote the cookie (`golem15.sociallogin`'s `token_cookie` or the application). A frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token; such a frontend sends the bearer header to keep rotating in the body.
- A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie. The old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself.
- A refused refresh writes and forgets no cookie.
- When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block.
- Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads.
- Keep the SameSite sentence that follows.
- In the classes API bullet (:192), extend the SessionToken item with `SessionTokenCookie(r)`: the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie.
- In the TestSessionRoutesAcceptCookies paragraph (:266), rewrite the parenthesis after `POST /refresh` to say that through `token` (the `auth.cookie` default) the rotated token comes back in a rewritten cookie with a `{}` body, through `auth_token` in the body with no cookie, and that the old token is refused afterwards.
- After the TestLoginThrottleClientIP paragraph, add one paragraph naming `TestRefreshRewritesAuthCookie` and its cases: the token cookie rewritten with the `LoadCookieConfig` attributes, `auth_token` and bearer refreshes left body-only (bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken `auth.cookie` block. Name `TestSessionTokenCookie` (in `-short`) as the carrier table.
Run go vet, the targeted tests, go test -short and the full Docker-backed suite of the plugin, so the commit is green (CLAUDE.md). If an existing test other than the loop you edited relied on a body token from a token-cookie refresh, stop and report it; planning found none (logout_test and serve_order_test only expect 401 from cookie refreshes). 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 classes/session_user.go controllers/api_controller.go refresh_cookie_test.go session_cookie_test.go README.md, then commit with git -C on the same path. The subject:
fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
The body is one short paragraph:
- 8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token;
- now the cookie named auth.cookie.name gets the rotated token and the body is {};
- bearer and auth_token refreshes are unchanged, and refusals write no cookie.
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). Do not stage anything in sm-demo-app. 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 classes/session_user.go controllers/api_controller.go refresh_cookie_test.go session_cookie_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 -run '^TestSessionTokenCookie$' . && GOWORK=off go test -count=1 -run '^(TestRefreshRewritesAuthCookie|TestSessionTokenCookie|TestSessionRoutesAcceptCookies|TestLogoutEndsTheSession|TestBootBeforeDatabase|TestRefresh|TestRefreshSubjectChecks|TestOffboarding)$' . && grep -qF 'func SessionTokenCookie(r *http.Request) string' classes/session_user.go && grep -qF 'func bearerToken(r *http.Request) string' classes/session_user.go && grep -qF 'func tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error)' controllers/api_controller.go && grep -qF 'func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any)' controllers/api_controller.go && grep -qF 'TokenCookie(token, refreshTTL(app))' controllers/api_controller.go && R=$(awk '/^func Refresh\(/,/^}/' controllers/api_controller.go) && grep -qF 'classes.SessionTokenCookie(r)' <<<"$R" && grep -qF 'writeSession(w, app, rotate, next, map[string]any{})' <<<"$R" && ! grep -qE '"token": +next' <<<"$R" && awk '/^\/\/ Refresh /,/^func Refresh\(/' controllers/api_controller.go | grep -qF 'auth.cookie' && grep -qF 'SessionTokenCookie(r)' README.md && grep -qF 'TestRefreshRewritesAuthCookie' README.md && grep -qF 'TestSessionTokenCookie' README.md && ! grep -qF 'answers the rotated token in the body and writes no cookie' README.md && 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='rewrites that cookie, so an HttpOnly session survives' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && git merge-base --is-ancestor a484d0e "$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 classes/session_user.go controllers/api_controller.go refresh_cookie_test.go session_cookie_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app log --format=%h 18597e5..HEAD -- plugins/golem15/user)"</automated>
</verify>
<done>TestRefreshRewritesAuthCookie failed at a484d0e (failure line recorded) and now passes. A refresh carried by the auth.cookie cookie gets that cookie back with the rotated token and a {} body; bearer, bearer-plus-cookie and auth_token refreshes answer the token in the body with no cookie; refusals set no cookie; a broken auth.cookie answers the opaque 500 on a cookie refresh and leaves the token refreshable by bearer. classes.SessionTokenCookie exists with its -short table test. README describes the behaviour, the new export and the tests. go vet, go test -short and the full suite are green. One fix commit (five files) is on sm-user-plugin master on top of a484d0e: no trailer, go.mod and go.sum unchanged, untagged, not on any remote branch. The tree is clean and sm-demo-app is untouched.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: golem15.user.jwt.token_cookie (default off) puts the session token of /login, /register and /activate-by-code into the HttpOnly auth.cookie cookie, committed in sm-user-plugin</name>
<files>../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/config/config.yaml, ../sm-demo-app/plugins/golem15/user/token_cookie_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
<precondition>Task 1's verify passed: sm-user-plugin master's HEAD is the refresh-rewrite commit and the tree is clean.</precondition>
<behavior>
- TestTokenCookie (token_cookie_test.go, package user, Docker through newAdminEnv; skips under -short). Every env sets golem15.user.registration.use_register_throttle false, so the shared harness database's registration count cannot interfere. Register bodies use a unique email (label plus adminStamp), password correct-horse and a name. Activation codes come from seedUser with IsActivated false, then classes.IssueActivationCode(t.Context(), e.gdb, user), packed as userSub(user)+"!"+code.
- "off by default" (L-2): with token_cookie unset, POST /login answers 200 with exactly the keys token and user; POST /register (activate_mode auto, the default) answers exactly token and user; POST /activate-by-code answers exactly message, token and user. None sets a cookie (assertNoCookie).
- "on" (L-1, L-2, L-5): golem15.user.jwt.token_cookie true.
- POST /login answers 200 with exactly the key user (its email is the seeded one) and sets the cookie checked by assertSessionCookie(e.cookieConfig(t), 20160*60). That value C alone serves GET /fetch (cookieSend token) as the user.
- A cookie-carried POST /refresh with C answers {} and a new cookie C2, and C2 serves GET /fetch (Task 1 composed with the flag).
- cookieSend POST /tokens with C2 and {"name":"cli"} answers 201 with a non-empty token in the body and sets no cookie: personal API tokens are untouched.
- A wrong password answers 401 with invalidLoginBody and no cookie.
- POST /register answers exactly the key user and a session cookie that serves GET /fetch as the new account.
- POST /activate-by-code answers exactly message ("Account activated") and user (is_activated true) and a session cookie that serves GET /fetch.
- "on with configured auth.cookie": token_cookie true, auth.cookie.name auth_token, golem15.user.jwt.refresh_ttl 60. POST /login sets exactly one cookie, auth_token, with MaxAge 3600 and no token cookie, and that value serves GET /fetch through the auth_token cookie.
- "on with a broken auth.cookie" (L-3, QD-4): token_cookie true, auth.cookie.same_site "sideways".
- POST /login with the correct password answers 500 with exactly {"error":true,"message":"Internal server error"} and no cookie; the user's last_seen stays nil and the user has no user_throttle row.
- POST /register answers the same 500 and no users row (Unscoped) exists for that email.
- POST /activate-by-code answers the same 500 and the user is still not activated. Then e.app.Config.Set("auth.cookie.same_site", "lax"), and the same packed code answers 200 with exactly message and user and a session cookie (the code was not consumed by the 500).
- RED at the Task 1 commit: the "on" subtest fails (the body carries token and no cookie is set).
</behavior>
<action>
RED. Create token_cookie_test.go (package user) with TestTokenCookie as the behavior block describes. Reuse assertSessionCookie, assertNoCookie and the body decoder from refresh_cookie_test.go, and cookieSend, fetchedID, userSub, seedUser, reload and cookieConfig from the existing files. Do not use sessionLogin, userLogin or login in token_cookie-on envs, because they read the token from the body; call e.call on /_user/api/v1/login with {"email","password"}.
The doc comment says TestTokenCookie pins golem15.user.jwt.token_cookie: off keeps the body token and sets no cookie; on puts the session token of /login, /register and /activate-by-code into the HttpOnly auth.cookie cookie and leaves it out of the body; a broken auth.cookie block answers the opaque 500 before anything is signed in, created or activated.
Run GOWORK=off go test -count=1 -run '^TestTokenCookie$' . It must fail against the Task 1 handlers. Record the failure line. Do not commit a red test.
GREEN, controllers/api_controller.go (per L-1 to L-5, QD-2, QD-4):
- Add sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error).
- It returns nil, nil when app or app.Config is nil, or when app.Config.Bool("golem15.user.jwt.token_cookie") is false, without reading auth.cookie.
- Otherwise it calls tokenCookieConfig(app) and returns the address of the config, or the error.
- Its doc comment calls it the golem15.sociallogin token_cookie twin, and says a nil config means the token goes into the body.
- In Login, Register and ActivateByCode, right after the sessionDeps check and before anything else, call cc, err := sessionCookie(app). On error, log pluginLog.Error("golem15.user: token cookie config", "err", err), call writeOpaque500(w) and return.
- Then replace each route's final token write with a writeSession call. Pass the same non-token keys the route answers today, and let writeSession add the token key when cc is nil:
- Login: writeSession(w, app, cc, token, map[string]any{"user": payload}).
- Register: in the token branch only, writeSession(w, app, cc, token, map[string]any{"user": payload}). The user-mode and admin-mode answers stay as they are.
- ActivateByCode: writeSession(w, app, cc, token, map[string]any{"message": "Account activated", "user": payload}).
- Extend each of the three doc comments by one sentence: with golem15.user.jwt.token_cookie on, the token goes into the auth.cookie session cookie instead of the body.
- Do not touch api_tokens.go, sessionDeps, mintFor or IssueToken. gofmt the file.
config/config.yaml: in the jwt block, after blacklist_sweep_interval, add token_cookie: false (two-space indent). Put a comment above it saying three things: on, POST /login, /register (when it signs the account in) and /activate-by-code set the HttpOnly session cookie from auth.cookie.* and leave token out of the body; a POST /refresh carried by that cookie rewrites it either way; keep auth.cookie.name token or auth_token.
README.md, in the same commit:
- Configuration table: a row after golem15.user.jwt.blacklist_sweep_interval, reading | `golem15.user.jwt.token_cookie` | `false` | followed by a meaning cell. The cell says that when on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set the HttpOnly session cookie with the `auth.cookie.*` attributes (`bouncer.LoadCookieConfig`; Max-Age `refresh_ttl`) and leave `token` out of the body (`user`, and `message` on activate-by-code, stay). When off, the token is in the body and no cookie is set. It is the twin of `golem15.sociallogin.token_cookie`.
- JWT sessions bullet, after the Task 1 refresh sentences, add sentences that say:
- what golem15.user.jwt.token_cookie does on the three routes (see the config row);
- SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie;
- with the key on, a broken `auth.cookie` block answers those routes with the opaque 500 before they sign in, create or activate anything;
- personal API tokens (`POST /tokens`) are not sessions and are unaffected.
- In the Task 1 parenthesis naming who may have written the cookie, add this plugin's `golem15.user.jwt.token_cookie`.
- Testing: after the TestRefreshRewritesAuthCookie paragraph, add one paragraph naming `TestTokenCookie` and its cases: off by default, on for the three routes with the cookie serving `/fetch` and a cookie refresh, `POST /tokens` unaffected, a configured cookie name and refresh window, and a broken `auth.cookie` block that signs in, creates and activates nothing.
Run go vet, the targeted tests, go test -short and the full Docker-backed suite of the plugin. TestLogin, TestRegister and TestActivateByCode must pass unchanged; they are the default-off byte-shape pins.
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 commits). Record the result in the SUMMARY.
- If a failure names /login, /refresh, a session cookie or a body token of the user plugin, stop and fix it in this task.
- Record any other failure as unrelated, and 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 config/config.yaml token_cookie_test.go README.md, then commit with git -C on the same path. The subject:
feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token
The body is one short paragraph: default off, so body-token clients are unchanged; when on, the cookie carries the auth.cookie attributes via bouncer.LoadCookieConfig and the body keeps user (and message); SameSite stays Lax, with no CSRF token; personal API tokens are untouched.
Add no trailer of any kind. Do not push or tag. Record the short SHA 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 token_cookie_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestTokenCookie|TestRefreshRewritesAuthCookie|TestLogin|TestLoginSixthAttempt|TestLoginRequiresActivation|TestRegister|TestActivateByCode|TestSessionRoutesAcceptCookies)$' . && grep -qF 'func sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error)' controllers/api_controller.go && grep -qF 'Bool("golem15.user.jwt.token_cookie")' controllers/api_controller.go && L=$(awk '/^func Login\(/,/^}/' controllers/api_controller.go) && grep -qF 'sessionCookie(app)' <<<"$L" && grep -qF 'writeSession(w, app, cc, token, ' <<<"$L" && ! grep -qE '"token": +token' <<<"$L" && G=$(awk '/^func Register\(/,/^}/' controllers/api_controller.go) && grep -qF 'sessionCookie(app)' <<<"$G" && grep -qF 'writeSession(w, app, cc, token, ' <<<"$G" && ! grep -qE '"token": +token' <<<"$G" && grep -qF '"Activation email sent"' <<<"$G" && A=$(awk '/^func ActivateByCode\(/,/^}/' controllers/api_controller.go) && grep -qF 'sessionCookie(app)' <<<"$A" && grep -qF 'writeSession(w, app, cc, token, ' <<<"$A" && ! grep -qE '"token": +token' <<<"$A" && awk '/^jwt:/{f=1;next} /^[^ #]/{f=0} f' config/config.yaml | grep -qxF ' token_cookie: false' && grep -qF '| `golem15.user.jwt.token_cookie` | `false` |' README.md && grep -qF 'TestTokenCookie' README.md && 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='opt-in golem15.user.jwt.token_cookie' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && test "$(git rev-parse "$C^")" = "$(git log -1 --format=%H --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master)" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum controllers/api_tokens.go && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md config/config.yaml controllers/api_controller.go token_cookie_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app log --format=%h 18597e5..HEAD -- plugins/golem15/user)" && git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet HEAD -- modules/bouncer</automated>
</verify>
<done>TestTokenCookie failed at the Task 1 commit (failure line recorded) and now passes. With the key off, the three routes answer today's bodies and set no cookie, and TestLogin, TestRegister and TestActivateByCode pass unchanged. With it on, they set the HttpOnly auth.cookie cookie (LoadCookieConfig attributes, Max-Age refresh_ttl) and leave token out of the body. The cookie serves /fetch and rotates through /refresh, and POST /tokens is untouched. A broken auth.cookie answers the opaque 500 before any sign-in, account or activation. config.yaml and README document the key. go vet, go test -short and the full suite are green, and the sociallogin/oauthserver cross-check result is recorded. A second commit (four files) sits on top of Task 1's: no trailer, go.mod, go.sum and api_tokens.go unchanged, untagged, unpushed. The tree is clean; sm-demo-app and summercms.go modules/bouncer are untouched.</done>
</task>
<task type="auto">
<name>Task 3: fill item 7 and reword the G3 refresh sentence in the escalation note, add the todo's Outcome, and commit both in summercms.go</name>
<files>.planning/notes/2026-10-22-external-user-plugin-escalations.md, .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md</files>
<precondition>Task 2's verify passed: sm-user-plugin master carries both commits. git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet HEAD -- on the note and the todo succeeds (no other session is editing them).</precondition>
<action>
Get the two short SHAs from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user:
- S1 = git log -1 --format=%h --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master
- S2 = git log -1 --format=%h --fixed-strings --grep='opt-in golem15.user.jwt.token_cookie' master
Edit .planning/notes/2026-10-22-external-user-plugin-escalations.md with Edit, using scoped replacements only, never a whole-file Write.
Tracker row 7, using rows 1-3 as the format:
- The Quick / todo cell becomes [261022-wah](../quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) · [todo](../todos/pending/2026-10-22-user-login-opt-in-session-cookie.md).
- Status becomes done (unreleased).
- Shipped becomes `S1`, `S2` → v0.5.0.
- Label, Verdict and g15office stay.
Verdicts:
- **G3.**: replace only its third sentence, the one about /refresh and cookies, with: Item 7 (`S1`) made a `/refresh` carried by the `auth.cookie` cookie rewrite it; bearer- and `auth_token`-carried refreshes stay body-only. The other sentences stay.
- **7.**: in the "Done locally" format, it becomes: Done locally (`S1`, `S2`). `S1`: a `/refresh` carried by the `auth.cookie` cookie (`token` by default, no bearer) rewrites it and answers `{}`, whatever `token_cookie` says; bearer and other-cookie refreshes stay body-only; refusals write no cookie. `S2`: `golem15.user.jwt.token_cookie` (default off) makes /login, /register (auto sign-in) and /activate-by-code set the HttpOnly `auth.cookie` cookie via `bouncer.LoadCookieConfig` and omit body `token`; off keeps today's bodies. SameSite=Lax, no CSRF; API tokens untouched.
Reply to send, G3 paragraph: in its "Ships in SummerCMS v0.5.0 (sm-user-plugin 8e4b863, …)" line, keep everything up to the last sentence. Replace that sentence (it begins "A cookie-carried /refresh returns") with: A /refresh carried by the session cookie named in auth.cookie (token by default) rewrites that cookie with the rotated token and answers {} (see "Cookie on /login" below). A /refresh carried by the Authorization header, or by the auth_token cookie, returns the rotated token in the body and writes no cookie; the previous token is revoked, so store the new one yourself.
Reply to send, "Cookie on /login": keep the "Agreed as an opt-in. …" line. Replace the TODO line under it with one line in the G2-G4 format:
Ships in SummerCMS v0.5.0 (sm-user-plugin S1 and S2, tagged with that wave): set golem15.user.jwt.token_cookie: true (default false) and POST /login, POST /register (when it signs the new account in) and POST /activate-by-code set the HttpOnly session cookie with the auth.cookie attributes (bouncer.LoadCookieConfig: name token, path /, SameSite=Lax, Secure from app.url, Max-Age the refresh window) and leave token out of the body; user (and message) stay. A POST /refresh carried by that cookie rewrites it with the rotated token and answers {}; a bearer refresh is unchanged. With the key off nothing changes. SameSite stays Lax and there is no CSRF token. Keep auth.cookie.name at token or auth_token, the cookies the session routes read.
In both reply lines, substitute the real S1 and S2, unquoted, as G2-G4 do. Leave item 8's TODO line and everything else in the note unchanged.
Edit the todo .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md. Insert an "## Outcome (2026-10-22)" section after the "## Note from G3 (2026-10-22)" paragraph and before "## Invoke", in the format of 2026-10-22-user-logout-blacklist-missing-under-serve.md. It is one paragraph that:
- names quick `261022-wah`, the two commits `S1` and `S2` on sm-user-plugin master and their base `a484d0e`;
- describes S1: /refresh rewrites the auth.cookie cookie when it carried the token without a bearer header, answers {} and keeps bearer and auth_token refreshes body-only; classes.SessionTokenCookie is the new export; TestRefreshRewritesAuthCookie and TestSessionTokenCookie cover it;
- describes S2: golem15.user.jwt.token_cookie on /login, /register and /activate-by-code through writeSession; the auth.cookie config is loaded before any side effect, so a broken block answers the opaque 500; README and config.yaml updated; TestTokenCookie covers it;
- ends with: Reply note item 7 and the G3 refresh sentence filled. Not tagged, not pushed: ships with the v0.5.0 wave.
Leave the todo in pending/.
Commit exactly these two files with a pathspec commit, so nothing else in the index is included:
git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go commit -m "docs: fill item 7 in the external-escalation reply" -- .planning/notes/2026-10-22-external-user-plugin-escalations.md .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
No trailer. Do not touch STATE.md, state.json, the PLAN, the SUMMARY or any other file; the orchestrator commits those and handles STATE and g15office. Do not push.
</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-opt-in-session-cookie.md && P=/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && S1=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master) && S2=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='opt-in golem15.user.jwt.token_cookie' master) && test -n "$S1" && test -n "$S2" && grep -qE '^\| 7 \| Cookie option on `/login` \| Feature \| \[261022-wah\]\(\.\./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/\) · \[todo\].*\| strnsccy \| done \(unreleased\) \| `[0-9a-f]{7,}`, `[0-9a-f]{7,}` → v0\.5\.0 \|$' "$N" && grep -qF "\`$S1\`, \`$S2\` → v0.5.0" "$N" && grep -qF "**7.** Done locally (\`$S1\`, \`$S2\`)" "$N" && grep -qF "Item 7 (\`$S1\`)" "$N" && grep -qF "Ships in SummerCMS v0.5.0 (sm-user-plugin $S1 and $S2, tagged with that wave)" "$N" && grep -qF 'golem15.user.jwt.token_cookie' "$N" && grep -qF 'rewrites that cookie with the rotated token' "$N" && ! grep -qF 'does not rewrite a cookie' "$N" && ! grep -qF 'does not rewrite the cookie' "$N" && test "$(grep -c '^TODO' "$N")" = 1 && grep -qF 'TODO: ships in SummerCMS v0.5.0 — config key <name>.' "$N" && grep -qF 'Ships in SummerCMS v0.5.0 (sm-user-plugin 8e4b863, tagged with that wave)' "$N" && grep -qF 'Ships in SummerCMS v0.5.0 (sm-user-plugin 4bf44fc, tagged with that wave)' "$N" && grep -q '^## Outcome' "$T" && awk '/^## Note from G3/{g=NR} /^## Outcome/{o=NR} /^## Invoke/{i=NR} END{exit !(g && o && i && g<o && o<i)}' "$T" && grep -qF "$S1" "$T" && grep -qF "$S2" "$T" && grep -qF 'a484d0e' "$T" && test ! -e .planning/todos/done/2026-10-22-user-login-opt-in-session-cookie.md && C=$(git log -1 --format=%H --fixed-strings --grep='docs: fill item 7 in the external-escalation reply') && test -n "$C" && git merge-base --is-ancestor ec69f54 "$C" && F=$(git show --name-only --format= "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "$N $T " && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet HEAD -- "$N" "$T" && test -z "$(git branch -r --contains "$C")"</automated>
</verify>
<done>Escalation note row 7 reads done (unreleased) with `S1`, `S2` → v0.5.0 and links the quick and the todo. Verdict 7 says "Done locally" with both SHAs, and the G3 verdict's refresh sentence names item 7. The G3 reply now says a refresh carried by the auth.cookie cookie rewrites it while bearer and auth_token refreshes stay body-only. The Cookie-on-/login reply carries the "Ships in SummerCMS v0.5.0 (sm-user-plugin S1 and S2, tagged with that wave)" line naming golem15.user.jwt.token_cookie. Only item 8's TODO line remains. The todo has an Outcome section between Note from G3 and Invoke, naming both commits, the base and the tests, and it is still in pending/. One docs commit in summercms.go touches exactly those two files: no trailer, not pushed. Nothing else in summercms.go was touched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser JavaScript to the session token | a token in a JSON body or a JS-readable cookie is exposed to any script on the page (XSS) |
| browser to /refresh and the cookie-carried session routes | the cookie travels on requests the browser makes on its own, including cross-site ones subject to SameSite |
| request carriers (Authorization header, token and auth_token cookies) | an untrusted request may carry several tokens; which one is rotated and which cookie is written must follow one fixed order |
| operator configuration (auth.cookie.*, golem15.user.jwt.token_cookie) | an invalid block must fail loudly without half-done account changes |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-wah-01 | Information disclosure | session token in the /login, /register, /activate-by-code and cookie-refresh bodies | medium | mitigate | With token_cookie on, the token goes only into the HttpOnly cookie (writeSession omits the body key; Task 2). A cookie-carried refresh answers {} (Task 1). TestTokenCookie and TestRefreshRewritesAuthCookie assert the exact key sets. |
| T-wah-02 | Spoofing | cross-site requests riding the session cookie (CSRF) on /refresh and the signed-in routes | medium | accept | Locked L-4: SameSite stays Lax (the auth.cookie default, from LoadCookieConfig only), which keeps the cookie off cross-site POSTs; no CSRF token; change-password still needs the current password. The README says not to set same_site none for this cookie. |
| T-wah-03 | Tampering | carrier confusion: rotating one token and writing it into another carrier's cookie, or writing over an app-owned cookie | medium | mitigate | SessionTokenCookie mirrors SessionToken's order through one shared bearerToken parser, and returns "" whenever a bearer supplied the token. Refresh rewrites only when that carrier equals auth.cookie.name. Tests: bearer wins over a cookie, auth_token stays body-only, a configured name, and the TestSessionTokenCookie table. |
| T-wah-04 | Elevation of privilege | an HttpOnly cookie left holding a rotated (blacklisted) jti after /refresh, so the session dies or the client resends a dead token | high | mitigate | Task 1 rewrites the auth.cookie cookie on every successful cookie-carried refresh. The rotated cookie is proven to authenticate and the old token to be refused. Refusals write nothing, so no cookie gets a token that was not issued. |
| T-wah-05 | Denial of service | a broken auth.cookie block half-completing a request (account created, activation code consumed, login recorded, token rotated) before answering 500 | medium | mitigate | QD-4: the three routes load the config right after sessionDeps, and /refresh loads it before bouncer.Refresh. TestTokenCookie checks that no users row exists, the user is not activated with the code still usable, and last_seen and the throttle row are unwritten. TestRefreshRewritesAuthCookie checks that the token still refreshes by bearer. |
| T-wah-06 | Denial of service | a core plugin's existing body-token clients broken by the change | high | mitigate | token_cookie defaults to false, and sessionCookie then never reads auth.cookie. writeSession with a nil config writes the same key set as today, so the bytes are unchanged. Bearer and auth_token refreshes are unchanged and the bearer path never reads auth.cookie. TestLogin, TestRegister, TestActivateByCode and the "off by default" subtest pin it. The only changed answer (the token-cookie refresh) was never released (QD-6). |
| T-wah-07 | Information disclosure | a session cookie without Secure on an https site | low | transfer | bouncer.LoadCookieConfig derives Secure from app.url and refuses a Secure that resolves to false in production; the plugin passes those attributes through unchanged. Operators keep app.url correct. |
| T-wah-SC | Tampering | npm/pip/cargo/Go module installs | low | accept | No dependency is added; go.mod and go.sum are unchanged (both plugin 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 commits.
- TestRefreshRewritesAuthCookie was red at a484d0e and is green at the first commit. TestTokenCookie 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, go.mod/go.sum unchanged. The sm-demo-app pointer is neither staged nor committed; summercms.go modules/bouncer is unmodified.
- One docs commit in summercms.go touches exactly the escalation note and the todo.
</verification>
<success_criteria>
- An application can opt in with golem15.user.jwt.token_cookie: true and keep the session in an HttpOnly cookie across sign-in, registration, activation by code, refresh and fetch, with no token in any body.
- With the key off, every existing response of the plugin is byte-identical, apart from the never-released token-cookie refresh answer.
- A refresh rotates exactly the cookie that carried the token when that cookie is the auth.cookie cookie, and nothing else; bearer refreshes never read auth.cookie.
- Item 7 is recorded as done (unreleased) with both SHAs, the G3 refresh wording is corrected, and the reply is ready apart from item 8.
</success_criteria>
<output>
Create `.planning/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-SUMMARY.md` in summercms.go when done. Include:
- both plugin commits' short SHAs and the base;
- both RED failure lines;
- the suite results and the consumer cross-check result;
- the docs commit SHA in summercms.go;
- a confirmation that nothing was pushed, tagged or pointer-bumped.
Do not commit the SUMMARY; the orchestrator commits it with the PLAN and STATE.
</output>

View File

@@ -0,0 +1,124 @@
---
phase: quick-261022-wah
plan: 01
quick_id: 261022-wah
subsystem: sm-user-plugin (sessions)
status: complete
tags: [user-plugin, jwt, cookie, refresh, escalation-item-7]
requires: [sm-user-plugin a484d0e, summercms bouncer.LoadCookieConfig / CookieConfig.TokenCookie]
provides:
- classes.SessionTokenCookie(r)
- golem15.user.jwt.token_cookie (default false)
- cookie-carried POST /refresh rewrites the auth.cookie cookie
affects: [golem15.sociallogin token_cookie sessions now survive /refresh]
tech-stack:
added: []
patterns: [writeSession cookie-or-body 200 writer (twin of sociallogin writeSession), config loaded before side effects]
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/refresh_cookie_test.go
- ../sm-demo-app/plugins/golem15/user/token_cookie_test.go
modified:
- ../sm-demo-app/plugins/golem15/user/classes/session_user.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
decisions:
- "Cookie-carried /refresh answers {} and rewrites only the cookie named auth.cookie.name, independent of token_cookie; bearer and auth_token refreshes stay body-only"
- "golem15.user.jwt.token_cookie (default off) on /login, /register (token branch), /activate-by-code; auth.cookie loaded right after sessionDeps so a broken block answers the opaque 500 before any side effect"
metrics:
duration: ~12 min
completed: 2026-10-22
actuals:
tokens: 15500
tasks: 3
commits: 2
plan_head_before: a484d0e
plan_head_after: 8bdf2c7
---
# Quick 261022-wah: Opt-in HttpOnly session cookie and cookie-carried /refresh rotation Summary
sm-user-plugin now rewrites the `auth.cookie` session cookie on a cookie-carried `POST /refresh` (body `{}`). A new opt-in key, `golem15.user.jwt.token_cookie`, makes `/login`, `/register` and `/activate-by-code` put the session token into that HttpOnly cookie instead of the body. Both go through one `writeSession` helper that mirrors sociallogin's.
## Commits
sm-user-plugin master, base `a484d0e` (master had not moved). Local only.
| Task | Commit | Subject |
|------|--------|---------|
| 1 | `846087d` | fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation |
| 2 | `8bdf2c7` | feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token |
| 3 | `6a1974c` (summercms.go) | docs: fill item 7 in the external-escalation reply |
`commits: 2` counts the plugin repo (`git rev-list --count a484d0e..master`). The docs commit `6a1974c` is in summercms.go on top of `ec69f54`.
## RED evidence
- Task 1, at `a484d0e`: `refresh_cookie_test.go:33: body keys [token], want []: {"token":"eyJ..."}` (TestRefreshRewritesAuthCookie/token_cookie_rotates). The body key-set assertion runs before the cookie assertion, so it failed there first. "configured auth.cookie name" and "broken auth.cookie" (200 instead of 500) failed too.
- Task 2, at `846087d`: `token_cookie_test.go:58: body keys [token user], want [user]` (TestTokenCookie/on). "on with configured auth.cookie" and "on with a broken auth.cookie" (200 instead of the opaque 500) failed too.
## Suite results
- Both commits: `GOWORK=off go vet ./...`, `go test -short -count=1 ./...` and the full Docker-backed `go test -count=1 ./...` were green. Docker was reachable directly, without sudo.
- TestLogin, TestRegister and TestActivateByCode pass unchanged (they pin the default-off behaviour).
- Consumer cross-check from sm-demo-app (`go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/...` via go.work against this checkout):
- Every session, cookie and token test passes, including sociallogin `TestTokenCookieMode`, which was also run on its own.
- Three failures are unrelated to this change. Each is a source-file path lookup relative to the plugin dir, and all are environmental:
- sociallogin `TestPHPMethodMap`: `lstat ../fonoteka.go/plugins/golem15/user: no such file or directory`
- oauthserver `TestClientRegistry/verify_secret_uses_constant_time_comparison`: `open ../summercms.go/modules/wristband/oidc_token.go`
- oauthserver `TestPHPMethodMap`: `lstat ../summercms.go/modules/wristband`
- These repos were not patched.
## What was built
- `classes.SessionTokenCookie(r)` returns the name of the session cookie that supplied `SessionToken`'s token, or `""` when a bearer header supplied it or there is none. It uses the unexported `bearerToken(r)` helper, which `sessionTokens` now uses too, so the bearer-then-cookies order lives in one place.
- `controllers` gains three helpers:
- `tokenCookieConfig`: a nil-safe `bouncer.LoadCookieConfig`.
- `writeSession`: writes the cookie and leaves the token out of the body, or puts the token in the body and sets no cookie.
- `sessionCookie`: reads the `token_cookie` flag and returns the cookie config when it is on.
- `Refresh` loads `auth.cookie` only for a cookie-carried request, and does so before `bouncer.Refresh`. A broken block therefore answers the opaque 500 without rotating anything, and bearer refreshes never read the block.
- `Login`, `Register` (token branch) and `ActivateByCode` call `sessionCookie` right after `sessionDeps`, before any side effect, and answer through `writeSession`. `api_tokens.go`, `mintFor` and `IssueToken` are untouched.
- `config.yaml` has `jwt.token_cookie: false` with a comment.
- The README has the new config row, the JWT sessions bullet (cookie refresh, token_cookie, SameSite/CSRF note, broken-block behaviour, API tokens unaffected), `SessionTokenCookie` in the API reference, and Testing paragraphs.
- The escalation note now has:
- row 7 marked done (unreleased), with `846087d`, `8bdf2c7` → v0.5.0;
- verdicts G3 and 7 updated;
- the G3 reply sentence reworded;
- a "Cookie on /login" reply line.
Only item 8's TODO line remains. The todo has an Outcome section and stays in `pending/`.
## Deviations from Plan
- **Test helper shape:** the plan asked for "a small decoder that returns the body as map[string]any". It is folded into `assertBodyKeys(t, rec, keys...)`, which decodes the body, asserts the exact key set and returns the map.
- **Shared test constants:** `opaque500Body` lives in `refresh_cookie_test.go`. The route-path constants and the small helpers (`inactiveUser`, `activationCode`, `assertCookieServes`, `assertOpaque500`, `tokenCookieSettings`, `registerBody`) live in `token_cookie_test.go`.
- **[Rule 1 - Bug, own test]** The first draft of TestTokenCookie compared the 401 message against `invalidLoginBody`. In the test package that constant is the whole JSON body, so the test now compares the full body. This was fixed before GREEN and never committed.
- **Escalation note, line 51** ("remaining are item 7 (cookie) then item 8") was left unchanged as the plan required. It is now slightly stale.
## Known Stubs
None.
## Threat Flags
None. No new endpoints were added. The cookie attributes come only from `bouncer.LoadCookieConfig`, which matches the plan's threat model.
## Release hold
Nothing was pushed or tagged:
- neither plugin commit is on a remote branch, and neither is tagged;
- go.mod and go.sum are unchanged;
- the sm-demo-app `plugins/golem15/user` pointer was neither staged nor committed;
- summercms.go `modules/bouncer` is unmodified.
STATE.md, PLAN, CONTEXT and this SUMMARY are left for the orchestrator to commit.
## Self-Check: PASSED
- Files exist: refresh_cookie_test.go, token_cookie_test.go, session_user.go, api_controller.go, config.yaml, README.md, the note and the todo.
- Commits exist: `846087d` and `8bdf2c7` (sm-user-plugin), `6a1974c` (summercms.go).
- All three task `<verify>` gates printed VERIFY-OK.

View File

@@ -0,0 +1,112 @@
---
phase: quick-261022-wah
verified: 2026-10-22T22:05:00Z
status: passed
score: 8/8 must-haves verified
covered_files:
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- .planning/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-CONTEXT.md
- .planning/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-PLAN.md
- .planning/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-SUMMARY.md
- .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
covered_digest: "v2:sha256:fa87e2eb366a276afffe8caacff6898c96904138aef15acac2fb56ac23889f8b"
behavior_unverified: 0
overrides_applied: 0
---
# Quick 261022-wah: Opt-in HttpOnly session cookie and cookie-carried /refresh rotation. Verification Report
**Goal:** Opt-in HttpOnly session cookie on user-plugin POST /login (`golem15.user.jwt.token_cookie`, default off) plus /register and activate-by-code, attributes from auth.cookie via `bouncer.LoadCookieConfig`, body token omitted when the cookie is set (like sociallogin `writeSession`). A POST /refresh carried by the auth.cookie cookie rotates that cookie (body `{}`); bearer-only and other-cookie refreshes stay body-only. SameSite=Lax, no CSRF. With the key off, existing responses stay byte-identical.
**Verified:** 2026-10-22
**Status:** passed
**Re-verification:** No (initial verification)
Note on fingerprint scope: the plugin source files live in a sibling repo (`../sm-demo-app/plugins/golem15/user`, sm-user-plugin commits `846087d`, `8bdf2c7` on `a484d0e`). `verification.fingerprint` refuses paths outside the summercms.go root, so `covered_files` lists only the in-root planning files. The plugin code was verified directly at HEAD `8bdf2c7`.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | token_cookie off (the default): /login and /register answer `{token,user}`, /activate-by-code answers `{message,token,user}`, no cookie; TestLogin, TestRegister and TestActivateByCode are unchanged and green | VERIFIED | `writeSession` with `cc == nil` sets `body["token"]` and calls the same `writeJSON`. `wire.WriteJSON` encodes maps with sorted keys, so the bytes match the old literal maps. `sessionCookie` returns nil without reading auth.cookie when the key is off. TestTokenCookie/off_by_default asserts exact key sets and no cookie (PASS). The diff does not touch session_test.go, register_test.go or account_test.go, and those tests pass. |
| 2 | token_cookie on: the three routes return no `token` key, set exactly one auth.cookie cookie with the LoadCookieConfig attributes, HttpOnly and Max-Age refresh_ttl, and that cookie authenticates /fetch | VERIFIED | `Login`, `Register` (token branch) and `ActivateByCode` call `writeSession(w, app, cc, …)`, and `cc.TokenCookie(token, refreshTTL(app))` writes the cookie. TestTokenCookie/on and on_with_configured_auth.cookie pass: exact body keys, `assertSessionCookie` compares the cookie against `cfg.TokenCookie` attributes and Max-Age 20160*60 or 3600, and `assertCookieServes` checks /fetch. |
| 3 | A cookie-carried (auth.cookie name, no bearer) /refresh returns 200 `{}` with one rotated Set-Cookie, with token_cookie on or off; the new token authenticates /fetch and jwt.auth, and the old token is refused | VERIFIED | `Refresh`: `classes.SessionTokenCookie(r)` gives the carrier name, `rotate = &cc` when `cc.Name == name`, then `writeSession(w, app, rotate, next, map[string]any{})`. TestRefreshRewritesAuthCookie/token_cookie_rotates (token_cookie off) and TestTokenCookie/on (token_cookie on) pass, and both check the old token gets 401 and the new token reaches /fetch and `guarded`. |
| 4 | A bearer refresh (also with the cookie present) or an other-cookie refresh answers exactly `{token}` and sets no cookie | VERIFIED | `SessionTokenCookie` returns "" when `bearerToken(r)` is non-empty, and `rotate` stays nil when the carrier name differs from `cc.Name`. These subtests pass: bearer_wins_over_the_cookie (cookie B is not rotated), bearer_only, auth_token_cookie_stays_body-only, and configured_auth.cookie_name (a token-cookie refresh is body-only when name=auth_token). |
| 5 | A refused refresh (401) sets and forgets no cookie. A broken auth.cookie gives a cookie-carried refresh the opaque 500 before rotation, and the token still refreshes by bearer. With token_cookie on, the three routes answer 500 before any side effect. A bearer refresh never reads auth.cookie | VERIFIED | The config load sits before `bouncer.Refresh` and only inside `if name := SessionTokenCookie(r); name != ""`. `sessionCookie` runs right after `sessionDeps` in all three routes. Passing tests: refusals_set_no_cookie (banned and blacklisted), broken_auth.cookie (500, then a bearer refresh of the same token returns 200), and TestTokenCookie/on_with_a_broken_auth.cookie (no last_seen, no throttle row, no user row, not activated; after the config is fixed the same code activates). |
| 6 | POST /tokens is unchanged and sets no cookie when token_cookie is on | VERIFIED | `git diff a484d0e..HEAD` shows no change to `controllers/api_tokens.go` or `classes/registration.go`. TestTokenCookie/on: POST /tokens returns 201 `{token,meta}` and `assertNoCookie` passes. |
| 7 | Two local commits on top of a484d0e (refresh first, then token_cookie), green; no trailer; not pushed or tagged; go.mod and go.sum unchanged; sm-demo-app pointer not committed or staged | VERIFIED | `git log`: `a484d0e` → `846087d` (fix: refresh) → `8bdf2c7` (feat: token_cookie). Neither commit body has a trailer. `git branch -r --contains 846087d` and `git tag --contains 846087d` print nothing. The go.mod/go.sum diff is empty. sm-demo-app `git ls-tree HEAD plugins/golem15/user` still points to `930b158`, nothing is staged, and the last pointer commit is `e587980`. I re-ran the checks at HEAD: vet OK, `-short` OK, and the full Docker suite OK. At `846087d` (a `git archive` copy with the replace path made absolute): vet OK, and the Docker cookie, refresh and login tests pass. One `-short` failure in that copy, TestRegisterCORSPath, comes from the copy's location: it reads `appRoot/config/http.yaml`, which is outside the plugin. |
| 8 | Escalation note row 7 reads done (unreleased) with both SHAs and v0.5.0; verdicts 7 and G3 and the G3 and Cookie-on-/login replies describe the shipped behaviour and name the key; only item 8's TODO remains; the todo has an Outcome and stays in pending; the commit touches only those two files | VERIFIED | `git show --stat 6a1974c` lists exactly the 2 files. Row 7: `done (unreleased) \| 846087d, 8bdf2c7 → v0.5.0`. Both the G3 and 7 verdicts are rewritten. The G3 reply sentence about /refresh is reworded, and the "Cookie on /login" reply names `golem15.user.jwt.token_cookie`. `grep '^TODO'` finds only line 100 (item 8). The todo has `## Outcome (2026-10-22)` before `## Invoke` and is still under `todos/pending/`. |
**Score:** 8/8 truths verified (0 present, behavior-unverified)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `classes/session_user.go` | `func SessionTokenCookie(r *http.Request) string`; bearer parsing shared with `sessionTokens` | VERIFIED | Present. `bearerToken` is used by both `SessionTokenCookie` and `sessionTokens`. Nil-safe. |
| `controllers/api_controller.go` | `tokenCookieConfig`, `writeSession(w, app, cc, token, body)`, `sessionCookie`; Refresh and the three routes wired | VERIFIED | All three helpers are present with the planned signature. There are 4 `writeSession` call sites (Login, Refresh, ActivateByCode, Register token branch). Register's admin and user mode bodies are unchanged. |
| `config/config.yaml` | `token_cookie: false` with a comment | VERIFIED | Lines 106-110. |
| `refresh_cookie_test.go` | TestRefreshRewritesAuthCookie, TestSessionTokenCookie, assertSessionCookie, assertNoCookie | VERIFIED | 261 lines. 7 Docker subtests and a 7-row `-short` table plus the nil case. |
| `token_cookie_test.go` | TestTokenCookie: off, on, configured, broken | VERIFIED | 237 lines, 4 subtests, all with substantive assertions. |
| `README.md` | config row, JWT sessions bullet, SessionTokenCookie in the API reference, Testing paragraphs | VERIFIED | All four are present. The old "writes no cookie" sentence is replaced. |
| escalation note | row 7, verdicts, replies | VERIFIED | See truth 8. |
| todo | `## Outcome` | VERIFIED | See truth 8. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `Refresh` | `classes.SessionTokenCookie` / `SessionToken` | rewrite only when the carrier name == `auth.cookie.name` | WIRED | `if name := classes.SessionTokenCookie(r); name != ""` … `if cc.Name == name { rotate = &cc }`. TestSessionTokenCookie checks that the named cookie carries exactly `SessionToken`'s value. |
| `writeSession` / `tokenCookieConfig` | `bouncer.LoadCookieConfig`, `CookieConfig.TokenCookie` | `cc.TokenCookie(token, refreshTTL(app))` | WIRED | Attributes come only from LoadCookieConfig. The plugin adds no SameSite or Secure code of its own. |
| written cookie | jwt guard / `SessionCookieNames` | the guard reads token and auth_token | WIRED | `SessionCookieNames` is unchanged. `assertCookieServes` and `guarded` pass for both the `token` and `auth_token` names. |
| commit SHAs | note row 7 / verdict 7 / reply | `→ v0.5.0` | WIRED | The SHAs in the note match the actual commits. |
### Data-Flow Trace (Level 4)
Not applicable. The phase changes HTTP response writers, not rendered data. Cookie values are traced end to end by the tests: login or refresh, then the Set-Cookie value, then /fetch through that cookie as the same user sub.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| vet at HEAD | `GOWORK=off go vet ./...` | clean | PASS |
| short suite at HEAD | `GOWORK=off go test -short -count=1 ./...` | all ok | PASS |
| full Docker suite at HEAD (run once) | `GOWORK=off go test -count=1 ./...` | all ok (root package 54s) | PASS |
| named cookie and session tests at HEAD | `go test -v -run 'TestRefreshRewritesAuthCookie\|TestSessionTokenCookie\|TestTokenCookie\|TestSessionRoutesAcceptCookies\|TestLogin$\|TestRegister$\|TestActivateByCode$\|TestRefresh$'` | every test and subtest PASS | PASS |
| commit 1 on its own (`846087d`, scratch archive) | vet, plus the Docker refresh and cookie tests | vet OK, tests ok | PASS |
### Probe Execution
None declared. The plan's `<verify>` gates are executor-side. Behaviour was checked with the tests above instead.
### Requirements Coverage
| Requirement | Description | Status | Evidence |
|-------------|-------------|--------|----------|
| wah-1 | cookie-carried /refresh rewrites the auth.cookie cookie; bearer and other-cookie refreshes stay body-only; refusals write no cookie | SATISFIED | Truths 3, 4, 5 |
| wah-2 | token_cookie on /login, /register, /activate-by-code; off is byte-identical; a config error gives the opaque 500 | SATISFIED | Truths 1, 2, 5, 6 |
| wah-3 | escalation note item 7, G3 reword, todo Outcome; separate docs commit | SATISFIED | Truth 8 |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (plugin diff a484d0e..8bdf2c7) | none | TBD/FIXME/XXX/TODO/HACK in added lines | none | clean |
| `.planning/notes/2026-10-22-external-user-plugin-escalations.md` | 100 | `TODO:` (item 8) | Info | Pre-existing. The plan requires it to stay, since item 8 is out of scope. |
| `.planning/notes/2026-10-22-external-user-plugin-escalations.md` | 51 | "remaining are item 7 … then item 8" | Info | Slightly out of date now. The SUMMARY says it was left as planned. |
| `controllers/api_controller.go` Refresh | config-load block | broken auth.cookie also gives an `auth_token`-carried refresh the 500 | Info | Matches the must-have ("a cookie-carried refresh answers the opaque 500"). A broken auth.cookie block is a deploy-time misconfiguration. |
| `controllers/api_controller.go` Register | after sessionDeps | with token_cookie on and a broken auth.cookie, /register gives the 500 even in user or admin activate mode, which would not set a cookie | Info | Intentional (QD-4: fail before side effects). No effect while the key is off. |
### Human Verification Required
None. Every truth is exercised through the real router (surf.Assemble with the jwt guard) on Postgres via testcontainers, including cookie attributes, rotation and refusal.
### Gaps Summary
None found. The code matches the plan and the SUMMARY. Default-off bodies are byte-identical because the same sorted-key map encoding is used. Cookie attributes come only from `bouncer.LoadCookieConfig`. The refresh carrier detection shares one bearer parser with `SessionToken`. Every must-have truth has a test, and those tests pass at HEAD. The release hold is intact: nothing pushed or tagged, no pointer bump, go.mod and go.sum unchanged. Not re-run by this verifier: the SUMMARY's consumer cross-check from sm-demo-app (sociallogin `TestTokenCookieMode`).
---
_Verified: 2026-10-22_
_Verifier: Claude (msd-verifier)_