Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/cli/__tests__/osc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
});
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/osc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ip> [--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 <ip> [--port ${DEFAULT_FB4_PORT}]`,
' wavegrid projects osc routing --file <path-to-routing.json>',
' wavegrid projects osc show',
Expand All @@ -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) => {
Expand Down
14 changes: 11 additions & 3 deletions packages/desktop/__tests__/osc-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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' });
Expand Down Expand Up @@ -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/);
});
Expand Down
13 changes: 7 additions & 6 deletions packages/desktop/src/main/osc-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
DEFAULT_BEYOND_PORT,
DEFAULT_FB4_PORT,
LOOPBACK_HOST,
normalizeOscHost,
type OscConfig
} from '@wavegrid/layout';
Expand Down Expand Up @@ -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: '',
Expand All @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/src/renderer/lib/use-wavegrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ export interface WavegridApi {
sessions: {
/** Active (non-expired) UI login sessions for a project, newest first. */
list(project: string): Promise<SessionInfo[]>;
/** 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<SessionInfo[]>;
};
keys: {
Expand Down
166 changes: 166 additions & 0 deletions packages/server/__tests__/session-revocation.test.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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<void> => 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<string> {
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);
});
});
33 changes: 30 additions & 3 deletions packages/server/src/http-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading