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
25 changes: 15 additions & 10 deletions src/observer/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,28 +125,33 @@ export class ObserverServer {

private _handleHttp(req: IncomingMessage, res: ServerResponse): void {
const url = req.url ?? "/";
// Match routes on the PATHNAME, not the raw url — req.url includes the
// query string, so a bare `url === "/api/state"` failed to resolve
// "/api/state?token=…" (it 404'd). Parsing the pathname makes ?token=
// auth work the same as the Authorization header. (audit follow-up)
const pathname = new URL(url, `http://127.0.0.1:${this._port}`).pathname;

// Dashboard pages are served without auth (token is embedded in WS/API URLs)
if (url === "/" || url === "/index.html") {
if (pathname === "/" || pathname === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(getDashboardHtml());
return;
}

// Multi-session grid dashboard (only enabled when registry is attached)
if ((url === "/grid" || url === "/grid/") && this._registry) {
if ((pathname === "/grid" || pathname === "/grid/") && this._registry) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(getGridHtml());
return;
}

if (url === "/api/grid" && this._registry) {
if (pathname === "/api/grid" && this._registry) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(this._registry.gridSnapshot()));
return;
}

if (url.startsWith("/api/session/") && this._registry) {
if (pathname.startsWith("/api/session/") && this._registry) {
const sid = decodeURIComponent(url.replace("/api/session/", "").split("?")[0]!);
const entry = this._registry.getEntry(sid);
if (!entry) {
Expand All @@ -166,26 +171,26 @@ export class ObserverServer {
}

// All /api/* routes require auth
if (url.startsWith("/api/") && !this._checkAuth(req)) {
if (pathname.startsWith("/api/") && !this._checkAuth(req)) {
res.writeHead(401, { "Content-Type": "text/plain" });
res.end("Unauthorized — pass ?token=<token> or Authorization: Bearer <token>");
return;
}

if (url === "/api/state") {
if (pathname === "/api/state") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(this._sessionStore.state));
return;
}

if (url === "/api/events") {
if (pathname === "/api/events") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(this._sessionStore.events.slice(-100)));
return;
}

// Full event history for timeline scrubbing (bounded by MAX_EVENT_FETCH).
if (url.startsWith("/api/events/all")) {
if (pathname.startsWith("/api/events/all")) {
const parsed = new URL(url, `http://127.0.0.1:${this._port}`);
const start = Number(parsed.searchParams.get("start") ?? "0");
const end = Number(parsed.searchParams.get("end") ?? String(Number.MAX_SAFE_INTEGER));
Expand All @@ -195,14 +200,14 @@ export class ObserverServer {
return;
}

if (url === "/api/timeline") {
if (pathname === "/api/timeline") {
const timeline = deriveTimeline(this._sessionStore.events);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(timeline));
return;
}

if (url.startsWith("/api/screenshot")) {
if (pathname.startsWith("/api/screenshot")) {
const parsed = new URL(url, `http://127.0.0.1:${this._port}`);
const seq = Number(parsed.searchParams.get("seq") ?? "0");
const path = screenshotAt(this._sessionStore.events, seq);
Expand Down
31 changes: 17 additions & 14 deletions tests/observer-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,26 @@ describe("observer HTTP/WS server (G3 follow-up)", () => {
expect((await fetch(`${base}/api/state`)).status).toBe(401);
});

it("serves the exact-match data routes when authed via Authorization header", async () => {
// NOTE: /api/state, /api/events, /api/timeline are matched with `url ===`,
// which includes the query string — so they only resolve when there is NO
// query (i.e. token supplied via the Authorization header, not ?token=).
// The query-bearing routes below use startsWith and accept ?token=.
// (Flagged as a server routing quirk; not changed here — test task.)
const hdr = { headers: { authorization: `Bearer ${token}` } };
for (const route of ["/api/state", "/api/events", "/api/timeline"]) {
expect((await fetch(`${base}${route}`, hdr)).status, route).toBe(200);
it("serves all data routes with ?token= (routing matches on pathname)", async () => {
// Routes match on the pathname, so the query string (?token=) no longer
// breaks the exact-match routes — they resolve the same as the
// startsWith ones. (Regression guard for the audit routing fix.)
for (const route of [
"/api/state",
"/api/events",
"/api/timeline",
"/api/events/all?start=0",
"/api/screenshot?seq=0",
]) {
const sep = route.includes("?") ? "&" : "?";
const res = await fetch(`${base}${route}${sep}token=${token}`);
expect(res.status, route).toBe(200);
}
});

it("serves the query-bearing data routes with ?token=", async () => {
for (const route of ["/api/events/all?start=0", "/api/screenshot?seq=0"]) {
const res = await fetch(`${base}${route}&token=${token}`);
expect(res.status, route).toBe(200);
}
it("also accepts the Authorization: Bearer header", async () => {
const hdr = { headers: { authorization: `Bearer ${token}` } };
expect((await fetch(`${base}/api/state`, hdr)).status).toBe(200);
});

it("404s unknown routes", async () => {
Expand Down
Loading