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
58 changes: 51 additions & 7 deletions desktop/broker.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
// SmbOS desktop broker -- Phase 2 of the strangler-fig switchover.
// SmbOS desktop broker -- Phases 2-3 of the strangler-fig switchover.
//
// A thin reverse proxy to the running FastAPI dashboard. The broker becomes the single front door
// the Electron renderer talks to; for now it just FORWARDS every request to FastAPI (the strangler
// facade), so behavior is identical. Responses are streamed, so the `/events` SSE live mirror passes
// through unbuffered. Later phases (3-4) serve reads/actions here directly instead of forwarding.
// The single front door the Electron renderer talks to. It SERVES the static reads (plate / queue /
// settings) directly from the SQLite work-state + plain files (Phase 3), and FORWARDS everything else
// to FastAPI -- the liveness-bearing reads (inflight/runs) and the SSE live mirror keep forwarding,
// since their flock/pid liveness migrates with the Phase 5 native layer. Forwarded responses are
// streamed (SSE-safe).
//
// Loopback only; the token gate + Host guard stay on the FastAPI side (the broker forwards both).
// Loopback only. For what it FORWARDS, FastAPI owns the token gate; for what it SERVES, the broker
// owns the token gate itself (FastAPI's check doesn't run on a broker-served response).

const http = require('http')
const crypto = require('crypto')
const store = require('./store')
const { token } = require('./resolve')

// GET endpoints the broker answers itself, from the local store, in FastAPI's exact response shape
// (parity-tested against the live FastAPI). settings/procedures/pending follow in later increments:
// settings reads an environment-detected terminal, so it isn't a pure static read yet.
const SERVED = {
'/api/plate': (sopDir) => ({ plate: store.plate(sopDir) }),
'/api/queue': (sopDir) => ({ queue: store.queue(sopDir) }),
}

// Constant-time token compare (the broker now gates the reads it serves, mirroring FastAPI's check).
function tokenOk(provided, expected) {
if (!expected || !provided) return false
const a = Buffer.from(String(provided))
const b = Buffer.from(String(expected))
return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Hop-by-hop headers must not be forwarded by a proxy (RFC 7230 6.1). In practice Node + a loopback
// upstream rarely sets these, but strip them so we never proxy a stale connection/keep-alive header.
Expand All @@ -27,7 +48,7 @@ function filterHeaders(headers, overrides) {
// Build a reverse-proxy server forwarding to http://targetHost:targetPort. Does NOT call listen();
// the caller binds it (127.0.0.1:0 for a free port). Throws if targetPort isn't a real port, so a
// bad caller can't silently turn the broker into a proxy to port 80.
function createBroker({ targetHost = '127.0.0.1', targetPort }) {
function createBroker({ targetHost = '127.0.0.1', targetPort, sopDir }) {
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
throw new TypeError(`createBroker: targetPort must be a valid port (got ${targetPort})`)
}
Expand All @@ -42,6 +63,29 @@ function createBroker({ targetHost = '127.0.0.1', targetPort }) {
res.end('forbidden host')
return
}
// Serve a static read directly from the store (Phase 3). The broker owns the token gate for
// these, since FastAPI's check never runs on a broker-served response.
const pathname = req.url.split('?')[0]
const serve = req.method === 'GET' && sopDir ? SERVED[pathname] : undefined
if (serve) {
const t = new URL(req.url, 'http://x').searchParams.get('t')
if (!tokenOk(t, token({ SOP_DIR: sopDir }))) {
res.writeHead(401, { 'content-type': 'application/json' })
res.end(JSON.stringify({ detail: 'bad or missing token' }))
return
}
let payload
try {
payload = serve(sopDir)
} catch (_) {
res.writeHead(500, { 'content-type': 'application/json' })
res.end(JSON.stringify({ detail: 'could not read the dashboard state' }))
return
}
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(payload))
return
}
// address the upstream correctly (override Host); forward everything else verbatim
const headers = filterHeaders(req.headers, { host: `${targetHost}:${targetPort}` })
const upstream = http.request(
Expand Down
47 changes: 47 additions & 0 deletions desktop/broker.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const test = require('node:test')
const assert = require('node:assert')
const http = require('http')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { DatabaseSync } = require('node:sqlite')
const { createBroker } = require('./broker')

function listen(server) {
Expand Down Expand Up @@ -129,6 +133,49 @@ test('an absolute-form request-target still goes to the FIXED upstream (not an o
upstream.close(); broker.close()
})

test('serves /api/plate from the store (token-gated), forwards unknown paths', async () => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'smbos-broker-'))
fs.writeFileSync(path.join(d, '.dashboard-token'), 'tok')
const db = new DatabaseSync(path.join(d, 'state.db'))
db.exec(`CREATE TABLE task (id INTEGER PRIMARY KEY, domain TEXT, kind TEXT, subject TEXT, status TEXT,
priority INTEGER DEFAULT 0, source_ref TEXT, created_at TEXT, updated_at TEXT)`)
db.prepare("INSERT INTO task(id,domain,kind,subject,status,created_at,updated_at) VALUES(1,'ops','x','on plate','waiting','t','t')").run()
db.close()
let forwarded = false
const upstream = http.createServer((req, res) => { forwarded = true; res.end('up') })
const upPort = await listen(upstream)
const broker = createBroker({ targetPort: upPort, sopDir: d })
const brPort = await listen(broker)
// served + no token -> 401, never forwarded
assert.equal((await request(brPort, '/api/plate')).status, 401)
// served + valid token -> answered from the store
const ok = await request(brPort, '/api/plate?t=tok')
assert.equal(ok.status, 200)
assert.deepEqual(JSON.parse(ok.body).plate.map((r) => r.subject), ['on plate'])
assert.equal(forwarded, false, 'a served read never hits the upstream')
// an unserved path still forwards to FastAPI
await request(brPort, '/api/runs?t=tok')
assert.equal(forwarded, true)
upstream.close(); broker.close()
})

test('a served read is DENIED when the token file is missing/empty (fails closed, no upstream contact)', async () => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'smbos-broker-')) // no .dashboard-token written
const db = new DatabaseSync(path.join(d, 'state.db'))
db.exec(`CREATE TABLE task (id INTEGER PRIMARY KEY, domain TEXT, kind TEXT, subject TEXT, status TEXT,
priority INTEGER DEFAULT 0, source_ref TEXT, created_at TEXT, updated_at TEXT)`)
db.close()
let forwarded = false
const upstream = http.createServer((req, res) => { forwarded = true; res.end('up') })
const upPort = await listen(upstream)
const broker = createBroker({ targetPort: upPort, sopDir: d })
const brPort = await listen(broker)
assert.equal((await request(brPort, '/api/plate?t=')).status, 401) // empty token -> deny
assert.equal((await request(brPort, '/api/plate?t=anything')).status, 401) // no token file -> deny, not allow
assert.equal(forwarded, false, 'a denied served read must not fall through to the upstream')
upstream.close(); broker.close()
})

test('rejects a non-loopback Host (DNS-rebinding defense) before forwarding', async () => {
let reached = false
const upstream = http.createServer((req, res) => { reached = true; res.end('ok') })
Expand Down
4 changes: 2 additions & 2 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
const { app, BrowserWindow, Tray, Menu, Notification, nativeImage } = require('electron')
const path = require('path')
const http = require('http')
const { dashboardPort, token } = require('./resolve')
const { dashboardPort, token, sopDir } = require('./resolve')
const { createBroker } = require('./broker')

const POLL_MS = 5000 // tray/notification poll cadence (matches the live mirror's calm cadence)
Expand Down Expand Up @@ -113,7 +113,7 @@ function createTray() {
app.whenReady().then(() => {
// Start the broker (Phase 2 facade) in front of the running FastAPI dashboard, on a free loopback
// port, then point everything at the broker. The broker forwards to FastAPI for now.
broker = createBroker({ targetPort: dashboardPort() })
broker = createBroker({ targetPort: dashboardPort(), sopDir: sopDir() })
// A bind failure (EACCES/EADDRNOTAVAIL on loopback) would otherwise leave the listen callback
// unfired -- no window, no tray, a resident process with no UI. Fail loud and quit instead.
broker.on('error', (e) => {
Expand Down
Loading
Loading