From 54ff7bd665ca4976568f8e94eddc2bedf77638d7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 22:36:19 +0200 Subject: [PATCH] docs(quick-261022-usi): user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase under serve --- .planning/STATE.md | 9 +- .../261022-usi-CONTEXT.md | 60 ++++ .../261022-usi-PLAN.md | 259 ++++++++++++++++++ .../261022-usi-SUMMARY.md | 102 +++++++ .../261022-usi-VERIFICATION.md | 111 ++++++++ 5 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md create mode 100644 .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-PLAN.md create mode 100644 .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md create mode 100644 .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-VERIFICATION.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 39a79183..8b1270d9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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-22T19:51:56.252Z" +last_updated: "2026-10-22T20:36:03.317Z" last_activity: 2026-10-22 -last_activity_desc: "Completed quick task 261022-tto: user-plugin session routes accept the token/auth_token cookies" -state_head: b70abe48e6c5b5df1a0f26096379e2ca62cafcf1 +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 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-tto: user-plugin session routes accept the token/auth_token cookies +Last activity: 2026-10-22 — Completed quick task 261022-usi: user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase (logout revokes under serve) Progress: [████████░░] 82% @@ -1046,6 +1046,7 @@ Recent decisions affecting current work: | 261022-k9k | Admin keyboard shortcuts: Ctrl+S, Ctrl+Enter, Ctrl+Shift+S (Create and new), / search, Esc, ? help | 2026-10-22 | 604fee7 | [261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl](./quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/) | | 261022-tb4 | Password /login refuses a not-activated account while require_activation is on (sm-user-plugin 15ff979, unreleased: hold until the escalation batch lands) | 2026-10-22 | 15ff979 (sm-user-plugin) | [261022-tb4-password-login-must-refuse-is-activated-](./quick/261022-tb4-password-login-must-refuse-is-activated-/) | | 261022-tto | User plugin /fetch, /refresh, /update, /change-password accept token/auth_token session cookies (sm-user-plugin 8e4b863, ships v0.5.0) | 2026-10-22 | 8e4b863 (sm-user-plugin) | [261022-tto-fetch-refresh-update-change-password-mus](./quick/261022-tto-fetch-refresh-update-change-password-mus/) | +| 261022-usi | User plugin jwt blacklist and GORM hooks wired via lagoon.OnDatabase so logout revokes under serve; jwt guard uses SessionCookieNames (sm-user-plugin 4bf44fc, ships v0.5.0) | 2026-10-22 | 4bf44fc (sm-user-plugin) | [261022-usi-user-plugin-jwt-blacklist-and-hooks-wire](./quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/) | ## Deferred Items diff --git a/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md new file mode 100644 index 00000000..49bb19b9 --- /dev/null +++ b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md @@ -0,0 +1,60 @@ +# Quick Task 261022-usi: User-plugin jwt blacklist and hooks wired via lagoon.OnDatabase under serve; jwt guard uses SessionCookieNames - Context + +**Gathered:** 2026-10-22 +**Status:** Ready for planning + + +## Task Boundary + +External escalation G4. Under `summer serve`, `party.Activate` runs every plugin `Boot` before `surf.ServeCommand` calls `lagoon.Publish`. The user plugin's `Boot` (`../sm-demo-app/plugins/golem15/user/plugin.go`, repo sm-user-plugin, base `8e4b863`) looks up `*sql.DB` / `*gorm.DB`, finds none, registers the jwt guard with `bl=nil`, and never publishes `bouncer.BlacklistStore`. Logout (`EndSession`, `api_controller.go`) looks the store up at request time and writes nothing, so a logged-out token keeps working. `classes.RegisterHooks(gdb)` has the same Boot-before-DB hole. + +Fix inside the user plugin only. Framework (`summercms.go`) stays unchanged. + + + + +## Implementation Decisions + +### Guard wiring: lazy store +- `bouncer.Registry.Register` refuses a second "jwt" registration, so there is no re-registration. +- Boot registers the jwt guard **once** with a small plugin-local `bouncer.BlacklistStore` that resolves `app.Lookup[bouncer.BlacklistStore]()` on every call. When no store is published: `IsBlacklisted` returns `false, nil`, `Add` and `Sweep` are no-ops. The guard therefore exists with or without a DB, as it does today. +- The lazy wrapper is NOT itself published as `bouncer.BlacklistStore` (that would make the lookup recurse). Only the real store is published. +- That single `reg.Register(... "jwt" ...)` line passes `classes.SessionCookieNames()...` instead of the hardcoded `"token", "auth_token"` (G3 leftover). + +### Database-ready work through `lagoon.OnDatabase` +- From Boot, one `lagoon.OnDatabase(app, func(sqlDB, gdb) error { ... })` call does all of these: `classes.RegisterHooks(gdb)`, `app.Publish[bouncer.BlacklistStore](bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist"))`, `p.startBlacklistSweep(store)`. +- OnDatabase runs the callback immediately when the DB is already published (the tests that publish first keep working) and otherwise queues it for `lagoon.Publish`. +- Remove the direct `app.Lookup[*gorm.DB]` / `*sql.DB` branches from Boot. + +### Test: full serve order +- New Docker-backed test in sm-user-plugin (skips under `-short`): build the app, run Register + Boot with **no** DB published, then `lagoon.Publish`, then build routes (`surf.BuildRouter` or the plugin's existing test router helper), log in, log out, then show that the same token on a `jwt.auth` route answers 401 and that a `jwt_blacklist` row exists for its jti. +- Same test (or a sibling) asserts `RegisterHooks` ran after Publish (an observable hook effect, or the callback being registered on gdb). +- A test that publishes first does not count as the regression test. + +### Claude's Discretion +- Name and file of the lazy store type (for example `classes/blacklist.go` or in `plugin.go`). +- Whether `startBlacklistSweep` stays a method or moves. +- Exact assertion for "RegisterHooks ran", picked from whatever RegisterHooks installs. + + + + +## Specific Ideas + +- Pattern to follow: `../sm-demo-app/plugins/golem15/paymentgatewaysubscriptions/boot.go:113-121` and `modules/lagoon/README.md` (OnDatabase from Boot). +- Plugin repo: commit in the sm-user-plugin submodule at `../sm-demo-app/plugins/golem15/user`. Commit locally only: no push, no tag, no app pointer bump (release hold for SummerCMS v0.5.0). +- Update the plugin README if its description of Boot or the blacklist wiring changes. +- Afterwards (orchestrator, in summercms.go planning docs): fill G4 in `.planning/notes/2026-10-22-external-user-plugin-escalations.md` (tracker row `done (unreleased)` + commit → v0.5.0, and replace the G4 `TODO` paragraph in the reply), and add an Outcome to the todo, which stays in pending. + + + + +## Canonical References + +- `.planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md` +- `.planning/notes/2026-10-22-external-user-plugin-escalations.md` +- `modules/lagoon/ondatabase.go`, `modules/lagoon/README.md` +- `modules/bouncer/registry.go` (Register refuses duplicates), `modules/bouncer/blacklist.go` (BlacklistStore interface), `modules/bouncer/jwt.go` (NewJWTGuard) +- `modules/surf/serve.go:75-88` (Publish then BuildRouter) + + diff --git a/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-PLAN.md b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-PLAN.md new file mode 100644 index 00000000..bd57b250 --- /dev/null +++ b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-PLAN.md @@ -0,0 +1,259 @@ +--- +phase: quick-261022-usi +plan: 01 +quick_id: 261022-usi +slug: user-plugin-jwt-blacklist-and-hooks-wire +type: execute +mode: quick +wave: 1 +depends_on: [] +autonomous: true +requirements: + - usi-1 # G4: under summer serve (party.Activate before lagoon.Publish) POST /logout revokes the token: the jwt guard sees the Postgres jwt_blacklist store, the store and the plugin's GORM hooks are wired through lagoon.OnDatabase; the jwt guard reads classes.SessionCookieNames() (G3 leftover) +files_modified: + # sm-user-plugin (module golem15.user), worked in the sm-demo-app submodule checkout. Commit there only. + - ../sm-demo-app/plugins/golem15/user/plugin.go + - ../sm-demo-app/plugins/golem15/user/blacklist.go + - ../sm-demo-app/plugins/golem15/user/blacklist_test.go + - ../sm-demo-app/plugins/golem15/user/serve_order_test.go + - ../sm-demo-app/plugins/golem15/user/admin_harness_test.go + - ../sm-demo-app/plugins/golem15/user/README.md + +estimate: + tokens: 90000 + raw_tokens: 90000 + tasks: 2 + confidence: low # estimate-calibration: factor 1, sample_count 0 + +must_haves: + truths: + - "Booted in the serve order (party.Activate with no database published, then lagoon.Publish, then the router), a session token that POST /logout ended is refused with 401 on a jwt.auth route through the bearer header and through each session cookie, at GET /fetch and at POST /refresh, and jwt_blacklist holds one row for its jti." + - "Before lagoon.Publish no bouncer.BlacklistStore is published and the plugin's GORM hooks have not run; when lagoon.Publish runs, classes.RegisterHooks runs with the published *gorm.DB, a *bouncer.PostgresBlacklist on jwt_blacklist is published as bouncer.BlacklistStore, and the blacklist sweep starts." + - "Boot registers the jwt guard exactly once, with or without a database, with the cookie names from classes.SessionCookieNames() and a plugin-local store that resolves the published bouncer.BlacklistStore on every call. Without a published store no token counts as blacklisted and Add and Sweep do nothing. A store error passes through unchanged, so the guard still refuses the token. The wrapper itself is never published." + - "When the database is published before Boot (the plugin's own tests, the sociallogin and oauthserver harnesses), hooks, store and sweep are still wired synchronously inside Boot, so the existing suites stay green unchanged." + - "go vet, go test -short and the full Docker-backed go test of the plugin pass at one fix commit on sm-user-plugin master. Nothing is pushed or tagged, the sm-demo-app pointer is not bumped and summercms.go is not modified." + artifacts: + - path: ../sm-demo-app/plugins/golem15/user/plugin.go + provides: "Boot wires RegisterHooks, the Postgres blacklist store and its sweep through one lagoon.OnDatabase call; the jwt guard gets the lazy store and classes.SessionCookieNames()" + contains: "lagoon.OnDatabase(app, func(" + - path: ../sm-demo-app/plugins/golem15/user/blacklist.go + provides: "appBlacklist, the unexported bouncer.BlacklistStore that resolves the published store per call" + contains: "type appBlacklist struct" + - path: ../sm-demo-app/plugins/golem15/user/admin_harness_test.go + provides: "adminEnvConfig (extracted, unchanged config) and newServeOrderEnv (Activate, hook point, Publish, uploads, Migrate, Assemble)" + contains: "func newServeOrderEnv" + - path: ../sm-demo-app/plugins/golem15/user/serve_order_test.go + provides: "TestBootBeforeDatabase, the Activate-then-Publish regression test (Docker, skips under -short)" + contains: "func TestBootBeforeDatabase" + - path: ../sm-demo-app/plugins/golem15/user/blacklist_test.go + provides: "TestAppBlacklist: no store, wrapper-published-as-store, delegation, error pass-through (runs under -short)" + contains: "func TestAppBlacklist" + - path: ../sm-demo-app/plugins/golem15/user/README.md + provides: "JWT-sessions bullet describes the OnDatabase wiring and the per-request store; Testing names TestBootBeforeDatabase and TestAppBlacklist" + contains: "TestBootBeforeDatabase" + key_links: + - from: ../sm-demo-app/plugins/golem15/user/plugin.go (Boot) + to: summercms.go modules/lagoon/ondatabase.go (OnDatabase) and modules/lagoon/connection.go (Publish -> runDatabaseHooks) + via: "the callback is queued in Boot and drained by the serve command's lagoon.Publish, which runs before BuildRouter" + pattern: "lagoon\\.OnDatabase\\(app, func\\(" + - from: ../sm-demo-app/plugins/golem15/user/plugin.go (jwt guard registration) + to: ../sm-demo-app/plugins/golem15/user/blacklist.go (appBlacklist.store) + via: "bouncer.NewJWTGuard captures the wrapper at Boot; the wrapper looks up bouncer.BlacklistStore on the same app per request" + pattern: "&appBlacklist\\{app: app\\}" + - from: ../sm-demo-app/plugins/golem15/user/plugin.go (jwt guard registration) + to: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (SessionCookieNames) + via: "the guard, SessionToken, EndSession and the controllers read the same cookie list" + pattern: "classes\\.SessionCookieNames\\(\\)\\.\\.\\.\\)" + - from: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (EndSession, the POST /logout path) + to: the store the OnDatabase callback publishes + via: "EndSession already looks bouncer.BlacklistStore up at request time; once the callback publishes it, logout writes the jwt_blacklist row the guard then reads" + pattern: "Publish\\[bouncer\\.BlacklistStore\\]\\(store\\)" +--- + + +Close escalation G4. Under `summer serve`, the generated main runs party.Activate (every plugin Boot) before surf's serve command calls lagoon.Publish. The user plugin's Boot finds no database, so it registers the jwt guard with a nil blacklist and never publishes bouncer.BlacklistStore. classes.EndSession (POST /logout) then finds no store and writes nothing, so a logged-out token keeps working. classes.RegisterHooks has the same hole. After this plan the database-dependent wiring runs through lagoon.OnDatabase, and the guard resolves the store per request. While reworking that Register line, switch the jwt guard to classes.SessionCookieNames() (G3 leftover). + +Purpose: logout revokes the session in production (serve), not only in tests that publish the database first. +Output: one fix commit on sm-user-plugin master (code, tests and README together), made in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user with git -C. No push, no tag, no sm-demo-app pointer bump (SummerCMS v0.5.0 release hold). summercms.go is not modified. The orchestrator handles the planning docs (the G4 row and reply in the escalation note, the todo's Outcome and STATE.md); they are not executor tasks. + +Locked decisions (from 261022-usi-CONTEXT.md, labelled here for traceability): +- D-01 Guard wiring, lazy store. bouncer.Registry.Register refuses a duplicate name, so the guard is never registered twice. Boot registers the jwt guard once with a plugin-local bouncer.BlacklistStore. That store resolves the app's published bouncer.BlacklistStore on every call. With no store published, IsBlacklisted answers false with a nil error, and Add and Sweep do nothing. The wrapper is never published as bouncer.BlacklistStore, because the lookup would then recurse. That one Register call passes classes.SessionCookieNames()... in place of the two hardcoded cookie-name literals. +- D-02 Database work goes through lagoon.OnDatabase. One lagoon.OnDatabase(app, ...) call from Boot does three things: classes.RegisterHooks(gdb), publishing bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist") as bouncer.BlacklistStore, and p.startBlacklistSweep(store). The direct *gorm.DB and *sql.DB lookup branches leave Boot. +- D-03 The test uses the full serve order. A new Docker-backed test skips under -short. It boots with no database published, then calls lagoon.Publish, then builds the routes. Then it logs in, logs out, and shows that the same token gets 401 on a jwt.auth route and that a jwt_blacklist row exists for its jti. It also shows that RegisterHooks ran after Publish. A test that publishes first does not count. + +Planner's choices (Claude's discretion in CONTEXT; the executor does not revisit them): +- QD-1 The lazy store is an unexported `appBlacklist` in a new file blacklist.go at the plugin root (package user). Exporting it would add public API to a core plugin for no consumer. +- QD-2 startBlacklistSweep stays a method and is not changed. +- QD-3 "RegisterHooks ran after Publish" is observed with a test hook. The test registers it through classes.RegisterHook; it records the *gorm.DB it was called with. classes keeps its registrants in a process-global slice with no unregister, and adding one would be new exported API. So the hook is gated by an atomic.Bool that t.Cleanup clears, which makes it inert for later boots in the same test binary. Production registers no hooks today, so the hook is the only observable effect. +- QD-4 The serve-order boot is a harness function, newServeOrderEnv(t, beforePublish), in admin_harness_test.go. It shares the config with newAdminEnv through an extracted adminEnvConfig(t), so the existing helpers (sessionLogin, guarded, assertSessionEnded, call, send) work on it. The order mirrors surf's serve command: Activate, then lagoon.Publish, publishUploads and BuildRouter. lagoon.Migrate sits before Assemble only because the shared harness database may not be migrated yet when this test runs first. +- QD-5 appBlacklist treats a published value that is itself an *appBlacklist as no store, which guards against recursion. A store error goes back unchanged, so bouncer's guard still fails closed with "Authentication error". +- QD-6 The OnDatabase call sits where RegisterHooks used to run (after JWTSecret, before the registry). The closure uses Boot's `app` parameter, never p.app, because one registered Plugin value is shared by every party.Activate in a test binary. Boot returns OnDatabase's error, because the callback runs immediately when the database is already published. + +Source coverage audit: +| Source | Item | Covered by | +|--------|------|------------| +| GOAL | logout revokes under serve | Task 1 (GREEN, TestBootBeforeDatabase) | +| CONTEXT D-01 | single registration, lazy store, not published, SessionCookieNames | Task 1 GREEN; Task 2 TestAppBlacklist | +| CONTEXT D-02 | OnDatabase: hooks + publish + sweep; direct lookups removed | Task 1 GREEN and its grep gates | +| CONTEXT D-03 | Activate-then-Publish Docker test, -short skip, login/logout/401/row, hooks after Publish | Task 1 RED/GREEN | +| CONTEXT specifics | README updated; commit locally in the plugin repo; no push/tag/bump | Task 2 | +| CONTEXT specifics | G4 row/reply, todo Outcome | orchestrator (excluded by the quick constraints) | +| REQ / RESEARCH | none (quick task, no research) | n/a | + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@CLAUDE.md +@.planning/STATE.md +@.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md +@.planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md +@../sm-demo-app/plugins/golem15/user/plugin.go +@modules/lagoon/ondatabase.go + +Working copies (observed at planning time, 2026-10-22): +- sm-user-plugin is at ../sm-demo-app/plugins/golem15/user (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user). Branch master at 8e4b863 (G3), 2 ahead of origin/master, working tree clean, latest tag v0.1.8. Do not fetch, merge, rebase or reset. If master has moved past 8e4b863, build on top and record `git log --oneline 8e4b863..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 runs with GOWORK=off. go.mod:176 replaces summercms with ../../../../summercms.go (summercms.go's working tree). lagoon.OnDatabase has existed since v0.1.x. +- Docker: docker info answered (server 29.9.1). If the Docker socket is denied, run go test through sudo -u golem (CLAUDE.md). +- sm-demo-app (../sm-demo-app) has another session's uncommitted edits (main.go and the plugins/golem15/user pointer). Read-only here: run tests, never stage or commit. +- The planning docs in summercms.go belong to the orchestrator. Do not touch STATE.md, the escalation note or the todo. + +Source facts (plugin checkout at 8e4b863): +- plugin.go: Plugin{app, sweepStop} :54-57. Boot :67-114: JWTSecret :69, RegisterHooks branch :73-77, registry lookup-or-publish :78-84, blacklist block :85-98 (direct database lookups, Publish, startBlacklistSweep), jwt guard Register :99 with two hardcoded cookie names, api_token guard :102-112, RegisterUserAuthorizer :113. startBlacklistSweep :116-147 (reads p.app.Config golem15.user.jwt.blacklist_sweep_interval, default 10m, starts at most once per Plugin value through sweepStop). withDB :214-227 keeps its own *gorm.DB lookup, which is legitimate for console commands. Middlewares :233-249 reads the registry when the router is built. Imports already include database/sql, lagoon, bouncer, gorm. +- classes/registry.go: RegisterHook appends to the package-level hookRegistrars; RegisterHooks runs them all. There are no production registrants (only classes/user_groups_test.go). classes/session_user.go: SessionCookieNames() :27 returns a copy of token, auth_token. EndSession (:123) and RequestUser (:74) look bouncer.BlacklistStore up at request time. controllers/api_controller.go :209 (Refresh) and :919 (sessionGuard) do too. +- summercms.go: lagoon.OnDatabase runs fn immediately when a *gorm.DB is published (it uses gdb.DB() when no *sql.DB was published), and otherwise queues fn; lagoon.Publish (connection.go:162) publishes both handles and then drains the queue, wrapping a callback error as "lagoon: database hook: ...". surf serve.go:70-88 order: OpenFromApp, lagoon.Publish, publishUploads, installCooler, BuildRouter. bouncer.NewJWTGuard(secret, users, bl, cookieNames...) (jwt.go:86) consults bl only when it is non-nil; an IsBlacklisted error gives "Authentication error" (401). bouncer.BlacklistStore (blacklist.go:16) has Add(ctx, jti, expiresAt, validUntil), IsBlacklisted(ctx, jti) and Sweep(ctx, now). bouncer.NewMemoryBlacklist and bouncer.NewPostgresBlacklist exist. backpack.App.Publish refuses a second value of the same type. cabana keeps its own backend blacklist (adminBlacklist, own table) and never publishes bouncer.BlacklistStore. +- Tests (package user, plugin root): admin_harness_test.go has adminHarnessDB :91 (skips under -short; shared database user_admin in the session container), newAdminEnv :131-192 (config block :134-160, then lagoon.Publish :162, mailCapture logger :165, attach.Publish with memblob :169-173, party.Activate :176, configure, lagoon.Migrate :184, surf.Assemble :187) and the adminEnv fields h, app, gdb, bucket, mail. logout_test.go has the constants userTokensPath, userLogoutPath, userRefreshPath and blacklistedBody :16-23, sessionLogin :208, guarded :220, assertSessionEnded :231 (jwt.auth through bearer/token/auth_token, /fetch, /refresh) and assertLoggedOut :264. session_test.go has sessionConfig, parseToken :644 and bootPlugin :710 (reads p.sweepStop right after Boot). TestLogin checks the store is published after a DB-first Boot. TestLoginRouteGroup boots with no database. TestLoginBlacklistSweep uses a 50ms sweep. session_user_test.go has failingBlacklist/errBlacklistDown :24-35 and sessionUserApp(t, environ...) :39 (an app without a database). No root TestMain; Docker tests skip through adminHarnessDB. +- The names appBlacklist, adminEnvConfig, newServeOrderEnv, TestBootBeforeDatabase and TestAppBlacklist, and the files blacklist.go, blacklist_test.go and serve_order_test.go, are all unused today. +- README.md: the JWT-sessions bullet :20 ("Boot registers the `jwt` guard ...", "A background sweep prunes the blacklist."), the classes list with SessionCookieNames :192, the Testing section :247 (TestLogoutEndsTheSession :262, TestSessionRoutesAcceptCookies :264). +- Consumers that boot this checkout through sm-demo-app's go.work replace: plugins/golem15/sociallogin and plugins/golem15/oauthserver harnesses publish the database before party.Activate (the immediate OnDatabase path). + + + + + + Task 1 (tracer): booting in the serve order (Activate, then Publish) wires the blacklist and hooks, so logout revokes the token on jwt.auth end to end on Postgres + ../sm-demo-app/plugins/golem15/user/plugin.go, ../sm-demo-app/plugins/golem15/user/blacklist.go, ../sm-demo-app/plugins/golem15/user/admin_harness_test.go, ../sm-demo-app/plugins/golem15/user/serve_order_test.go + git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user status --short is empty, its branch is master and 8e4b863 is an ancestor of HEAD; Docker is reachable for testcontainers (directly or through sudo -u golem). + + - TestBootBeforeDatabase (serve_order_test.go, package user) boots through newServeOrderEnv. First party.Activate(app, golem15.user) runs with no database published; then lagoon.Publish, attach.Publish, lagoon.Migrate and surf.Assemble follow, which is the serve command's order (per D-03). + - At the hook point between Activate and Publish: app.Lookup for *gorm.DB is not ok (the order is real), app.Lookup[bouncer.BlacklistStore]() is not ok, and the test hook has not run. + - After Publish, the published bouncer.BlacklistStore is a *bouncer.PostgresBlacklist (the real store, not the wrapper; per D-01 and D-02), and the test hook ran with exactly e.gdb, which shows RegisterHooks ran on Publish. + - e.sessionLogin(t, "serve-order") returns a token. e.guarded answers 200 with the bearer header, and also with each name from classes.SessionCookieNames() as the only carrier. + - POST /logout with the bearer satisfies assertLoggedOut. Then e.assertSessionEnded(t, token) passes: jwt.auth answers 401 through the bearer, token and auth_token carriers, /fetch answers 401 and /refresh answers blacklistedBody. jwt_blacklist has exactly 1 row for the token's jti (from parseToken). + - RED at 8e4b863: the test fails at the store-after-Publish assertion, because Boot saw no database and never published a store. + + +RED. In admin_harness_test.go, move the config block of newAdminEnv into adminEnvConfig(t *testing.T) *compass.Config without changing it: the temp dir with app.yaml, compass.Open with the three environ entries, and the cfg.Set loop. newAdminEnv then calls adminEnvConfig and keeps its own order (adminHarnessDB first, Publish before Activate) unchanged. Add newServeOrderEnv(t *testing.T, beforePublish func(*backpack.App)) *adminEnv after newAdminEnv (per QD-4), in this order: +- adminHarnessDB(t) first, so the test skips under -short. +- backpack.New(adminEnvConfig(t)), then publish the mailCapture logger. +- party.Activate(app, []string{"golem15.user"}). +- beforePublish(app) when it is non-nil. +- lagoon.Publish(app, sqlDB, gdb). +- attach.Publish with a memblob bucket closed in t.Cleanup. +- lagoon.Migrate(gdb, plugins), then surf.Assemble. +It returns an adminEnv with the same fields newAdminEnv fills. Its doc comment says it boots like surf's serve command after the generated main's party.Activate, and that Migrate is there only for a harness database that is not migrated yet. + +Write serve_order_test.go with TestBootBeforeDatabase as the behavior block describes. The doc comment says it pins logout revocation when Boot runs before the database is published. The test hook is set up before newServeOrderEnv (per QD-3): +- an atomic.Pointer[gorm.DB] for the handle and an atomic.Bool set to true, which t.Cleanup sets back to false; +- a hook registered through classes.RegisterHook that stores its *gorm.DB argument only while the Bool is true and always returns nil; +- a comment saying the registrant list is process-global, which is why the hook is gated. +The beforePublish callback makes the three not-published assertions with t.Fatal. Read the jti with parseToken(t, token)["jti"] and count rows with e.gdb.Raw(SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?). Run the test. It must fail against the unchanged plugin.go; record the failure line for the SUMMARY. Do not commit a red test: go test stays green at every commit, and the single commit comes at the end of Task 2. + +GREEN, per D-01 and QD-1/QD-5. Create blacklist.go (package user) with the unexported type appBlacklist struct { app *backpack.App } and the compile-time assertion var _ bouncer.BlacklistStore = (*appBlacklist)(nil). A helper method store() returns the bouncer.BlacklistStore published on b.app. It returns nil in three cases: nothing is published, the published value is nil, or the published value is itself an *appBlacklist. +- IsBlacklisted: with no store, false and nil; otherwise the store's result and error, unchanged. +- Add and Sweep: with no store, nil; otherwise they delegate and return the store's error. +The doc comment explains why: the jwt guard is built in Boot, before serve publishes the database, so the store is resolved per call; the type must never be published itself. + +GREEN, per D-02 and QD-6. In plugin.go Boot, keep JWTSecret first. Remove the RegisterHooks branch and the whole blacklist block: the direct *sql.DB and *gorm.DB lookups, the local blacklist variable, its Publish and the sweep start. At the position where RegisterHooks ran, add one call that returns its error: lagoon.OnDatabase(app, func(sqlDB *sql.DB, gdb *gorm.DB) error { ... }). The callback: +- runs classes.RegisterHooks(gdb) and returns its error; +- builds store := bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist"); +- calls app.Publish[bouncer.BlacklistStore](store) and returns its error; +- calls p.startBlacklistSweep(store) and returns nil. +It uses Boot's app parameter, never p.app. Put a one- or two-line comment above the call saying that serve publishes the database after Boot, so this runs on lagoon.Publish, or at once when the database is already published. Comments must not quote the removed code. + +Change the jwt registration to reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, &appBlacklist{app: app}, classes.SessionCookieNames()...)). Keep the registry lookup-or-publish, the api_token guard, RegisterUserAuthorizer, startBlacklistSweep (QD-2), withDB, Middlewares and the import set (database/sql and gorm stay used) as they are. gofmt both files. + +Run the verify command. If an existing test fails because it relied on Boot publishing a store from a published *sql.DB without a *gorm.DB, stop and report it; planning found no such test. A failure unrelated to this change is not patched here: stop and report it. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l plugin.go blacklist.go admin_harness_test.go serve_order_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestBootBeforeDatabase|TestLogoutEndsTheSession|TestSessionRoutesAcceptCookies|TestLogin|TestLoginRouteGroup|TestLoginBlacklistSweep)$' . && grep -qF 'lagoon.OnDatabase(app, func(' plugin.go && grep -qF '&appBlacklist{app: app}' plugin.go && grep -qF 'classes.SessionCookieNames()...)' plugin.go && grep -qF 'Publish[bouncer.BlacklistStore](store)' plugin.go && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF '"auth_token"')" = 0 && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF 'Lookup[*sql.DB]')" = 0 && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF 'Lookup[*gorm.DB]')" = 1 && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF '"jwt", bouncer.NewJWTGuard')" = 1 && grep -qF 'type appBlacklist struct' blacklist.go && grep -qF 'func newServeOrderEnv' admin_harness_test.go && grep -qF 'func adminEnvConfig' admin_harness_test.go && grep -qF 'func TestBootBeforeDatabase' serve_order_test.go + + TestBootBeforeDatabase failed at 8e4b863 (failure line recorded) and now passes. It proves three things: nothing is published before Publish, the real Postgres store and the GORM hooks are installed when lagoon.Publish runs, and a token ended by logout gets 401 on jwt.auth through every carrier, at /fetch and at /refresh, with its jwt_blacklist row. Boot registers the jwt guard once with appBlacklist and classes.SessionCookieNames(), and the direct database lookups are gone from Boot. TestLogoutEndsTheSession, TestSessionRoutesAcceptCookies and the TestLogin* tests stay green. Nothing is committed yet. + + + + Task 2: pin the lazy store without Docker, document the wiring, run every suite and commit in sm-user-plugin + ../sm-demo-app/plugins/golem15/user/blacklist_test.go, ../sm-demo-app/plugins/golem15/user/README.md + Task 1's verify passed and the plugin tree holds only Task 1's four files as changes (git -C ../sm-demo-app/plugins/golem15/user status --short lists exactly plugin.go, admin_harness_test.go, blacklist.go and serve_order_test.go). + + - TestAppBlacklist (blacklist_test.go, package user) runs under -short with no Docker. Each app comes from sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret). + - No store published: IsBlacklisted returns (false, nil); Add and Sweep return nil (per D-01). + - A second app with an appBlacklist published as its bouncer.BlacklistStore: the same no-op answers come back, and the calls return instead of recursing (QD-5). + - A bouncer.NewMemoryBlacklist published: Add through the wrapper with a future expiresAt and validUntil makes the memory store itself report the jti blacklisted (the call is delegated, not copied); the wrapper's IsBlacklisted reports true for that jti and false for another jti. + - failingBlacklist published: IsBlacklisted, Add and Sweep each return an error for which errors.Is(err, errBlacklistDown) holds, so an unreachable table still fails closed through the guard. + + +Write blacklist_test.go with TestAppBlacklist as the behavior block describes. Reuse sessionUserApp, sessionSecret, failingBlacklist and errBlacklistDown from the package's existing test files, and use t.Context(). Run it with -short; it must pass against Task 1's blacklist.go. If it exposes a bug in appBlacklist, fix blacklist.go here. + +README.md, in the same commit (CLAUDE.md documentation rule; no exported API changes, so only behaviour text): +- In the JWT-sessions bullet (line 20), after the sentence on Boot registering the `jwt` guard and exposing `jwt.auth`, add this. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts the sweep through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. Change "A background sweep prunes the blacklist." only if the new text makes it redundant. +- Do not name the unexported wrapper type in the README; describe the behaviour only. +- In the Testing section, after the TestLogoutEndsTheSession paragraph, add a paragraph covering two tests. `TestBootBeforeDatabase` boots in the serve order (`party.Activate` with no database, then `lagoon.Publish`, through `newServeOrderEnv` in `admin_harness_test.go`). It checks that the blacklist store and the GORM hooks are installed on Publish and not before, and that a token ended by `POST /logout` is refused on a `jwt.auth` route through every carrier, at `/fetch` and at `/refresh`, with its `jwt_blacklist` row. `TestAppBlacklist` (in `-short`) covers the guard's per-request store lookup: no store, delegation and a failing store. + +Run go vet, then go test -short, then the full Docker-backed suite of the plugin. + +Consumer cross-check, read-only: from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app run go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/... . go.work replaces sm-user-plugin with this checkout, and their harnesses publish the database before Activate, which exercises the immediate OnDatabase path. Record the result in the SUMMARY. If a failure names the user plugin's Boot, the jwt guard, bouncer.BlacklistStore or jwt_blacklist, stop and fix it in this task. Record any other failure as unrelated; do not patch those repos. Do not stage anything in sm-demo-app. + +Commit in the plugin repo only, staging by path: +git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add plugin.go blacklist.go blacklist_test.go serve_order_test.go admin_harness_test.go README.md +Then git -C commit with this subject: +fix: logout revokes the session under serve: the jwt blacklist and GORM hooks are wired when the database is published +In the body, two short paragraphs: Boot runs before serve publishes the database, so the store, hooks and sweep now go through lagoon.OnDatabase, and the guard resolves the store per request; the jwt guard reads classes.SessionCookieNames(). 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, tag or bump the sm-demo-app pointer (v0.5.0 release hold). Record the short SHA, the base commit and the test results in the SUMMARY for the orchestrator's G4 entry. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l blacklist_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -short -count=1 -run '^TestAppBlacklist$' . && GOWORK=off go test -count=1 ./... && grep -qF 'lagoon.OnDatabase' README.md && grep -qF 'TestBootBeforeDatabase' README.md && grep -qF 'TestAppBlacklist' README.md && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='wired when the database is published' master) && test -n "$C" && S=$(git log -1 --format=%s "$C") && grep -qF 'wired when the database is published' <<<"$S" && B=$(git log -1 --format=%b "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && N=$(git diff --name-only "$C^" "$C") && test "$(sort <<<"$N" | tr '\n' ' ')" = "README.md admin_harness_test.go blacklist.go blacklist_test.go plugin.go serve_order_test.go " && test -z "$(git tag --points-at "$C")" && R=$(git branch -r --contains "$C") && test -z "$R" + + TestAppBlacklist passes under -short. The plugin's go vet, go test -short and full Docker-backed go test are green. The sociallogin and oauthserver cross-check result is recorded. sm-user-plugin master has one new fix commit (plugin.go, blacklist.go, blacklist_test.go, serve_order_test.go, admin_harness_test.go, README.md) with no trailer, go.mod and go.sum unchanged, not on any remote branch and untagged. The tree is clean. sm-demo-app and summercms.go are untouched. The SUMMARY gives the SHA and base for the orchestrator. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser/API client to jwt.auth | an untrusted bearer or cookie token is checked against the revocation list | +| plugin Boot to serve startup | revocation state exists only after the database is published; Boot runs earlier | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-usi-01 | Elevation of privilege | jwt.auth guard after POST /logout under serve (a logged-out or stolen token keeps working) | high | mitigate | The OnDatabase callback publishes the Postgres store on lagoon.Publish, so EndSession writes the row, and the guard's appBlacklist reads it per request. TestBootBeforeDatabase pins the 401 through every carrier and the jwt_blacklist row in the serve order. | +| T-usi-02 | Elevation of privilege | appBlacklist while no store is published (before Publish, console commands) | low | accept | serve publishes the database before BuildRouter and listen, so no HTTP request is served without the store. Console commands serve no HTTP. This matches today's nil-store behaviour. | +| T-usi-03 | Denial of service | appBlacklist recursion if the wrapper itself were published | low | mitigate | store() treats a published *appBlacklist as no store; Boot publishes only the *bouncer.PostgresBlacklist; TestAppBlacklist and TestBootBeforeDatabase (type assertion) pin both. | +| T-usi-04 | Spoofing | blacklist table unreachable (IsBlacklisted error) | medium | mitigate | appBlacklist returns the store's error unchanged, so bouncer's guard answers 401 "Authentication error" (fail closed, as today). TestAppBlacklist pins the pass-through. | +| T-usi-SC | Tampering | npm/pip/cargo/Go module installs | low | accept | No dependency is added: go.mod and go.sum are unchanged (Task 2 verify checks it). No package-legitimacy checkpoint is needed. | + + + +- sm-user-plugin: GOWORK=off go vet ./..., GOWORK=off go test -short -count=1 ./... and GOWORK=off go test -count=1 ./... are green at the fix commit. +- TestBootBeforeDatabase was red at 8e4b863 and is green at the fix commit. +- The sociallogin and oauthserver suites, run through sm-demo-app's go.work against this checkout, have their result recorded in the SUMMARY. +- One local commit on sm-user-plugin master: no trailer, not pushed, not tagged. The sm-demo-app pointer is unchanged and summercms.go is unmodified. + + + +- Under the serve boot order, logout revokes the session: jwt.auth answers 401 for the logged-out token through the bearer header and both cookies, and the jwt_blacklist row exists. +- The plugin's GORM hooks, blacklist store and sweep are wired exactly once through lagoon.OnDatabase, whether the database is published before or after Boot. +- The jwt guard is registered once, with classes.SessionCookieNames() and a store resolved per request. +- The README describes the wiring and names both new tests. + + + +Create `.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md` in summercms.go when done. Include the plugin commit's short SHA and base, the RED failure line, the suite results, the consumer cross-check result, and a confirmation that nothing was pushed, tagged or pointer-bumped. Do not commit the SUMMARY; the orchestrator commits planning docs. + diff --git a/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md new file mode 100644 index 00000000..ff6e4f95 --- /dev/null +++ b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md @@ -0,0 +1,102 @@ +--- +phase: quick-261022-usi +plan: 01 +quick_id: 261022-usi +subsystem: sm-user-plugin (golem15.user) +status: complete +tags: [user-plugin, jwt, blacklist, logout, lagoon.OnDatabase, serve-order, G4, G3-leftover] +requires: [lagoon.OnDatabase, bouncer.NewPostgresBlacklist, classes.SessionCookieNames] +provides: + - "Boot wires classes.RegisterHooks, the Postgres jwt_blacklist store and its sweep through one lagoon.OnDatabase call" + - "appBlacklist: plugin-local bouncer.BlacklistStore resolving the published store per call" + - "jwt guard reads classes.SessionCookieNames()" +affects: [sm-sociallogin-plugin and sm-oauthserver-plugin harnesses (immediate OnDatabase path, green)] +tech-stack: + added: [] + patterns: ["lagoon.OnDatabase from Boot for database-dependent wiring", "lazy per-call service lookup for guards built in Boot"] +key-files: + created: + - ../sm-demo-app/plugins/golem15/user/blacklist.go + - ../sm-demo-app/plugins/golem15/user/blacklist_test.go + - ../sm-demo-app/plugins/golem15/user/serve_order_test.go + modified: + - ../sm-demo-app/plugins/golem15/user/plugin.go + - ../sm-demo-app/plugins/golem15/user/admin_harness_test.go + - ../sm-demo-app/plugins/golem15/user/README.md +decisions: + - "User plugin Boot wires RegisterHooks, the jwt_blacklist store and its sweep through lagoon.OnDatabase; the jwt guard is registered once with a lazy store that resolves the published bouncer.BlacklistStore per request (G4)" +metrics: + duration: ~10 min + completed: 2026-10-22 +actuals: + tokens: 5533 # chars/4 over the realized plugin diff (git show 4bf44fc) + tasks: 2 + commits: 1 # measured in sm-user-plugin: git rev-list --count 8e4b863..4bf44fc +plan_head_before: 8e4b863 # sm-user-plugin master (not summercms.go) +plan_head_after: 4bf44fc # sm-user-plugin master (not summercms.go) +--- + +# Quick 261022-usi: user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase Summary + +Under `summer serve` (party.Activate before lagoon.Publish) POST /logout now revokes the session: the user plugin's Boot wires the GORM hooks, the Postgres `jwt_blacklist` store and its sweep through `lagoon.OnDatabase`, and the jwt guard resolves the published store on every request through an unexported `appBlacklist`; the guard also reads `classes.SessionCookieNames()` (G3 leftover). + +## Commit (sm-user-plugin) + +- Repo: `/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user`, branch `master` +- Base: `8e4b863` (master had not moved; `git log 8e4b863..master` was empty before the commit) +- Fix commit: **`4bf44fc`** `fix: logout revokes the session under serve: the jwt blacklist and GORM hooks are wired when the database is published` +- Files: plugin.go, blacklist.go, blacklist_test.go, serve_order_test.go, admin_harness_test.go, README.md. go.mod/go.sum unchanged. No trailer. +- Not pushed, not tagged, not on any remote branch. sm-demo-app: nothing staged or committed (its `main.go` and `plugins/golem15/user` pointer were already dirty from another session and were left as they were). summercms.go: not modified (only this SUMMARY, uncommitted). + +## RED at base + +`TestBootBeforeDatabase` against the unchanged plugin.go (8e4b863): + +``` +--- FAIL: TestBootBeforeDatabase (4.50s) + serve_order_test.go:48: no bouncer.BlacklistStore published after lagoon.Publish +``` + +The not-published-before-Publish assertions passed; the store-after-Publish assertion failed because Boot saw no database and never published a store. + +## What changed + +- **plugin.go Boot:** the direct `*gorm.DB`/`*sql.DB` lookups, the local blacklist, its Publish and the sweep start are gone. One `lagoon.OnDatabase(app, func(sqlDB, gdb) error {...})` call (at the position RegisterHooks used to run, using Boot's `app`) runs `classes.RegisterHooks(gdb)`, publishes `bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")` as `bouncer.BlacklistStore` and calls `p.startBlacklistSweep(store)`; Boot returns OnDatabase's error. The jwt guard: `bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, &appBlacklist{app: app}, classes.SessionCookieNames()...)`. `withDB` keeps its own `*gorm.DB` lookup (console commands). +- **blacklist.go:** `appBlacklist{app}`; `store()` returns nil when nothing/nil is published or the published value is itself an `*appBlacklist`. With no store: IsBlacklisted `(false, nil)`, Add/Sweep `nil`. Otherwise it delegates and returns the store's error unchanged (guard still fails closed). +- **admin_harness_test.go:** `adminEnvConfig(t)` extracted unchanged from newAdminEnv; `newServeOrderEnv(t, beforePublish)` boots Activate → hook point → lagoon.Publish → attach.Publish → lagoon.Migrate → surf.Assemble. +- **serve_order_test.go:** `TestBootBeforeDatabase` (Docker; skips under -short) with a gated `classes.RegisterHook` test hook (atomic.Bool cleared in t.Cleanup). +- **blacklist_test.go:** `TestAppBlacklist` (-short): no store, wrapper-published-as-store (no recursion), delegation to a MemoryBlacklist, failingBlacklist error pass-through. +- **README.md:** JWT-sessions bullet describes the OnDatabase wiring, the per-request store and the cookie list (the separate "A background sweep prunes the blacklist." sentence folded into the new text); Testing names both new tests. + +## Test results (sm-user-plugin, GOWORK=off, at 4bf44fc's tree) + +- `gofmt -l` clean; `go vet ./...` clean. +- Task 1 targeted run (TestBootBeforeDatabase, TestLogoutEndsTheSession, TestSessionRoutesAcceptCookies, TestLogin, TestLoginRouteGroup, TestLoginBlacklistSweep): ok. +- `go test -short -count=1 ./...`: ok (root, classes, models, updates). +- `go test -short -run '^TestAppBlacklist$'`: PASS, 4 subtests. +- `go test -count=1 ./...` (Docker, direct socket access, no sudo needed): ok (root 46.3s, classes 40.1s, models, updates). + +## Consumer cross-check (read-only, from sm-demo-app via go.work) + +`go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/...`: +- Failing, unrelated to this change: `TestPHPMethodMap` in both plugins and `TestClientRegistry/verify_secret_uses_constant_time_comparison` in oauthserver. They read source files at sibling paths (`../fonoteka.go/plugins/golem15/user`, `../summercms.go/modules/wristband/...`) that do not exist relative to the sm-demo-app mount (`lstat ... no such file or directory`). None names the user plugin's Boot, the jwt guard, bouncer.BlacklistStore or jwt_blacklist. +- With those two tests skipped (`-skip '^(TestPHPMethodMap|TestClientRegistry)$'`): sociallogin ok (27.6s), oauthserver ok (73.9s). All other TestClientRegistry subtests pass. These harnesses publish the database before Activate, so they exercise the immediate OnDatabase path. + +## Deviations from Plan + +1. **[Rule 1 - test plan detail] TestAppBlacklist delegation uses `validUntil = now`, not a future validUntil.** `bouncer.MemoryBlacklist.IsBlacklisted` reports true only once `validUntil` has been reached (the grace window), so a future validUntil would never show as blacklisted. The test adds with `expiresAt = now+1h, validUntil = now`. +2. **Task 2 verify command is locale-dependent.** Its `sort <<<"$N"` under `LANG=en_US.UTF-8` orders `README.md` between `plugin.go` and `serve_order_test.go`, so the file-list comparison failed although the file set is exactly the planned six. Every check passes under `LC_ALL=C` (VERIFY2-OK). No code impact. +3. **Protected-branch commit.** The plan directs the commit onto sm-user-plugin `master` (core plugin repos commit on master per the project's workflow); the executor's default-branch assertion was not applied there, as instructed by the orchestrator. + +## Known Stubs + +None. + +## Threat Flags + +None. T-usi-01 (logged-out token keeps working under serve) and T-usi-03/T-usi-04 are mitigated and pinned by TestBootBeforeDatabase and TestAppBlacklist; no new endpoints or trust-boundary surface. + +## Self-Check: PASSED + +- FOUND: blacklist.go, blacklist_test.go, serve_order_test.go in the plugin checkout +- FOUND: commit 4bf44fc on sm-user-plugin master; plugin tree clean diff --git a/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-VERIFICATION.md b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-VERIFICATION.md new file mode 100644 index 00000000..b1fafcea --- /dev/null +++ b/.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-VERIFICATION.md @@ -0,0 +1,111 @@ +--- +phase: quick-261022-usi +verified: 2026-10-22T22:40:00Z +status: passed +score: 5/5 must-haves verified +covered_files: + - .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md + - .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-PLAN.md + - .planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md +covered_digest: "v2:sha256:f92f6387937ba08414cea64a81de2af21a7973621075f979546f97c321d8defd" +# The implementation lives in sm-user-plugin (outside this project root), so the +# fingerprint tool refuses those paths. The code is pinned by commit 4bf44fc +# (base 8e4b863) at sm-demo-app/plugins/golem15/user. +code_commit: 4bf44fc +code_base: 8e4b863 +behavior_unverified: 0 +overrides_applied: 0 +--- + +# Quick 261022-usi: user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase: Verification Report + +**Goal:** Under serve, the user plugin's Boot runs before lagoon.Publish, so the jwt blacklist was never wired and logout did not revoke the token. The fix wires BlacklistStore and RegisterHooks through lagoon.OnDatabase and tests Activate-then-Publish. The jwt guard now passes classes.SessionCookieNames() instead of the hardcoded token/auth_token names. +**Verified:** 2026-10-22 +**Status:** passed +**Re-verification:** No (initial verification) +**Code under test:** sm-user-plugin `4bf44fc` (base `8e4b863`), `/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user`, run with `GOWORK=off`. `go.mod` replaces summercms with the `summercms.go` working tree. + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | Booted in serve order (Activate with no DB, then lagoon.Publish, then router), a token ended by POST /logout gets 401 on jwt.auth through the bearer header and each session cookie, at /fetch and /refresh, and jwt_blacklist holds one row for its jti | VERIFIED | Verifier ran `go test -count=1 -run '^TestBootBeforeDatabase$'` (Docker): `--- PASS: TestBootBeforeDatabase (4.53s)`. The test boots through `newServeOrderEnv` (Activate, then the beforePublish hook, then lagoon.Publish, attach.Publish, Migrate, surf.Assemble). It checks 200 before logout for the bearer and every `classes.SessionCookieNames()` cookie, then `assertLoggedOut` and `assertSessionEnded` (401 on jwt.auth via bearer/token/auth_token, 401 at /fetch via bearer and both cookies, refresh = blacklistedBody via all three). `SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?` == 1. **RED reproduced independently:** the base 8e4b863 tree plus the new test/harness files, in a scratch copy, gives `serve_order_test.go:48: no bouncer.BlacklistStore published after lagoon.Publish`. | +| 2 | Before Publish no BlacklistStore is published and the hooks have not run; on Publish, RegisterHooks runs with the published *gorm.DB, a *bouncer.PostgresBlacklist on jwt_blacklist is published as BlacklistStore, and the sweep starts | VERIFIED | TestBootBeforeDatabase's beforePublish asserts no *gorm.DB, no BlacklistStore and no hook call. After Publish it asserts `store.(*bouncer.PostgresBlacklist)` and `hooked.Load() == e.gdb` (pointer identity with the published handle). plugin.go:76-87: the callback runs `classes.RegisterHooks(gdb)`, then `NewPostgresBlacklist(sqlDB, "jwt_blacklist")` and `Publish`, then `p.startBlacklistSweep(store)`, then returns nil. lagoon.Publish (connection.go:175) returns the callback's error. So a successful Publish plus a published store means startBlacklistSweep was reached. The sweep goroutine itself is covered by TestLoginBlacklistSweep (green in the full suite). | +| 3 | Boot registers the jwt guard exactly once, with or without a DB, with classes.SessionCookieNames() and a plugin-local store resolved per call. Without a store: not blacklisted, Add/Sweep do nothing. Store errors pass through. The wrapper is never published | VERIFIED | plugin.go:96 is the only non-comment `"jwt", bouncer.NewJWTGuard` line (count 1). It sits outside the OnDatabase callback and passes `&appBlacklist{app: app}, classes.SessionCookieNames()...`. No `"auth_token"` literal is left in plugin.go code (count 0). blacklist.go: `store()` returns nil when nothing or nil is published, or when the published value is an `*appBlacklist`. The methods delegate and return the store's error unchanged. The only `Publish[bouncer.BlacklistStore]` in non-test code publishes the PostgresBlacklist (plugin.go:81). `TestAppBlacklist` with -short passes all 4 subtests: no store, wrapper published as store (no recursion), delegation to MemoryBlacklist, failingBlacklist `errors.Is(errBlacklistDown)` for all three methods. TestLoginRouteGroup (boot without DB) is green. | +| 4 | When the DB is published before Boot (plugin tests, sociallogin/oauthserver harnesses), hooks, store and sweep are wired synchronously in Boot and existing suites stay green unchanged | VERIFIED | lagoon/ondatabase.go:36-43: OnDatabase calls fn immediately when a *gorm.DB is published, and Boot returns that error. newAdminEnv is unchanged in order (Publish before Activate); only its config block moved into `adminEnvConfig`. The full Docker suite is green (root 48.7s, classes 46.6s, models, updates), including TestLogin, TestLoginBlacklistSweep and TestLogoutEndsTheSession. Consumer cross-check (sm-demo-app go.work): the only failures are TestPHPMethodMap in both plugins and TestClientRegistry/verify_secret_uses_constant_time_comparison. All of them fail on `lstat`/`open` of sibling source paths (`../fonoteka.go/...`, `../summercms.go/modules/wristband/...`) that do not resolve from the sm-demo-app mount. They are unrelated to Boot, the guard or the blacklist, as the SUMMARY reports. | +| 5 | go vet, go test -short and the full Docker go test pass at one fix commit on sm-user-plugin master. Nothing pushed or tagged, sm-demo-app pointer not bumped, summercms.go not modified | VERIFIED | Verifier runs: `gofmt -l .` empty; `go vet ./...` OK; `go test -short -count=1 ./...` ok; `go test -count=1 ./...` ok. `git log 8e4b863..master` = one commit, 4bf44fc. It touches exactly README.md, admin_harness_test.go, blacklist.go, blacklist_test.go, plugin.go and serve_order_test.go; go.mod and go.sum are untouched. `git branch -r --contains 4bf44fc` is empty, `git tag --points-at 4bf44fc` is empty, there is no Co-authored-by trailer, and the plugin tree is clean. sm-demo-app HEAD still records `plugins/golem15/user` at 930b158 (its working-tree pointer change belongs to another session and was not staged). summercms.go `git status`: only planning/state files, no code changes. | + +**Score:** 5/5 truths verified (0 present, behavior-unverified) + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `plugin.go` | Boot wires hooks, store and sweep through one lagoon.OnDatabase call; guard gets the lazy store and SessionCookieNames | VERIFIED | contains `lagoon.OnDatabase(app, func(`; the closure uses Boot's `app`; direct `Lookup[*sql.DB]` is gone from Boot (count 0); the one remaining `Lookup[*gorm.DB]` is in `withDB` (console commands) | +| `blacklist.go` | `type appBlacklist struct`, unexported BlacklistStore resolving per call | VERIFIED | 61 lines, compile-time interface assertion, recursion guard | +| `admin_harness_test.go` | `adminEnvConfig` + `newServeOrderEnv` | VERIFIED | serve-order boot mirrors surf/serve.go:70-88 (Publish, uploads, router) | +| `serve_order_test.go` | `TestBootBeforeDatabase` (Docker, skips under -short through adminHarnessDB) | VERIFIED | passes; RED at base reproduced | +| `blacklist_test.go` | `TestAppBlacklist` (-short) | VERIFIED | 4/4 subtests pass | +| `README.md` | JWT-sessions bullet documents the OnDatabase wiring; Testing names both tests | VERIFIED | both test names present; the unexported wrapper type is not named (0 hits) | + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| plugin.go Boot | lagoon.OnDatabase / lagoon.Publish -> runDatabaseHooks | callback queued in Boot, drained by Publish before BuildRouter | WIRED | ondatabase.go queues when no *gorm.DB is published; connection.go:175 drains; serve.go publishes before BuildRouter | +| jwt guard registration | appBlacklist.store | `&appBlacklist{app: app}` | WIRED | per-call `app.Lookup[bouncer.BlacklistStore]()` | +| jwt guard registration | classes.SessionCookieNames | `classes.SessionCookieNames()...)` | WIRED | the same list is used by SessionToken/EndSession | +| EndSession (POST /logout) | store published by the callback | request-time lookup (session_user.go:123) | WIRED | the jwt_blacklist row count of 1 in TestBootBeforeDatabase proves the write path end to end | + +### Data-Flow Trace (Level 4) + +| Artifact | Data | Source | Real data | Status | +|----------|------|--------|-----------|--------| +| jwt guard blacklist check | jti revocation | Postgres `jwt_blacklist` via the PostgresBlacklist published on Publish | Yes (row asserted in a testcontainers Postgres) | FLOWING | + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| Serve-order logout revocation | `GOWORK=off go test -count=1 -run '^TestBootBeforeDatabase$' .` | PASS (4.53s) | PASS | +| Same test at base 8e4b863 (scratch copy) | same | FAIL `serve_order_test.go:48: no bouncer.BlacklistStore published after lagoon.Publish` | PASS (RED confirmed) | +| Lazy store contract | `GOWORK=off go test -short -count=1 -v -run '^TestAppBlacklist$' .` | 4/4 PASS | PASS | +| Vet + short suite | `go vet ./... && go test -short -count=1 ./...` | ok | PASS | +| Full Docker suite | `GOWORK=off go test -count=1 ./...` | ok (root 48.7s, classes 46.6s, models, updates) | PASS | +| Consumer cross-check | `go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/...` (sm-demo-app) | only path-based failures, unrelated (lstat/open of sibling repo paths) | PASS (unrelated failures noted) | + +### Probe Execution + +None declared for this quick task. + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| usi-1 (G4 + G3 leftover) | 261022-usi-PLAN | logout revokes under serve; store + hooks through lagoon.OnDatabase; guard reads SessionCookieNames | SATISFIED | Truths 1-5 | + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| (none) | - | TBD/FIXME/XXX/TODO/HACK grep over the six changed files: no hits | - | - | + +Info (not gaps): +- `startBlacklistSweep` reads `p.app` (the shared Plugin value) rather than the callback's `app`. In production there is one app per process. This is the same as before the change and was left as is by planner choice QD-2. +- The verify command's locale-dependent `sort` (SUMMARY deviation 2) is a plan-script quirk with no code impact. The file set was confirmed directly with `git diff --name-only`. +- SUMMARY deviation 1 (`validUntil = now` in TestAppBlacklist) is correct: MemoryBlacklist reports a jti only once validUntil is reached, and the test passes. + +### Human Verification Required + +None. + +### Gaps Summary + +No gaps. The serve-order regression test fails at the base commit and passes at 4bf44fc. The verifier reproduced both outcomes. The full Docker-backed suite is green, and the commit is local only and complete. + +--- + +_Verified: 2026-10-22_ +_Verifier: Claude (msd-verifier)_