/** * The panel's data route, mounted on the Host's web server. * * Shape and path are copied from the plugins that are provably running in this * profile, not invented here: * * dsh-vision-diff inject = ['webServer', 'tools'] route path '/dsh-vision-diff' * dsh-warden inject = ['webServer'] route path '/dsh-warden' * dsh-daily inject = ['timer', 'webServer'] route path '/dsh-daily' * * and on the browser side all three do a plain `fetch(API)` / `fetch(API + '/x')` * from the panel, where `API` is that same `'/'` path. * * What this replaced: the route used to live on `connection.fetch.register` at an * exact `/api/dsh.den/status` path, and the panel reached it through an RPC-shaped * shim. The host half of that was observable — `den_status` reported * `statusRoute.mounted: true` — but no code in this profile has ever demonstrated * that a browser plugin page can reach an exact route under `/api`, and the client * half of the channel had already been found to be invented rather than published. * Being the only one of nine client plugins to do it a different way is not a * neutral choice when the cost is a panel that silently never loads. */ import { toLosslessJson } from './atom.js' import { invokeAction } from './panel-api.js' /** The route prefix, exposed so the client and the tests cannot drift from it. */ export const ROUTE = '/dsh-den' /** The single POST action name the panel uses for a status read. */ const DEFAULT_ACTION = 'status' /** Refuse a body larger than this rather than buffering it: the panel sends a form. */ const MAX_BODY_BYTES = 1024 * 1024 /** * Read a request body. * * `req` is a Node `IncomingMessage`, not a web `Request`: the handler signature is * `(req, res)` and the installed plugins read the body with `req.on('data')` and * `req.on('end')`. Only a web `Request` has `.text()`, so a handler written against * `.text()` would throw on the first POST — and the two offline harnesses would not * notice, because they supply whichever shape the code expects. * * Both shapes are accepted so the same handler is drivable from a test without * standing up an HTTP server, but the Node path is the one that runs. */ function readBody(req) { if (req && typeof req.on === 'function') { return new Promise((resolve) => { let raw = '' req.on('data', (chunk) => { raw += chunk if (raw.length > MAX_BODY_BYTES && typeof req.destroy === 'function') req.destroy() }) req.on('end', () => resolve(raw)) req.on('error', () => resolve('')) }) } if (req && typeof req.text === 'function') { return Promise.resolve(req.text()).catch(() => '') } if (req && typeof req.body === 'string') return Promise.resolve(req.body) return Promise.resolve('') } /** Parse a body, distinguishing "not JSON" from "empty". */ async function readJsonBody(req) { const raw = await readBody(req) if (!raw || !raw.trim()) return {} try { const parsed = JSON.parse(raw) return parsed && typeof parsed === 'object' ? parsed : {} } catch { return undefined } } /** Split a request path into the segments after the route prefix. */ function segmentsOf(url, route) { const path = String(url || '').split('?')[0] const rest = path.startsWith(route) ? path.slice(route.length) : path return rest.split('/').filter(Boolean) } /** * Write a JSON response. * * Node's `ServerResponse` is the real case: the installed plugins set * `res.statusCode` and `res.setHeader(...)`, then `res.end(...)`. A web `Response` * is accepted as well so the handler stays directly callable from a test; the two * branches are the same three facts in different vocabularies. */ function writeJson(res, status, body) { const text = `${JSON.stringify(toLosslessJson(body))}\n` if (res && typeof res.writeHead === 'function') { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) if (typeof res.end === 'function') res.end(text) return text } if (res && typeof res.end === 'function') { if ('statusCode' in res) res.statusCode = status if (typeof res.setHeader === 'function') { res.setHeader('content-type', 'application/json; charset=utf-8') res.setHeader('cache-control', 'no-store') } res.end(text) return text } return new Response(text, { status, headers: { 'content-type': 'application/json; charset=utf-8' } }) } /** * What a request says about itself, kept verbatim and bounded. * * Verbatim, because a label would be this plugin's opinion about who called, and the * whole point of keeping this is to let a reader decide for themselves. Bounded, because * a client may send an arbitrarily long header and this value is stored in the status * snapshot and read by a model. * * @returns {{method: string|null, userAgent: string|null, origin: string|null, * referer: string|null}} */ export function observationOf(req, { maxHeader = 200 } = {}) { const headers = req?.headers && typeof req.headers === 'object' ? req.headers : {} const clip = (value) => { if (typeof value !== 'string' || value === '') return null return value.length > maxHeader ? `${value.slice(0, maxHeader)}…` : value } return { method: String(req?.method || '').toUpperCase() || null, userAgent: clip(headers['user-agent']), origin: clip(headers.origin), referer: clip(headers.referer), } } /** Run one action, turning a thrown error into an in-band refusal. */ async function runAction(service, action, payload, options) { // `invokeAction` guards most branches, but `status` is a straight pass-through to // `service.status()`. A throwing service must still read as a refusal the panel can // render, not as an HTTP 500 the panel cannot distinguish from a dead route. try { return await invokeAction(service, action, payload, options) } catch (error) { return { ok: false, error: { code: 'DEN_ACTION_FAILED', message: String(error?.message ?? error) } } } } /** * Whether a request to this route came from somewhere other than the panel. * * A loopback port is reachable from every page the browser has open, and this route can make * den speak, so "it is only on 127.0.0.1" is not the whole answer. Two cheap facts separate the * panel from the rest of the web: a browser marks a cross-site request with `Sec-Fetch-Site`, * and it cannot send `application/json` without a preflight first. A request that fails either * test is refused here — before its body is read. * * This is a check on where a request came from, not a login. A caller that is not a browser at * all (a curl probe, a test) sends neither header and is still allowed, and any local process * can still read `~/.dsh/den` directly. What it stops is a page the human happens to visit * driving den through the port the panel uses. * * @returns {string|null} the reason to refuse, or null to carry on */ export function foreignRequest(req) { const headers = req?.headers && typeof req.headers === 'object' ? req.headers : {} const text = (name) => (typeof headers[name] === 'string' ? headers[name].trim() : '') const site = text('sec-fetch-site').toLowerCase() if (site === 'cross-site' || site === 'same-site') { return `the request came from a ${site} page, not from the panel` } const origin = text('origin') if (origin) { let originHost try { originHost = new URL(origin).host } catch { return 'the request carried an Origin that cannot be read' } const host = text('host') if (!host || originHost !== host) return 'the request came from another origin' } const method = String(req?.method || '').toUpperCase() const type = text('content-type').toLowerCase() // Only when a content type was declared: a browser always declares one on a POST, so this still // blocks the `text/plain` simple request a foreign page would send, while a caller that is not a // browser at all — the end-to-end route test, a curl probe — keeps working without the header. if (method === 'POST' && type && !type.includes('application/json')) { return 'a POST has to be JSON: a page cannot send that cross-site without a preflight' } return null } /** * Mount the panel route. * * @returns {{mounted: boolean, reason: string|null, message: string|null, * path: string, dispose: () => void}} */ export function mountPanelRoute(ctx, { service, nextTriggerAt, route = ROUTE } = {}) { const fail = (reason, message) => ({ mounted: false, reason, message: message ?? null, path: route, dispose: () => {}, }) if (!ctx || typeof ctx !== 'object') return fail('no-context') // `webServer` is declared in `inject`, so it is read as a property. The guard is // still here because a profile without a web server must cost the user the panel // and not the assistant — `dsh-vision-diff` wraps its registration the same way. let webServer try { webServer = ctx.webServer } catch (error) { return fail('webserver-lookup-threw', String(error?.message ?? error)) } if (!webServer) return fail('no-webserver-service') if (typeof webServer.register !== 'function') return fail('webserver-has-no-register') let dispose try { dispose = webServer.register({ kind: 'prefix', path: route, handler: async function (req, res) { try { // Record contact. `statusRoute.mounted` is the host describing itself; this is // the only signal that something actually reached the route. A previous // transport here reported `mounted: true` while answering 401 to every request, // so the distinction is not academic. // // What is handed over is what the request said about itself, verbatim. The // earlier version recorded only the first arrival, which cannot tell a // deployment probe from the panel: "some request reached this route once" and // "the panel is polling it every fifteen seconds" are different claims, and // only the second one says the UI works. Classifying the caller is left to // whoever reads this, because a bare user-agent is evidence while a label is // this plugin's opinion about who called. try { service?.markRouteReached?.(observationOf(req)) } catch { /* observability must never fail the request it is observing */ } // Before the body is read: a page that is not this panel has no business here. const foreign = foreignRequest(req) if (foreign) { return writeJson(res, 403, { ok: false, error: { code: 'FOREIGN_REQUEST', message: foreign } }) } const segments = segmentsOf(req?.url, route) const method = String(req?.method || '').toUpperCase() if (method === 'GET') { const outcome = await runAction(service, DEFAULT_ACTION, {}, { sessionId: undefined, nextTriggerAt }) return send(res, outcome) } if (method !== 'POST') { return writeJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: `use GET or POST, not ${method}` } }) } const body = await readJsonBody(req) if (body === undefined) { return writeJson(res, 400, { ok: false, error: { code: 'BAD_JSON', message: 'the request body is not JSON' } }) } const action = typeof body.action === 'string' && body.action ? body.action : segments[0] || DEFAULT_ACTION const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined const outcome = await runAction(service, action, body, { sessionId, nextTriggerAt }) return send(res, outcome) } catch (error) { // A failing panel must never take the host down; report it in-band. return writeJson(res, 500, { ok: false, error: { code: 'DEN_PANEL_FAILED', message: String(error?.message ?? error) }, }) } }, }) } catch (error) { // Registering can throw — a duplicate path, or a route shape the service // rejects. The message is the whole point: it is the only way to say why. return fail('register-threw', String(error?.message ?? error)) } if (typeof dispose !== 'function') return fail('register-returned-no-disposer') return { mounted: true, reason: null, message: null, path: route, dispose: () => { try { dispose() } catch { /* disposal must not throw into apply() */ } }, } } /** * Report one action outcome in the panel's response shape. * * `invokeAction` returns `error` as a plain string, because that is what the model * and the human read. The panel needs a code as well as the words, so it is * normalised here rather than in the action layer — one place to keep the two * shapes honest. * * Note the status: a refused action is a 200 with `ok: false`, not a 4xx. The panel * has to render the reason, and a transport-level failure is indistinguishable from * a dead route. */ function send(res, outcome) { if (outcome && outcome.ok) return writeJson(res, 200, { ok: true, value: outcome.value }) const raw = outcome?.error const message = typeof raw === 'string' ? raw : String(raw?.message ?? raw ?? 'the action failed') const code = typeof raw?.code === 'string' ? raw.code : 'DEN_ACTION_FAILED' return writeJson(res, 200, { ok: false, error: { code, message } }) }