From be82bccfc36c9c44043fc2dc3c3608c86796645c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 15:21:03 +0200 Subject: [PATCH] docs(quick-261011-kzi): fix Phase 09 gaps (fonoteka route inventory, per-guard token throttle) --- .planning/STATE.md | 9 +- .../261011-kzi-PLAN.md | 250 ++++++++++++++++++ .../261011-kzi-SUMMARY.md | 105 ++++++++ 3 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 .planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-PLAN.md create mode 100644 .planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index dd441f9..8654cae 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,10 +5,10 @@ current_phase: 11 current_phase_name: Jobs, realtime and search infrastructure status: planning stopped_at: Phase 16.3 complete, ready to plan Phase 11 -last_updated: "2026-10-11T12:43:47.733Z" +last_updated: "2026-10-11T13:20:59.821Z" last_activity: 2026-10-11 -last_activity_desc: "Completed quick task 261011-joh: sieve generic collection helpers module" -state_head: c85cb64b6ee7a7bb467f194bf094802cda01ce50 +last_activity_desc: "Completed quick task 261011-kzi: Fix Phase 09 gaps (fonoteka route inventory, per-guard token throttle)" +state_head: b45af8ebbf67ef90e67b2ad701dddc4e5c7e645a progress: total_phases: 40 completed_phases: 13 @@ -33,7 +33,7 @@ See: .planning/PROJECT.md (updated 2026-09-16) Phase: 11 — Jobs, realtime and search infrastructure Plan: Not started Status: Ready to plan -Last activity: 2026-10-11 - Completed quick task 261011-joh: sieve generic collection helpers module +Last activity: 2026-10-11 - Completed quick task 261011-kzi: Fix Phase 09 gaps (fonoteka route inventory, per-guard token throttle) Progress: [████░░░░░░] 41% @@ -761,6 +761,7 @@ Recent decisions affecting current work: | 28 | Admin navbar View site link (admin origin root, new tab) | 2026-10-10 | 85fe541 | — | | 261011-joh | sieve: generic collection helpers module (Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set) | 2026-10-11 | 6933004, a0bf8ec | [261011-joh-sieve-generic-collection-helpers-module](./quick/261011-joh-sieve-generic-collection-helpers-module/) | | 30 | go test -short runs without Docker: TestMain flag parsing fix in 4 modules, cabana list fixture skip | 2026-10-11 | 7ef2e4f, c85cb64 | — | +| 261011-kzi | Fix Phase 09 gaps: fonoteka phase09AdminRoutes inventory + per-guard cabana last_used_at throttle | 2026-10-11 | b45af8e | [261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-](./quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/) | ## Deferred Items diff --git a/.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-PLAN.md b/.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-PLAN.md new file mode 100644 index 0000000..ebb55dd --- /dev/null +++ b/.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-PLAN.md @@ -0,0 +1,250 @@ +--- +phase: quick-261011-kzi +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + - modules/cabana/api_tokens.go + - modules/cabana/token_guard.go + - modules/cabana/http.go + - modules/cabana/token_guard_test.go + - modules/cabana/token_smoke_test.go +autonomous: true +requirements: [QUICK-261011-kzi] + +estimate: + tokens: 45000 + raw_tokens: 45000 + tasks: 2 + confidence: low + +must_haves: + truths: + - "In fonoteka.go, TestPhase09SecurityRoutes passes, and scripts/check-phase9.sh --security (run from summercms.go) exits 0." + - "phase09AdminRoutes lists all ten post-104a1c9 cabana routes as protected (public=false): PUT /account, POST /account/avatar, DELETE /account/avatar, GET /account/tokens, POST /account/tokens, GET /account/tokens/scopes, DELETE /account/tokens/{id}, GET /field-types, POST /markdown/preview, POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload. Each one was checked against cabana's mount and sits inside the r.GroupRaw(api, []string{\"backend\"}, ...) block. The assertions in TestPhase09SecurityRoutes are unchanged, so the test still fails on any unlisted or unguarded admin route." + - "The fonoteka.go commit changes only plugins/golem15/fonoteka/admin_phase09_security_test.go. The uncommitted submodule pointer changes from other sessions (plugins/golem15/feedback, golem, user) stay unstaged and untouched." + - "Each compositeGuard owns the in-memory last_used_at throttle; no package-level throttle map is left. Two guards over separate stores (separate test databases) that both hold token ID 7 each stamp last_used_at once. A regression test proves this; it fails against the old process-global map." + - "Production behavior is unchanged. Within one guard, two authentications less than a minute apart stamp once, and a stamp older than a minute allows the next one. The backend guard and the cookie-less resolver guard built in one cabana activation share one throttle, so a single-app binary debounces the same way the process-global map did. The SQL UPDATE in gormTokenStore.StampLastUsed stays conditional." + - "The verifier's reproduction command (the 11-test -run list from 09-VERIFICATION.md) passes, as do go vet ./..., go test -short ./..., go test -race -short on the cabana token tests and the Docker-backed go test -count=1 ./modules/cabana." + artifacts: + - path: ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + provides: "phase09AdminRoutes with the ten added backend-guarded routes" + contains: "/account/tokens/scopes" + - path: modules/cabana/api_tokens.go + provides: "stampThrottle type (per-instance last_used_at debounce) and stampLastUsed taking a throttle" + contains: "type stampThrottle struct" + - path: modules/cabana/token_guard.go + provides: "compositeGuard.stamps field, allocated by newCompositeGuard" + contains: "stamps *stampThrottle" + - path: modules/cabana/token_guard_test.go + provides: "TestTokenLastUsedThrottlePerGuard regression test" + contains: "func TestTokenLastUsedThrottlePerGuard" + key_links: + - from: modules/cabana/http.go + to: modules/cabana/token_guard.go + via: "resolverGuard.stamps = guard.stamps (one throttle per activation, shared by the backend middleware and ResolveBackend)" + pattern: "resolverGuard.stamps = guard.stamps" + - from: modules/cabana/token_guard.go + to: modules/cabana/api_tokens.go + via: "authenticateToken calls stampLastUsed with g.stamps instead of a package global" + pattern: "stampLastUsed\\(r.Context\\(\\), g.stamps" +--- + + +Close the two findings of the Phase 09 re-verification (commit 21fdcfc, `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md`): + +1. BLOCKER. In the sibling app repo fonoteka.go, `TestPhase09SecurityRoutes` fails with "unexpected admin routes", which makes `scripts/check-phase9.sh --security` exit 1. Ten cabana routes were added after 104a1c9 and are missing from fonoteka's `phase09AdminRoutes` inventory. The fix is test-only and follows the precedent commits 549840d and ca746fc in fonoteka.go. +2. ADVISORY (Phase 16.3 scope). `TestBackendTokenPostgres` fails with `last_used_at not set` when it runs after the other token tests in one process. The cause is the process-global last_used_at debounce map in `modules/cabana/api_tokens.go`, keyed by token ID. Every fresh test database restarts IDs, so an earlier test's stamp suppresses a later write. The fix moves the throttle state onto the guard instance and keeps production behavior unchanged. + +Planning-time observation (live, 2026-10-11): +- Both repos were clean apart from fonoteka.go's three modified submodule pointers and summercms.go's untracked `.msd/`. +- `go test ./plugins/golem15/fonoteka -run '^TestPhase09SecurityRoutes$'` fails and lists exactly the ten routes, each with `Middleware:[]string{"backend"}`. +- In `modules/cabana/http.go` the ten routes are registered at lines 266-278 and 384, all inside `r.GroupRaw(api, []string{"backend"}, ...)`, which spans lines 262-386. +- The verifier's 11-test `-run` command reproduces the stamp failure. `-run Token` alone and the full package run pass, so the failure depends on test order. + +Shape: two independent defect fixes in two repos, with no new layer or architecture. A tracer slice adds nothing here, so both tasks are plain `auto` tasks. Task 2 is test-first. Task 1 runs first because fonoteka.go builds against the summercms.go working tree through go.work, and `check-phase9.sh --security` also runs cabana's `^TestPhase09` tests. + +Purpose: get Phase 09's security gate green again, and remove an order-dependent failure from the Docker-backed cabana suite. +Output: one test-only commit in fonoteka.go and one fix commit in summercms.go. No push. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/STATE.md +@CLAUDE.md +@../fonoteka.go/CLAUDE.md +@.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md + +Environment notes: +- The login shell is fish. Wrap bash syntax (&&, $(...), for-loops) in `bash -c '...'`. +- Docker is available. If the socket is denied, run the tests through `sudo -u golem` (see CLAUDE.md). +- Several sessions work in these repos at once. Stage files by explicit path, never `git add -A` or `git commit -a`, and use no Co-Authored-By trailer (user rule, overrides any attribution default). + + + + + + Task 1: List the ten post-Phase 9 cabana routes in fonoteka's Phase 9 route inventory + /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + + - /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go (phase09AdminRoutes, lines ~119-177) + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/http.go lines 244-400 (service.mount: the public auth group, the public lang group, the backend group that runs to line ~386, the public adminPrefix group) + - `git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go show 549840d ca746fc` (shape of the inventory entries, comment style, commit message style) + + +Pre-flight: run `git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go status --short`. The expected state is three modified submodule pointers (plugins/golem15/feedback, plugins/golem15/golem, plugins/golem15/user) owned by other sessions. Leave them alone. If admin_phase09_security_test.go itself already shows uncommitted edits, stop and report rather than overwrite them. + +Confirm the guard before listing anything. For each of the ten routes, find its registration in summercms.go modules/cabana/http.go service.mount and confirm it lies inside the r.GroupRaw(api, []string{"backend"}, ...) block (lines 262-386 at planning time), not in the public auth, lang or adminPrefix groups. At planning time the account, avatar and token routes were at lines 266-272, GET /field-types at 276, POST /markdown/preview at 278 and the upload route at 384. If a route turns out to be outside the backend group, do not list it as protected: stop and report it as a security finding instead. + +Edit phase09AdminRoutes and add exactly these ten entries, all with public=false, in the existing `{"METHOD " + adminAPI("/path"), false},` form: +- PUT /account +- POST /account/avatar +- DELETE /account/avatar +- GET /account/tokens +- POST /account/tokens +- GET /account/tokens/scopes +- DELETE /account/tokens/{id} +- GET /field-types +- POST /markdown/preview +- POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload + +Placement, following ca746fc's inline grouping: +- Put the My account and token routes, GET /field-types and POST /markdown/preview right after the existing GET /navigation entry, in short commented groups. + - My account page: profile update and avatar. + - Phase 16.3: personal API token management. These routes are wrapped in interactiveOnly inside the backend group, so a g15_ token never reaches them. + - Phase 16.2: plugin field type assets. + - Markdown preview: renders a form preview and stores nothing. +- Put the upload route after the existing Phase 12.2 fileupload file routes at the end of the slice, with a "Phase 16.2: plugin field type uploads (D-18)" comment. D-18 comes from the doc comment in cabana's field_type_upload.go. + +Do not touch TestPhase09SecurityRoutes, TestPhase09SecurityMatrix, phase09Surface or any assertion. The change is an inventory update only, so the test keeps failing on any unlisted or unguarded admin route. Do not add routes beyond these ten. If the test then reports another unexpected route, stop and report it rather than listing it. + +Run gofmt on the file. Commit in fonoteka.go, staging only this file by path: `git -C add plugins/golem15/fonoteka/admin_phase09_security_test.go`. Use a message in the precedent style, no co-author trailer: +- subject: test(quick-261011-kzi): list the post-Phase 9 cabana admin routes in the Phase 9 route inventory +- body bullet 1: TestPhase09SecurityRoutes expected the pre-account/token admin surface; the ten account, avatar, token, field-type, markdown preview and field upload routes all carry the backend guard +- body bullet 2: Inventory update only: the test still fails on any unguarded or unlisted admin route + +Do not push. + + + bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && test -z "$(gofmt -l plugins/golem15/fonoteka/admin_phase09_security_test.go)" && go vet ./plugins/golem15/fonoteka && go test -count=1 ./plugins/golem15/fonoteka -run "^TestPhase09Security" && cd ../summercms.go && scripts/check-phase9.sh --security && test "$(git -C ../fonoteka.go show --name-only --format= HEAD | tr -d "[:space:]")" = "plugins/golem15/fonoteka/admin_phase09_security_test.go"' + + +- TestPhase09SecurityRoutes and TestPhase09SecurityMatrix pass in fonoteka.go. +- `scripts/check-phase9.sh --security` prints "phase9 security passed" and exits 0. +- The HEAD commit in fonoteka.go changes exactly one file, admin_phase09_security_test.go, adding ten protected entries (with comments) and no assertion changes. +- The three submodule pointer modifications are still unstaged. + + + + + Task 2: Make cabana's token last_used_at throttle belong to the guard instance (regression test first) + modules/cabana/api_tokens.go, modules/cabana/token_guard.go, modules/cabana/http.go, modules/cabana/token_guard_test.go, modules/cabana/token_smoke_test.go + + - modules/cabana/api_tokens.go lines 234-292 (tokenStore interface, gormTokenStore.StampLastUsed with its conditional UPDATE, the package-level sync.Map debounce, stampLastUsed) + - modules/cabana/token_guard.go lines 40-125 (compositeGuard struct, newCompositeGuard, authenticateToken calling stampLastUsed) + - modules/cabana/http.go lines 150-178 (store construction, the registered backend guard `guard`, the cookie-less `resolverGuard` published as *backendResolver) + - modules/cabana/token_guard_test.go lines 150-205 (the errStampStore stamp-failure block and TestTokenLastUsedDebounce, which manipulate the global map today) + - modules/cabana/token_smoke_test.go lines 223-260 and 315-365 (TestTokenGatesSmoke: memTokenStore with stampN, `guard` also published as the resolver, the debounce block that resets token 101) + + + - TestTokenLastUsedThrottlePerGuard (new, internal package cabana, runs under -short). + - Setup: build two guards with newCompositeGuard, each over its own memTokenStore, with the same tracerCatalog registry and a stubUsers principal that holds acme.demo.access. Mint a different token for each store, but store both rows with ID 7, BackendUserID 1 and scopes ["demo"]. + - Authenticate once on guard A, then once on guard B, within the same minute. + - Expect storeA.stampN == 1 and storeB.stampN == 1. Against the old process-global map storeB.stampN is 0, so the test is RED before the fix. + - Authenticate a second time on guard B. storeB.stampN stays 1, because the debounce still applies within one guard. + - TestTokenLastUsedDebounce keeps its intent. Two auths within a minute give one stamp. After the guard's own throttle entry for 42 is backdated by two minutes, the next auth stamps again, giving stampN == 2. + - TestTokenGatesSmoke keeps its intent. After the guard's own throttle entry for 101 is cleared, two auths give stampN == 1. + - The stamp-failure block (errStampStore) still authenticates successfully. Its fresh guard g2 needs no reset. + + +Pre-flight (concurrency, per CLAUDE.md): +- Run `git status --short` and `git log --oneline -5 -- modules/cabana` in summercms.go. +- If any of the five files listed above has uncommitted edits from another session, stop and report. +- At planning time the tree was clean and the last cabana commit was 7ef2e4f. + +RED: add TestTokenLastUsedThrottlePerGuard to modules/cabana/token_guard_test.go as described in the behavior block. Reuse the existing helpers tracerCatalog, memTokenStore (and its put and stampN), stubJWTGuard, stubUsers and mintAPIToken. Run `go test -short -count=1 ./modules/cabana -run '^TestTokenLastUsedThrottlePerGuard$'` and confirm it FAILS on storeB's stamp count before any production change. + +GREEN, in modules/cabana/api_tokens.go: +- Replace the package-level debounce variable and its doc comment with an unexported type stampThrottle. It holds a sync.Map from uint token ID to time.Time, in a field named last. +- Give stampThrottle a method due(id uint, now time.Time) bool. It keeps today's semantics exactly: Load the previous time; return false if now minus that time is under time.Minute; otherwise Store now and return true. A nil *stampThrottle returns true (no in-memory debounce, since the SQL UPDATE is still conditional), so a zero-value guard never panics. +- Change stampLastUsed to take the throttle: stampLastUsed(ctx context.Context, stamps *stampThrottle, store tokenStore, id uint, now time.Time). It returns early for a nil store or id 0 as today, then returns when stamps.due is false, otherwise calls store.StampLastUsed and logs a warning on error, unchanged. +- Update the doc comment to say the throttle gates last_used_at writes to at most once per minute per token ID per guard instance, with one instance per cabana activation, and that the SQL UPDATE is additionally conditional. +- Do not change gormTokenStore, the tokenStore interface or the UPDATE statement. + +In modules/cabana/token_guard.go: +- Add a field stamps *stampThrottle to compositeGuard. +- newCompositeGuard keeps its signature and sets stamps to a new &stampThrottle{}. Every existing call site keeps compiling and each guard gets its own state. +- authenticateToken calls stampLastUsed(r.Context(), g.stamps, g.store, row.ID, now). + +In modules/cabana/http.go: right after resolverGuard is built with newCompositeGuard, assign resolverGuard.stamps = guard.stamps. Add a one-line comment saying the admin middleware and ResolveBackend share one throttle per activation. Today's process-wide sharing between those two paths is a real production property: one token used on the admin API and on plugin routes still writes at most once a minute. This assignment preserves it, so production behavior is unchanged. + +Adjust the existing tests in package cabana so nothing refers to the removed global: +- token_guard_test.go stamp-failure block: drop the reset of token 99. g2 is a fresh guard with a fresh throttle. +- TestTokenLastUsedDebounce: drop the initial reset of token 42, because the guard is fresh. Replace the two-minute backdating with a Store on guard.stamps.last for uint(42). +- token_smoke_test.go TestTokenGatesSmoke: replace the reset of token 101 with a Delete on guard.stamps.last for uint(101). That `guard` is also the published resolver and already stamped 101 earlier in the test, so the reset is still needed. + +Run `grep -rn "lastStamp" modules/cabana` and confirm it returns nothing. No README or docs/ page mentions this unexported state (checked at planning time), so no documentation change is needed. + +Commit in summercms.go, staging only the five files by explicit path, no co-author trailer: +- subject: fix(cabana): scope the token last_used_at throttle to the guard instance +- body bullet 1: The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order +- body bullet 2: Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token +- body bullet 3: TestTokenLastUsedThrottlePerGuard pins the per-guard isolation + +Do not push. Do not edit STATE.md here; the quick-task workflow records state through `msd_run query state.*` handlers. + + + bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short -count=1 -race ./modules/cabana -run "Token|ResolveBackend" && go test -short ./... && go test -count=1 ./modules/cabana -run "^(TestAllowsScope|TestBackendTokenGuard|TestBackendTokenPostgres|TestInteractiveOnly|TestResolveBackend|TestScopeCoversAll|TestScopeMatch|TestTokenRouteGates|TestTokenTracerPostgres|TestTokenGatesSmoke|TestAccountTokens)$" && go test -count=1 ./modules/cabana && grep -q "resolverGuard.stamps = guard.stamps" modules/cabana/http.go && grep -q "func TestTokenLastUsedThrottlePerGuard" modules/cabana/token_guard_test.go' + + +- TestTokenLastUsedThrottlePerGuard failed before the production change (recorded in the SUMMARY) and passes after it. +- The verifier's 11-test reproduction command passes, including TestBackendTokenPostgres. +- The Docker-backed `go test -count=1 ./modules/cabana`, `go test -short ./...`, `go vet ./...` and the -race run of the token tests all pass. +- No package-level debounce state remains in cabana. compositeGuard owns stamps, and http.go shares the backend guard's throttle with the resolver guard. +- The summercms.go HEAD commit touches only the five listed cabana files. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| HTTP client → admin API (`/{prefix}/api/v1/*`) | Untrusted requests. Every non-public route must sit behind the `backend` guard (JWT or g15_ personal token). | +| Personal API token → `backend_api_tokens.last_used_at` | Authenticated token use triggers a DB write. The in-memory throttle bounds the write rate. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-kzi-01 | Elevation of privilege | fonoteka `phase09AdminRoutes` inventory | high | mitigate | Before listing each of the ten routes as protected, Task 1 confirms it sits inside cabana's `r.GroupRaw(api, []string{"backend"}, ...)` block. The test assertions stay as they are, so an admin route added later without the guard, or left out of the list, still fails TestPhase09SecurityRoutes. A route found outside the backend group stops the task as a finding. | +| T-kzi-02 | Repudiation | `stampLastUsed` / `compositeGuard` | low | mitigate | The per-guard throttle stops one database's token IDs from suppressing another's last_used_at audit stamp. TestTokenLastUsedThrottlePerGuard and the verifier's ordering command pin this. | +| T-kzi-03 | Denial of service | last_used_at write rate (`http.go` guard wiring) | low | mitigate | Guards are built once per activation, not per request. `resolverGuard.stamps = guard.stamps` keeps one throttle per app for both auth paths, and gormTokenStore's UPDATE stays conditional on `last_used_at < now - 1 minute`, so the DB write rate is unchanged. | +| T-kzi-04 | Tampering | concurrent access to `stampThrottle` | low | mitigate | The state stays in a `sync.Map` with the same Load/Store semantics as before. Task 2 runs the token tests under `-race`. | +| T-kzi-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No dependency is added or upgraded in either repo; the package-legitimacy gate does not apply. | + + + +- fonoteka.go: `go test -count=1 ./plugins/golem15/fonoteka -run '^TestPhase09Security'` passes. +- summercms.go: `scripts/check-phase9.sh --security` exits 0. +- summercms.go: `go vet ./...`, `go test -short ./...`, `go test -short -race ./modules/cabana -run 'Token|ResolveBackend'`, the verifier's 11-test command and `go test -count=1 ./modules/cabana` (Docker) all pass. +- `git -C ../fonoteka.go status --short` still shows the three submodule pointer modifications, unstaged. +- Two new commits, one per repo, with no Co-Authored-By trailer and nothing pushed. + + + +- The Phase 09 security gate is green again. The fonoteka route inventory matches the assembled admin surface, and every listed protected route was confirmed backend-guarded at its registration. +- TestBackendTokenPostgres no longer depends on test order. The throttle state belongs to the guard instance, the single-app production debounce is unchanged and a -short regression test pins the isolation. + + + +Create `.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-SUMMARY.md` when done. Record both commit hashes, the observed RED failure of TestTokenLastUsedThrottlePerGuard before the fix, and the output of `check-phase9.sh --security`. + diff --git a/.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-SUMMARY.md b/.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-SUMMARY.md new file mode 100644 index 0000000..12ac5e1 --- /dev/null +++ b/.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-SUMMARY.md @@ -0,0 +1,105 @@ +--- +phase: quick-261011-kzi +plan: 01 +subsystem: cabana (admin auth) + fonoteka.go Phase 9 security inventory +tags: [security, admin-api, api-tokens, tests, phase-09-gap] +status: complete +requires: [] +provides: + - "fonoteka phase09AdminRoutes covering the ten post-104a1c9 backend-guarded cabana routes" + - "cabana stampThrottle: per-guard last_used_at debounce" +affects: + - "scripts/check-phase9.sh --security (green again)" + - "modules/cabana Docker suite (TestBackendTokenPostgres no longer order-dependent)" +tech-stack: + added: [] + patterns: + - "In-memory debounce state owned by the guard instance, shared explicitly between the guards of one activation" +key-files: + created: [] + modified: + - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + - modules/cabana/api_tokens.go + - modules/cabana/token_guard.go + - modules/cabana/http.go + - modules/cabana/token_guard_test.go + - modules/cabana/token_smoke_test.go +decisions: + - "The last_used_at throttle belongs to compositeGuard; http.go shares the backend guard's throttle with the cookie-less ResolveBackend guard, so one activation keeps one debounce, as the process-global map did for a single-app binary" + - "A nil *stampThrottle never debounces (the SQL UPDATE stays conditional), so a zero-value guard cannot panic" +commits: 1 +plan_head_before: 21fdcfcba6d7a5c192ea449dd5c29a63e841470e +plan_head_after: b45af8ebbf67ef90e67b2ad701dddc4e5c7e645a +commit_hashes: + - "fonoteka.go@e074028 test(quick-261011-kzi): list the post-Phase 9 cabana admin routes in the Phase 9 route inventory" + - "summercms.go@b45af8e fix(cabana): scope the token last_used_at throttle to the guard instance" +metrics: + duration: "~12 min" + completed: 2026-10-11 +actuals: + tokens: 4500 + tasks: 2 + commits: 2 +--- + +# Quick 261011-kzi: Fix Phase 09 gaps Summary + +Closes both findings of the Phase 09 re-verification. fonoteka's Phase 9 route inventory now lists the ten backend-guarded account, token, field-type, markdown preview and field upload routes. The cabana token `last_used_at` debounce is a per-guard `stampThrottle` instead of a process-global map keyed by token ID. + +## Commits + +| Repo | Hash | Message | Files | +|------|------|---------|-------| +| fonoteka.go | e074028 | test(quick-261011-kzi): list the post-Phase 9 cabana admin routes in the Phase 9 route inventory | plugins/golem15/fonoteka/admin_phase09_security_test.go | +| summercms.go | b45af8e | fix(cabana): scope the token last_used_at throttle to the guard instance | modules/cabana/{api_tokens,token_guard,http,token_guard_test,token_smoke_test}.go | + +The frontmatter `commits: 1` / `plan_head_*` is the measured summercms.go range (21fdcfc..b45af8e). fonoteka.go adds one more commit (7fc790d..e074028), so the total is 2 (`actuals.commits`). Nothing was pushed, and neither commit has a co-author trailer. + +## Task 1: fonoteka Phase 9 route inventory (fonoteka.go@e074028) + +- Checked all ten routes against `modules/cabana/http.go` `service.mount`. They are all registered inside `r.GroupRaw(api, []string{"backend"}, ...)`: the account, avatar and token routes, `/field-types` and `/markdown/preview` next to `/navigation`, and `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` at the end of the group. None is in the public auth, lang or adminPrefix groups, so there was no security finding. +- Added the ten `public=false` entries in commented groups (My account, Phase 16.3 tokens, Phase 16.2 field type assets, Markdown preview, Phase 16.2 uploads D-18). No assertion, `phase09Surface` or other test code was changed. +- `go test -count=1 ./plugins/golem15/fonoteka -run '^TestPhase09Security'` passes: TestPhase09SecurityRoutes PASS, TestPhase09SecurityMatrix PASS. +- `scripts/check-phase9.sh --security` printed `phase9 security passed` and exited 0. It also passed again after the Task 2 cabana change. +- The three submodule pointers in fonoteka.go (plugins/golem15/feedback, golem, user) are still modified and unstaged. + +## Task 2: per-guard last_used_at throttle (summercms.go@b45af8e) + +**RED (before the production change):** + +``` +--- FAIL: TestTokenLastUsedThrottlePerGuard (0.00s) + token_guard_test.go:239: stamps A=1 B=0, want 1 each +FAIL git.golem15.com/golem15/summercms/modules/cabana 0.114s +``` + +**GREEN:** +- `api_tokens.go`: removed the `lastStamp` package `sync.Map`. Added `type stampThrottle struct { last sync.Map }` with `due(id, now) bool` (same Load/compare/Store semantics; a nil receiver returns true). `stampLastUsed` now takes `stamps *stampThrottle`. `gormTokenStore`, the `tokenStore` interface and the conditional UPDATE are unchanged. +- `token_guard.go`: added `compositeGuard.stamps *stampThrottle`. `newCompositeGuard` allocates it with the same signature, and `authenticateToken` calls `stampLastUsed(r.Context(), g.stamps, g.store, row.ID, now)`. +- `http.go`: added `resolverGuard.stamps = guard.stamps` (one throttle per activation, shared by the admin middleware and ResolveBackend). +- Tests: added TestTokenLastUsedThrottlePerGuard (two guards, separate stores, both token ID 7, one stamp each, and the debounce still holds within guard B). TestTokenLastUsedDebounce backdates through `guard.stamps.last`, TestTokenGatesSmoke resets through `guard.stamps.last.Delete(uint(101))`, and the errStampStore block no longer needs a reset. `grep -rn lastStamp modules/cabana` returns nothing. + +**Verification (all pass):** +- `go vet ./...` +- `go test -short -count=1 -race ./modules/cabana -run "Token|ResolveBackend"` +- `go test -short ./...` +- The verifier's 11-test `-run` command, Docker-backed: all PASS, including TestBackendTokenPostgres (0.28s) after TestAccountTokens and TestTokenTracerPostgres +- `go test -count=1 ./modules/cabana` (Docker): ok, 81.0s + +## Deviations from Plan + +None. The plan was executed as written. + +## Deferred Issues + +- Out of scope and not touched: `gofmt -l modules/cabana` flags five files that were already unformatted at HEAD before this task (account_tokens.go, account_tokens_test.go, token_postgres_smoke_test.go, token_routes_test.go, token_scopes_test.go). The five files this task changed are gofmt-clean. + +## Threat Flags + +None. No new surface. T-kzi-01 through T-kzi-04 are mitigated as planned (guard placement confirmed for each route, per-guard throttle, shared throttle per activation, sync.Map under -race). + +## Self-Check: PASSED + +- FOUND: fonoteka.go@e074028 (changes only admin_phase09_security_test.go) +- FOUND: summercms.go@b45af8e (changes only the five cabana files) +- FOUND: `type stampThrottle struct` in api_tokens.go, `stamps *stampThrottle` in token_guard.go, `resolverGuard.stamps = guard.stamps` in http.go, `func TestTokenLastUsedThrottlePerGuard` in token_guard_test.go, `/account/tokens/scopes` in admin_phase09_security_test.go