13 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| guide | Cross-Tab Role Change Sync | How an admin changing a user's role (or their own role) at /admin/players propagates the new role to every other open tab of the SPA, plus the LAST_ADMIN invariant that protects concurrent demotion. |
|
2026-07-23T08:42:24Z |
What this feature does
The Cross-Tab Invalidation guide
describes how a logout in one tab instantly clears every other open tab.
Feature 913 adds a parallel, non-destructive channel: when an admin
changes any user's role (admin ↔ player) at /admin/players, every other
open tab of the same browser profile receives the change and updates its
in-memory CurrentUser — without forcing a re-login or a page reload.
Two concerns had to be solved together:
- UI propagation. Without a broadcast, an admin who demoted another
admin from
admintoplayerwould still see them asadminin every other tab, and conversely the demoted user would still see admin-only navigation entries until they refreshed. - The LAST_ADMIN invariant. The
[Admin Endpoints](/api/admin.md)surface already rejects a role demotion that would leave the system with zero admins (409 LAST_ADMIN). Feature 913 also verifies that concurrent demotion requests cannot race past that check: if two admins try to demote each other at the same instant, exactly one request succeeds and the other receives409 LAST_ADMIN.
Why a new concept doc
The change spans the pure contract module (auth-session-events.pure.ts),
the AuthService transport, the UserStore, the admin players component
(origin of the publish), and the home/shell component (consumer of the
notification). It also adds a backend test that pins down the concurrent
demotion invariant. Reading this doc together with
Cross-Tab Invalidation and
Admin — Challenges/Players area is the fastest
way to understand the full wiring.
Tester flows
Open the SPA in two browser tabs of the same browser profile. Both tabs must be on authenticated routes. Sign in as Admin A in Tab A and Admin B (also an admin) in Tab B (or use two browser profiles and a regular user in Tab B — the broadcast only fires to tabs signed in as the target user, so a regular-user tab will receive the role update if it is the target).
Self demotion (admin changes their own role)
- Have two admin accounts (
admin1,admin2). - Sign in as
admin1in Tab A and Tab B (duplicate the tab). - In Tab A, navigate to
/admin/players, find the row foradmin1, and use the role dropdown to switch it fromadmintoplayer. The dropdown closes, the row updates, and the status line readsRole updated to player. - In Tab B the following happens without any user interaction:
- The header user menu updates to show the player variant
(no admin-only entries, no
Playersnav item). - If Tab B happens to be on
/admin/*, a toast notification appears with the textYour role was changed to "player" by another admin. You may need to refresh or log in again to continue.
- The header user menu updates to show the player variant
(no admin-only entries, no
- Confirm in DevTools → Application → Session Storage of Tab B that
the persisted
hipctf.auth.v1session blob now recordsrole: 'player'.
Admin demotes another admin (peer notification)
- Sign in as
admin1in Tab A andadmin2in Tab B. Both are on/admin/players. - In Tab A, change
admin2's role fromadmintoplayer. - Tab B updates
admin2's row toplayer(the source of truth is still the server — the nextGET /admin/playerswill reconcile any drift) andadmin2's own header in Tab B shows the player variant. - If
admin2was viewing/admin/*at the moment of the change, Tab B shows the role-changed toast and the admin-only navigation items disappear.
Demoting a player to player (no-op for peers)
Changing a regular player between equivalent rows (or assigning them a
role they already hold) still publishes a message, but the
applyRoleChangeToUser guard short-circuits when the role is unchanged,
so no toast is shown and no sessionStorage write occurs.
Negative cases
| Action | Expected |
|---|---|
Two admins open /admin/players; admin1 demotes admin2, and at the same instant admin2 demotes admin1 |
The server rejects one of the two requests with 409 LAST_ADMIN; exactly one admin row remains. The successful demotion still publishes to other tabs; the rejected demotion returns an error toast (You cannot demote the last admin). |
Admin demotes themselves while on a tab that is on /login (logged out) |
No broadcast is processed — the receiving tab has no currentUser, so handlePeerRoleChange returns early. |
A non-HIPCTF tab on the same browser profile writes hipctf.role.invalidate.v1 to localStorage with garbage |
The payload validator (isStorageRoleChangeEvent) ignores it — no state change. |
| Same tab echoes its own broadcast (test environment that delivers self-messages) | The origin === crossTabOrigin guard prevents self-processing and rebroadcast loops. |
| A third tab is signed in as a different user than the target | The me.id !== msg.userId guard prevents that tab from applying the change. |
Visual / interactive elements
There is one new UI affordance — a toast — and the existing header user-menu gains/losses admin-only entries dynamically.
| Element | Selector | After peer role change (target tab on /admin/*) |
|---|---|---|
| Notification toast | [data-testid="notification-info"] (see Notifications) |
Visible with the role-changed message. Auto-dismisses per the existing toast policy. |
| Admin nav items | [data-testid="shell-nav-admin"] |
Hidden when the new role is player. |
| User menu trigger | [data-testid="user-menu-trigger"] |
Still visible; admin-only entries (Players, Settings) removed when role is player. |
| Players row | [data-testid="admin-players-row"] |
Source-of-truth is still the server; a peer change re-renders the row on the next GET /admin/players call. |
How it works (developer map)
Pure contract module additions
frontend/src/app/core/services/auth-session-events.pure.ts now
additionally defines:
| Export | Purpose |
|---|---|
CROSS_TAB_ROLE_CHANNEL_NAME = 'hipctf.auth.role.v1' |
Stable BroadcastChannel name for role-change messages. Separate from the invalidation channel so the two transports cannot alias. |
CROSS_TAB_ROLE_STORAGE_KEY = 'hipctf.role.invalidate.v1' |
localStorage fallback key used when BroadcastChannel is unavailable. |
CROSS_TAB_ROLE_EVENT_TYPE = 'hipctf.session.role-changed' |
Marker for the discriminated-union guard. |
CrossTabRoleChangeMessage |
{ type: 'role-changed'; origin: string; ts: number; userId: string; newRole: 'admin' | 'player' }. |
encodeRoleChangeMessage(origin, userId, newRole) |
Builds a message stamped with Date.now(). |
isCrossTabRoleChangeMessage(value) |
Type-guard used by both BroadcastChannel and storage listeners; rejects session-invalidated payloads so the two channels never double-fire. |
isStorageRoleChangeEvent(event) |
Guards storage events: only accepts the namespaced key with a parseable payload. |
applyRoleChangeToUser(user, newRole) |
Pure helper: returns the same reference when roles match, a new {...user, role} object otherwise, and null for a null user. |
The payload carries only the target user id and the new role — no tokens, no session blob, no other user attributes.
AuthService wiring additions
frontend/src/app/core/services/auth.service.ts
installCrossTabListener()now opens a secondBroadcastChannelnamedhipctf.auth.role.v1and subscribes a'message'handler (onRoleBroadcastMessage). The existing'storage'listener is extended to dispatch bothisStorageInvalidationEventandisStorageRoleChangeEvent— the storage event fires only in the tabs other than the writer, which is exactly the fan-out we want.publishRoleChange(userId, newRole)is the new public entry point. It encodes a message with the per-tabcrossTabOriginand posts it on both transports. Failures are swallowed (the storage fallback still fires from the other side). It deliberately does not clear local state — the originating tab is the one that just successfully calledPATCH /api/v1/admin/players/:id/role.applyPeerRoleChange(newRole)updates the localcurrentUsersignal in-place and writes the new user intosessionStorageso a subsequent hard reload hydrates with the updated role. It does not publish.onPeerRoleChange(cb)is the new subscriber API; returns an unsubscribe closure. Used byHomeComponent.handlePeerRoleChange(raw)is the single fan-in for both transport sources: it parses, drops self-origin messages, drops messages when there is no signed-in user, drops messages that target a different user id, then callsapplyPeerRoleChangeand notifies subscribers.teardownCrossTabListener()closes the role channel and clearspeerRoleChangeListeners.
UserStore.setRole
frontend/src/app/core/services/user.store.ts gains a setRole(role)
helper that updates the in-memory user signal and calls
auth.applyPeerRoleChange(role). This is the path used when the
broadcast was received from a peer tab.
Publish path — AdminUsersComponent.onToggleRole
frontend/src/app/features/admin/admin-users.component.ts
After the successful PATCH /api/v1/admin/players/:id/role response,
the component now:
- Updates the local
rowssignal with the returnedupdatedpayload. - If the currently signed-in user matches
updated.idand the role actually changed (me.role !== updated.role), it callsauth.applyPeerRoleChange(updated.role)to update the local user signal immediately, thenauth.publishRoleChange(updated.id, updated.role)to fan the change out to every other tab.
The build fix captured in
.kilo/plans/913-build-fix.md adds the missing AuthService injection
so TypeScript can resolve this.auth.
Consumer path — HomeComponent.handlePeerRoleChange
frontend/src/app/features/home/home.component.ts
ngOnInit()callsauth.onPeerRoleChange(handlePeerRoleChange)and stores the unsubscribe handle inpeerRoleChangeUnsubscribe.ngOnDestroy()calls the unsubscribe (in addition to the existing peer-invalidation unsubscribe).handlePeerRoleChange(msg):- Returns early if there is no signed-in user, or the user id does not match the broadcast.
- Calls
userStore.setRole(msg.newRole)to update the signal and re-persist viaAuthService. - If the current route is under
/admin/*, shows an info toast:Your role was changed to "<role>" by another admin. You may need to refresh or log in again to continue.
Backend invariant — LAST_ADMIN under concurrent demotion
The existing PATCH /api/v1/admin/players/:id/role handler runs inside a
transaction and re-checks the remaining admin count before committing,
so the LAST_ADMIN invariant already holds for a sequential demotion.
tests/backend/admin-players.spec.ts adds a concurrent demotion
test that:
- Registers
originalas the first admin, then creates a second adminbob(so the system has two admins). - Concurrently fires
PATCH /api/v1/admin/players/original.id/rolefromoriginal's session andPATCH /api/v1/admin/players/bob.id/rolefrombob's session. - Asserts that exactly one request returns
200withrole: 'player', the other returns409with bodycode: 'LAST_ADMIN', and the database still has exactly one admin row (bob).
Because both demotions happen against the same users table inside the
DB-side transactional guard, the race resolves deterministically: the
first transaction commits with the last-admin check passing; the second
sees zero remaining admins and aborts with 409.
Test coverage
| File | What it pins down |
|---|---|
tests/frontend/auth-session-events.spec.ts |
Pure payload validation (encodeRoleChangeMessage, isCrossTabRoleChangeMessage, isStorageRoleChangeEvent), applyRoleChangeToUser no-op semantics, and rejection of session-invalidated payloads on the role channel. |
tests/backend/admin-players.spec.ts |
Concurrent two-admin demotion: one succeeds, the other gets 409 LAST_ADMIN, exactly one admin remains in the DB. |
See also
- Cross-Tab Invalidation — sibling channel for session invalidation.
- Authenticated Shell — the consumer of peer events.
- Admin Shell & Side Navigation — the
/admin/playerspage where role changes originate. - Admin Endpoints — the
PATCH /admin/players/:id/rolesurface and itsLAST_ADMINerror code. - Users Table — the
rolecolumn on theusertable. - Frontend Structure
- REST API Overview