From 7dc65c1edc84f84744ac1470cae393389f1f4df7 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 21 Sep 2026 07:46:01 +0000 Subject: [PATCH] fix(desktop): authenticate the embedded UI against a joined brain; isolate the store in desktop tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operatorToken embedded a sid from the local session store, which a remote brain validates against its own store — always rejected, so the artist UI showed a login screen even with imported secrets. Mint a sid-less token in receiver-only mode (TTL-bounded, not revocable). Desktop tests now run against a temp APPSTASH_BASE_DIR (jest setupFiles) instead of the developer's real ~/.wavegrid. deleteProject also removes the project's secrets file. Co-authored-by: Dan Lynch --- .../__tests__/operator-session.test.ts | 32 +++++++++++++++++++ .../desktop/__tests__/receiver-env.test.ts | 7 ++++ packages/desktop/jest.config.js | 1 + packages/desktop/jest.setup.js | 8 +++++ packages/desktop/src/main/operator-session.ts | 21 ++++++++++-- packages/settings/__tests__/store.test.ts | 12 ++++++- packages/settings/src/projects.ts | 2 ++ 7 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/jest.setup.js diff --git a/packages/desktop/__tests__/operator-session.test.ts b/packages/desktop/__tests__/operator-session.test.ts index 4169dce..5590488 100644 --- a/packages/desktop/__tests__/operator-session.test.ts +++ b/packages/desktop/__tests__/operator-session.test.ts @@ -1,8 +1,18 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { verifyJwt } from '@wavegrid/server'; import { openStore } from '@wavegrid/settings'; import { embeddedUrl, operatorToken } from '../src/main/operator-session'; +// `openStore()` with no base dir is the developer's own ~/.wavegrid. These tests +// create projects, generate secrets and can flip the active project, so without +// an isolated store a test run rewrites the machine's real setup — a laptop +// mid-show ends up pointed at a fixture. Pin the store to this run's temp dir. +process.env.APPSTASH_BASE_DIR = mkdtempSync(join(tmpdir(), 'wavegrid-operator-session-')); + const PROJECT = 'desk-auth'; beforeAll(() => { @@ -41,6 +51,28 @@ describe('desktop operator session', () => { expect(embeddedUrl('http://127.0.0.1:3000', 'no-users')).toBe('http://127.0.0.1:3000'); }); + it('omits the sid when the project joins a remote brain', () => { + // A joined brain validates `sid` against its own session store, so a + // locally-minted session id gets the token rejected outright. The token + // has to stand on its signature alone. + const store = openStore(); + const remote = 'joined-brain'; + store.createProject(remote, { preset: 'ring-6' }); + store.generateSecrets(remote); + store.addUser(remote, 'dan', 'hunter2', 'admin'); + store.saveProjectConfig(remote, { + layout: { preset: 'ring-6' }, + receiver: { server: 'wss://grace.hipzap.com' } + }); + + const payload = verifyJwt(operatorToken(remote)!); + expect(payload?.sub).toBe('dan'); + expect(payload?.role).toBe('admin'); + expect(payload?.sid).toBeUndefined(); + // No phantom session left behind for a brain that never saw it. + expect(store.listSessions(remote)).toHaveLength(0); + }); + it('hands the token off in the fragment, never the query', () => { const url = embeddedUrl('http://127.0.0.1:3000', PROJECT); expect(url.startsWith('http://127.0.0.1:3000#wg_token=')).toBe(true); diff --git a/packages/desktop/__tests__/receiver-env.test.ts b/packages/desktop/__tests__/receiver-env.test.ts index 306a74e..130f7bf 100644 --- a/packages/desktop/__tests__/receiver-env.test.ts +++ b/packages/desktop/__tests__/receiver-env.test.ts @@ -23,6 +23,13 @@ import { applyReceiverEnv, resolveProjectConfig } from '@/main/receiver-env'; const root = mkdtempSync(join(tmpdir(), 'wavegrid-receiver-env-')); +// The loader's `userStash` layer is a real file — ~/.wavegrid/config/config.json, +// the active project's mirror. Without an isolated base dir these tests read +// whoever's machine they run on, and a developer whose active project has joined +// a brain sees `SIMULATOR_URL` come back as that brain instead of the local +// default. Pin the store to this run's temp dir so the layer is empty. +process.env.APPSTASH_BASE_DIR = root; + /** Just the store surface the receiver env needs. */ const store = { requireSecret: () => 'receiver-key', diff --git a/packages/desktop/jest.config.js b/packages/desktop/jest.config.js index af35253..8a72a69 100644 --- a/packages/desktop/jest.config.js +++ b/packages/desktop/jest.config.js @@ -12,6 +12,7 @@ module.exports = { ] }, transformIgnorePatterns: [`/node_modules/*`], + setupFiles: ['/jest.setup.js'], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { diff --git a/packages/desktop/jest.setup.js b/packages/desktop/jest.setup.js new file mode 100644 index 0000000..1712b00 --- /dev/null +++ b/packages/desktop/jest.setup.js @@ -0,0 +1,8 @@ +// Every desktop suite gets an isolated settings store. Without this, openStore() +// and the config loader resolve to the developer's real ~/.wavegrid and a test +// run can create projects there or flip the active one. +const { mkdtempSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); + +process.env.APPSTASH_BASE_DIR = mkdtempSync(join(tmpdir(), 'wavegrid-desktop-test-')); diff --git a/packages/desktop/src/main/operator-session.ts b/packages/desktop/src/main/operator-session.ts index a833b3b..7bcd766 100644 --- a/packages/desktop/src/main/operator-session.ts +++ b/packages/desktop/src/main/operator-session.ts @@ -9,8 +9,11 @@ * read its secrets, add users and mint access keys — so requiring them to * re-type a password into their own machine protects nothing. * - * The session is a real store session with a recognisable user agent, so it - * appears in Access → Sessions and can be revoked like any other. + * Against a local brain the session is a real store session with a recognisable + * user agent, so it appears in Access → Sessions and can be revoked like any + * other. Against a joined remote brain there is no session to create — see + * `operatorToken` — so the token carries no `sid` and expires rather than being + * revoked. */ import { signJwt } from '@wavegrid/server'; import { openStore } from '@wavegrid/settings'; @@ -33,6 +36,20 @@ export function operatorToken(project: string): string | null { // the first start is signed with the same project's secret. process.env.WG_JWT_SECRET = store.requireSecret(project, 'jwtSecret'); + // Receiver-only mode: the brain is someone else's process, and it validates + // `sid` against *its* session store. A session we create here exists only in + // this laptop's store, so embedding its id gets the token rejected with + // "Session expired or revoked" — matching secrets are not enough. Both the + // brain's `/api/me` and its WebSocket upgrade skip the session lookup when + // there is no `sid`, so a sid-less token is the one that authenticates. + // It cannot be revoked from Access → Sessions; the TTL is what bounds it. + if (store.getProjectConfig(project)?.receiver?.server) { + return signJwt(account.username, { + role: account.role, + ttlSec: Math.floor(TTL_MS / 1000) + }); + } + const session = store.createSession(project, { username: account.username, role: account.role, diff --git a/packages/settings/__tests__/store.test.ts b/packages/settings/__tests__/store.test.ts index 9c42d02..6602a10 100644 --- a/packages/settings/__tests__/store.test.ts +++ b/packages/settings/__tests__/store.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { openStore } from '../src'; +import { openStore, projectSecretsFile } from '../src'; function tmpBase(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'wg-store-')); @@ -49,6 +49,16 @@ describe('SettingsStore projects', () => { expect(store.getActiveProject()).toBe('b'); expect(store.listProjects()).toEqual(['b']); }); + + it('deleting a project removes its secrets too', () => { + const store = openStore({ baseDir: tmpBase() }); + store.createProject('p', {}); + store.generateSecrets('p'); + const file = projectSecretsFile(store.paths, 'p'); + expect(fs.existsSync(file)).toBe(true); + store.deleteProject('p'); + expect(fs.existsSync(file)).toBe(false); + }); }); describe('SettingsStore secrets', () => { diff --git a/packages/settings/src/projects.ts b/packages/settings/src/projects.ts index de8b0c6..757bd95 100644 --- a/packages/settings/src/projects.ts +++ b/packages/settings/src/projects.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { projectConfigFile, projectDir, + projectSecretsFile, readJsonFile, type StorePaths, writeFileAtomic @@ -105,6 +106,7 @@ export function deleteProject(paths: StorePaths, name: string): boolean { writeRegistry(paths, reg); try { fs.rmSync(projectDir(paths, name), { recursive: true, force: true }); + fs.rmSync(projectSecretsFile(paths, name), { force: true }); } catch { /* best effort */ }