Files
HIPCTF2/docs/guides/cross-tab-role-change.md
T
2026-07-23 08:43:43 +00:00

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.
guide
auth
session
role
broadcast-channel
storage-event
admin
last-admin
tester
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:

  1. UI propagation. Without a broadcast, an admin who demoted another admin from admin to player would still see them as admin in every other tab, and conversely the demoted user would still see admin-only navigation entries until they refreshed.
  2. 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 receives 409 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)

  1. Have two admin accounts (admin1, admin2).
  2. Sign in as admin1 in Tab A and Tab B (duplicate the tab).
  3. In Tab A, navigate to /admin/players, find the row for admin1, and use the role dropdown to switch it from admin to player. The dropdown closes, the row updates, and the status line reads Role updated to player.
  4. 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 Players nav item).
    • If Tab B happens to be on /admin/*, a toast notification appears with the text Your role was changed to "player" by another admin. You may need to refresh or log in again to continue.
  5. Confirm in DevTools → Application → Session Storage of Tab B that the persisted hipctf.auth.v1 session blob now records role: 'player'.

Admin demotes another admin (peer notification)

  1. Sign in as admin1 in Tab A and admin2 in Tab B. Both are on /admin/players.
  2. In Tab A, change admin2's role from admin to player.
  3. Tab B updates admin2's row to player (the source of truth is still the server — the next GET /admin/players will reconcile any drift) and admin2's own header in Tab B shows the player variant.
  4. If admin2 was 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 second BroadcastChannel named hipctf.auth.role.v1 and subscribes a 'message' handler (onRoleBroadcastMessage). The existing 'storage' listener is extended to dispatch both isStorageInvalidationEvent and isStorageRoleChangeEvent — 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-tab crossTabOrigin and 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 called PATCH /api/v1/admin/players/:id/role.
  • applyPeerRoleChange(newRole) updates the local currentUser signal in-place and writes the new user into sessionStorage so 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 by HomeComponent.
  • 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 calls applyPeerRoleChange and notifies subscribers.
  • teardownCrossTabListener() closes the role channel and clears peerRoleChangeListeners.

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:

  1. Updates the local rows signal with the returned updated payload.
  2. If the currently signed-in user matches updated.id and the role actually changed (me.role !== updated.role), it calls auth.applyPeerRoleChange(updated.role) to update the local user signal immediately, then auth.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() calls auth.onPeerRoleChange(handlePeerRoleChange) and stores the unsubscribe handle in peerRoleChangeUnsubscribe.
  • 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 via AuthService.
    • 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:

  1. Registers original as the first admin, then creates a second admin bob (so the system has two admins).
  2. Concurrently fires PATCH /api/v1/admin/players/original.id/role from original's session and PATCH /api/v1/admin/players/bob.id/role from bob's session.
  3. Asserts that exactly one request returns 200 with role: 'player', the other returns 409 with body code: '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