--- phase: 10-admin-vue-spa reviewed: 2026-09-27T16:34:04Z re_reviewed: 2026-09-27T18:37:03Z re_review_base: 6918ec5 depth: standard files_reviewed: 118 files_reviewed_list: - .gitignore - admin/env.d.ts - admin/index.html - admin/package.json - admin/src/App.vue - admin/src/api/client.ts - admin/src/api/types.ts - admin/src/app/controllerRoutes.ts - admin/src/app/i18n.ts - admin/src/app/icons.ts - admin/src/app/listQuery.ts - admin/src/app/router.ts - admin/src/app/runtime.ts - admin/src/app/theme.ts - admin/src/app/winterUrl.ts - admin/src/components/form/FieldRenderer.vue - admin/src/components/form/FormErrorBanner.vue - admin/src/components/form/FormField.vue - admin/src/components/form/FormGrid.vue - admin/src/components/form/FormTabs.vue - admin/src/components/form/control.ts - admin/src/components/form/fields/CheckboxField.vue - admin/src/components/form/fields/DropdownField.vue - admin/src/components/form/fields/NumberField.vue - admin/src/components/form/fields/RelationField.vue - admin/src/components/form/fields/SwitchField.vue - admin/src/components/form/fields/TextField.vue - admin/src/components/form/fields/TextareaField.vue - admin/src/components/form/fields/UnsupportedField.vue - admin/src/components/form/formState.ts - admin/src/components/form/registry.ts - admin/src/components/list/CellValue.vue - admin/src/components/list/DataTable.vue - admin/src/components/list/FilterBar.vue - admin/src/components/list/ListToolbar.vue - admin/src/components/list/Pagination.vue - admin/src/components/relation/RelationManager.vue - admin/src/components/relation/RelationPickerModal.vue - admin/src/components/shell/AppShell.vue - admin/src/components/shell/Breadcrumbs.vue - admin/src/components/shell/PluginRail.vue - admin/src/components/shell/SectionFlyout.vue - admin/src/components/shell/SectionPanel.vue - admin/src/components/shell/UserMenu.vue - admin/src/components/ui/Button.vue - admin/src/components/ui/ConfirmDialog.vue - admin/src/components/ui/Toast.vue - admin/src/components/ui/confirm.ts - admin/src/main.ts - admin/src/state/useAuth.ts - admin/src/state/useBreadcrumbs.ts - admin/src/state/useNavigation.ts - admin/src/state/useSettings.ts - admin/src/state/useSidebar.ts - admin/src/state/useToasts.ts - admin/src/styles/main.css - admin/src/views/FormView.vue - admin/src/views/ListView.vue - admin/src/views/LoginView.vue - admin/src/views/NotFoundView.vue - admin/src/views/SettingsFormView.vue - admin/src/views/SettingsIndexView.vue - admin/tsconfig.json - admin/vite.config.ts - admin/vitest.config.ts - boardwalk/boardwalk.go - bouncer/jwt.go - cabana/admin_openapi.go - cabana/auth.go - cabana/contracts.go - cabana/crud.go - cabana/csrf.go - cabana/filter_schema.go - cabana/form_schema.go - cabana/http.go - cabana/lang.go - cabana/list_schema.go - cabana/messages.go - cabana/prefix.go - cabana/registry.go - cabana/relation.go - cabana/relation_field.go - cabana/schema_types.go - go.mod - internal/build/stubs/artifacts.tmpl - internal/tools/swagger2openapi/main.go - pact/capabilities.go - phrasebook/backend/lang/en/lang.yaml - phrasebook/backend/lang/pl/lang.yaml - phrasebook/lang.go - phrasebook/loader.go - phrasebook/translator.go - scripts/check-admin-dist.sh - scripts/check-admin-openapi.sh - scripts/check-phase10.sh - surf/router.go - ../fonoteka.go/config/admin.yaml - ../fonoteka.go/config/backend.yaml - ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go - ../fonoteka.go/plugins/golem15/fonoteka/admin_settings.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml - ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml - ../fonoteka.go/plugins/golem15/fonoteka/lang.go - ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml - ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml - ../fonoteka.go/scripts/check-openapi.sh re_review_files: - bouncer/jwt.go - bouncer/refresh.go - cabana/auth.go - cabana/http.go - admin/src/views/FormView.vue - admin/src/views/SettingsFormView.vue - bouncer/jwt_guard_test.go - bouncer/refresh_test.go - cabana/phase10_coverage_test.go - cabana/refresh_revocation_test.go findings: critical: 0 warning: 8 info: 10 total: 18 resolved: 1 status: issues_found --- # Phase 10: Code Review Report **Reviewed:** 2026-09-27T16:34:04Z **Re-reviewed:** 2026-09-27T18:37:03Z (delta `6918ec5..HEAD`: be4a923, a13a121, 2585671) **Depth:** standard **Files Reviewed:** 118 (+10 in the re-review, 4 of them tests) **Status:** issues_found (0 open critical, CR-01 resolved) ## Re-review (2026-09-27T18:37:03Z) Scope: `git diff 6918ec5..HEAD` of bouncer/jwt.go, bouncer/refresh.go, cabana/auth.go, cabana/http.go, admin/src/views/FormView.vue and admin/src/views/SettingsFormView.vue, plus the four changed test files. `go vet ./bouncer ./cabana` is clean. The refresh, guard and Phase 10 tests pass, including `TestAdminRefreshRevocation` against Postgres. **CR-01 is resolved.** Evidence: - `cabana/auth.go:224` now calls `bouncer.RefreshAudienceFor(r.Context(), s.users, ...)`. `s.users` is the same `lazyBackendUsers` value handed to `NewBackendJWTGuard` (`cabana/http.go:113-114,137`). - `bouncer/refresh.go:98-102` runs the subject check after every token-only check and before `MintAudience`. A refusal returns before the mint and before the old jti is blacklisted. - The check (`refresh.go:41-50`) reuses `subjectPrincipal` and `issuedBeforeCutoff` (`jwt.go:152-174`), the same code the guard now runs. `BackendUsers.FindByID` (`cabana/auth.go:37-62`) returns nil for a missing row, a soft-deleted row (`gorm.DeletedAt` on `BackendUser`) or `!IsActivated`, so all three map to `ErrSubjectRejected`. - It fails closed. A provider or DB error becomes `errAuthentication`, which gives a 401, mints nothing, blacklists nothing and leaves the cookie in place. A nil provider, a zero sub or a non-numeric sub is `ErrSubjectRejected`. - There is no race with a reset. `admin:reset-password` sets the cutoff to `now+1s` (`cabana/commands.go:131`), and `iat` is truncated to whole seconds. A refresh that loaded the user just before the reset committed therefore mints a token whose `iat` is still before the cutoff, and the guard rejects that token on its next use. - Cookie handling: on `ErrSubjectRejected` over the cookie transport, the handler sends an expiring `summer_admin` with the admin Path (`auth.go:229-231`). A Bearer refusal sets no cookie. The tests cover the reset (cookie and Bearer), deactivated, soft-deleted, provider-error and still-active cases (`cabana/refresh_revocation_test.go`, `cabana/phase10_coverage_test.go:384-482`, `bouncer/refresh_test.go:125-261`). **No regressions found in the guard or the user plugin:** - The guard's order is unchanged: verify, load subject, blacklist, cutoff (`jwt.go:113-137`). - The guard's messages are unchanged. A subject or cutoff refusal is still "User not found" (now the `ErrSubjectRejected` sentinel), and a provider error is still "Authentication error". - `bouncer.Refresh` and `bouncer.RefreshAudience` pass `check == nil`, so their flow and errors are the same. The only external caller, `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:190`, uses `bouncer.Refresh` and is untouched. No caller compares these errors by identity. The form width change (2585671) only removes `mx-auto max-w-[980px]` in both views, and `boardwalk/dist` was rebuilt in the same commit. No issue. New findings from the re-review: WR-08 and IN-08 to IN-10. WR-01 and WR-07 are still open, and their text is updated below where CR-01 changed their impact. ## Summary Reviewed the Phase 10 admin SPA (admin/), the boardwalk SPA server, and the cabana admin API changes: cookie transport and CSRF, the backend.uri prefix, relation field options and saves, filter options, messages, the phrasebook override layer, and the fonoteka controller wiring. The review read the changed files and traced the auth calls into bouncer (jwt.go, refresh.go, mint.go) and cabana/commands.go. The main controls hold up: - CSRF: every unsafe admin route is wrapped in `requireAjax`, and the cookie is `SameSite=Strict`, `HttpOnly` and `Secure`. - XSS: the SPA has no raw-HTML sink, so it renders plugin and server strings as text only. The CSP is `script-src 'self'`. - Redirects: `safeRedirect` and `mapWinterUrl` only produce in-app routes. - Relation saves: submitted ids go back through the scoped options query inside the save transaction. - Admin prefix: other plugins' routes under the prefix are refused at boot. There is one blocker. `POST /auth/refresh` does not check the `tokens_valid_after` cutoff that `admin:reset-password` uses to revoke sessions. It also mints a token with a fresh `iat`. The Phase 10 SPA refreshes automatically on any 401, so a password reset no longer ends existing browser sessions. The warnings cover: - logout leaving the cookie in place when the token is rejected - a relation-scope bypass the activation check does not catch - validation running before relation keys are assigned - filter options that cannot be scoped per admin - missing error handling in SPA loaders - pagination after deletes - an unbounded sliding refresh window ## Critical Issues ### CR-01: Refresh ignores the tokens_valid_after cutoff, so the SPA undoes session revocation — RESOLVED (be4a923, a13a121) **Resolution (re-review 2026-09-27T18:37:03Z):** Fixed as proposed, through a new opt-in `bouncer.RefreshAudienceFor` that runs the guard's subject checks (`subjectPrincipal` and `issuedBeforeCutoff`) immediately before minting (`bouncer/refresh.go:37-52,98-102`). The admin refresh handler uses it with the guard's provider (`cabana/auth.go:224`, `cabana/http.go:113,137`). A token issued before the cutoff, or held by a deleted or deactivated admin, gets a 401. The fix mints nothing, leaves the old jti un-blacklisted, and expires the cookie when the token came from it. A provider error fails closed. The requested regression test exists: `TestAdminRefreshRevocation` resets the password and refreshes the old cookie and the old Bearer. The sliding-iat part (WR-07) is a separate finding and stays open. The same gap on the frontend user refresh is recorded as WR-08. *Original finding:* **File:** `cabana/auth.go:217-241`, `bouncer/refresh.go:52-71`, `bouncer/mint.go:48-60`, `admin/src/api/client.ts:77-92` **Issue:** `summer admin:reset-password` (cabana/commands.go:128-133) sets `tokens_valid_after`, and the backend guard rejects any token whose `iat` is before it (bouncer/jwt.go:144). The refresh handler never loads the user. `RefreshAudience` checks only the signature, the audience, `iat + refresh_ttl` and the blacklist, then calls `MintAudience`, which stamps `iat = now`. The new token's `iat` is after the cutoff, so it passes the guard. Before Phase 10 a Bearer client had to call refresh on purpose. Now the SPA transport does it for every 401: the guard rejects the revoked cookie, `refreshSession()` gets a fresh cookie, and the request is replayed. Anyone holding a copied or stolen `summer_admin` cookie (or an old Bearer token) keeps full admin access for up to `refresh_ttl` (14 days) after a password reset. Refresh also skips `is_activated`. The guard still blocks a deactivated user, but refresh keeps minting tokens for them. **Fix:** Load the principal in the refresh handler and apply the same checks as the guard before minting: ```go func (s *service) refresh(w http.ResponseWriter, r *http.Request) { raw, fromCookie := sessionToken(r) // parse without exp validation to read sub and iat (reuse the refresh parser) sub, iat, err := bouncer.PeekRefreshClaims(s.secret, raw, bouncer.AudienceBackend) if err != nil { /* 401 */ } id, _ := strconv.ParseUint(sub, 10, 64) principal, err := lazyBackendUsers{app: s.app, reg: s.reg}.FindByID(r.Context(), uint(id)) if err != nil || principal == nil || (!principal.TokensValidAfter.IsZero() && iat.Before(principal.TokensValidAfter)) { s.expireSessionCookie(w) WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } next, err := bouncer.RefreshAudience(...) ... } ``` Also add a test: reset the password, then refresh with a token issued before the reset, and expect a 401. ## Warnings ### WR-01: Logout does not expire the cookie when the token is rejected **File:** `cabana/http.go:196`, `cabana/auth.go:243-269` **Issue:** The comment says logout "always expires the admin cookie". But `/auth/logout` sits behind the `backend` guard. An expired, blacklisted or cutoff-rejected token gets the guard's 401 before the handler runs. Inside the handler, a `VerifyClaimsAudience` failure also returns 401 before `expireSessionCookie`. In both cases no `Set-Cookie` is sent, and the cookie stays in the browser for its refresh-window `Max-Age`. The SPA only recovers through its refresh-and-retry path. If refresh fails, the stale cookie stays behind. With CR-01 unfixed, a later visit can bring that session back to life. *Re-review note:* CR-01's fix removes the "back to life" path for reset, deactivated and deleted admins, and a refused cookie refresh now expires the cookie (`cabana/auth.go:229-231`). This finding is still open. Logout still sits behind the guard (`cabana/http.go:199`) and returns 401 without `Set-Cookie`. A cookie refresh that fails for a token-only reason (blacklisted jti, past the refresh window, bad signature) also leaves the cookie in place on purpose (`auth.go:226-228`). **Fix:** Mount logout outside the guard (keep `requireAjax`), and call `s.expireSessionCookie(w)` first on every path: ```go func (s *service) logout(w http.ResponseWriter, r *http.Request) { s.expireSessionCookie(w) // unconditionally raw, _ := sessionToken(r) _, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend) ... } ``` For expired tokens, parse them without claims validation so their jti can still be blacklisted. ### WR-02: A belongsTo foreign key exposed as a scalar field skips the relation scope check **File:** `cabana/relation_field.go:136-152`, `cabana/crud.go:93-117`, `cabana/registry.go:76-92` **Issue:** `compileFieldRelations` binds `type: relation` fields. `BindWritableFields` separately makes every scalar form field that names a model column writable. Nothing stops fields.yaml from declaring both `genre` (relation) and `genre_id` (for example the older Winter `type: dropdown` pattern). In that case `genre_id` goes through `ProjectWritableFields` and `lagoon.Fill` with no `RelationExtendOptionsQuery` check, so an out-of-scope id can be stored. That breaks the stated invariant that "the options hook is never only cosmetic". The same applies to a pivot foreign key column. **Fix:** After `BindWritableFields`, fail activation if any `cc.Writable[i].FillKey` equals a `FieldRelations[*].Contract.ForeignKey`, with an error such as `field genre_id duplicates the foreign key of relation field genre`. ### WR-03: Model rules run before relation values are assigned **File:** `cabana/crud.go:336` vs `cabana/crud.go:353-356` **Issue:** `lagoon.Validate` runs on `target` before `assignBelongsTo` writes the submitted foreign key. A model rule on the foreign key column (a common Winter pattern such as `category_id: required` or `exists:...`) checks the stale value. On create, a valid submitted `genre` fails `required`. On update, the rule checks the old id and the new one is never validated. Relation shape errors (`liftRelationValues`) and scope errors also come back separately from scalar validation errors, so a bad form needs several round trips. **Fix:** Run `checkRelationScope` and `assignBelongsTo` before `mergedRules`/`lagoon.Validate`, and merge relation `ValidationError` details with the scalar validation messages into one 422. ### WR-04: Scope filter choices cannot be scoped to the signed-in admin **File:** `pact/capabilities.go:265-270`, `cabana/filter_schema.go:264-294` **Issue:** `FilterOptions(scope string) []Option` gets no `context.Context` and no `*gorm.DB`. A model cannot see the principal, so it cannot narrow choices the way `ListExtendQuery`/`FormExtendQuery` narrow rows (for example "collections I can see"). It also has no request-scoped DB handle. Any model-backed filter that lists tenant data returns every tenant's labels to any admin who can open the list. The contract is new in this phase, so changing it now is cheap. **Fix:** Change the capability to `FilterOptions(ctx context.Context, db *gorm.DB, scope string) []Option`, and pass `r.Context()` (with the principal) and `s.db()` from `filterOptions`. Optionally run the result through the controller's `ListExtendQuery`. ### WR-05: SPA loaders have no error handling, so network failures leave views stuck loading **File:** `admin/src/views/ListView.vue:96-115`, `admin/src/views/FormView.vue:141-159`, `admin/src/views/SettingsFormView.vue:34-44`, `admin/src/components/list/FilterBar.vue:44-49`, `admin/src/views/LoginView.vue:19-37` **Issue:** openapi-fetch rejects on network errors or aborted fetches, and on a thrown replay inside `transport.onResponse`. `loadList`, `load` (list schema), FormView `load`, SettingsFormView `load` and FilterBar `loadScope` have no `try/catch`. `loading` stays `true` forever, the skeleton never clears, no failure message appears, and each case is an unhandled promise rejection. In LoginView, `login()` throwing skips `failed.value = true`, so the form silently does nothing. **Fix:** Wrap each loader in `try { ... } catch { failed.value = true } finally { loading.value = false }`, keeping the `generation` guard in ListView. In LoginView, catch and set `failed`. ### WR-06: After a delete or unlink, the list can stay on a page past the last page **File:** `admin/src/views/ListView.vue:199-203`, `admin/src/components/relation/RelationManager.vue:172-179` **Issue:** Both reload with the same `page` after removing rows. Deleting or unlinking every row on the last page (for example page 3 of 3) reloads page 3, which is now empty. The user sees the "no records" state even though pages 1-2 still hold records. **Fix:** After reload, clamp the page: if `rows.length === 0 && meta.page > 1`, go to `meta.last_page`. In ListView use `replaceQuery({...query.value, page: meta.value.last_page})`; in RelationManager set `page.value = meta.value.last_page` and call `loadRows()` again. ### WR-07: Refresh re-mints iat, so the refresh window slides with no upper bound **File:** `bouncer/refresh.go:52-71`, `bouncer/mint.go:48-60`, `admin/src/state/useAuth.ts:117-127` **Issue:** `refresh_ttl` is measured from the presented token's `iat`, and each refresh issues a token with `iat = now`. The SPA refreshes on its own at 80% of the access lifetime. A browser session, or a stolen cookie refreshed at least once every 14 days, therefore never reaches an absolute expiry. tymon/jwt-auth, which the PHP side follows, keeps the original `iat` by default (`refresh_iat: false`), which caps the session at `refresh_ttl` after login. Check this against the PHP contract before changing it. **Fix:** Carry an `orig_iat` (or `auth_time`) claim through refresh, and measure `refresh_ttl` from it. *Re-review note:* Still open. be4a923 leaves the mint unchanged (`bouncer/refresh.go:103`). A password reset now ends a sliding session. Without a reset, a session refreshed at least once every 14 days still never expires. ### WR-08: The frontend user refresh still ignores tokens_valid_after, so the CR-01 gap remains for site users **File:** `bouncer/refresh.go:17-19`, `../fonoteka.go/plugins/golem15/user/controllers/api_controller.go:175-199`, `../fonoteka.go/plugins/golem15/user/controllers/api_controller.go:537-549` **Issue:** The user plugin's change-password handler sets `tokens_valid_after = iat - 1s`. That keeps the caller's own session and revokes every other session, and `classes/user_lookup.go:42-43` feeds the cutoff to the frontend guard. `POST /_user/api/v1/refresh` still calls `bouncer.Refresh`, which by design does no subject checks. A token stolen before the password change keeps being refreshed for the whole `refresh_ttl`, and each refreshed token has a new `iat` that passes the guard. This is the CR-01 bug on the frontend audience. It is not a regression from this delta, because `Refresh` was deliberately left unchanged for the core user plugin's contract. It is a real revocation bypass that the CR-01 fix makes easy to close. **Fix:** Do not change `bouncer.Refresh`. In the user plugin's `Refresh` handler, opt in to the subject checks with a frontend variant that keeps the missing-aud (`allowMissing`) behaviour, for example an exported `bouncer.RefreshFor(ctx, users, secret, raw, ...)` that passes a `check` to `refreshAudience(..., AudienceUser, true, ..., check)`. It should use the provider the frontend guard uses. First confirm with the owners of the user plugin contract that PHP's refresh also rejects a revoked subject. If it does, this restores parity. If it does not, record the choice in a decision note. ## Info ### IN-01: A large access TTL makes the proactive refresh fire in a loop **File:** `admin/src/state/useAuth.ts:117-127` **Issue:** `setTimeout(..., seconds * 800)` overflows the 32-bit browser timer limit when `admin.jwt.ttl` is above about 44,700 minutes (31 days). The callback then fires at once, `onRefreshed` reschedules it, and the SPA sends a continuous stream of `/auth/refresh` calls. **Fix:** `Math.min(seconds * 800, 2_147_483_647)`, or reject such a TTL on the server. ### IN-02: Nothing enforces the CSRF design's "no preflight on the admin API" assumption **File:** `cabana/csrf.go:8-15`, `surf/router.go:356` **Issue:** The `X-Requested-With` defence assumes the admin API never answers a CORS preflight. `pathScopedCORS` wraps the whole mux, and nothing refuses `http.cors.paths` that match `{prefix}/api/*` (for example `*`, or `backend.uri: /api`). A credentialed origin allow-list would then let a same-site origin send the header. **Fix:** Skip CORS handling for paths under the admin prefix, or fail boot when a configured CORS glob matches `{prefix}/api/v1/...`. ### IN-03: Choosing the transport by X-Requested-With is fragile for Bearer clients **File:** `cabana/auth.go:175-184` **Issue:** A Bearer-mode API client whose HTTP library adds `X-Requested-With: XMLHttpRequest` by default (Laravel's axios bootstrap, jQuery) gets `token_type: cookie`, a `Set-Cookie`, and no `access_token` from login. **Fix:** Select the cookie transport with an explicit signal, such as a `transport: "cookie"` body field or a dedicated header, rather than a common framework default. ### IN-04: Dead genre and style cases in the albums DropdownOptions **File:** `../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go` (DropdownOptions) **Issue:** `genre` is now `type: relation` and the album fields.yaml has no `style` dropdown, so the `genre`/`style` cases and their unscoped `SELECT id, name FROM ...` queries are unreachable. **Fix:** Remove both cases, so later readers do not assume these options apply. ### IN-05: Relation id lists have no size cap **File:** `cabana/relation_field.go:376-402`, `cabana/relation_field.go:455-457` **Issue:** A `belongsToMany` value accepts any number of ids. Past PostgreSQL's 65,535 bind-parameter limit, the `IN` scope check fails and the save returns a generic 500 instead of a 422. **Fix:** Cap the list, for example at 1,000 ids, with a validation message. ### IN-06: The gate's required-test check ignores the package **File:** `scripts/check-phase10.sh:79-80`, `scripts/check-phase10.sh:103-106` **Issue:** `passed` stores bare test names. A required test is satisfied by a passing test of the same name in any package in the run. Also, the `trap ... RETURN` in `run_self_test` never runs on the `exit 1` paths, so the scratch directory is left behind. **Fix:** Key `passed` as `pkg:Test`, pass the package with `PHASE10_REQUIRE`, and use `trap ... EXIT` in a subshell. ### IN-07: Logging out from a dirty form can leave the user on the form without a session **File:** `admin/src/state/useAuth.ts:171-184`, `admin/src/views/FormView.vue:279-291` **Issue:** `logout()` clears the session, then `router.replace({name: 'login'})` triggers FormView's dirty guard. If the admin picks cancel, the navigation is aborted and the error swallowed. The shell stays on the form with `currentUser` null and a server session that is already gone, so the next save fails with a 401. **Fix:** Set a "force leave" flag (as FormView's `leaving` does) before logout navigation, or bypass route-leave guards for the login redirect. ### IN-08: bouncer.Middleware still inlines the subject lookup that subjectPrincipal now owns **File:** `bouncer/jwt.go:45-62` vs `bouncer/jwt.go:152-168` **Issue:** be4a923 moved the guard's sub parse, nil-provider check, `FindByID` call and nil-user check into `subjectPrincipal`, but `Middleware` keeps its own copy of the same logic. The guard's blacklist-store error also still builds a fresh `errors.New("Authentication error")` (`jwt.go:128`) instead of reusing `errAuthentication`. The three paths now share messages only by convention, so a later change to one can leave the others behind. **Fix:** In `Middleware`, call `subjectPrincipal(r.Context(), users, sub)` and `write401(w, err.Error())`. Return `errAuthentication` at `jwt.go:128`. ### IN-09: RefreshAudienceFor takes a request context but the blacklist calls ignore it **File:** `bouncer/refresh.go:85`, `bouncer/refresh.go:116` **Issue:** The subject lookup now runs with the request `ctx`, but `IsBlacklisted` and `Add` in the same flow still use `context.Background()`. A cancelled or timed-out admin refresh can still block on a slow blacklist store, and request-scoped values such as tracing do not reach it. The old `Refresh` and `RefreshAudience` have no ctx, so they need the Background fallback. The new ctx-aware entry point does not. **Fix:** Pass a `ctx` parameter into `refreshAudience`. `Refresh` and `RefreshAudience` keep passing `context.Background()` so their behaviour does not change, and `RefreshAudienceFor` passes its `ctx`. ### IN-10: service.users is documented as the backend guard's provider, which is not guaranteed **File:** `cabana/http.go:44`, `cabana/http.go:113-119`, `cabana/http.go:137` **Issue:** `Activate` registers its own guard only when no `backend` guard exists yet. If another plugin, or a second `Activate` on the same app, registered `backend` first, requests are authenticated by that guard's provider while refresh checks subjects against cabana's `lazyBackendUsers`. The field comment "the backend guard's provider, reused by refresh" would then be false, and refresh could mint tokens for subjects the active guard refuses, or refuse subjects it accepts. Today only cabana registers `backend`, so this is latent. **Fix:** Fail activation when a `backend` guard is already registered by another owner. Alternatively, reword the comment to say refresh always checks cabana's `backend_users`, independent of the registered guard. --- _Reviewed: 2026-09-27T16:34:04Z_ _Re-reviewed: 2026-09-27T18:37:03Z (delta 6918ec5..HEAD)_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: standard_