docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-23 08:43:43 +00:00
parent e4ccf0fe13
commit 62dc88998a
2 changed files with 246 additions and 1 deletions
+242
View File
@@ -0,0 +1,242 @@
---
type: guide
title: Cross-Tab Role Change Sync
description: 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.
tags: [guide, auth, session, role, broadcast-channel, storage-event, admin, last-admin, tester]
timestamp: 2026-07-23T08:42:24Z
---
# What this feature does
The [Cross-Tab Invalidation](/guides/cross-tab-invalidation.md) 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](/guides/cross-tab-invalidation.md) and
[Admin — Challenges/Players area](/guides/admin-shell.md) 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](/guides/notifications.md)) | 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
- [Cross-Tab Invalidation](/guides/cross-tab-invalidation.md) — sibling
channel for session invalidation.
- [Authenticated Shell](/guides/authenticated-shell.md) — the consumer
of peer events.
- [Admin Shell & Side Navigation](/guides/admin-shell.md) — the
`/admin/players` page where role changes originate.
- [Admin Endpoints](/api/admin.md) — the `PATCH /admin/players/:id/role`
surface and its `LAST_ADMIN` error code.
- [Users Table](/database/users.md) — the `role` column on the `user`
table.
- [Frontend Structure](/architecture/frontend-structure.md)
- [REST API Overview](/api/rest-overview.md)
+4 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T06:39:17Z. they need. Last regenerated 2026-07-23T08:42:24Z.
# Architecture # Architecture
@@ -100,6 +100,9 @@ they need. Last regenerated 2026-07-23T06:39:17Z.
* [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) - * [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) -
How a logout in one tab, or an unauthorized SSE response, instantly clears How a logout in one tab, or an unauthorized SSE response, instantly clears
and redirects every other open tab of the SPA. and redirects every other open tab of the SPA.
* [Cross-Tab Role Change Sync](/guides/cross-tab-role-change.md) -
How a role change at `/admin/players` propagates to every other open tab,
plus the LAST_ADMIN invariant under concurrent admin demotions.
* [Local Development Workflow](/guides/dev-workflow.md) - How to run the * [Local Development Workflow](/guides/dev-workflow.md) - How to run the
backend and frontend together (`npm run dev`), the Angular dev-server backend and frontend together (`npm run dev`), the Angular dev-server
proxy, and the project-local `./data/` directory. proxy, and the project-local `./data/` directory.