From 997b6ae01a8b89117d4221a33c3ccbbbdb66f28a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 01:25:49 +0000 Subject: [PATCH 1/2] fix(auth): enforce session revocation on live sockets and add a sign-out button Revoking a session only ever mattered on the next request: an open socket kept its JWT and the phone kept driving the show. Sockets are now bound to the session id in their token, checked at handshake and re-checked against the store, and closed with 4001 session-revoked when the row is gone. The UI treats 4001 as 'signed out' and stops reconnecting, and the mobile menu gained a Sign out entry that revokes the session server-side via POST /api/logout. --- .../desktop/src/renderer/lib/use-wavegrid.ts | 4 +- packages/desktop/src/types/ipc.ts | 2 +- .../__tests__/session-revocation.test.ts | 166 ++++++++++++++++++ packages/server/src/http-app.ts | 33 +++- packages/server/src/hub.ts | 6 + packages/server/src/server.ts | 42 +++-- packages/settings/src/sessions.ts | 4 +- packages/ui/__tests__/auth-storage.test.ts | 72 ++++++++ packages/ui/__tests__/connection.test.ts | 22 ++- packages/ui/src/app.tsx | 23 ++- packages/ui/src/lib/auth-storage.ts | 50 ++++++ packages/ui/src/lib/connection.ts | 16 ++ packages/ui/src/lib/use-auth.ts | 49 ++++-- packages/ui/src/lib/use-socket.ts | 5 +- 14 files changed, 454 insertions(+), 40 deletions(-) create mode 100644 packages/server/__tests__/session-revocation.test.ts create mode 100644 packages/ui/__tests__/auth-storage.test.ts create mode 100644 packages/ui/src/lib/auth-storage.ts diff --git a/packages/desktop/src/renderer/lib/use-wavegrid.ts b/packages/desktop/src/renderer/lib/use-wavegrid.ts index 15895f6..1c95b4d 100644 --- a/packages/desktop/src/renderer/lib/use-wavegrid.ts +++ b/packages/desktop/src/renderer/lib/use-wavegrid.ts @@ -292,8 +292,8 @@ export function useProjectUsers({ project, rev }: ProjectScope): { } /** Active UI login sessions for a project (who's logged in). Local admin reads - * straight from the shared store; revoke removes the row (the client loses - * access on its next token refresh — sockets are untouched). */ + * straight from the shared store; revoke removes the row, which the server + * enforces on the next request and by closing that user's socket. */ export function useSessions({ project, rev }: ProjectScope): { sessions: SessionInfo[]; refresh: () => Promise; diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index 21a4af7..f18e85c 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -413,7 +413,7 @@ export interface WavegridApi { sessions: { /** Active (non-expired) UI login sessions for a project, newest first. */ list(project: string): Promise; - /** Revoke a session by id (takes effect on the client's next token refresh). */ + /** Revoke a session by id (rejects its next request and closes its socket). */ revoke(project: string, id: string): Promise; }; keys: { diff --git a/packages/server/__tests__/session-revocation.test.ts b/packages/server/__tests__/session-revocation.test.ts new file mode 100644 index 0000000..3d400bd --- /dev/null +++ b/packages/server/__tests__/session-revocation.test.ts @@ -0,0 +1,166 @@ +import { loadWavegridConfig } from '@wavegrid/layout'; +import { openStore } from '@wavegrid/settings'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { WebSocket } from 'ws'; + +import { startServer, type ServerHandle } from '../src/server'; + +/** + * Revocation has to bite on the live socket, not just on the next request: an + * operator booting a phone off the show watched it keep painting lasers. + */ +interface Client { + ws: WebSocket; + /** Resolves with the close code once the brain drops us. */ + closed: Promise<{ code: number; reason: string }>; +} + +/** Every client opened here, so none is left holding the event loop open. */ +const opened: WebSocket[] = []; + +function connect(port: number, token: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`); + opened.push(ws); + const closed = new Promise<{ code: number; reason: string }>((r) => { + ws.on('close', (code, reason) => r({ code, reason: reason.toString() })); + }); + ws.on('open', () => resolve({ ws, closed })); + ws.on('error', reject); + }); +} + +const wait = (ms: number): Promise => new Promise((r) => setTimeout(() => r(), ms)); + +/** The close event, or null if the socket is still up after `ms`. */ +async function closeWithin( + client: Client, + ms: number +): Promise<{ code: number; reason: string } | null> { + const timeout = wait(ms).then((): null => null); + return Promise.race([client.closed, timeout]); +} + +describe('session revocation', () => { + const saved = { ...process.env }; + let handle: ServerHandle; + let port: number; + let base: string; + + async function login(username: string, password: string): Promise { + const res = await fetch(`${base}/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const body = await res.json(); + expect(body.token).toBeTruthy(); + return body.token as string; + } + + beforeAll(async () => { + const store = mkdtempSync(join(tmpdir(), 'wg-revoke-store-')); + const state = mkdtempSync(join(tmpdir(), 'wg-revoke-state-')); + process.env.APPSTASH_BASE_DIR = store; + process.env.WAVEGRID_PROJECT = 'demo'; + process.env.WG_STATE_DIR = state; + process.env.WG_JWT_SECRET = 'revoke-test-secret'; + // The heartbeat carries the revocation sweep; keep it short so the + // out-of-process case doesn't wait out a show-tuned 15s. + process.env.WG_HEARTBEAT_MS = '50'; + delete process.env.WAVEGRID_LAYOUT; + delete process.env.WAVEGRID_MODE; + delete process.env.LIGHT_MAP_CONFIG; + + const s = openStore(); + s.createProject('demo', { + layout: { preset: 'grid-7x7' }, + server: { host: '127.0.0.1', port: 0 } + }); + s.setActiveProject('demo'); + s.addUser('demo', 'boss', 'bosspw'); // first user → admin + s.addUser('demo', 'alice', 'alicepw'); + s.addUser('demo', 'bob', 'bobpw'); + + handle = startServer(loadWavegridConfig(), { uiDir: null, advertise: false }); + await handle.ready; + const addr = handle.server.address(); + port = typeof addr === 'object' && addr ? addr.port : 0; + base = `http://127.0.0.1:${port}`; + }); + + afterAll(() => { + for (const ws of opened) ws.terminate(); + handle.stop(); + process.env = { ...saved }; + }); + + it('drops the revoked user’s live socket and leaves everyone else connected', async () => { + const [adminToken, aliceToken, bobToken] = await Promise.all([ + login('boss', 'bosspw'), + login('alice', 'alicepw'), + login('bob', 'bobpw') + ]); + const alice = await connect(port, aliceToken); + const bob = await connect(port, bobToken); + + const aliceSession = openStore() + .listSessions('demo') + .find((s) => s.username === 'alice'); + expect(aliceSession).toBeDefined(); + + const res = await fetch(`${base}/api/admin/sessions/${aliceSession!.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminToken}` } + }); + expect(res.status).toBe(200); + + const closed = await closeWithin(alice, 2000); + expect(closed?.code).toBe(4001); + expect(closed?.reason).toBe('session revoked'); + expect(await closeWithin(bob, 200)).toBeNull(); + + // Her token is finished for HTTP too, and can't open a new socket. + const me = await fetch(`${base}/api/me`, { + headers: { Authorization: `Bearer ${aliceToken}` } + }); + expect(me.status).toBe(401); + await expect(connect(port, aliceToken)).rejects.toThrow(/401/); + + // Bob is untouched: still authenticated on HTTP, still driving the show. + const bobMe = await fetch(`${base}/api/me`, { + headers: { Authorization: `Bearer ${bobToken}` } + }); + expect(bobMe.status).toBe(200); + bob.ws.close(); + }); + + it('drops a socket when the session is revoked out-of-process (desktop app)', async () => { + const token = await login('bob', 'bobpw'); + const client = await connect(port, token); + + // The desktop app revokes by writing the same store — no HTTP call to hook. + expect(openStore().revokeUserSessions('demo', 'bob')).toBeGreaterThan(0); + + const closed = await closeWithin(client, 2000); + expect(closed?.code).toBe(4001); + }); + + it('signing out ends the session, so the token is dead everywhere', async () => { + const token = await login('alice', 'alicepw'); + const client = await connect(port, token); + + const res = await fetch(`${base}/api/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }); + expect(res.status).toBe(200); + + expect(openStore().listSessions('demo').some((s) => s.username === 'alice')).toBe(false); + expect((await closeWithin(client, 2000))?.code).toBe(4001); + const me = await fetch(`${base}/api/me`, { headers: { Authorization: `Bearer ${token}` } }); + expect(me.status).toBe(401); + }); +}); diff --git a/packages/server/src/http-app.ts b/packages/server/src/http-app.ts index 4bfd3a0..405f807 100644 --- a/packages/server/src/http-app.ts +++ b/packages/server/src/http-app.ts @@ -15,6 +15,12 @@ import { signJwt, verifyJwt } from './jwt'; export interface HttpAppOptions { /** Directory of the built UI (Vite `dist`). Static serving is skipped if unset/missing. */ uiDir?: string | null; + /** + * Called after sessions were revoked through the API, so the embedding server + * can drop the sockets those sessions are still holding open instead of + * waiting for its next sweep. + */ + onSessionsRevoked?: () => void; } const MIME: Record = { @@ -219,6 +225,7 @@ function normalizeLightMap( */ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = {}) { const layout = resolved.layout; + const sessionsRevoked = () => opts.onSessionsRevoked?.(); const uiDir = opts.uiDir && fs.existsSync(opts.uiDir) ? opts.uiDir : null; const dims = { numCannons: layout.count, gridColumns: layout.cols }; @@ -307,8 +314,8 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { return; } // Record a cheap server-side session and bind the JWT to it, so admins - // can see who's logged in and revoke on the next refresh. Sockets are - // untouched — the token remains the only credential they check. + // can see who's logged in and revoke it. The socket opened with this + // token is bound to the same session and dies with it. const session = store.createSession(project, { username: user.username, role: user.role, @@ -331,6 +338,22 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { return; } + // ── POST /api/logout — end my own session ─────────────────────── + // Signing out has to reach the server: the token alone stays valid until it + // expires, and the socket it opened would keep running as that user. + if (pathname === '/api/logout' && method === 'POST') { + const token = bearerToken(req, url); + const payload = token ? verifyJwt(token) : null; + const project = activeProject(); + if (payload?.sid && project && openStore().revokeSession(project, payload.sid)) { + sessionsRevoked(); + } + // Never an error: a client that is throwing its token away is done either + // way, and telling it otherwise would only strand it signed in. + sendJson(res, 200, { ok: true }); + return; + } + // ── GET /api/me — who am I (any valid token) ──────────────────── if (pathname === '/api/me' && method === 'GET') { const token = bearerToken(req, url); @@ -371,6 +394,7 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { const caller = requireAdmin(req, url, res); if (!caller) return; const removed = openStore().revokeSession(caller.project, decodeURIComponent(m[1])); + if (removed) sessionsRevoked(); sendJson(res, removed ? 200 : 404, { ok: removed }); return; } @@ -440,7 +464,10 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { const store = openStore(); try { const removed = store.removeUser(caller.project, username); - if (removed) store.revokeUserSessions(caller.project, username); + if (removed) { + store.revokeUserSessions(caller.project, username); + sessionsRevoked(); + } sendJson(res, removed ? 200 : 404, { ok: removed }); } catch (e) { sendJson(res, 400, { ok: false, error: (e as Error).message }); diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index aed6aee..a55c8e9 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -1,5 +1,11 @@ export const OPEN_READY_STATE = 1; +/** Application close code for "your session is gone". An application code + * survives where a rejected handshake doesn't: browsers flatten those to 1006, + * so only a post-handshake close can tell the client why it was dropped. */ +export const WS_CLOSE_SESSION_REVOKED = 4001; +export const WS_REASON_SESSION_REVOKED = 'session revoked'; + export interface HubSocket { readyState: number; send(payload: string): void; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index b3d3da1..d04dc3c 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -12,7 +12,7 @@ import { computeCoverage } from './coverage'; import type { BlendMode, CannonState, Orientation, Rotation } from './grid'; import {compositeLayer, createGrid, DEFAULT_ALPHA, defaultOrientation, mapUiToGrid, remapGridForUi, resetGrid, setAllTargets, setCannonTarget, shiftGrid, tickGrid } from './grid'; import { createHttpApp, lanVisitors, resolveUiDir } from './http-app'; -import { fanout, type LivenessState,selectRevokedSockets, sweepLiveness } from './hub'; +import { fanout, type LivenessState,selectRevokedSockets, sweepLiveness, WS_CLOSE_SESSION_REVOKED, WS_REASON_SESSION_REVOKED } from './hub'; import type { JwtPayload } from './jwt'; import { verifyJwt } from './jwt'; import { ServerPatternEngine } from './pattern-engine'; @@ -187,7 +187,11 @@ if (restored) { // One origin: static UI + JSON API on the same port the WebSocket upgrades on. const uiDir = opts.uiDir !== undefined ? opts.uiDir : resolveUiDir(); -const httpApp = createHttpApp(resolved, { uiDir }); +const httpApp = createHttpApp(resolved, { + uiDir, + // An admin revoking through the API shouldn't have to wait for the sweep. + onSessionsRevoked: () => disconnectRevokedSessions() +}); const server = http.createServer((req, res) => { httpApp(req, res).catch((err) => { console.error(' ◈ HTTP error:', err instanceof Error ? err.message : String(err)); @@ -207,8 +211,8 @@ server.on('upgrade', (req, socket, head) => { const token = reqUrl.searchParams.get('token'); const key = reqUrl.searchParams.get('key'); - // Require either a valid JWT token or a valid receiver key. - // Connections with neither are rejected. + // Require either a valid JWT token whose session is still live, or a valid + // receiver key. Connections with neither are rejected. if (token) { const payload = verifyJwt(token); if (!payload) { @@ -260,12 +264,30 @@ function revokeClient(ws: WebSocket): void { clients.delete(ws); liveness.delete(ws); try { - ws.close(4001, 'session revoked'); + ws.close(WS_CLOSE_SESSION_REVOKED, WS_REASON_SESSION_REVOKED); } catch { ws.terminate(); } } +/** + * Close every socket whose session record is gone. Called on the heartbeat — + * revocation also happens in another process (the desktop app writes the same + * store), so the session row is the only thing that can tell us — and directly + * from the API routes that revoke, so an operator booting someone off doesn't + * wait out a heartbeat. + */ +function disconnectRevokedSessions(): void { + const target = resolveSyncTarget(); + if (!target) return; + try { + const live = new Set(target.store.listSessions(target.project).map((session) => session.id)); + for (const ws of selectRevokedSockets(clients, (sid) => live.has(sid))) revokeClient(ws); + } catch { + // Session checks are best-effort when the project store is unavailable. + } +} + // Broadcast the current grid snapshot to all UI clients. // Used for calibration, orientation changes, and paint/clear — so the // browser UI preview stays up-to-date. Receivers ignore these messages @@ -984,15 +1006,7 @@ const heartbeatTimer = setInterval(() => { dropClient ); - const target = resolveSyncTarget(); - if (!target) return; - try { - const liveSessionIds = new Set(target.store.listSessions(target.project).map((session) => session.id)); - const revoked = selectRevokedSockets(clients, (sid) => liveSessionIds.has(sid)); - for (const ws of revoked) revokeClient(ws); - } catch { - // Session checks are best-effort when the project store is unavailable. - } + disconnectRevokedSessions(); }, heartbeatIntervalMs); let advertiseHandle: AdvertiseHandle | null = null; diff --git a/packages/settings/src/sessions.ts b/packages/settings/src/sessions.ts index debea5f..97d56e2 100644 --- a/packages/settings/src/sessions.ts +++ b/packages/settings/src/sessions.ts @@ -8,8 +8,8 @@ import type { UserRole } from './users'; * A lightweight, server-visible record of a logged-in UI user. This is *not* a * new auth protocol — the JWT minted at login stays the credential. A session * is just a cheap, bounded row so an admin can answer "who's logged in?" and - * revoke access; sockets are untouched. Revoking a session takes effect on the - * next token refresh (JWTs are short-lived), never by kicking an open socket. + * revoke access. Deleting the row is what revocation means: the server rejects + * requests carrying the matching `sid` and closes the socket opened with it. */ export interface Session { /** Opaque session id; also carried as the JWT `sid` claim. */ diff --git a/packages/ui/__tests__/auth-storage.test.ts b/packages/ui/__tests__/auth-storage.test.ts new file mode 100644 index 0000000..f190f28 --- /dev/null +++ b/packages/ui/__tests__/auth-storage.test.ts @@ -0,0 +1,72 @@ +import { + clearCredentials, + type CredentialStore, + readLastUser, + readToken, + saveCredentials +} from '../src/lib/auth-storage'; +import { endSessionOnServer } from '../src/lib/use-auth'; + +/** An in-memory stand-in for `localStorage`. */ +function fakeStore(initial: Record = {}): CredentialStore & { + data: Record; +} { + const data = { ...initial }; + return { + data, + getItem: (k) => (k in data ? data[k] : null), + setItem: (k, v) => { + data[k] = v; + }, + removeItem: (k) => { + delete data[k]; + } + }; +} + +describe('credential storage', () => { + it('keeps the token and the username for the next sign-in', () => { + const store = fakeStore(); + saveCredentials('ada', 'tok-1', store); + expect(readToken(store)).toBe('tok-1'); + expect(readLastUser(store)).toBe('ada'); + }); + + it('drops the token on sign-out but still prefills who was here', () => { + const store = fakeStore({ wg_token: 'tok-1', wg_user: 'ada', wg_last_user: 'ada' }); + clearCredentials(store); + expect(readToken(store)).toBeNull(); + expect(store.data.wg_user).toBeUndefined(); + expect(readLastUser(store)).toBe('ada'); + }); + + it('survives a store that is not there (SSR / no window)', () => { + expect(() => clearCredentials(null)).not.toThrow(); + expect(readToken(null)).toBeNull(); + expect(readLastUser(null)).toBe(''); + }); +}); + +describe('endSessionOnServer', () => { + it('tells the brain to revoke this session, with the bearer token', async () => { + const calls: [string, RequestInit | undefined][] = []; + const post = (async (url: string, init?: RequestInit) => { + calls.push([url, init]); + return { ok: true } as Response; + }) as unknown as typeof fetch; + + await endSessionOnServer('tok-1', post); + expect(calls).toHaveLength(1); + const [url, init] = calls[0]; + expect(url).toBe('/api/logout'); + expect(init?.method).toBe('POST'); + expect((init?.headers as Record).Authorization).toBe('Bearer tok-1'); + }); + + it('never throws when the network is gone — signing out still has to land', async () => { + const post = (async () => { + throw new Error('offline'); + }) as unknown as typeof fetch; + await expect(endSessionOnServer('tok-1', post)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/ui/__tests__/connection.test.ts b/packages/ui/__tests__/connection.test.ts index 3d47a9d..264988b 100644 --- a/packages/ui/__tests__/connection.test.ts +++ b/packages/ui/__tests__/connection.test.ts @@ -1,9 +1,11 @@ import { connectionLabel, diagnoseConnection, + isSessionEndedCode, OPEN_CONNECTION, type Probe, - retryDelay + retryDelay, + WS_CLOSE_SESSION_REVOKED } from '../src/lib/connection'; /** A probe returning a fixed status per path, or throwing for "unreachable". */ @@ -63,6 +65,24 @@ describe('diagnoseConnection', () => { }); }); +describe('a session ended by an admin', () => { + it('is recognised from the close code alone, without probing', async () => { + const probe: Probe = () => { + throw new Error('should not probe'); + }; + const { cause, detail } = await diagnoseConnection(probe, WS_CLOSE_SESSION_REVOKED, 'tok'); + expect(cause).toBe('sessionExpired'); + expect(detail).toMatch(/administrator/i); + }); + + it('is the only close code that means "stop reconnecting"', () => { + expect(isSessionEndedCode(WS_CLOSE_SESSION_REVOKED)).toBe(true); + expect(isSessionEndedCode(1006)).toBe(false); + expect(isSessionEndedCode(1000)).toBe(false); + expect(isSessionEndedCode(null)).toBe(false); + }); +}); + describe('retryDelay', () => { it('backs off from half a second and caps at ten', () => { expect(retryDelay(1)).toBe(500); diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index 9d67b22..f31376d 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -459,7 +459,7 @@ function MasterSliders({ export default function Home() { const [configRev, setConfigRev] = useState(0); const config = useConfig(configRev); - const { user, token, checked, endedSession, lastUser, login, sessionEnded } = useAuth(); + const { user, token, checked, endedSession, lastUser, login, logout, sessionEnded } = useAuth(); const { connection, grid, orientation, playlistState, settings, epoch, send } = useSocket( config?.simulatorUrl ?? null, token, @@ -987,6 +987,27 @@ export default function Home() { {viewFlip ? 'My View' : 'Sky View'} +
+ {user} + +
)} diff --git a/packages/ui/src/lib/auth-storage.ts b/packages/ui/src/lib/auth-storage.ts new file mode 100644 index 0000000..9bd387b --- /dev/null +++ b/packages/ui/src/lib/auth-storage.ts @@ -0,0 +1,50 @@ +/** + * Where this device keeps its credential. The JWT is the whole credential, so + * "signed in" means nothing more than "a token is in local storage" — signing + * out or losing a session has to remove it, or the next reload walks straight + * back into a dead session. + */ + +const TOKEN_KEY = 'wg_token'; +/** Older builds cached the username alongside the token; still cleared. */ +const LEGACY_USER_KEY = 'wg_user'; +/** Last username signed in on this device — prefilled after a session ends so + * getting back in is one field, not two. */ +const LAST_USER_KEY = 'wg_last_user'; + +/** The slice of `localStorage` this module needs, so tests can pass a fake. */ +export interface CredentialStore { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +function browserStore(): CredentialStore | null { + return typeof window === 'undefined' ? null : window.localStorage; +} + +export function saveCredentials( + username: string, + token: string, + store: CredentialStore | null = browserStore() +): void { + if (!store) return; + store.setItem(TOKEN_KEY, token); + store.setItem(LAST_USER_KEY, username); + store.removeItem(LEGACY_USER_KEY); +} + +/** Forget the credential, keeping only the username to prefill the login form. */ +export function clearCredentials(store: CredentialStore | null = browserStore()): void { + if (!store) return; + store.removeItem(TOKEN_KEY); + store.removeItem(LEGACY_USER_KEY); +} + +export function readToken(store: CredentialStore | null = browserStore()): string | null { + return store?.getItem(TOKEN_KEY) ?? null; +} + +export function readLastUser(store: CredentialStore | null = browserStore()): string { + return store?.getItem(LAST_USER_KEY) ?? ''; +} diff --git a/packages/ui/src/lib/connection.ts b/packages/ui/src/lib/connection.ts index 8c4c7d2..c371526 100644 --- a/packages/ui/src/lib/connection.ts +++ b/packages/ui/src/lib/connection.ts @@ -27,6 +27,14 @@ export interface ConnectionInfo { attempts: number; } +/** The brain closes a socket with this code when its session was revoked. */ +export const WS_CLOSE_SESSION_REVOKED = 4001; + +/** A close code that means "this token is finished" — never worth retrying. */ +export function isSessionEndedCode(code: number | null): boolean { + return code === WS_CLOSE_SESSION_REVOKED; +} + export const OPEN_CONNECTION: ConnectionInfo = { state: 'open', cause: 'unknown', @@ -47,6 +55,14 @@ export async function diagnoseConnection( code: number | null, token: string | null ): Promise<{ cause: ConnectionCause; detail: string }> { + // The brain said why it closed, so there is nothing to probe for. + if (isSessionEndedCode(code)) { + return { + cause: 'sessionExpired', + detail: 'Your session was ended by an administrator — sign in again.' + }; + } + let config: { ok: boolean; status: number }; try { config = await probe('/api/config'); diff --git a/packages/ui/src/lib/use-auth.ts b/packages/ui/src/lib/use-auth.ts index 75ef349..8197682 100644 --- a/packages/ui/src/lib/use-auth.ts +++ b/packages/ui/src/lib/use-auth.ts @@ -1,5 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; +import { + clearCredentials, + readLastUser, + readToken, + saveCredentials +} from '@/lib/auth-storage'; + function decodePayload(token: string): { sub: string } | null { try { const parts = token.split('.'); @@ -31,9 +38,21 @@ export function takeTokenFromUrl(): string | null { return decodeURIComponent(match[1]); } -/** Last username signed in on this device — prefilled after a session ends so - * getting back in is one field, not two. */ -const LAST_USER_KEY = 'wg_last_user'; +/** + * Ask the brain to revoke this session. Best effort: the client is signing out + * regardless, and a device that just lost the network must still land on the + * login screen rather than hang on a failed request. + */ +export async function endSessionOnServer( + token: string, + post: typeof fetch = fetch +): Promise { + try { + await post('/api/logout', { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); + } catch { + // ignore + } +} export function useAuth() { const [user, setUser] = useState(null); @@ -48,53 +67,53 @@ export function useAuth() { if (handed) { const payload = decodePayload(handed); if (payload) { - localStorage.setItem('wg_token', handed); - localStorage.setItem(LAST_USER_KEY, payload.sub); + saveCredentials(payload.sub, handed); setUser(payload.sub); setToken(handed); setChecked(true); return; } } - const stored = localStorage.getItem('wg_token'); + const stored = readToken(); if (stored) { const payload = decodePayload(stored); if (payload) { setUser(payload.sub); setToken(stored); } else { - localStorage.removeItem('wg_token'); - localStorage.removeItem('wg_user'); + clearCredentials(); } } setChecked(true); }, []); const login = useCallback((username: string, jwt: string) => { - localStorage.setItem('wg_token', jwt); - localStorage.setItem(LAST_USER_KEY, username); - localStorage.removeItem('wg_user'); + saveCredentials(username, jwt); setToken(jwt); setUser(username); setEndedSession(false); }, []); const clear = useCallback((ended: boolean) => { - localStorage.removeItem('wg_token'); - localStorage.removeItem('wg_user'); + clearCredentials(); setToken(null); setUser(null); setEndedSession(ended); }, []); - const logout = useCallback(() => clear(false), [clear]); + /** Sign out here and end the session on the brain, so neither the token nor + * the socket it opened outlives the button press. */ + const logout = useCallback(() => { + if (token) void endSessionOnServer(token); + clear(false); + }, [clear, token]); /** Drop a token the server no longer accepts (expired, revoked, or issued for * another project) and fall back to the login screen — a dead token can only * ever reconnect into the same error. */ const sessionEnded = useCallback(() => clear(true), [clear]); - const lastUser = typeof window === 'undefined' ? '' : localStorage.getItem(LAST_USER_KEY) ?? ''; + const lastUser = readLastUser(); return { user, token, checked, endedSession, lastUser, login, logout, sessionEnded }; } diff --git a/packages/ui/src/lib/use-socket.ts b/packages/ui/src/lib/use-socket.ts index d8b4cd4..8f2cea6 100644 --- a/packages/ui/src/lib/use-socket.ts +++ b/packages/ui/src/lib/use-socket.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type ConnectionInfo, diagnoseConnection, + isSessionEndedCode, OPEN_CONNECTION, retryDelay } from '@/lib/connection'; @@ -73,7 +74,9 @@ export function useSocket( void diagnoseConnection(probe, e.code, token).then(({ cause, detail }) => { if (!disposed) setConnection({ state: 'down', cause, detail, code: e.code, attempts }); }); - retry = setTimeout(connect, retryDelay(attempts)); + // A revoked session can only reconnect into the same rejection, so stop + // hammering the brain and let the app hand back the login screen. + if (!isSessionEndedCode(e.code)) retry = setTimeout(connect, retryDelay(attempts)); }; ws.onerror = () => ws.close(); From b97d1b5834537772c55b0be5efb5f2ce2a2cfc87 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 01:44:56 +0000 Subject: [PATCH 2/2] fix(osc): default the BEYOND host to 127.0.0.1 --- packages/cli/__tests__/osc.test.ts | 15 ++++++++++++++- packages/cli/src/commands/osc.ts | 6 +++--- packages/desktop/__tests__/osc-target.test.ts | 14 +++++++++++--- packages/desktop/src/main/osc-target.ts | 13 +++++++------ 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/cli/__tests__/osc.test.ts b/packages/cli/__tests__/osc.test.ts index 81e446f..cc4dea1 100644 --- a/packages/cli/__tests__/osc.test.ts +++ b/packages/cli/__tests__/osc.test.ts @@ -43,6 +43,18 @@ describe('runOscSetup', () => { }); }); + it('defaults a BEYOND target to this machine when no host is given', async () => { + isolate(); + const store = getStore(); + store.createProject('local-show', { layout: { preset: 'ring-6' } }); + + await runOscSetup('beyond', {}); + + expect(store.getProjectConfig('local-show')?.osc).toEqual({ + beyond: { host: '127.0.0.1', port: 8000, gridOrder: 'row' } + }); + }); + it('honors explicit port + grid order', async () => { isolate(); const store = getStore(); @@ -79,7 +91,8 @@ describe('runOscSetup', () => { isolate(); getStore().createProject('p', { layout: { preset: 'ring-6' } }); process.exitCode = 0; - await runOscSetup('beyond', {}); + // FB4 is its own box on the network — unlike BEYOND, there is nothing to assume. + await runOscSetup('fb4', {}); expect(process.exitCode).toBe(1); process.exitCode = 0; }); diff --git a/packages/cli/src/commands/osc.ts b/packages/cli/src/commands/osc.ts index 01b6c0a..5bc3705 100644 --- a/packages/cli/src/commands/osc.ts +++ b/packages/cli/src/commands/osc.ts @@ -38,7 +38,7 @@ function confirm(project: string, osc: OscConfig | undefined): void { const USAGE = [ ' Usage:', ' wavegrid projects osc (interactive wizard)', - ` wavegrid projects osc beyond --host [--port ${DEFAULT_BEYOND_PORT}] [--grid-order row|column]`, + ` wavegrid projects osc beyond [--host ${LOOPBACK_HOST}] [--port ${DEFAULT_BEYOND_PORT}] [--grid-order row|column]`, ` wavegrid projects osc fb4 --host [--port ${DEFAULT_FB4_PORT}]`, ' wavegrid projects osc routing --file ', ' wavegrid projects osc show', @@ -60,8 +60,8 @@ function num(flags: Flags, key: string): number | undefined { /** Non-interactive setters (no TTY / scripted). Return true on success. */ function applyFromFlags(flags: Flags, kind: string): boolean { if (kind === 'beyond') { - const host = normalizeOscHost(str(flags, 'host') ?? ''); - if (!host) return false; + // BEYOND normally runs on this machine, so `--host` is optional. + const host = normalizeOscHost(str(flags, 'host') ?? '') || LOOPBACK_HOST; const port = num(flags, 'port') ?? DEFAULT_BEYOND_PORT; const gridOrder = str(flags, 'grid-order') === 'column' ? 'column' : 'row'; const project = save(flags, (config) => { diff --git a/packages/desktop/__tests__/osc-target.test.ts b/packages/desktop/__tests__/osc-target.test.ts index 9b50dd8..787d0e4 100644 --- a/packages/desktop/__tests__/osc-target.test.ts +++ b/packages/desktop/__tests__/osc-target.test.ts @@ -3,7 +3,7 @@ import type { OscTarget } from '@/types/ipc'; const NONE: OscTarget = { kind: 'none', - host: '', + host: '127.0.0.1', port: 7001, gridOrder: 'row', file: '', @@ -16,6 +16,13 @@ describe('toOscTarget', () => { expect(toOscTarget({}).kind).toBe('none'); }); + it('offers this machine as the host when nothing is configured', () => { + // BEYOND usually runs on the show laptop, and a blank field only ever + // produced a save error. + expect(toOscTarget(null).host).toBe('127.0.0.1'); + expect(toOscTarget({ osc: { routingConfig: '/tmp/routing.json' } }).host).toBe('127.0.0.1'); + }); + it('reads a BEYOND target with its grid order', () => { const t = toOscTarget({ osc: { beyond: { host: '10.0.0.2', port: 7001, gridOrder: 'column' } } }); expect(t).toMatchObject({ kind: 'beyond', host: '10.0.0.2', port: 7001, gridOrder: 'column' }); @@ -64,9 +71,10 @@ describe('applyOscTarget', () => { expect(out.ui).toEqual({ port: 4000 }); }); - it('trims the host and rejects an empty one', () => { + it('trims the host, defaulting BEYOND to this machine', () => { expect(applyOscTarget(null, { ...NONE, kind: 'beyond', host: ' 10.0.0.2 ' }).osc?.beyond?.host).toBe('10.0.0.2'); - expect(() => applyOscTarget(null, { ...NONE, kind: 'beyond', host: ' ' })).toThrow(/BEYOND needs/); + expect(applyOscTarget(null, { ...NONE, kind: 'beyond', host: ' ' }).osc?.beyond?.host).toBe('127.0.0.1'); + // FB4 is separate hardware — there is no sane local default to guess. expect(() => applyOscTarget(null, { ...NONE, kind: 'fb4', host: '' })).toThrow(/FB4 needs/); expect(() => applyOscTarget(null, { ...NONE, kind: 'routing', file: '' })).toThrow(/routing JSON/); }); diff --git a/packages/desktop/src/main/osc-target.ts b/packages/desktop/src/main/osc-target.ts index 9ed2b48..cd81f46 100644 --- a/packages/desktop/src/main/osc-target.ts +++ b/packages/desktop/src/main/osc-target.ts @@ -4,6 +4,7 @@ import { DEFAULT_BEYOND_PORT, DEFAULT_FB4_PORT, + LOOPBACK_HOST, normalizeOscHost, type OscConfig } from '@wavegrid/layout'; @@ -39,16 +40,18 @@ export function toOscTarget(stored: ProjectConfig | null): OscTarget { if (osc.routingConfig) { return { kind: 'routing', - host: '', + host: LOOPBACK_HOST, port: DEFAULT_BEYOND_PORT, gridOrder: 'row', file: osc.routingConfig, hasUnifiedRouting: osc.routing != null }; } + // Nothing stored yet: BEYOND almost always runs on this laptop, so offer + // loopback rather than a blank field that saves as an error. return { kind: 'none', - host: '', + host: LOOPBACK_HOST, port: DEFAULT_BEYOND_PORT, gridOrder: 'row', file: '', @@ -69,10 +72,8 @@ export function applyOscTarget(existing: ProjectConfig | null, target: OscTarget const keep = routing ? { routing } : {}; if (target.kind === 'beyond') { - const host = normalizeOscHost(target.host); - if (!host) { - throw new Error('BEYOND needs the host running BEYOND — 127.0.0.1 for this laptop, or its LAN IP.'); - } + // An empty field means "the usual setup": BEYOND on this machine. + const host = normalizeOscHost(target.host) || LOOPBACK_HOST; return { ...prev, osc: {