SoA-Web is the server half of the SoA-Prod desktop app, pulled out and made into a standalone Node process. Browsers connect to /, get a static SPA, and open a WebSocket to /ws. The server owns one PTY per tab per session, tracks sessions by signed HttpOnly cookie, and brings up a Cloudflare Quick Tunnel on boot so the URL is reachable from a phone via QR. Everything Electron-specific that used to live in the desktop (native menus, auto-updater, Picovoice, local Whisper, macOS SFSpeech, file-icon generation, DMG build, notarization) is dropped.
Browser (xterm.js) Node server
─────────────────── ──────────────────────────────
│ │ WS │ │
│ Shell UI │ ◄───── /ws ────► SessionStore ──► TabManager ──► node-pty ──► $SHELL
│ Tabs, input │ │ ▲ │
│ TRON palette │ HTTP │ │ │
│ │ ◄──── / ───────► Express (static + api) │
──────────────────── ──────────────────────────────
- HTTP boot. Browser loads
/(static SPA). First byte fetches/_config.js(injects the auth mode) and/_protocol.js(rewrites the server's CommonJSprotocol.jsinto an ESM shim so the client imports the same schema the server uses). - Auth. If
SOA_WEB_AUTH=shared, the SPA shows a password form; on success it POSTs/api/login, the server returns a signed cookie (soa_web_auth). Inopen/none, the server auto-provisions a session on any authed request. - WebSocket upgrade.
/wsreads the cookie, verifies the HMAC, looks up the session inSessionStore. No session = 401. - HELLO. Server sends
{t:'hello', d:{tabs, activeId, replay}}— the tab list, which tab was active, and a per-tab scrollback snapshot (bounded ring buffer kept server-side). A browser reload is non-destructive: xterm receives the replay bytes before any liveterm-dataarrives, so the user lands exactly where they left off. - Tab creation. Client sends
{t:'input', d:{kind:'new-tab', cols, rows}}. Server spawns a PTY viaTabManager.open, streams{t:'term-data', d:{id, data}}frames back as the shell produces output. - Typing. Key events →
{kind:'term-keys', id, text}frames →tab.write(text). - Resize. xterm fit-addon computes new cols/rows on every window resize and when switching tabs. Server calls
pty.resize. - Close. Browser close → WS close → session detaches socket but keeps its PTYs. Session GC sweeps idle sessions after
SOA_WEB_SESSION_TTL_MS(default 6h).
Sessions are server-side. The cookie carries only a random 256-bit token; the server maps token → in-memory session. HMAC over the cookie prevents forgery; rotating the sign key invalidates all sessions on next request. No JWT means no revocation pain and no accidental "forever" tokens.
This is a single-user self-hosted design. What that buys:
- No user account model, no password reset flow, no billing, no tenant isolation.
- Every session runs as the same OS user the server was launched as. If you run
npm startas yourself, every browser in shared-secret mode ends up with your shell. - Filesystem isolation, CPU/memory limits, and resource sandboxing are delegated to the deploy (Docker, systemd-nspawn, Fly Machines, etc.), not the app.
If you want multi-tenant — say, a hosted service where strangers sign up — the pieces that need to change are:
- Swap
sharedauth for an OIDC/OAuth flow (Auth0, Clerk, Cloudflare Access). - Make
TabManager.openspawn the PTY inside a per-user container (e.g.docker exec, Firecracker, gVisor). The rest of the protocol doesn't change. - Add per-session rate limits on
term-keys(trivial DoS vector otherwise). - Persist tab state to disk so a process restart doesn't drop shells.
Frames are JSON over WebSocket:
{ "v": 1, "t": "<type>", "d": { … }, "id"?: "<correlation>" }
See server/src/protocol.js for the authoritative list of t values and input.kind values. Server → client types: hello, snapshot, term-data, term-exit, notice, pong, bye. Client → server: auth, input, ping, request.
The one clever bit: the same file is shipped to the browser. server/src/index.js serves /_protocol.js by reading protocol.js and rewriting the trailing module.exports = {...} into export { ... }. The two sides cannot drift.
auth: cookie signing, tamper rejection, mode resolution, constant-time comparison.protocol: round-trip framing, rejection of malformed/wrong-version frames.sessions: create/destroy, token lookup, idle GC.
End-to-end (scripts/smoke-ws.js) covers the full HTTP → WS → PTY round-trip.
- TLS. Terminate in front with Cloudflare Tunnel (the default autopair path), nginx/Caddy, or a managed host. The app intentionally speaks plain HTTP; there's no good reason to handle certs in-process.
On boot, PairingManager.start() runs cloudflared tunnel --url http://localhost:$PORT, parses the trycloudflare.com URL out of the child process's stdout, and exposes it via /api/pair/status. The sidebar QR widget polls that endpoint and renders a QR over the server-generated SVG route (/api/pair/qr?text=…). Falls back to ngrok, then localtunnel, if cloudflared is missing. Disable the auto-start with SOA_WEB_AUTOPAIR=0. The tunnel is not suitable for production workloads (random subdomain, no SLA) — for a stable URL, run a named Cloudflare Tunnel or put the service behind your own domain.
Vercel (or any serverless host) can't run this server: WebSockets to long-lived PTYs need a persistent process, not invocation-scoped functions.
- CSRF on
/api/login. Current cookie isSameSite=Lax, which handles the common cases. A public deploy should add an explicit CSRF token. - WS origin check. Currently accepts any origin with a valid cookie — fine for self-hosted, tighten for public deploys via an
Originallowlist in the upgrade handler. - Voice. Pluggable transcription endpoint (browser sends audio → server returns text) would restore the desktop's dictation flow without a local Whisper.