diff --git a/src/gateway/server-methods.authorization.test.ts b/src/gateway/server-methods.authorization.test.ts new file mode 100644 index 00000000000..cbefe0531e5 --- /dev/null +++ b/src/gateway/server-methods.authorization.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { ErrorCodes, type ErrorShape } from "./protocol/index.js"; +import { handleGatewayRequest } from "./server-methods.js"; + +type AuthResult = { + ok: boolean | null; + error?: ErrorShape; + called: boolean; +}; + +async function runMethod(method: string, role: "node" | "operator", scopes: string[] = []) { + let called = false; + let ok: boolean | null = null; + let error: ErrorShape | undefined; + + await handleGatewayRequest({ + req: { + type: "req", + id: "test-1", + method, + params: {}, + } as never, + client: { + connect: { + role, + scopes, + }, + } as never, + isWebchatConnect: () => false, + respond: (nextOK, _payload, nextError) => { + ok = nextOK; + error = nextError; + }, + context: {} as never, + extraHandlers: { + [method]: async () => { + called = true; + }, + }, + }); + + return { + ok, + error, + called, + } satisfies AuthResult; +} + +describe("gateway method authorization", () => { + it("allows node role to use chat/session methods needed by mobile chat UI", async () => { + const chatHistory = await runMethod("chat.history", "node"); + expect(chatHistory.called).toBe(true); + expect(chatHistory.ok).toBe(null); + + const chatSend = await runMethod("chat.send", "node"); + expect(chatSend.called).toBe(true); + expect(chatSend.ok).toBe(null); + + const sessionsList = await runMethod("sessions.list", "node"); + expect(sessionsList.called).toBe(true); + expect(sessionsList.ok).toBe(null); + }); + + it("still blocks non-allowed methods for node role", async () => { + const result = await runMethod("status", "node"); + expect(result.called).toBe(false); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(result.error?.message).toContain("unauthorized role: node"); + }); + + it("keeps node-only methods restricted from operator role", async () => { + const result = await runMethod("node.event", "operator", ["operator.admin"]); + expect(result.called).toBe(false); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(result.error?.message).toContain("unauthorized role: operator"); + }); +}); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1d8437f73d2..ae172ddde72 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -33,7 +33,16 @@ const APPROVALS_SCOPE = "operator.approvals"; const PAIRING_SCOPE = "operator.pairing"; const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); -const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); +const NODE_ONLY_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); +// Node clients (iOS/Android) embed chat UI and need these RPCs. +const NODE_ALLOWED_METHODS = new Set([ + ...NODE_ONLY_METHODS, + "health", + "sessions.list", + "chat.history", + "chat.send", + "chat.abort", +]); const PAIRING_METHODS = new Set([ "node.pair.request", "node.pair.list", @@ -96,13 +105,16 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c } const role = client.connect.role ?? "operator"; const scopes = client.connect.scopes ?? []; - if (NODE_ROLE_METHODS.has(method)) { + if (NODE_ONLY_METHODS.has(method)) { if (role === "node") { return null; } return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } if (role === "node") { + if (NODE_ALLOWED_METHODS.has(method)) { + return null; + } return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } if (role !== "operator") { diff --git a/src/gateway/server.roles-allowlist-update.e2e.test.ts b/src/gateway/server.roles-allowlist-update.e2e.test.ts index 1e63c588e43..749959f7e75 100644 --- a/src/gateway/server.roles-allowlist-update.e2e.test.ts +++ b/src/gateway/server.roles-allowlist-update.e2e.test.ts @@ -148,6 +148,23 @@ describe("gateway role enforcement", () => { expect(binsRes.ok).toBe(true); expect(Array.isArray(binsRes.payload?.bins)).toBe(true); + const healthRes = await rpcReq<{ ok?: boolean }>(nodeWs, "health", {}); + expect(healthRes.ok).toBe(true); + expect(healthRes.payload?.ok).toBe(true); + + const historyRes = await rpcReq<{ messages?: unknown[] }>(nodeWs, "chat.history", { + sessionKey: "main", + }); + expect(historyRes.ok).toBe(true); + expect(Array.isArray(historyRes.payload?.messages)).toBe(true); + + const sessionsRes = await rpcReq<{ sessions?: unknown[] }>(nodeWs, "sessions.list", { + includeGlobal: true, + includeUnknown: false, + }); + expect(sessionsRes.ok).toBe(true); + expect(Array.isArray(sessionsRes.payload?.sessions)).toBe(true); + const statusRes = await rpcReq(nodeWs, "status", {}); expect(statusRes.ok).toBe(false); expect(statusRes.error?.message ?? "").toContain("unauthorized role");