diff --git a/packages/desktop/__tests__/main-externals.test.ts b/packages/desktop/__tests__/main-externals.test.ts index b0912e8..88729c0 100644 --- a/packages/desktop/__tests__/main-externals.test.ts +++ b/packages/desktop/__tests__/main-externals.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'fs'; +import { readdirSync, readFileSync } from 'fs'; import { join } from 'path'; /** @@ -16,12 +16,13 @@ const DESKTOP = join(__dirname, '..'); function mainImports(): string[] { // Read the source rather than importing it: this must reflect what Rollup // sees, not what a test bundler resolves. + // Every main-process source, discovered rather than listed — a new file that + // imports a @wavegrid package is exactly the case this guards. const files = [ 'src/main.ts', - 'src/main/brain.ts', - 'src/main/doctor.ts', - 'src/main/ipc.ts', - 'src/main/network.ts' + ...readdirSync(join(DESKTOP, 'src/main')) + .filter((f) => f.endsWith('.ts')) + .map((f) => `src/main/${f}`) ]; const found = new Set(); for (const file of files) { diff --git a/packages/desktop/__tests__/operator-session.test.ts b/packages/desktop/__tests__/operator-session.test.ts new file mode 100644 index 0000000..4169dce --- /dev/null +++ b/packages/desktop/__tests__/operator-session.test.ts @@ -0,0 +1,49 @@ +import { verifyJwt } from '@wavegrid/server'; +import { openStore } from '@wavegrid/settings'; + +import { embeddedUrl, operatorToken } from '../src/main/operator-session'; + +const PROJECT = 'desk-auth'; + +beforeAll(() => { + const store = openStore(); + store.createProject(PROJECT, { preset: 'ring-6' }); + store.generateSecrets(PROJECT); +}); + +describe('desktop operator session', () => { + it('mints a token the server accepts, bound to a revocable session', () => { + const store = openStore(); + store.addUser(PROJECT, 'dan', 'hunter2', 'admin'); + + const token = operatorToken(PROJECT); + expect(token).not.toBeNull(); + + const payload = verifyJwt(token!); + expect(payload?.sub).toBe('dan'); + expect(payload?.role).toBe('admin'); + // Visible in Access → Sessions, so it can be revoked like any other login. + const session = store.listSessions(PROJECT).find((s) => s.id === payload?.sid); + expect(session?.userAgent).toContain('wavegrid-desktop'); + }); + + it('prefers an admin over an operator account', () => { + const store = openStore(); + store.addUser(PROJECT, 'guest-op', 'hunter2', 'operator'); + expect(verifyJwt(operatorToken(PROJECT)!)?.sub).toBe('dan'); + }); + + it('falls back to the login screen when the project has no accounts', () => { + const store = openStore(); + store.createProject('no-users', { preset: 'ring-6' }); + store.generateSecrets('no-users'); + expect(operatorToken('no-users')).toBeNull(); + expect(embeddedUrl('http://127.0.0.1:3000', 'no-users')).toBe('http://127.0.0.1:3000'); + }); + + 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); + expect(url).not.toContain('?'); + }); +}); diff --git a/packages/desktop/src/main/laser-view.ts b/packages/desktop/src/main/laser-view.ts index f6656fa..d1a81af 100644 --- a/packages/desktop/src/main/laser-view.ts +++ b/packages/desktop/src/main/laser-view.ts @@ -8,6 +8,8 @@ */ import { shell, WebContentsView } from 'electron'; +import { status } from '@/main/brain'; +import { embeddedUrl } from '@/main/operator-session'; import { runtime } from '@/main/runtime'; import type { LaserSyncState } from '@/types/ipc'; @@ -27,8 +29,15 @@ export function resetLaserView(): void { * this the embedded UI keeps rendering the previous project's layout. */ export function invalidateLaserView(): void { + const previous = loadedUrl; loadedUrl = null; - if (view && !view.webContents.isDestroyed()) void view.webContents.reload(); + if (!view || view.webContents.isDestroyed() || !previous) return; + // Not `reload()`: the UI strips the session token out of the address once it + // has consumed it, so reloading would land on the new project's login screen. + void view.webContents.loadURL(embeddedUrl(previous, status().project)).catch(() => { + // Brain restarting — the next sync loads it. + }); + loadedUrl = previous; } function ensureView(): WebContentsView | null { @@ -65,7 +74,9 @@ export function syncLaser(state: LaserSyncState): void { if (!v) return; if (loadedUrl !== url) { loadedUrl = url; - void v.webContents.loadURL(url).catch(() => { + // Carries a session for the active project, so a project switch doesn't + // strand the operator on a login screen inside their own app. + void v.webContents.loadURL(embeddedUrl(url, status().project)).catch(() => { // Brain not up yet / refused — the renderer shows its own empty state. }); } diff --git a/packages/desktop/src/main/operator-session.ts b/packages/desktop/src/main/operator-session.ts new file mode 100644 index 0000000..a833b3b --- /dev/null +++ b/packages/desktop/src/main/operator-session.ts @@ -0,0 +1,59 @@ +/** + * Signing the embedded artist UI in, so switching projects doesn't ask the + * operator for a password every time. + * + * Sessions are per project by design (a token minted for project A is not + * valid once B is active) and that stays true for anything reaching the brain + * over the network. This shortcut applies only to the UI embedded in this + * desktop app: whoever is at the keyboard already owns the store — they can + * 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. + */ +import { signJwt } from '@wavegrid/server'; +import { openStore } from '@wavegrid/settings'; + +const DESKTOP_USER_AGENT = 'wavegrid-desktop (this laptop)'; +const TTL_MS = 12 * 60 * 60 * 1000; + +/** + * A token for the project's admin, or null when the project has no accounts + * yet — the embedded UI then shows its normal login screen. + */ +export function operatorToken(project: string): string | null { + const store = openStore(); + if (!store.hasProject(project)) return null; + const users = store.listUserInfos(project); + const account = users.find((u) => u.role === 'admin') ?? users[0]; + if (!account) return null; + + // The brain sets this when it starts; set it anyway so a token minted before + // the first start is signed with the same project's secret. + process.env.WG_JWT_SECRET = store.requireSecret(project, 'jwtSecret'); + + const session = store.createSession(project, { + username: account.username, + role: account.role, + ip: '127.0.0.1', + userAgent: DESKTOP_USER_AGENT, + ttlMs: TTL_MS + }); + return signJwt(account.username, { + sid: session.id, + role: account.role, + ttlSec: Math.floor(TTL_MS / 1000) + }); +} + +/** + * The URL the embedded view should load: the brain's own URL, carrying a + * one-shot token in the fragment. A fragment (never a query) keeps the token + * out of the server's logs, and the UI strips it from the address on arrival. + */ +export function embeddedUrl(url: string, project: string | null): string { + if (!project) return url; + const token = operatorToken(project); + return token ? `${url}#wg_token=${encodeURIComponent(token)}` : url; +} diff --git a/packages/ui/__tests__/hand-off-token.test.ts b/packages/ui/__tests__/hand-off-token.test.ts new file mode 100644 index 0000000..784ef44 --- /dev/null +++ b/packages/ui/__tests__/hand-off-token.test.ts @@ -0,0 +1,49 @@ +import { takeTokenFromUrl } from '../src/lib/use-auth'; + +interface FakeWindow { + location: { hash: string; pathname: string; search: string }; + history: { replaceState: (a: null, b: string, url: string) => void }; +} + +function fakeWindow(hash: string): FakeWindow { + const w: FakeWindow = { + location: { hash, pathname: '/', search: '' }, + history: { + replaceState: (_a, _b, url) => { + const i = url.indexOf('#'); + w.location.hash = i === -1 ? '' : url.slice(i); + } + } + }; + (globalThis as unknown as { window: FakeWindow }).window = w; + return w; +} + +afterEach(() => { + delete (globalThis as unknown as { window?: FakeWindow }).window; +}); + +describe('takeTokenFromUrl', () => { + it('reads the handed-off session and erases it from the address', () => { + const w = fakeWindow('#wg_token=abc.def.ghi'); + expect(takeTokenFromUrl()).toBe('abc.def.ghi'); + expect(w.location.hash).toBe(''); + }); + + it('decodes a percent-encoded token', () => { + fakeWindow('#wg_token=a%2Bb'); + expect(takeTokenFromUrl()).toBe('a+b'); + }); + + it('keeps any other fragment the UI owns', () => { + const w = fakeWindow('#tab=patterns&wg_token=abc'); + expect(takeTokenFromUrl()).toBe('abc'); + expect(w.location.hash).toBe('#tab=patterns'); + }); + + it('is a no-op without a handoff', () => { + const w = fakeWindow('#tab=patterns'); + expect(takeTokenFromUrl()).toBeNull(); + expect(w.location.hash).toBe('#tab=patterns'); + }); +}); diff --git a/packages/ui/src/lib/use-auth.ts b/packages/ui/src/lib/use-auth.ts index 601d4ff..75ef349 100644 --- a/packages/ui/src/lib/use-auth.ts +++ b/packages/ui/src/lib/use-auth.ts @@ -14,6 +14,23 @@ function decodePayload(token: string): { sub: string } | null { } } +/** + * A session handed to us in the address fragment by a trusted embedder (the + * desktop app, which owns the store this token was minted from). Consumed once + * and erased from the address — the token stays the only credential, so this is + * transport, not a privilege: the server validates it exactly as it would a + * token from the login form. + */ +export function takeTokenFromUrl(): string | null { + if (typeof window === 'undefined') return null; + const hash = window.location.hash.replace(/^#/, ''); + const match = /(?:^|&)wg_token=([^&]+)/.exec(hash); + if (!match) return null; + const rest = hash.replace(/(?:^|&)wg_token=[^&]+/, '').replace(/^&/, ''); + window.history.replaceState(null, '', window.location.pathname + window.location.search + (rest ? `#${rest}` : '')); + 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'; @@ -27,6 +44,18 @@ export function useAuth() { const [endedSession, setEndedSession] = useState(false); useEffect(() => { + const handed = takeTokenFromUrl(); + if (handed) { + const payload = decodePayload(handed); + if (payload) { + localStorage.setItem('wg_token', handed); + localStorage.setItem(LAST_USER_KEY, payload.sub); + setUser(payload.sub); + setToken(handed); + setChecked(true); + return; + } + } const stored = localStorage.getItem('wg_token'); if (stored) { const payload = decodePayload(stored);