fix(gateway): allow node chat and session rpc methods

This commit is contained in:
Ayaan Zaidi
2026-02-12 13:24:10 +05:30
parent 9ab339e897
commit a08dab815e
3 changed files with 110 additions and 2 deletions

View File

@@ -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");
});
});

View File

@@ -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") {

View File

@@ -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");