diff --git a/.agents/skills/wavegrid-simple-show/SKILL.md b/.agents/skills/wavegrid-simple-show/SKILL.md index 9e1e246..6bc0a4c 100644 --- a/.agents/skills/wavegrid-simple-show/SKILL.md +++ b/.agents/skills/wavegrid-simple-show/SKILL.md @@ -50,10 +50,21 @@ wavegrid projects config # resolved config + provenance (secrets masked wavegrid projects config set layout ring-6 # fix the physical layout wavegrid projects config set port 3333 # change the port wavegrid projects secrets list # which secrets exist (never prints values) -wavegrid projects users list # UI logins +wavegrid projects users list # UI logins (admin vs operator) +wavegrid projects guest new # mint ONE shared passphrase to hand out (printed once) wavegrid doctor # diagnose everything (see below) ``` +**Roles & shared guest access.** Every UI login has a role: the first user in a +project is an **admin** (manages users, roles, sessions, secrets); later ones +default to **operator** (drive the show only). For a "public password" everyone +can share, use **guest access** instead of a real account: `wavegrid projects +guest new` mints one shared passphrase — anyone who signs in with it becomes an +**operator**, never an admin. It's printed once (only a hash is stored); rotate +to invalidate it, `guest disable` to pause it, `guest rm` to remove it. In the +desktop app this lives under **Access → Guest access**. The shared receiver key +is unrelated and never grants admin. + The project **name is just a label** — the physical shape comes from `layout.preset`. If the canvas shows a grid when you expected a ring, set the preset. ## Doctor diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0eb66ee..292f276 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,12 @@ import { runConfigSet } from './commands/config-set'; import { runDevicesAssign, runDevicesList, runDevicesRemove, runDevicesRename } from './commands/devices'; import { runDoctor } from './commands/doctor'; import { runEnvExport } from './commands/env'; +import { + runGuestClear, + runGuestEnabled, + runGuestRotate, + runGuestStatus +} from './commands/guest'; import { runInit } from './commands/init'; import { pickCommand, pickSubcommand, printSubcommands, type SubCommand } from './commands/menu'; import { runOscSetup } from './commands/osc'; @@ -35,6 +41,7 @@ ${c.bold('Projects')} — manage and edit projects projects config set Set a field (layout, mode, port, host, ui-port) projects secrets list|init List / generate the project's secrets projects users list|add|rm Manage UI login users + projects guest status|new Shared guest passphrase (one low-privilege operator) projects devices list|assign List / name / shard-assign devices that joined projects osc Set the OSC laser target (BEYOND/FB4) — wizard projects export [--out f] Write a portable project bundle @@ -82,6 +89,7 @@ const PROJECTS_SUBS: SubCommand[] = [ { value: 'config', description: 'Print or set the project config' }, { value: 'secrets', description: 'List or generate the project secrets' }, { value: 'users', description: 'List, add, or remove UI login users' }, + { value: 'guest', description: 'Shared guest passphrase — one low-privilege operator to hand out' }, { value: 'devices', description: 'List, rename, or forget devices that joined the project' }, { value: 'osc', description: 'Set the OSC laser target (BEYOND/FB4/routing) — interactive wizard' }, { value: 'export', description: 'Write a portable project bundle (no machine identity)' }, @@ -110,6 +118,14 @@ const USERS_SUBS: SubCommand[] = [ { value: 'rm', description: 'Remove a UI login user' } ]; +const GUEST_SUBS: SubCommand[] = [ + { value: 'status', description: 'Show whether shared guest access is set up / on' }, + { value: 'new', description: 'Mint a fresh shared passphrase (printed once)' }, + { value: 'enable', description: 'Turn shared guest logins on' }, + { value: 'disable', description: 'Turn shared guest logins off (passphrase kept)' }, + { value: 'rm', description: 'Remove shared guest access entirely' } +]; + const DEVICES_SUBS: SubCommand[] = [ { value: 'list', description: 'List devices that have joined the project' }, { value: 'rename', description: 'Give a device a project-specific friendly name' }, @@ -170,6 +186,7 @@ const KNOWN_COMMANDS = [ 'print-config', 'secrets', 'users', + 'guest', 'devices', 'env', 'doctor' @@ -251,6 +268,25 @@ async function dispatchUsers( else unknownSub('users', sub); } +async function dispatchGuest( + args: string[], + flags: Flags, + prompter: Inquirerer, + nonInteractive: boolean +): Promise { + // Bare `guest` in a script prints status (safe, read-only default). + let given = args[0]; + if (given == null && nonInteractive) given = 'status'; + const sub = (await resolveSub(given, 'guest', GUEST_SUBS, prompter, nonInteractive)) ?? undefined; + if (sub == null) return; + if (sub === 'status') runGuestStatus(flags); + else if (sub === 'new' || sub === 'rotate') runGuestRotate(flags); + else if (sub === 'enable' || sub === 'on') runGuestEnabled(flags, true); + else if (sub === 'disable' || sub === 'off') runGuestEnabled(flags, false); + else if (sub === 'rm' || sub === 'remove' || sub === 'clear') runGuestClear(flags); + else unknownSub('guest', sub); +} + async function dispatchDevices( args: string[], flags: Flags, @@ -319,6 +355,9 @@ async function dispatchProjects( case 'users': await dispatchUsers(rest, flags, prompter, nonInteractive); break; + case 'guest': + await dispatchGuest(rest, flags, prompter, nonInteractive); + break; case 'devices': await dispatchDevices(rest, flags, prompter, nonInteractive); break; @@ -421,6 +460,9 @@ export async function run(argvInput: string[] = process.argv.slice(2)): Promise< case 'users': await dispatchUsers(positionals.slice(1), flags, prompter, nonInteractive); break; + case 'guest': + await dispatchGuest(positionals.slice(1), flags, prompter, nonInteractive); + break; case 'devices': await dispatchDevices(positionals.slice(1), flags, prompter, nonInteractive); break; diff --git a/packages/cli/src/commands/guest.ts b/packages/cli/src/commands/guest.ts new file mode 100644 index 0000000..63b442b --- /dev/null +++ b/packages/cli/src/commands/guest.ts @@ -0,0 +1,66 @@ +import c from 'yanse'; + +import { type Flags, getStore, resolveProjectName } from '../project'; + +/** `wavegrid guest status` — is shared guest access set up / on? */ +export function runGuestStatus(flags: Flags): void { + const store = getStore(); + const project = resolveProjectName(store, flags); + const guest = store.guestStatus(project); + + console.log(''); + console.log(c.bold(` Shared guest access · ${project}`)); + if (!guest.configured) { + console.log(c.gray(' Not set up — create one with `wavegrid guest new`')); + } else { + console.log(` ${guest.enabled ? c.green('• enabled') : c.yellow('• disabled')}`); + console.log(c.gray(' Guests log in as operator (never admin) with the shared passphrase.')); + } + console.log(''); +} + +/** + * `wavegrid guest new` (alias `rotate`) — mint a fresh shared passphrase and + * print it once. Only its hash is stored; the old passphrase stops working. + */ +export function runGuestRotate(flags: Flags): void { + const store = getStore(); + const project = resolveProjectName(store, flags); + const existed = store.guestStatus(project).configured; + const passphrase = store.rotateGuestPassphrase(project); + + console.log(''); + console.log(c.green(` ✓ ${existed ? 'Rotated' : 'Created'} the shared guest passphrase for ${project}`)); + console.log(''); + console.log(` ${c.bold(c.cyan(passphrase))}`); + console.log(''); + console.log(c.gray(' Share this now — it is not shown again. Guests sign in as operator.')); + console.log(''); +} + +/** `wavegrid guest enable|disable` — flip logins without changing the passphrase. */ +export function runGuestEnabled(flags: Flags, enabled: boolean): void { + const store = getStore(); + const project = resolveProjectName(store, flags); + try { + const guest = store.setGuestEnabled(project, enabled); + console.log(''); + console.log(c.green(` ✓ Shared guest access ${guest.enabled ? 'enabled' : 'disabled'} for ${project}`)); + console.log(''); + } catch (e) { + console.log(''); + console.log(c.red(` ${(e as Error).message}`)); + console.log(''); + process.exitCode = 1; + } +} + +/** `wavegrid guest rm` — remove shared guest access entirely. */ +export function runGuestClear(flags: Flags): void { + const store = getStore(); + const project = resolveProjectName(store, flags); + store.clearGuest(project); + console.log(''); + console.log(c.green(` ✓ Removed shared guest access from ${project}`)); + console.log(''); +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index d1ef96a..c223664 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -147,6 +147,36 @@ export function registerAllIpc(): void { return store.hasProject(project) ? store.listSessions(project) : []; }); + ipcMain.handle('guest:status', (_e, project: string) => { + const store = openStore(); + return store.hasProject(project) + ? store.guestStatus(project) + : { configured: false, enabled: false, updatedAt: null }; + }); + ipcMain.handle('guest:rotate', (_e, project: string) => { + const store = openStore(); + if (!store.hasProject(project)) { + return { passphrase: '', status: { configured: false, enabled: false, updatedAt: null } }; + } + // The cleartext is returned exactly once for the admin to copy and share; + // only its scrypt hash is persisted. It is never logged. + const passphrase = store.rotateGuestPassphrase(project); + return { passphrase, status: store.guestStatus(project) }; + }); + ipcMain.handle('guest:setEnabled', (_e, project: string, enabled: boolean) => { + const store = openStore(); + return store.hasProject(project) + ? store.setGuestEnabled(project, enabled) + : { configured: false, enabled: false, updatedAt: null }; + }); + ipcMain.handle('guest:clear', (_e, project: string) => { + const store = openStore(); + if (store.hasProject(project)) store.clearGuest(project); + return store.hasProject(project) + ? store.guestStatus(project) + : { configured: false, enabled: false, updatedAt: null }; + }); + ipcMain.handle('secrets:status', (_e, project: string) => secretStatus(project)); ipcMain.handle('secrets:generate', (_e, project: string, force: boolean) => { const store = openStore(); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 8798888..447d5e7 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { BrainStatus, DeviceInfo, EditableConfig, LaserSyncState, LightMapView, NewProjectInput, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc'; +import type { BrainStatus, DeviceInfo, EditableConfig, GuestStatus, LaserSyncState, LightMapView, NewProjectInput, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc'; // The single, narrow bridge exposed to the renderer. The renderer never imports // @wavegrid/settings or `fs`; everything goes through these typed calls. @@ -39,6 +39,14 @@ const api: WavegridApi = { list: (project) => ipcRenderer.invoke('sessions:list', project) as Promise, revoke: (project, id) => ipcRenderer.invoke('sessions:revoke', project, id) as Promise }, + guest: { + status: (project) => ipcRenderer.invoke('guest:status', project) as Promise, + rotate: (project) => + ipcRenderer.invoke('guest:rotate', project) as Promise<{ passphrase: string; status: GuestStatus }>, + setEnabled: (project, enabled) => + ipcRenderer.invoke('guest:setEnabled', project, enabled) as Promise, + clear: (project) => ipcRenderer.invoke('guest:clear', project) as Promise + }, secrets: { status: (project) => ipcRenderer.invoke('secrets:status', project) as Promise, generate: (project, force) => diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index cb13229..e269f1a 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -8,6 +8,7 @@ import { ConstructiveIcon } from '@/components/ui/constructive-icon'; import { useBrainStatus, useDevices, + useGuest, useLightMap, usePresets, useProjectConfig, @@ -75,6 +76,13 @@ export function App() { refresh: refreshSessions, revoke: revokeSession } = useSessions(editingProject); + const { + guest, + refresh: refreshGuest, + rotate: rotateGuest, + setEnabled: setGuestEnabled, + clear: clearGuest + } = useGuest(editingProject); const { secrets, refresh: refreshSecrets, @@ -153,10 +161,10 @@ export function App() { setRoute('config'); }, []); - const withBusy = React.useCallback(async (fn: () => Promise) => { + const withBusy = React.useCallback(async (fn: () => Promise): Promise => { setBusy(true); try { - await fn(); + return await fn(); } finally { setBusy(false); } @@ -248,10 +256,11 @@ export function App() { if (route === 'access') { void refreshUsers(); void refreshSessions(); + void refreshGuest(); void refreshSecrets(); } if (route === 'lights') void refreshLightMap(); - }, [route, refresh, refreshDevices, refreshConfig, refreshUsers, refreshSessions, refreshSecrets, refreshLightMap]); + }, [route, refresh, refreshDevices, refreshConfig, refreshUsers, refreshSessions, refreshGuest, refreshSecrets, refreshLightMap]); return ( void withBusy(() => setUserRole(u, r))} onRevokeSession={(id) => void withBusy(() => revokeSession(id))} onRefreshSessions={() => void refreshSessions()} + guest={guest} + onRotateGuest={() => withBusy(() => rotateGuest())} + onSetGuestEnabled={(enabled) => void withBusy(() => setGuestEnabled(enabled))} + onClearGuest={() => void withBusy(() => clearGuest())} onGenerateSecrets={(force) => void withBusy(() => generateSecrets(force))} busy={busy} /> diff --git a/packages/desktop/src/renderer/lib/use-wavegrid.ts b/packages/desktop/src/renderer/lib/use-wavegrid.ts index 78cf3c1..625dd23 100644 --- a/packages/desktop/src/renderer/lib/use-wavegrid.ts +++ b/packages/desktop/src/renderer/lib/use-wavegrid.ts @@ -4,6 +4,7 @@ import type { BrainStatus, DeviceInfo, EditableConfig, + GuestStatus, LightMapView, NewProjectInput, ProjectSummary, @@ -213,6 +214,57 @@ export function useSessions(project: string | null): { return { sessions, refresh, revoke }; } +/** Shared guest-access status + controls. Rotate mints a fresh passphrase and + * returns its cleartext once (for the admin to copy); the store keeps only a + * hash. Enabling/disabling flips logins on/off without changing it. */ +export function useGuest(project: string | null): { + guest: GuestStatus; + refresh: () => Promise; + rotate: () => Promise; + setEnabled: (enabled: boolean) => Promise; + clear: () => Promise; + } { + const [guest, setGuest] = React.useState({ + configured: false, + enabled: false, + updatedAt: null + }); + + const refresh = React.useCallback(async () => { + if (!project) { + setGuest({ configured: false, enabled: false, updatedAt: null }); + return; + } + setGuest(await window.wavegrid.guest.status(project)); + }, [project]); + + const rotate = React.useCallback(async () => { + if (!project) return ''; + const { passphrase, status } = await window.wavegrid.guest.rotate(project); + setGuest(status); + return passphrase; + }, [project]); + + const setEnabled = React.useCallback( + async (enabled: boolean) => { + if (!project) return; + setGuest(await window.wavegrid.guest.setEnabled(project, enabled)); + }, + [project] + ); + + const clear = React.useCallback(async () => { + if (!project) return; + setGuest(await window.wavegrid.guest.clear(project)); + }, [project]); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + return { guest, refresh, rotate, setEnabled, clear }; +} + /** Required-secret status for a project (name/description/set only). `generate` * triggers one-time generation, or rotation with force=true. */ export function useProjectSecrets(project: string | null): { diff --git a/packages/desktop/src/renderer/routes/access-route.tsx b/packages/desktop/src/renderer/routes/access-route.tsx index 93b3e41..8b207c7 100644 --- a/packages/desktop/src/renderer/routes/access-route.tsx +++ b/packages/desktop/src/renderer/routes/access-route.tsx @@ -1,4 +1,4 @@ -import { KeyRound, MonitorSmartphone, RefreshCw, ShieldCheck, Trash2, UserPlus, Users } from 'lucide-react'; +import { Copy, KeyRound, MonitorSmartphone, RefreshCw, ShieldCheck, Ticket, Trash2, UserPlus, Users } from 'lucide-react'; import * as React from 'react'; import { @@ -41,7 +41,7 @@ import { TableRow } from '@/components/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { RequiredSecretInfo, SessionInfo, UserAccount, UserRole } from '@/types/ipc'; +import type { GuestStatus, RequiredSecretInfo, SessionInfo, UserAccount, UserRole } from '@/types/ipc'; const ROLE_STYLE = 'border-input bg-background h-8 rounded-md border px-2 text-sm'; @@ -368,6 +368,149 @@ function SessionsTab({ ); } +function GuestTab({ + guest, + onRotate, + onSetEnabled, + onClear, + busy +}: { + guest: GuestStatus; + onRotate: () => Promise; + onSetEnabled: (enabled: boolean) => void; + onClear: () => void; + busy: boolean; +}) { + // The freshly-minted passphrase, held only in this component to reveal once. + const [revealed, setRevealed] = React.useState(null); + const [copied, setCopied] = React.useState(false); + + const rotate = async () => { + const passphrase = await onRotate(); + if (passphrase) { + setRevealed(passphrase); + setCopied(false); + } + }; + + const copy = async () => { + if (!revealed) return; + await navigator.clipboard.writeText(revealed); + setCopied(true); + }; + + return ( +
+
+ + Shared guest access —{' '} + {!guest.configured ? 'not set up' : guest.enabled ? 'on' : 'off'} + +
+ {guest.configured && ( + + )} + +
+
+ + {!guest.configured ? ( + + + + + + No shared passphrase + + Create one shared passphrase to hand out. Anyone who signs in with it becomes an{' '} + operator — they can drive the show but can’t manage users, roles, or + sessions. Admins keep their own personal logins. + + + + ) : ( +
+
+ + {guest.enabled ? 'enabled' : 'disabled'} + + {guest.updatedAt && ( + + last rotated {relativeTime(guest.updatedAt)} + + )} +
+

+ The passphrase is stored only as a hash — it can’t be shown again. Rotate to get a new + one; the old one stops working on the next token refresh. Guests always log in as{' '} + operator, never admin. +

+ + + + + + + Remove shared guest access? + + The shared passphrase is deleted. Anyone using it loses access on their next + refresh. Personal admin/operator logins are unaffected. + + + + Cancel + + Remove + + + + +
+ )} + + !o && setRevealed(null)}> + + + Share this passphrase + + Copy it now — it won’t be shown again. Guest access is on; anyone with this passphrase + can sign in as an operator. + + +
+ + {revealed} + + +
+ + + +
+
+
+ ); +} + function SecretsTab({ secrets, onGenerate, @@ -451,11 +594,15 @@ interface AccessRouteProps { users: UserAccount[]; sessions: SessionInfo[]; secrets: RequiredSecretInfo[]; + guest: GuestStatus; onAddUser: (username: string, password: string, role: UserRole) => Promise; onRemoveUser: (username: string) => void; onSetUserRole: (username: string, role: UserRole) => void; onRevokeSession: (id: string) => void; onRefreshSessions: () => void; + onRotateGuest: () => Promise; + onSetGuestEnabled: (enabled: boolean) => void; + onClearGuest: () => void; onGenerateSecrets: (force: boolean) => void; busy: boolean; } @@ -470,11 +617,15 @@ export function AccessRoute({ users, sessions, secrets, + guest, onAddUser, onRemoveUser, onSetUserRole, onRevokeSession, onRefreshSessions, + onRotateGuest, + onSetGuestEnabled, + onClearGuest, onGenerateSecrets, busy }: AccessRouteProps) { @@ -503,6 +654,7 @@ export function AccessRoute({ Users Sessions + Guest access Secrets @@ -522,6 +674,15 @@ export function AccessRoute({ busy={busy} /> + + + diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index 7024b07..fd2378e 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -78,6 +78,14 @@ export interface SessionInfo { expiresAt: number; } +/** Shared guest-access status. The passphrase itself is never included — it is + * returned only once, from a rotate action, for the admin to copy and share. */ +export interface GuestStatus { + configured: boolean; + enabled: boolean; + updatedAt: number | null; +} + /** A required project secret and whether it is currently set. Only the name, * description, and presence flag ever cross IPC — never the secret value. */ export interface RequiredSecretInfo { @@ -209,6 +217,18 @@ export interface WavegridApi { /** Revoke a session by id (takes effect on the client's next token refresh). */ revoke(project: string, id: string): Promise; }; + guest: { + /** Shared guest-access status (never the passphrase). */ + status(project: string): Promise; + /** Mint/rotate the shared passphrase, enabling guest access. Returns the + * cleartext exactly once for the admin to copy — it is not persisted or + * retrievable afterwards. */ + rotate(project: string): Promise<{ passphrase: string; status: GuestStatus }>; + /** Enable/disable guest logins without changing the passphrase. */ + setEnabled(project: string, enabled: boolean): Promise; + /** Remove guest access entirely (deletes the passphrase). */ + clear(project: string): Promise; + }; secrets: { status(project: string): Promise; /** Generate missing secrets (or rotate all with force). Returns the updated diff --git a/packages/server/__tests__/http-app.test.ts b/packages/server/__tests__/http-app.test.ts index 010ab7d..ab031e2 100644 --- a/packages/server/__tests__/http-app.test.ts +++ b/packages/server/__tests__/http-app.test.ts @@ -184,6 +184,57 @@ describe('createHttpApp', () => { expect(del.status).toBe(200); }); + it('admin can mint/rotate a shared guest passphrase; guests log in as operator', async () => { + const admin = await login('admin', 'secretpw'); + const authz = { authorization: `Bearer ${admin.token}` }; + + // Off by default. + const initial = await (await fetch(`${base}/api/admin/guest`, { headers: authz })).json(); + expect(initial.guest).toEqual({ configured: false, enabled: false, updatedAt: null }); + + // Mint — cleartext returned exactly once. + const rotated = await fetch(`${base}/api/admin/guest/rotate`, { method: 'POST', headers: authz }); + expect(rotated.status).toBe(200); + const { passphrase, guest } = await rotated.json(); + expect(passphrase).toMatch(/^[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/); + expect(guest.enabled).toBe(true); + + // Anyone with the passphrase logs in — as an operator, never admin. + const guestLogin = await login('whoever', passphrase); + expect(guestLogin.role).toBe('operator'); + const forbidden = await fetch(`${base}/api/admin/guest`, { + headers: { authorization: `Bearer ${guestLogin.token}` } + }); + expect(forbidden.status).toBe(403); + + // Disabling turns logins off without deleting the passphrase. + const off = await fetch(`${base}/api/admin/guest/enabled`, { + method: 'POST', + headers: { ...authz, 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: false }) + }); + expect((await off.json()).guest.enabled).toBe(false); + const denied = await fetch(`${base}/api/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'whoever', password: passphrase }) + }); + expect(denied.status).toBe(401); + + // Remove entirely. + const cleared = await fetch(`${base}/api/admin/guest`, { method: 'DELETE', headers: authz }); + expect((await cleared.json()).guest.configured).toBe(false); + }); + + it('operators cannot manage guest access', async () => { + const op = await login('op', 'operatorpw'); + const res = await fetch(`${base}/api/admin/guest/rotate`, { + method: 'POST', + headers: { authorization: `Bearer ${op.token}` } + }); + expect(res.status).toBe(403); + }); + it('round-trips the light map through per-project state', async () => { const initial = await (await fetch(`${base}/api/light-map`)).json(); expect(initial.physicalLights).toHaveLength(layout.count); diff --git a/packages/server/src/http-app.ts b/packages/server/src/http-app.ts index cd0a2b0..535c381 100644 --- a/packages/server/src/http-app.ts +++ b/packages/server/src/http-app.ts @@ -5,7 +5,7 @@ * — same-origin means no `ui.port` / `simulatorUrl` to keep in sync. */ import { type ResolvedConfig } from '@wavegrid/layout'; -import { DEFAULT_SESSION_TTL_MS, openStore, type UserRole } from '@wavegrid/settings'; +import { DEFAULT_SESSION_TTL_MS, GUEST_USERNAME, openStore, type UserRole } from '@wavegrid/settings'; import * as fs from 'fs'; import type { IncomingMessage, ServerResponse } from 'http'; import { extname, join, normalize, resolve } from 'path'; @@ -235,11 +235,17 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { } const project = activeProject(); const store = openStore(); - if (!project || store.listUsers(project).length === 0) { + const guestOn = project ? store.guestStatus(project).enabled : false; + if (!project || (store.listUsers(project).length === 0 && !guestOn)) { sendJson(res, 503, { ok: false, error: 'Auth not configured' }); return; } - const user = store.authenticate(project, username, password); + // Try a real account first; fall back to the shared guest passphrase + // (always operator, never admin) so a shared "public password" just works + // regardless of the username typed. + const user = + store.authenticate(project, username, password) ?? + store.authenticateGuest(project, password); if (!user) { sendJson(res, 401, { ok: false, error: 'Invalid username or password' }); return; @@ -283,7 +289,14 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { sendJson(res, 401, { ok: false, error: 'Session expired or revoked' }); return; } - const role = store.getUserRole(project, payload.sub); + // The shared guest isn't a stored user; it's an operator while guest + // access stays enabled. + const role = + payload.sub === GUEST_USERNAME + ? store.guestStatus(project).enabled + ? ('operator' as UserRole) + : null + : store.getUserRole(project, payload.sub); if (!role) { sendJson(res, 401, { ok: false, error: 'Unknown user' }); return; @@ -384,6 +397,50 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = { } } + // ── Admin-only shared guest access (role-gated) ──────────────── + if (pathname === '/api/admin/guest' && method === 'GET') { + const caller = requireAdmin(req, url, res); + if (!caller) return; + sendJson(res, 200, { ok: true, guest: openStore().guestStatus(caller.project) }); + return; + } + if (pathname === '/api/admin/guest/rotate' && method === 'POST') { + const caller = requireAdmin(req, url, res); + if (!caller) return; + // The cleartext passphrase is returned exactly once, here, for the admin + // to copy and share; only its hash is persisted. + const store = openStore(); + const passphrase = store.rotateGuestPassphrase(caller.project); + sendJson(res, 200, { ok: true, passphrase, guest: store.guestStatus(caller.project) }); + return; + } + if (pathname === '/api/admin/guest/enabled' && method === 'POST') { + const caller = requireAdmin(req, url, res); + if (!caller) return; + let body: { enabled?: boolean }; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + sendJson(res, 400, { ok: false, error: 'Invalid request' }); + return; + } + try { + const guest = openStore().setGuestEnabled(caller.project, body.enabled === true); + sendJson(res, 200, { ok: true, guest }); + } catch (e) { + sendJson(res, 400, { ok: false, error: (e as Error).message }); + } + return; + } + if (pathname === '/api/admin/guest' && method === 'DELETE') { + const caller = requireAdmin(req, url, res); + if (!caller) return; + const store = openStore(); + store.clearGuest(caller.project); + sendJson(res, 200, { ok: true, guest: store.guestStatus(caller.project) }); + return; + } + // ── GET/POST /api/light-map ───────────────────────────────────── if (pathname === '/api/light-map') { const file = lightMapFile(); diff --git a/packages/settings/__tests__/guest.test.ts b/packages/settings/__tests__/guest.test.ts new file mode 100644 index 0000000..6b35038 --- /dev/null +++ b/packages/settings/__tests__/guest.test.ts @@ -0,0 +1,110 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { openStore } from '../src'; + +function tmpBase(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'wg-guest-')); +} + +function seed() { + const store = openStore({ baseDir: tmpBase() }); + store.createProject('demo', { layout: { preset: 'grid-7x7' } }); + return store; +} + +describe('shared guest access', () => { + it('starts unconfigured and disabled', () => { + const store = seed(); + expect(store.guestStatus('demo')).toEqual({ + configured: false, + enabled: false, + updatedAt: null + }); + }); + + it('cannot be enabled before a passphrase is minted', () => { + const store = seed(); + expect(() => store.setGuestEnabled('demo', true)).toThrow(/mint a guest passphrase/i); + }); + + it('minting returns a cleartext passphrase once and enables access', () => { + const store = seed(); + const passphrase = store.rotateGuestPassphrase('demo'); + expect(passphrase).toMatch(/^[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/); + + const status = store.guestStatus('demo'); + expect(status.configured).toBe(true); + expect(status.enabled).toBe(true); + expect(typeof status.updatedAt).toBe('number'); + }); + + it('never persists the passphrase in cleartext', () => { + const store = seed(); + const passphrase = store.rotateGuestPassphrase('demo'); + const file = path.join(store.paths.data, 'projects', 'demo', 'guest.json'); + const raw = fs.readFileSync(file, 'utf8'); + expect(raw).not.toContain(passphrase); + const parsed = JSON.parse(raw); + expect(parsed.hash).toBeTruthy(); + expect(parsed.salt).toBeTruthy(); + }); + + it('authenticates a matching passphrase as an operator (never admin)', () => { + const store = seed(); + const passphrase = store.rotateGuestPassphrase('demo'); + const who = store.authenticateGuest('demo', passphrase); + expect(who).toEqual({ username: 'guest', role: 'operator' }); + }); + + it('rejects the wrong passphrase and any passphrase while disabled', () => { + const store = seed(); + const passphrase = store.rotateGuestPassphrase('demo'); + expect(store.authenticateGuest('demo', 'nope-nope-nope')).toBeNull(); + + store.setGuestEnabled('demo', false); + expect(store.guestStatus('demo').enabled).toBe(false); + expect(store.authenticateGuest('demo', passphrase)).toBeNull(); + }); + + it('rotating invalidates the previous passphrase', () => { + const store = seed(); + const first = store.rotateGuestPassphrase('demo'); + const second = store.rotateGuestPassphrase('demo'); + expect(second).not.toBe(first); + expect(store.authenticateGuest('demo', first)).toBeNull(); + expect(store.authenticateGuest('demo', second)).toEqual({ + username: 'guest', + role: 'operator' + }); + }); + + it('re-enabling keeps the same passphrase working', () => { + const store = seed(); + const passphrase = store.rotateGuestPassphrase('demo'); + store.setGuestEnabled('demo', false); + store.setGuestEnabled('demo', true); + expect(store.authenticateGuest('demo', passphrase)).toEqual({ + username: 'guest', + role: 'operator' + }); + }); + + it('clearing removes guest access entirely', () => { + const store = seed(); + const passphrase = store.rotateGuestPassphrase('demo'); + store.clearGuest('demo'); + expect(store.guestStatus('demo')).toEqual({ + configured: false, + enabled: false, + updatedAt: null + }); + expect(store.authenticateGuest('demo', passphrase)).toBeNull(); + }); + + it('reserves the "guest" username for shared access', () => { + const store = seed(); + expect(() => store.addUser('demo', 'guest', 'pw123456')).toThrow(/reserved/i); + }); +}); diff --git a/packages/settings/src/guest.ts b/packages/settings/src/guest.ts new file mode 100644 index 0000000..df37b47 --- /dev/null +++ b/packages/settings/src/guest.ts @@ -0,0 +1,144 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +import { projectDir, readJsonFile, type StorePaths, writeFileAtomic } from './paths'; +import { type UserInfo } from './users'; + +/** + * Shared "guest" access: a single low-privilege passphrase an admin can hand out + * so casual users can drive the show without their own account. It always maps + * to the `operator` role — never admin — so sharing it can never grant + * administration, and the receiver key is unaffected. + * + * The passphrase is stored only as a salted scrypt hash (never in plaintext); + * the cleartext is returned exactly once, when it is minted or rotated, for the + * admin to copy and share. Forgotten passphrases are rotated, not recovered. + */ + +/** The reserved username every guest session logs in as. */ +export const GUEST_USERNAME = 'guest'; + +const KEYLEN = 64; +const FILE_MODE = 0o600; + +/** Ambiguous characters (0/O, 1/l/I) are omitted so a shared passphrase is easy + * to read aloud and type. */ +const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; + +interface StoredGuest { + enabled: boolean; + salt: string; + hash: string; + updatedAt: number; +} + +/** Guest-access status safe to hand to callers/renderers — no passphrase material. */ +export interface GuestStatus { + /** True when a passphrase has been minted (whether or not it is enabled). */ + configured: boolean; + /** True when guest logins are currently accepted. */ + enabled: boolean; + /** When the passphrase was last minted/rotated (epoch ms), or null. */ + updatedAt: number | null; +} + +function guestFile(paths: StorePaths, project: string): string { + return path.join(projectDir(paths, project), 'guest.json'); +} + +function readGuest(paths: StorePaths, project: string): StoredGuest | null { + return readJsonFile(guestFile(paths, project)); +} + +function writeGuest(paths: StorePaths, project: string, guest: StoredGuest): void { + writeFileAtomic(guestFile(paths, project), JSON.stringify(guest, null, 2) + '\n', FILE_MODE); +} + +function hashPassphrase(passphrase: string, salt: string): string { + return scryptSync(passphrase, salt, KEYLEN).toString('hex'); +} + +/** A readable, dependency-free passphrase, e.g. `njkr-8p2q-wxst`. */ +function generatePassphrase(): string { + const groups = 3; + const per = 4; + const bytes = randomBytes(groups * per); + const chars: string[] = []; + for (let i = 0; i < bytes.length; i++) { + chars.push(ALPHABET[bytes[i] % ALPHABET.length]); + } + const out: string[] = []; + for (let g = 0; g < groups; g++) out.push(chars.slice(g * per, g * per + per).join('')); + return out.join('-'); +} + +export function guestStatus(paths: StorePaths, project: string): GuestStatus { + const guest = readGuest(paths, project); + return { + configured: guest != null, + enabled: guest?.enabled === true, + updatedAt: guest?.updatedAt ?? null + }; +} + +/** + * Mint a brand-new shared passphrase (enabling guest access) and return its + * cleartext once. Only the hash is persisted. Rotating invalidates the previous + * passphrase, so anyone using the old one is locked out on their next refresh. + */ +export function rotateGuestPassphrase(paths: StorePaths, project: string): string { + const passphrase = generatePassphrase(); + const salt = randomBytes(16).toString('hex'); + writeGuest(paths, project, { + enabled: true, + salt, + hash: hashPassphrase(passphrase, salt), + updatedAt: Date.now() + }); + return passphrase; +} + +/** + * Enable or disable guest logins without changing the passphrase. Enabling + * requires a passphrase to already have been minted; call + * `rotateGuestPassphrase` first otherwise. + */ +export function setGuestEnabled(paths: StorePaths, project: string, enabled: boolean): GuestStatus { + const guest = readGuest(paths, project); + if (!guest) { + if (enabled) throw new Error('Mint a guest passphrase before enabling guest access.'); + return guestStatus(paths, project); + } + guest.enabled = enabled; + writeGuest(paths, project, guest); + return guestStatus(paths, project); +} + +/** Remove guest access entirely (deletes the passphrase). */ +export function clearGuest(paths: StorePaths, project: string): void { + try { + fs.rmSync(guestFile(paths, project)); + } catch { + /* already absent */ + } +} + +/** + * Constant-time check of a candidate guest passphrase. Returns the guest + * operator identity on success, or null when guest access is off or the + * passphrase does not match. + */ +export function authenticateGuest( + paths: StorePaths, + project: string, + passphrase: string +): UserInfo | null { + const guest = readGuest(paths, project); + if (!guest || !guest.enabled) return null; + const expected = Buffer.from(guest.hash, 'hex'); + const actual = Buffer.from(hashPassphrase(passphrase, guest.salt), 'hex'); + if (expected.length !== actual.length) return null; + if (!timingSafeEqual(expected, actual)) return null; + return { username: GUEST_USERNAME, role: 'operator' }; +} diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts index 2ceb0bd..fef3bab 100644 --- a/packages/settings/src/index.ts +++ b/packages/settings/src/index.ts @@ -65,6 +65,9 @@ export { type RequiredSecret } from './required'; // Users export { type StoredUser, type UserInfo, type UserRole } from './users'; +// Shared guest access (one low-privilege operator passphrase) +export { GUEST_USERNAME, type GuestStatus } from './guest'; + // UI sessions (cheap server-visible login records) export { type CreateSessionInput, diff --git a/packages/settings/src/store.ts b/packages/settings/src/store.ts index 35969ed..e12e38c 100644 --- a/packages/settings/src/store.ts +++ b/packages/settings/src/store.ts @@ -1,4 +1,12 @@ import { type DeviceIdentity, getDevice, setDeviceName } from './device'; +import { + authenticateGuest, + clearGuest, + type GuestStatus, + guestStatus, + rotateGuestPassphrase, + setGuestEnabled +} from './guest'; import { deleteLightMap, getActiveLightMap, @@ -130,6 +138,15 @@ export interface SettingsStore { verifyUser(project: string, username: string, password: string): boolean; authenticate(project: string, username: string, password: string): UserInfo | null; + // Shared guest access (one low-privilege operator passphrase to hand out) + guestStatus(project: string): GuestStatus; + /** Mint/rotate the shared passphrase, enabling guest access. Returns the + * cleartext exactly once — only its hash is persisted. */ + rotateGuestPassphrase(project: string): string; + setGuestEnabled(project: string, enabled: boolean): GuestStatus; + clearGuest(project: string): void; + authenticateGuest(project: string, passphrase: string): UserInfo | null; + // UI sessions (cheap server-visible login records; sockets untouched) createSession(project: string, input: CreateSessionInput): Session; listSessions(project: string): Session[]; @@ -209,6 +226,12 @@ export function openStore(opts: StoreOptions = {}): SettingsStore { verifyUser: (project, username, password) => verifyUser(paths, project, username, password), authenticate: (project, username, password) => authenticate(paths, project, username, password), + guestStatus: (project) => guestStatus(paths, project), + rotateGuestPassphrase: (project) => rotateGuestPassphrase(paths, project), + setGuestEnabled: (project, enabled) => setGuestEnabled(paths, project, enabled), + clearGuest: (project) => clearGuest(paths, project), + authenticateGuest: (project, passphrase) => authenticateGuest(paths, project, passphrase), + createSession: (project, input) => createSession(paths, project, input), listSessions: (project) => listSessions(paths, project), getSession: (project, id) => getSession(paths, project, id), diff --git a/packages/settings/src/users.ts b/packages/settings/src/users.ts index 39d84b3..b8ff663 100644 --- a/packages/settings/src/users.ts +++ b/packages/settings/src/users.ts @@ -85,6 +85,9 @@ export function addUser( if (!username || !password) { throw new Error('addUser requires a non-empty username and password.'); } + if (username === 'guest') { + throw new Error('"guest" is reserved for shared guest access; pick another username.'); + } const existing = readUsers(paths, project); const prior = existing.find((u) => u.username === username); const resolvedRole: UserRole =