docs(quick-261022-x7a): user-plugin golem15.user.password.public_routes switch, login/forgot/reset 404 when false
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
---
|
||||
phase: quick-261022-x7a
|
||||
plan: 01
|
||||
quick_id: 261022-x7a
|
||||
slug: user-plugin-golem15-user-password-public
|
||||
type: execute
|
||||
mode: quick
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements:
|
||||
- x7a-1 # golem15.user.password.public_routes (default true, config.yaml next to password.min_length): false leaves POST /login, /forgot-password and /reset-password unregistered so they get the router's own 404 like any unknown path; every other route, its middleware and the turnstile guard on /register unchanged; unset/true = byte-identical route table; no pact/surf change, no custom 404
|
||||
- x7a-2 # tests (route-table recording like turnstile_routes_test, real-router 404 probe against an unknown path and the current statuses) and README, in the same plugin commit; existing tests keep the default
|
||||
- x7a-3 # escalation note item 8 (tracker, verdict, reply) and 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/routes.go
|
||||
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
|
||||
- ../sm-demo-app/plugins/golem15/user/password_routes_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/README.md
|
||||
# summercms.go planning docs, edited and committed by Task 2 (pathspec commit of exactly these two files)
|
||||
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
- .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 2
|
||||
confidence: low # estimate-calibration: factor 1, sample_count 0
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "With golem15.user.password.public_routes unset, true (bool or the string true) or at its config.yaml default, Routes registers exactly the 18 routes it registers at 8bdf2c7, in the same order with the same middleware; TestTurnstileRoutes and every existing test pass unchanged."
|
||||
- "With the key false (bool, the string false, or SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false), Routes registers the same table minus exactly POST /_user/api/v1/login, /forgot-password and /reset-password (15 routes); every remaining route keeps its order and middleware, and with golem15.user.turnstile closed POST /register still carries turnstile:closed."
|
||||
- "Through the assembled router with the key false, POST /login (correct password), POST /forgot-password (a real address), POST /reset-password (a valid code) and GET /login answer with the same status (404), body and Content-Type as the same method on an unknown /_user/api/v1 path, set no cookie, stamp no last_seen and send no mail; with the default they answer 401 invalid-login, 200 with message, 422 and 405 as today."
|
||||
- "With the key false, POST /register (auto mode) still answers 200 with token and user, and that token is served by GET /fetch, rotated by POST /refresh and ended by POST /logout; GET /oauth-providers answers 200."
|
||||
- "A value other than true, false, an empty string or null (for example nope, yes or 0) makes Routes return an error naming golem15.user.password.public_routes, so boot fails, as golem15.user.turnstile does."
|
||||
- "One local feat commit on sm-user-plugin master on top of 8bdf2c7 touches exactly routes.go, config/config.yaml, password_routes_test.go and README.md; go vet, go test -short and the full Docker-backed suite are green; no trailer, not pushed, not tagged; go.mod, go.sum and controllers/ unchanged; the sm-demo-app pointer is neither committed nor staged."
|
||||
- "Escalation note row 8 reads done (unreleased) with the short SHA → v0.5.0, verdict 8 and the Password-routes reply paragraph name golem15.user.password.public_routes and describe the shipped behaviour, no line starts with TODO, no tracker row is open; 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/routes.go
|
||||
provides: "publicPasswordRoutes (default true, strict true/false parse, error naming the key); Routes skips the three password routes when it is false"
|
||||
contains: "func (p *Plugin) publicPasswordRoutes() (bool, error)"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/config/config.yaml
|
||||
provides: "password.public_routes: true with a comment, after min_length"
|
||||
contains: "public_routes: true"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/password_routes_test.go
|
||||
provides: "TestPasswordPublicRoutes (-short, route-table recording) and TestPasswordPublicRoutesAnswer404 (Docker, assembled router)"
|
||||
contains: "func TestPasswordPublicRoutes"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/README.md
|
||||
provides: "Password bullet sentence, API reference note, config table row, turnstile row clause, Testing paragraph"
|
||||
contains: "golem15.user.password.public_routes"
|
||||
- path: .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
provides: "row 8 done (unreleased) with the SHA; verdict 8; Password-routes reply line filled; the order paragraph says all six are done"
|
||||
contains: "golem15.user.password.public_routes: false"
|
||||
- path: .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md
|
||||
provides: "## Outcome section between ## Solution and ## Invoke"
|
||||
contains: "## Outcome"
|
||||
key_links:
|
||||
- from: ../sm-demo-app/plugins/golem15/user/routes.go (Routes)
|
||||
to: ../sm-demo-app/plugins/golem15/user/routes.go (publicPasswordRoutes)
|
||||
via: "Routes calls publicPasswordRoutes once, returns its error, and wraps exactly the /login, /forgot-password and /reset-password registrations in the public branch; /login keeps guard... inside that branch"
|
||||
pattern: "publicPasswordRoutes\\(\\)"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/config/config.yaml (password.public_routes)
|
||||
to: "compass MergePlugin (party.Activate) and publicPasswordRoutes via app.Config.Lookup"
|
||||
via: "the default reaches the app as golem15.user.password.public_routes; an unset key (bareApp merges no plugin config) also means true, so the default never depends on the merge"
|
||||
pattern: "golem15\\.user\\.password\\.public_routes"
|
||||
- from: "surf.Assemble (newAdminEnv)"
|
||||
to: "Plugin.Routes"
|
||||
via: "configure functions run after party.Activate and before surf.Assemble, so a configured false is what Routes reads; Routes runs once, so a later Config.Set does not change the table (a restart applies the key)"
|
||||
pattern: "surf\\.Assemble"
|
||||
- from: Task 1 commit SHA (sm-user-plugin master)
|
||||
to: .planning/notes/2026-10-22-external-user-plugin-escalations.md row 8, verdict 8 and the reply
|
||||
via: "Task 2 reads the SHA with git log --format=%h --fixed-strings --grep=golem15.user.password.public_routes"
|
||||
pattern: "→ v0\\.5\\.0"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close external escalation item 8 in sm-user-plugin: a config switch that stops the user plugin registering its three password routes, so they answer 404 like any unknown path.
|
||||
|
||||
The external app wants `/login`, `/forgot-password` and `/reset-password` to 404 (it signs users in another way). It asked for a framework seam to wrap another plugin's routes; none is needed, because the user plugin owns those routes and can skip registering them. Leaving them unregistered gets the router's own 404 (and no 405 that would reveal them), with no custom handler.
|
||||
|
||||
Purpose: an application that does not offer password sign-in can remove those endpoints with one key, without any change for the many applications that use them.
|
||||
Output: one local feat commit on sm-user-plugin master, 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 (pact, surf, compass) is not modified.
|
||||
|
||||
Locked by the todo spec (.planning/todos/pending/2026-10-22-user-password-routes-404-switch.md, authoritative; labelled here for traceability, the executor does not revisit them):
|
||||
- L-1 Key golem15.user.password.public_routes, default true, in config/config.yaml next to password.min_length.
|
||||
- L-2 When false, POST /login, POST /forgot-password and POST /reset-password are not registered, so they 404 like any unknown path. No custom 404 handler.
|
||||
- L-3 Everything else stays registered: /register (allow_registration already refuses at the handler), /activate, /activate-by-code, /fetch, /refresh, /logout, /update, /change-password, /marketing-consent, /avatar*, /oauth-providers, /tokens. golem15.user.turnstile still wraps /register when /login is off.
|
||||
- L-4 No change in pact or surf. With the key on (the default) the route table is byte-identical to today's.
|
||||
- L-5 Tests reuse the turnstile_routes_test.go recording pattern (recordingRouter, recordedRoute), probe 404 against the current login/forgot/reset statuses, and existing tests keep the default.
|
||||
- L-6 Base sm-user-plugin 8bdf2c7. Hold push and tag for SummerCMS v0.5.0; do not bump the sm-demo-app pointer.
|
||||
- L-7 Fill item 8 in the escalation note like the earlier rows (done (unreleased), the commit → v0.5.0, name the key) and replace the TODO in the reply paragraph. Leave the todo in pending with an Outcome. Do not send the reply.
|
||||
|
||||
Planner's choices (the executor does not revisit them):
|
||||
- QD-1 The key is read by a new method publicPasswordRoutes() (bool, error) in routes.go, next to turnstileGuard, called once by Routes. It reads app.Config.Lookup, not app.Config.Bool: Bool reads an unset key as false, and bareApp (the -short harness) merges no plugin config, so Bool alone would drop the routes whenever config.yaml is not merged. Unset, nil and an empty (trimmed) string mean true. A bool is used as is. A string is trimmed and parsed with strconv.ParseBool (so an env value works). Any other value or an unparseable string returns an error naming the key, so boot fails, like golem15.user.turnstile in the same file; a typo cannot silently 404 the login of a production site.
|
||||
- QD-2 The key is read when the routes are built (surf.Assemble at boot). Changing it needs a restart; the README says so. Handlers read their other keys per request; this one cannot, because an unregistered route has no handler.
|
||||
- QD-3 Only routes.go changes in Go code. controllers/ (Login, ForgotPassword, ResetPassword stay exported and untouched), pact and surf are not modified. The direct-handler tests (account_test.go, mail_test.go, session_test.go) are unaffected by construction.
|
||||
- QD-4 Tests live in a new file, password_routes_test.go: TestPasswordPublicRoutes (-short, recordingRouter on bareApp-style apps) and TestPasswordPublicRoutesAnswer404 (Docker, newAdminEnv). TestTurnstileRoutes and recordUserRoutes stay as they are. The 404 probe compares against the same method on an unknown path in the same env, so it pins "like any unknown path" without hard-coding surf's 404 text.
|
||||
- QD-5 One plugin commit (feat), README in the same commit (CLAUDE.md docs rule). The framework-repo planning docs are a separate docs commit staged by pathspec. The orchestrator's final quick commit (PLAN, SUMMARY, STATE) stays separate.
|
||||
- QD-6 Compatibility: the key is additive and defaults to true, so every application keeps today's routes. The only boot-time behaviour added is the refusal of an invalid value of a key that did not exist before.
|
||||
|
||||
Source coverage audit:
|
||||
| Source | Item | Covered by |
|
||||
|--------|------|------------|
|
||||
| GOAL / todo Solution, L-1, L-2 | key next to min_length, default true; false skips the three POST routes; router's own 404 | Task 1 (routes.go, config.yaml, both tests) |
|
||||
| L-3 | every other route stays; turnstile still wraps /register | Task 1 (route-table equality minus three, turnstile closed case, live /register, /fetch, /refresh, /logout, /oauth-providers) |
|
||||
| L-4 | no pact/surf change; default byte-identical table | Task 1 (unset/true/config-default equality to the base table; commit gate checks the file list and controllers/) |
|
||||
| L-5 | recording pattern reused; 404 vs current statuses; existing tests keep default | Task 1 (TestPasswordPublicRoutes, TestPasswordPublicRoutesAnswer404 default and off subtests; full suite) |
|
||||
| CONTEXT specifics (orchestrator) | plugin README config key docs | Task 1 README edits |
|
||||
| L-6 | base 8bdf2c7; no push/tag; no pointer bump; no co-author tags | Task 1 commit gates |
|
||||
| L-7 | note item 8 (tracker, verdict, reply TODO), todo Outcome, stays pending; docs separate commit | Task 2 |
|
||||
| REQ / RESEARCH | none (quick task, no research) | n/a |
|
||||
| CONTEXT Deferred Ideas | none (no CONTEXT.md for this quick) | n/a |
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/todos/pending/2026-10-22-user-password-routes-404-switch.md
|
||||
@.planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
@../sm-demo-app/plugins/golem15/user/routes.go
|
||||
@../sm-demo-app/plugins/golem15/user/turnstile_routes_test.go
|
||||
|
||||
Working copies (observed at planning time):
|
||||
- sm-user-plugin is at /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user. Branch master at 8bdf2c7, 7 commits ahead of origin/master (930b158), working tree clean, no tag on HEAD; the last plugin tag is v0.1.8. Do not fetch, merge, rebase or reset. If master has moved past 8bdf2c7, build on top and record git log --oneline 8bdf2c7..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 replaces summercms with ../../../../summercms.go (v0.4.3 required).
|
||||
- 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 main.go, and the plugins/golem15/user pointer 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 a9e3f24) 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 2 commits only the note and the todo.
|
||||
|
||||
Source facts (plugin checkout at 8bdf2c7):
|
||||
- routes.go: Routes :12-39 calls turnstileGuard :13, then r.Group("/_user/api/v1", surf.Use("throttle:user-api"), ...) registering 18 routes in this order: POST /login (guard...) :18, POST /logout, GET /fetch, POST /refresh, POST /register (guard...) :22, GET /oauth-providers, POST /forgot-password :24, POST /reset-password :25, POST /activate, POST /activate-by-code, POST /update, POST /change-password, POST /marketing-consent, POST /avatar, POST /avatar/remove, POST /tokens, GET /tokens, DELETE /tokens/{id} (the last three with "jwt.auth"), then g.Where("id", "[0-9]+"). turnstileGuard :46-58 is nil-safe on p.app and p.app.Config and returns an error naming golem15.user.turnstile for an invalid value; its doc comment says any other value is refused so a typo cannot silently leave the forms unprotected. Imports: fmt, strings, controllers, pact, surf.
|
||||
- config/config.yaml: the password block :25-27 is bcrypt_cost: 10 then min_length: 8, two-space indent; the next top-level key is api_tokens. Comments use #.
|
||||
- turnstile_routes_test.go (package user, runs under -short): recordedRoute {method, path, middleware}, recordingRouter with newRecordingRouter, recordUserRoutes(t, mode) (bareApp plus golem15.user.turnstile, then (&Plugin{app: app}).Routes(rec)), turnstileOf. TestTurnstileRoutes compares tables route by route (method, path, strings.Join of middleware).
|
||||
- admin_users_edge_test.go:80 bareApp(t): compass.Open on a temp dir with app.yaml "name: user-bare" and Environ SUMMER_ENV=development, backpack.New; it merges no plugin config. plugin.go: configFS embeds the config dir and ConfigFS() returns it; compass Config.MergePlugin("golem15.user", configFS) makes config/config.yaml keys golem15.user.*.
|
||||
- compass (summercms.go modules/compass, read-only): Lookup(path) (any, bool); Bool reads unset as false and a non-bool string through strconv.ParseBool, false on error. env.go maps SUMMER_A__B_C to a.b_c with the value as a string, so SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false gives the string "false".
|
||||
- surf (read-only): an unmatched path gets ServeMux's own 404 (http.NotFound text, or the error page for a browser navigation); a path registered for another method gets 405 with Allow. The harness send sets no Accept, so the stdlib text applies, but the test compares with an unknown path and does not depend on it.
|
||||
- admin_harness_test.go: newAdminEnv(t, configure...) :165 publishes the DB, runs party.Activate (which merges config.yaml), then the configure functions, then lagoon.Migrate and surf.Assemble (which calls Routes). call :287, send :301 (a fresh RemoteAddr per request, bearer when token is non-empty), mails(to) :370, seedUser :513 (password correct-horse, activated, unique email), reload :554.
|
||||
- Reusable test helpers: tokenCookieSettings(t, keys) in token_cookie_test.go:179 (turns use_register_throttle off and sets keys; works for any key), registerBody(label) :193 (unique email, correct-horse, name), userLoginPath and userRegisterPath constants :14-15; assertNoCookie and assertBodyKeys in refresh_cookie_test.go:236/:244; fetchCode and refresh in offboarding_test.go:45/:50; userSub in session_cookie_test.go:247; invalidLoginBody in session_test.go:40 ({"error":true,"message":"Invalid email or password"}); classes.IssueResetCode(ctx, db, user) (string, error) in classes/codes.go:55, packed as userSub(user)+"!"+code. POST /logout answers 200 {"message":"Logged out"}. models.User.LastSeen is a pointer, nil until login or refresh stamps it.
|
||||
- Default answers today: POST /login with an unknown or wrong password answers 401 invalidLoginBody; POST /forgot-password with a valid address answers 200 {"message": ...}; POST /reset-password with {} answers 422 (validation); GET /login answers 405.
|
||||
- The names publicPasswordRoutes, TestPasswordPublicRoutes, TestPasswordPublicRoutesAnswer404, password_routes_test.go and public_routes are unused in the plugin today. No other plugin in sm-demo-app checks passwords; the sociallogin and quizzes harness tests POST /_user/api/v1/login with the default config.
|
||||
- README.md: Password reset and change bullet :21; API reference route table :90-109 and the note after it :111; config table :205-227 with golem15.user.password.min_length :224 and the golem15.user.turnstile row :227 (its cell begins "Cloudflare Turnstile on `POST /login` and `POST /register`"); Testing :248-279, ending with the TestTokenCookie paragraph :275, then the TestAdminOrganisationMembershipClearsRole paragraph.
|
||||
- Escalation note (summercms.go): tracker row 8 :29 (status open, Shipped empty; rows 1-3 and 7 :22-28 are the format, the todo link stays as the second link); the paragraph after the six invocations :51 (its sentence about which items are done locally); verdict **8.** :62 (verdicts **7.**, **9.**, **10.** :61-64 are the "Done locally (`sha`). ..." format); reply "Password-routes switch" :98-100, whose "Agreed, in the user plugin, ..." line :99 stays and whose TODO line :100 is the only line starting with TODO.
|
||||
- Todo: sections ## Problem :16, ## Solution :22, ## Invoke :32. The Outcome goes between Solution and Invoke, in the format of .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md's Outcome (one paragraph).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1 (tracer): golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, proven on the route table and through the assembled router on Postgres, committed in sm-user-plugin</name>
|
||||
<files>../sm-demo-app/plugins/golem15/user/routes.go, ../sm-demo-app/plugins/golem15/user/config/config.yaml, ../sm-demo-app/plugins/golem15/user/password_routes_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 8bdf2c7 is an ancestor of HEAD. Docker is reachable for testcontainers, directly or through sudo -u golem.</precondition>
|
||||
<behavior>
|
||||
- TestPasswordPublicRoutes (password_routes_test.go, package user, no database, runs under -short). Every table comes from (&Plugin{app: app}).Routes(newRecordingRouter()), as recordUserRoutes does.
|
||||
- "unset": a bareApp. Routes succeeds with 18 routes that include POST /_user/api/v1/login, /forgot-password and /reset-password. This is the base table B.
|
||||
- "true" and "true string": the key set to bool true, and to the string "true". Each table equals B route by route (method, path, joined middleware, order).
|
||||
- "config.yaml default": a bareApp after app.Config.MergePlugin("golem15.user", configFS). app.Config.Lookup of the key returns the bool true, and the table equals B.
|
||||
- "false", "false string", "padded false string": the key set to bool false, to "false" and to " false ". Each table has 15 routes and equals B with exactly those three POST routes removed, the rest in the same order with the same middleware (so /register, /logout, /fetch, /refresh, /activate, /activate-by-code, /update, /change-password, /marketing-consent, /avatar, /avatar/remove, /oauth-providers and the three /tokens routes stay).
|
||||
- "env false": an app from compass.Open on a temp dir (app.yaml "name: user-bare") with Environ SUMMER_ENV=development and SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false gives the same 15-route table.
|
||||
- "false with turnstile closed": the key false and golem15.user.turnstile "closed". The table is the 15-route table with turnstile:closed appended to POST /_user/api/v1/register only; no other route carries a turnstile name.
|
||||
- "empty means default": the key set to "" gives B.
|
||||
- "invalid": the values "nope", "yes" and the int 0 each make Routes return an error whose text contains golem15.user.password.public_routes.
|
||||
- TestPasswordPublicRoutesAnswer404 (same file, Docker through newAdminEnv; skips under -short). Both envs use tokenCookieSettings so the registration throttle is off.
|
||||
- "default answers": tokenCookieSettings(t, nil). app.Config.Lookup of the key returns the bool true (config.yaml merged by party.Activate). POST /_user/api/v1/no-such-route answers 404. For a seeded user, POST /login with the email and a wrong password answers 401 with invalidLoginBody; POST /forgot-password with the email answers 200 with exactly the key message; POST /reset-password with {} answers 422; GET /login answers 405. None of these is 404.
|
||||
- "off answers like an unknown path": tokenCookieSettings(t, {"golem15.user.password.public_routes": false}). U is POST /_user/api/v1/no-such-route and UG is GET of the same path. For a seeded user u: POST /login with u's email and correct-horse, POST /forgot-password with u's email, and POST /reset-password with a valid IssueResetCode code (packed userSub(u)+"!"+code) and a matching password and password_confirmation each have U's status (404), body and Content-Type and set no cookie; GET /login has UG's status, body and Content-Type. Afterwards reload(u).LastSeen is nil and e.mails(u.Email) is empty.
|
||||
- "off keeps the rest": in the off env, POST /register with registerBody answers 200 with exactly the keys token and user; fetchCode of that token is 200; refresh of it answers 200 with exactly the key token; POST /logout with the rotated token answers 200; GET /_user/api/v1/oauth-providers answers 200.
|
||||
- RED at 8bdf2c7: the "false" case of TestPasswordPublicRoutes fails (18 routes, want 15), and "invalid" fails (no error).
|
||||
</behavior>
|
||||
<action>
|
||||
RED. Create password_routes_test.go (package user) with TestPasswordPublicRoutes and TestPasswordPublicRoutesAnswer404 as the behavior block describes (per L-5). Reuse recordedRoute, recordingRouter, newRecordingRouter and turnstileOf from turnstile_routes_test.go and leave that file and TestTurnstileRoutes untouched. Add small helpers in the new file, each with a one-line doc comment:
|
||||
- recordRoutesOn(t, app) returning the recorded routes and the Routes error.
|
||||
- passwordRoutesApp(t, value any) returning a bareApp with golem15.user.password.public_routes set to value through app.Config.Set (t.Fatal on error).
|
||||
- withoutPasswordRoutes(routes) returning the table without POST /_user/api/v1/login, /forgot-password and /reset-password.
|
||||
- assertSameRoutes(t, got, want) comparing length, then method, path and strings.Join of middleware route by route.
|
||||
- assertLikeUnknown(t, got, unknown) requiring the same status, body and Content-Type header, a 404 status, and assertNoCookie.
|
||||
The env case builds its app inline with compass.Open (temp dir, app.yaml, the two Environ entries) and backpack.New, as bareApp does. TestPasswordPublicRoutesAnswer404 starts with adminHarnessDB-based skipping through newAdminEnv like the other Docker tests. Doc comments: TestPasswordPublicRoutes pins golem15.user.password.public_routes on the route table (default and true register every route, false drops exactly the three password routes and nothing else, the turnstile guard stays on /register, an invalid value fails Routes); TestPasswordPublicRoutesAnswer404 pins that the dropped routes answer exactly like an unknown path through the assembled router, while the default answers are unchanged and /register, /fetch, /refresh and /logout keep working.
|
||||
|
||||
Run GOWORK=off go test -short -count=1 -run '^TestPasswordPublicRoutes$' . It must fail against the unchanged routes.go. Record the first failure line for the SUMMARY. Do not commit a red test.
|
||||
|
||||
GREEN, routes.go (per L-2, L-3, L-4, QD-1, QD-2):
|
||||
- Add publicPasswordRoutes() (bool, error) on *Plugin after turnstileGuard. It returns true, nil when p.app or p.app.Config is nil. Otherwise it reads p.app.Config.Lookup("golem15.user.password.public_routes"). Not found or a nil value: true. A bool: that value. A string: trimmed; empty means true; otherwise strconv.ParseBool, and on a parse error it returns fmt.Errorf("golem15.user: golem15.user.password.public_routes must be true or false, got %q", the raw string). Any other type: the same message with %v.
|
||||
- Its doc comment says: the key decides whether POST /login, /forgot-password and /reset-password are registered; default true; false leaves them unregistered so they answer the router's 404 like any unknown path; read when the routes are built, so a change needs a restart; an invalid value is refused so a typo cannot silently remove (or keep) the sign-in routes.
|
||||
- In Routes, call it right after turnstileGuard and return its error. Inside the group, wrap only the POST /login registration (still with guard...) and the POST /forgot-password and POST /reset-password registrations in "if public" branches at their current positions, so the default table keeps its order. Do not touch any other registration, the group middleware or g.Where.
|
||||
- Add strconv to the imports. gofmt the file.
|
||||
|
||||
config/config.yaml (per L-1): in the password block, after min_length: 8, add public_routes: true (two-space indent) with a comment above it saying that false leaves POST /login, /forgot-password and /reset-password unregistered (they 404 like any unknown path), /register and the other routes stay, it is read at boot, and it takes true or false.
|
||||
|
||||
README.md, in the same commit (CLAUDE.md docs rule):
|
||||
- Password reset and change bullet (:21): append a sentence saying that with `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
|
||||
- API reference: after the note line that follows the route table (:111), add one line saying that with `golem15.user.password.public_routes` false the `/login`, `/forgot-password` and `/reset-password` rows are not registered.
|
||||
- Configuration table: a row after golem15.user.password.min_length, reading | `golem15.user.password.public_routes` | `true` | followed by a meaning cell. The cell says that when `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot.
|
||||
- golem15.user.turnstile row: after "on `POST /login` and `POST /register`", add a parenthesis saying only `/register` while `golem15.user.password.public_routes` is false.
|
||||
- Testing: after the TestTokenCookie paragraph, add one paragraph naming `TestPasswordPublicRoutes` (in `-short`) and `TestPasswordPublicRoutesAnswer404` and their cases: unset, `true` and the `config.yaml` default register every route; `false` (also from a `SUMMER_` variable) drops exactly the three password routes and keeps every other route and its middleware, the turnstile on `/register` included; an invalid value fails `Routes`; through the assembled router the dropped routes (and a `GET /login`) answer exactly like an unknown path and sign in, mail and reset nothing, while the default answers are unchanged and `/register`, `/fetch`, `/refresh` and `/logout` still work.
|
||||
|
||||
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). A failure unrelated to this change is not patched here: stop and report it.
|
||||
|
||||
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/quizzes/... (go.work points them at this checkout; their harnesses POST /_user/api/v1/login with the default). If a failure names /login or the user plugin's routes, 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: git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add routes.go config/config.yaml password_routes_test.go README.md, then commit with git -C on the same path. The subject:
|
||||
feat: golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, so they 404 like any unknown path
|
||||
The body is one short paragraph: default true, so the route table is unchanged; false skips only those three registrations (the router's own 404, no custom handler, no framework change); /register keeps allow_registration and the turnstile guard; read at boot, and an invalid value fails boot like golem15.user.turnstile.
|
||||
|
||||
Add no trailer of any kind; the user's global CLAUDE.md forbids co-author tags and overrides any harness attribution reminder. Do not push or tag (v0.5.0 release hold). Record the short SHA (git log -1 --format=%h), the RED line, the suite results and the consumer cross-check result in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l routes.go password_routes_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 -run '^(TestPasswordPublicRoutes|TestTurnstileRoutes)$' . && GOWORK=off go test -count=1 -run '^(TestPasswordPublicRoutes|TestPasswordPublicRoutesAnswer404|TestTurnstileRoutes|TestLogin|TestRegister|TestTokenCookie|TestSessionRoutesAcceptCookies|TestLogoutEndsTheSession)$' . && grep -qF 'func (p *Plugin) publicPasswordRoutes() (bool, error)' routes.go && grep -qF '"golem15.user.password.public_routes"' routes.go && R=$(awk '/^func \(p \*Plugin\) Routes\(/,/^}/' routes.go) && grep -qF 'publicPasswordRoutes()' <<<"$R" && grep -qF 'g.Post("/login", controllers.Login(p.app), guard...)' <<<"$R" && grep -qF 'g.Post("/register", controllers.Register(p.app), guard...)' <<<"$R" && grep -qF 'g.Post("/forgot-password", controllers.ForgotPassword(p.app))' <<<"$R" && grep -qF 'g.Post("/reset-password", controllers.ResetPassword(p.app))' <<<"$R" && awk '/^password:/{f=1;next} /^[^ #]/{f=0} f' config/config.yaml | grep -qxF ' public_routes: true' && grep -qF '| `golem15.user.password.public_routes` | `true` |' README.md && grep -qF 'TestPasswordPublicRoutesAnswer404' 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='golem15.user.password.public_routes' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && git merge-base --is-ancestor 8bdf2c7 "$C" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum controllers && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md config/config.yaml password_routes_test.go routes.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/pact modules/surf modules/compass</automated>
|
||||
</verify>
|
||||
<done>TestPasswordPublicRoutes failed at 8bdf2c7 (failure line recorded) and now passes with TestPasswordPublicRoutesAnswer404. Unset, true and the config.yaml default give today's 18-route table; false (bool, string or env) gives it minus exactly POST /login, /forgot-password and /reset-password, the turnstile guard staying on /register; an invalid value fails Routes naming the key. Through the assembled router the dropped routes and GET /login answer exactly like an unknown path and change nothing, the default answers are unchanged, and /register, /fetch, /refresh, /logout and /oauth-providers still work with the key false. config.yaml and README document the key. go vet, go test -short and the full suite are green; the sociallogin/quizzes cross-check result is recorded. One feat commit (four files) is on sm-user-plugin master on top of 8bdf2c7: no trailer, go.mod, go.sum and controllers/ unchanged, untagged, not on any remote branch. The tree is clean; sm-demo-app and summercms.go modules are untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: fill item 8 in the escalation note (tracker, verdict, reply), 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-password-routes-404-switch.md</files>
|
||||
<precondition>Task 1's verify passed: sm-user-plugin master carries the public_routes commit. 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 short SHA from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user: S = git log -1 --format=%h --fixed-strings --grep='golem15.user.password.public_routes' master.
|
||||
|
||||
Edit .planning/notes/2026-10-22-external-user-plugin-escalations.md with Edit, using scoped replacements only, never a whole-file Write (per L-7).
|
||||
|
||||
Tracker row 8, using rows 1-3 and 7 as the format:
|
||||
- The Quick / todo cell becomes [261022-x7a](../quick/261022-x7a-user-plugin-golem15-user-password-public/) · [todo](../todos/pending/2026-10-22-user-password-routes-404-switch.md).
|
||||
- Status becomes done (unreleased).
|
||||
- Shipped becomes `S` → v0.5.0.
|
||||
- Label, Verdict and g15office (h7yrafgd) stay.
|
||||
|
||||
In the paragraph after the six invocations, replace its sentence about which items are done locally and which is the last of the six with: All six are done locally; item 8 (password-routes 404) was the last. Keep its other sentences.
|
||||
|
||||
Verdict **8.**: in the "Done locally" format it becomes: Done locally (`S`). Plugin config; no framework wrap. `golem15.user.password.public_routes` (default true); false leaves `POST /login`, `/forgot-password` and `/reset-password` unregistered, so they get the router's own 404 like any unknown path (a `GET` too, no 405); no custom handler, no `pact`/`surf` change. `/register` stays (`allow_registration` gates it, `golem15.user.turnstile` still guards it), and so do `/activate*`, `/fetch`, `/refresh`, `/logout`, `/change-password` and `/tokens`. Read when the routes are built (a restart applies it); a value other than true or false fails boot.
|
||||
|
||||
Reply to send, "Password-routes switch": keep the "Agreed, in the user plugin, ..." line. Replace the TODO line under it with one line in the G2-G4 and Cookie-on-/login format:
|
||||
Ships in SummerCMS v0.5.0 (sm-user-plugin S, tagged with that wave): set golem15.user.password.public_routes: false (default true; SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false in the environment) and POST /login, POST /forgot-password and POST /reset-password are not registered, so they answer the same 404 as any unknown path, whatever the method. POST /register stays (allow_registration still decides whether it takes accounts, and golem15.user.turnstile still guards it), as do /activate, /activate-by-code, /fetch, /refresh, /logout, /update, /change-password and /tokens. With the key true or unset nothing changes. The key is read when the routes are built, so a change needs a restart; a value other than true or false stops boot with an error naming the key.
|
||||
|
||||
Substitute the real S, unquoted in the reply line as G2-G4 do. Leave everything else in the note unchanged.
|
||||
|
||||
Edit the todo .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md. Insert an "## Outcome (YYYY-MM-DD)" section, dated with the execution day (2026-10-23 at planning time), after the "## Solution" section and before "## Invoke", in the format of the Outcome in 2026-10-22-user-login-opt-in-session-cookie.md. It is one paragraph that:
|
||||
- names quick `261022-x7a`, the commit `S` on sm-user-plugin master and its base `8bdf2c7`;
|
||||
- says routes.go reads `golem15.user.password.public_routes` through `publicPasswordRoutes` (unset, empty or true register every route; a bool or a true/false string; anything else fails `Routes` naming the key) and, when false, skips only `POST /login`, `/forgot-password` and `/reset-password`; the turnstile guard stays on `/register`; the default route table is unchanged; no `pact`/`surf`/controller change;
|
||||
- names `TestPasswordPublicRoutes` (`-short`, route-table recording) and `TestPasswordPublicRoutesAnswer404` (Docker, the dropped routes answer exactly like an unknown path, the rest keep working); README and `config.yaml` updated;
|
||||
- ends with: Reply note item 8 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 8 in the external-escalation reply" -- .planning/notes/2026-10-22-external-user-plugin-escalations.md .planning/todos/pending/2026-10-22-user-password-routes-404-switch.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. Do not send the reply.
|
||||
</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-password-routes-404-switch.md && P=/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && S=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='golem15.user.password.public_routes' master) && test -n "$S" && grep -qE '^\| 8 \| Password-routes 404 switch \| Feature \| \[261022-x7a\]\(\.\./quick/261022-x7a-user-plugin-golem15-user-password-public/\) · \[todo\]\(\.\./todos/pending/2026-10-22-user-password-routes-404-switch\.md\) \| h7yrafgd \| done \(unreleased\) \| `[0-9a-f]{7,}` → v0\.5\.0 \|$' "$N" && grep -qF "| \`$S\` → v0.5.0 |" "$N" && grep -qF "**8.** Done locally (\`$S\`)" "$N" && grep -qF "Ships in SummerCMS v0.5.0 (sm-user-plugin $S, tagged with that wave): set golem15.user.password.public_routes: false" "$N" && grep -qF 'All six are done locally' "$N" && test "$(grep -c '^TODO' "$N")" = 0 && ! grep -qE '^\| [0-9]+ \|.*\| open \|' "$N" && grep -qF 'Agreed, in the user plugin, not as a framework' "$N" && grep -qF 'Ships in SummerCMS v0.5.0 (sm-user-plugin 846087d and 8bdf2c7, 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 '/^## Solution/{s=NR} /^## Outcome/{o=NR} /^## Invoke/{i=NR} END{exit !(s && o && i && s<o && o<i)}' "$T" && grep -qF "$S" "$T" && grep -qF '8bdf2c7' "$T" && grep -qF '261022-x7a' "$T" && test ! -e .planning/todos/done/2026-10-22-user-password-routes-404-switch.md && C=$(git log -1 --format=%H --fixed-strings --grep='docs: fill item 8 in the external-escalation reply') && test -n "$C" && git merge-base --is-ancestor a9e3f24 "$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 8 reads done (unreleased) with `S` → v0.5.0 and links the quick and the todo; the order paragraph says all six are done; verdict 8 says "Done locally" with the SHA and names the key; the Password-routes reply carries the "Ships in SummerCMS v0.5.0 (sm-user-plugin S, tagged with that wave)" line naming golem15.user.password.public_routes. No line starts with TODO and no tracker row is open, so the reply is complete (still held until v0.5.0 is tagged). The todo has an Outcome section between Solution and Invoke naming the commit, the base and both 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 |
|
||||
|----------|-------------|
|
||||
| internet to the password routes | POST /login, /forgot-password and /reset-password accept credentials, addresses and reset codes from anyone; an app that signs users in another way wants them gone |
|
||||
| operator configuration (golem15.user.password.public_routes, YAML or SUMMER_ env) | a mistyped value must not silently remove or keep the sign-in routes |
|
||||
| shared core plugin to its many consuming applications | a default change would break every app that signs in with a password |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-x7a-01 | Spoofing | POST /login, /forgot-password, /reset-password reachable on an app that does not offer password sign-in (credential stuffing, reset-mail abuse) | medium | mitigate | With the key false Routes never registers them, so no handler runs (L-2). TestPasswordPublicRoutesAnswer404 proves a correct password signs nothing in (no last_seen), forgot-password sends no mail, and a valid reset code is answered like an unknown path. |
|
||||
| T-x7a-02 | Information disclosure | route existence revealed by a distinct answer (a custom 404 body or a 405 on GET) | low | mitigate | No custom handler; the routes are absent, so every method gets the router's own answer. The test compares status, body and Content-Type with the same method on an unknown path, GET /login included (405 by default, 404 when off). |
|
||||
| T-x7a-03 | Denial of service | a typo (for example "flase", "yes") silently 404ing sign-in in production, or silently keeping it | medium | mitigate | QD-1: publicPasswordRoutes accepts only bool, a true/false string or empty/unset; anything else fails Routes naming the key, so boot fails loudly, as golem15.user.turnstile does. Tested with "nope", "yes" and 0. |
|
||||
| T-x7a-04 | Denial of service | existing applications losing their password routes after the upgrade | high | mitigate | Default true in config.yaml, and unset or empty also mean true, so the table does not depend on the config merge. TestPasswordPublicRoutes pins the unset, true and config.yaml-default tables equal to 8bdf2c7's; controllers/ is unchanged (commit gate) and the full suite stays green. |
|
||||
| T-x7a-05 | Elevation of privilege | /register losing its Turnstile guard when /login is off | medium | mitigate | The guard slice is still applied to /register; the "false with turnstile closed" case pins turnstile:closed on /register only. |
|
||||
| T-x7a-06 | Spoofing | other ways to obtain a session remain (POST /register in auto mode signs in, /activate-by-code, sociallogin FinishLogin, the exported controllers.Login an app could mount) | low | accept | Out of scope by spec (L-3): the switch removes exactly the three password routes. /register stays gated by allow_registration; no other plugin in the stack checks passwords. The README and reply name what stays registered. |
|
||||
| T-x7a-SC | Tampering | npm/pip/cargo/Go module installs | low | accept | No dependency is added; go.mod and go.sum are unchanged (the commit gate checks 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 the commit.
|
||||
- TestPasswordPublicRoutes was red at 8bdf2c7 and is green at the commit; TestPasswordPublicRoutesAnswer404 is green; TestTurnstileRoutes, TestLogin, TestRegister, TestTokenCookie and TestLogoutEndsTheSession pass unchanged.
|
||||
- The sociallogin and quizzes 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: four files, no trailer, not pushed, not tagged, go.mod/go.sum/controllers unchanged. The sm-demo-app pointer is neither staged nor committed; summercms.go modules are unmodified.
|
||||
- One docs commit in summercms.go touches exactly the escalation note and the todo.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- An application sets golem15.user.password.public_routes: false (or the SUMMER_ variable) and POST /login, /forgot-password and /reset-password answer exactly like any unknown path, while registration, activation, the session routes and API tokens keep working.
|
||||
- With the key unset or true, every application's route table and every response of the plugin are unchanged.
|
||||
- An invalid value stops boot with an error naming the key.
|
||||
- Item 8 is recorded as done (unreleased) with the SHA, and the reply has no TODO left (still held until v0.5.0 is tagged).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261022-x7a-user-plugin-golem15-user-password-public/261022-x7a-SUMMARY.md` in summercms.go when done. Include:
|
||||
- the plugin commit's short SHA and the base;
|
||||
- the RED failure line;
|
||||
- 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, and that the reply was not sent.
|
||||
|
||||
Do not commit the SUMMARY; the orchestrator commits it with the PLAN and STATE.
|
||||
</output>
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
phase: quick-261022-x7a
|
||||
plan: 01
|
||||
quick_id: 261022-x7a
|
||||
subsystem: sm-user-plugin (routes, config)
|
||||
tags: [user-plugin, routes, config, external-escalation]
|
||||
status: complete
|
||||
requires: [sm-user-plugin 8bdf2c7]
|
||||
provides: [golem15.user.password.public_routes]
|
||||
affects: [sm-user-plugin routes table, escalation note item 8]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [route-table recording test (recordingRouter), strict config parse failing boot like golem15.user.turnstile]
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-demo-app/plugins/golem15/user/password_routes_test.go
|
||||
modified:
|
||||
- ../sm-demo-app/plugins/golem15/user/routes.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-password-routes-404-switch.md
|
||||
decisions:
|
||||
- "golem15.user.password.public_routes takes a bool or the string true/false (case-insensitive, trimmed; empty/unset = true); anything else (1, 0, t, f, yes, an int) fails Routes naming the key (plan-checker advisory 1, replaces strconv.ParseBool)"
|
||||
metrics:
|
||||
completed: 2026-10-23
|
||||
duration: ~25 min
|
||||
actuals:
|
||||
tokens: 9100 # chars/4 over the two commits' diffs
|
||||
tasks: 2
|
||||
commits: 2 # one in sm-user-plugin (8bdf2c7..3828a65), one in summercms.go (a9e3f24..e5dd457); measured per repo with git rev-list --count
|
||||
plan_head_before: "sm-user-plugin 8bdf2c7; summercms.go a9e3f24"
|
||||
plan_head_after: "sm-user-plugin 3828a65; summercms.go e5dd457"
|
||||
---
|
||||
|
||||
# Quick 261022-x7a: golem15.user.password.public_routes Summary
|
||||
|
||||
An opt-in user-plugin key, `golem15.user.password.public_routes` (default true). When it is false, the plugin does not register POST /login, /forgot-password or /reset-password, so they get the router's own 404 like any unknown path. All other routes and the turnstile guard on /register are unchanged.
|
||||
|
||||
## Commits
|
||||
|
||||
| Repo | SHA | Base | Message |
|
||||
|------|-----|------|---------|
|
||||
| sm-user-plugin (master) | `3828a65` | `8bdf2c7` | feat: golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, so they 404 like any unknown path |
|
||||
| summercms.go (master) | `e5dd457` | `a9e3f24` | docs: fill item 8 in the external-escalation reply |
|
||||
|
||||
`git rev-list --count 8bdf2c7..3828a65` = 1 and `a9e3f24..e5dd457` = 1. sm-user-plugin master had not moved past 8bdf2c7. Neither commit has a trailer.
|
||||
|
||||
## What changed
|
||||
|
||||
- **routes.go**: added `publicPasswordRoutes() (bool, error)` after `turnstileGuard`. It reads `app.Config.Lookup`, so an unset key and a nil config mean true. A bool is used as is. A string is trimmed and compared case-insensitively with `true` or `false`, and an empty string means true. Any other value returns `golem15.user: golem15.user.password.public_routes must be true or false, got ...`. `Routes` calls it once, right after `turnstileGuard`, and wraps only `/login` (still with `guard...`), `/forgot-password` and `/reset-password` in `if public` at their current positions.
|
||||
- **config/config.yaml**: `password.public_routes: true` after `min_length`, with a comment.
|
||||
- **README.md**: a sentence in the Password bullet, a note under the API reference table, a config table row, a clause in the turnstile row and a Testing paragraph.
|
||||
- **password_routes_test.go**: `TestPasswordPublicRoutes` runs under `-short`. Its cases: unset, true, "true", "TRUE", empty, the config.yaml default, false, "false", " false ", "False", the env variable, false with turnstile closed, and the invalid values "nope", "yes", "1", "0", "t", "f" and int 0. `TestPasswordPublicRoutesAnswer404` runs against Docker. Its subtests are default answers, "off answers like an unknown path" and "off keeps the rest".
|
||||
- **Escalation note**: row 8 is now done (unreleased) with `3828a65` → v0.5.0. The order paragraph now says all six are done, verdict 8 is filled in, and the Password-routes reply line replaces the TODO. **Todo**: added an `## Outcome (2026-10-23)` section. The todo stays in pending/.
|
||||
|
||||
## Verification
|
||||
|
||||
- RED at 8bdf2c7: `TestPasswordPublicRoutes/false: password_routes_test.go:134: 18 routes, want 15`. The "invalid" case also failed, with `"nope": error <nil>`.
|
||||
- GREEN: `GOWORK=off go vet ./...` is clean. `go test -short -count=1 ./...` and the full Docker-backed `go test -count=1 ./...` pass in every package (root, classes, models, updates). Docker was reachable directly; `sudo -u golem` was not needed.
|
||||
- The plan's Task 1 and Task 2 `<automated>` blocks were run through `bash -c` (the shell is fish): STATIC_OK, GATES_OK and TASK2_OK. The full suites were not re-run after the commit because the committed tree is the one that was tested.
|
||||
- Consumer cross-check from sm-demo-app (go.work): `./plugins/golem15/quizzes/...` passes. In `./plugins/golem15/sociallogin/...` everything passes except `TestPHPMethodMap`, which fails with `lstat ../fonoteka.go/plugins/golem15/user: no such file or directory`. That test's default user-plugin path assumes the fonoteka.go layout, so the failure has nothing to do with this change. With `USER_PLUGIN=<this checkout>` it passes. It was not patched.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Plan-checker advisory 1] Strict string parse instead of strconv.ParseBool**
|
||||
- The plan's QD-1 said to use `strconv.ParseBool`. As the advisory asked, a string is accepted only when it is case-insensitively `true` or `false` (trimmed; empty means true). `"1"`, `"0"`, `"t"` and `"f"` now fail boot. Because of this, `strconv` was not imported. The invalid cases add "1", "0", "t" and "f", and the valid cases add "TRUE" and "False".
|
||||
- Commit: 3828a65
|
||||
|
||||
**2. Extra assertion in the off subtest**
|
||||
- After the off-mode reset-password probe, the test also checks that the user's password hash is unchanged. The plan asked for nothing beyond last_seen and mails, so this goes slightly further.
|
||||
|
||||
Otherwise the plan was executed as written.
|
||||
|
||||
## Confirmations
|
||||
|
||||
- Nothing was pushed or tagged. The sm-demo-app `plugins/golem15/user` pointer was neither staged nor committed. The summercms.go modules (pact, surf, compass), go.mod, go.sum and controllers/ are unchanged.
|
||||
- The reply in the escalation note was not sent; the v0.5.0 hold still applies.
|
||||
- STATE.md, ROADMAP.md, PLAN.md and this SUMMARY were not committed; that is left to the orchestrator.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: password_routes_test.go, routes.go publicPasswordRoutes, config.yaml public_routes, README row
|
||||
- FOUND: commit 3828a65 (sm-user-plugin), commit e5dd457 (summercms.go)
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
phase: quick-261022-x7a
|
||||
verified: 2026-10-23T00:30:00Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
covered_files:
|
||||
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
- .planning/quick/261022-x7a-user-plugin-golem15-user-password-public/261022-x7a-PLAN.md
|
||||
- .planning/quick/261022-x7a-user-plugin-golem15-user-password-public/261022-x7a-SUMMARY.md
|
||||
- .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md
|
||||
covered_digest: "v2:sha256:a8ecbcf388dc4f5634c14fd11d2ad4ec513dd24256952ae29ea3bba2d4f7b8fe"
|
||||
external_covered_commit: "sm-user-plugin 3828a65 (base 8bdf2c7): routes.go, config/config.yaml, password_routes_test.go, README.md; outside the summercms.go root, so the commit SHA stands in for a digest"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick 261022-x7a: golem15.user.password.public_routes Verification Report
|
||||
|
||||
**Goal:** A user-plugin key, `golem15.user.password.public_routes`, defaulting to true. When it is false, POST /login, /forgot-password and /reset-password are not registered, so they 404 like unknown paths. /register and the other routes stay. No framework wrap and no custom 404. Base is 8bdf2c7.
|
||||
**Verified:** 2026-10-23
|
||||
**Status:** passed
|
||||
**Re-verification:** No, this is the initial verification.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | With the key unset, true or at its config.yaml default, the 18-route table is the same as at 8bdf2c7, and the existing tests pass unchanged | VERIFIED | Diff of routes.go: with `public == true` the registration sequence, middleware and order are identical to the base. `TestPasswordPublicRoutes` cases true, "true", "TRUE", "", "config.yaml default" pass. `TestTurnstileRoutes` was not modified and passes. The full suite passes. |
|
||||
| 2 | False (bool, "false", or the SUMMER_ env value) gives the same table minus exactly the three POST routes (15 routes), with order and middleware kept and turnstile:closed still on /register | VERIFIED | Subtests false, "false", " false ", "False", "env false" and "false with turnstile closed" pass. A mutation check (the new test run against the 8bdf2c7 code in a scratch copy) fails with `18 routes, want 15`, so the test discriminates. |
|
||||
| 3 | Through the assembled router with the key off, the three routes and GET /login answer the same status, body and Content-Type as an unknown path, with no cookie, no last_seen, no mail and no password change. With the default they answer 401, 200, 422 and 405. | VERIFIED | `TestPasswordPublicRoutesAnswer404` passed in this run against Docker 29.9.1 in 5.08s, not skipped. Its subtests are "default answers" and "off answers like an unknown path". |
|
||||
| 4 | With the key off, /register (auto) answers 200 with a token, and /fetch, /refresh, /logout and /oauth-providers still work | VERIFIED | The "off keeps the rest" subtest passed. |
|
||||
| 5 | An invalid value (nope, yes, 0, ...) makes Routes return an error naming the key, so boot fails | VERIFIED | The "invalid" subtest covers "nope", "yes", "1", "0", "t", "f" and int 0. surf/router.go:669 wraps a Routes error as `surf: routes <id>: ...` and returns it from Assemble, so boot fails. |
|
||||
| 6 | One feat commit on sm-user-plugin master over 8bdf2c7 that touches exactly 4 files, with vet, -short and the full suite green, no trailer, no push, no tag, go.mod/go.sum/controllers unchanged, and the sm-demo-app pointer not staged | VERIFIED | `git show --stat 3828a65` lists exactly 4 files, and the parent is 8bdf2c7. There is no Co-Authored-By line. `origin/master` is ahead by 8, so nothing was pushed. `git tag --points-at HEAD` is empty. The diff for go.mod, go.sum and controllers/ is empty. In sm-demo-app nothing is staged (`git diff --cached` is empty), and the pointer shows only as unstaged `M`. |
|
||||
| 7 | Note row 8 reads done (unreleased) `3828a65` → v0.5.0, verdict 8 and the reply name the key, no line starts with TODO, no tracker row is open, and the todo has an Outcome and stays in pending/. Both files are in a commit touching only them. | VERIFIED | e5dd457 touches exactly those 2 files. Row 8 matches. In the reply, "TODO: ships ... <name>" is replaced by the key text. The only remaining "TODO" is the instruction line 68 ("Replace every `TODO`..."), which does not start with TODO. Every tracker row is done or declined. `## Outcome (2026-10-23)` is present, and the todo is still in pending/. |
|
||||
|
||||
**Score:** 7/7 truths verified (0 present but behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| sm-demo-app/plugins/golem15/user/routes.go | `func (p *Plugin) publicPasswordRoutes() (bool, error)`, used by Routes | VERIFIED | Called once after turnstileGuard; its error is returned. `if public` wraps only /login (keeping `guard...`) and forgot/reset. It is nil-safe on app and Config. |
|
||||
| .../user/config/config.yaml | `public_routes: true` after min_length | VERIFIED | Comment and key present at the right indent. The test "config.yaml default" confirms that MergePlugin yields `true`. |
|
||||
| .../user/password_routes_test.go | TestPasswordPublicRoutes and TestPasswordPublicRoutesAnswer404 | VERIFIED | 303 lines, substantive. It reuses recordingRouter, bareApp and newAdminEnv. |
|
||||
| .../user/README.md | Key documented | VERIFIED | Password bullet, API reference note, config table row, turnstile row clause and Testing paragraph. Every identifier in them exists. |
|
||||
| .planning/notes/2026-10-22-external-user-plugin-escalations.md | Contains "golem15.user.password.public_routes: false" | VERIFIED | Present in the reply paragraph. |
|
||||
| .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md | `## Outcome` | VERIFIED | Between Solution and Invoke. |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| Routes | publicPasswordRoutes | single call, error returned, three registrations gated | WIRED |
|
||||
| config.yaml password.public_routes | app.Config.Lookup in publicPasswordRoutes | MergePlugin; unset also means true | WIRED (the config.yaml default subtest and the newAdminEnv merged-default assertion) |
|
||||
| surf.Assemble | Plugin.Routes | router.go:669 propagates the error | WIRED |
|
||||
| plugin commit SHA | note row 8, verdict 8, reply | `3828a65` → v0.5.0 | WIRED |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Vet | `GOWORK=off go vet ./...` (plugin) | clean | PASS |
|
||||
| Short suite | `GOWORK=off go test -short -count=1 ./...` | all packages ok | PASS |
|
||||
| New and turnstile tests with Docker | `GOWORK=off go test -count=1 -v -run 'TestPasswordPublicRoutes\|TestTurnstileRoutes' .` | PASS (Answer404 took 5.08s and ran against Docker) | PASS |
|
||||
| Full Docker-backed suite | `GOWORK=off go test -count=1 ./...` | root 52.4s ok, classes ok, models ok, updates ok | PASS |
|
||||
| Test discriminates (mutation) | new test file over `git archive 8bdf2c7` in scratch | FAIL: `18 routes, want 15`, and the invalid values gave no error | PASS (the test is red without the feature) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
Not applicable. The plan declares no probe scripts.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Description | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| x7a-1 | Key, default true, false drops the three routes, router's own 404, nothing else changes | SATISFIED | Truths 1–5 |
|
||||
| x7a-2 | Tests and README in the same plugin commit | SATISFIED | Truth 6, artifacts |
|
||||
| x7a-3 | Escalation note item 8 and todo Outcome in a separate docs commit | SATISFIED | Truth 7 |
|
||||
|
||||
### Deviations Reviewed
|
||||
|
||||
- **Strict string parse instead of strconv.ParseBool.** This was intended (plan-checker advisory 1). It matches truth 5 better than the plan's QD-1: ParseBool would have accepted "0", which truth 5 lists as invalid.
|
||||
- **Extra password-unchanged assertion in the off subtest.** It is additive and harmless.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
None. The plugin commit adds no TBD, FIXME, XXX, TODO or HACK lines. There are no stubs and no custom 404 handler. pact, surf and compass are unchanged.
|
||||
|
||||
### Known Unrelated Issue
|
||||
|
||||
The sociallogin test `TestPHPMethodMap` in sm-demo-app defaults to a missing `../fonoteka.go` checkout. It does not touch this change and was not re-run here.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
None. The goal is achieved: the key exists with a true default. False leaves exactly the three password routes unregistered, and through the real router they answer exactly like an unknown path. /register and every other route are unchanged. There is no framework or custom-404 change, and both commits are scoped as the plan required.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-23_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
Reference in New Issue
Block a user