Files
summercms/.planning/phases/10-admin-vue-spa/10-REVIEW.md

28 KiB

phase, reviewed, re_reviewed, re_review_base, depth, files_reviewed, files_reviewed_list, re_review_files, findings, status
phase reviewed re_reviewed re_review_base depth files_reviewed files_reviewed_list re_review_files findings status
10-admin-vue-spa 2026-09-27T16:34:04Z 2026-09-27T18:37:03Z 6918ec5 standard 118
.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
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
critical warning info total resolved
0 8 10 18 1
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:

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

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:

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.

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