From c2531c98d91901fbf239e5483a3ad6eaa893f123 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 13 Oct 2026 21:10:38 -0500 Subject: [PATCH] fix(voice): allow authenticated Cloud callers Co-Authored-By: Paperclip --- .../2026-09-11-speko-voice-e2e-runbook.md | 12 +++ server/src/routes/voice-sessions.auth.test.ts | 85 +++++++++++++++++++ server/src/routes/voice-sessions.ts | 2 +- 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 server/src/routes/voice-sessions.auth.test.ts diff --git a/doc/plans/2026-09-11-speko-voice-e2e-runbook.md b/doc/plans/2026-09-11-speko-voice-e2e-runbook.md index 2e59578412b..ff6f06e3d9f 100644 --- a/doc/plans/2026-09-11-speko-voice-e2e-runbook.md +++ b/doc/plans/2026-09-11-speko-voice-e2e-runbook.md @@ -17,6 +17,18 @@ unchanged. See the [live-call push qualification](2026-10-09-speko-pr-qualificat for the real-provider text-input proof, timings, reproduction and remaining PSTN/interruption qualification. +## Cloud caller authentication (2026-10-13) + +Private voice routes accept both local authenticated sessions and trusted Cloud +user sessions. Cloud callers keep their authenticated user ID and current +company scope. Board API keys, MCP OAuth callers, Cloud control credentials, +and raw Cloud user headers do not create private caller authority. + +On a Cloud workspace, verify that the signed-in operator can load call history +and start a browser session. Repeat with a user outside the company and confirm +access is denied. Provider configuration alone does not qualify conversation +or spoken result delivery. + ## October task-parity qualification Updated 2026-10-07: September's browser proof is historical. The current task-parity diff --git a/server/src/routes/voice-sessions.auth.test.ts b/server/src/routes/voice-sessions.auth.test.ts new file mode 100644 index 00000000000..4bf3f98ee09 --- /dev/null +++ b/server/src/routes/voice-sessions.auth.test.ts @@ -0,0 +1,85 @@ +import express from "express"; +import request from "supertest"; +import type { Db } from "@paperclipai/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/error-handler.js"; +import type { VoiceSessionService } from "../services/voice/voice-session-service.js"; +import { voiceSessionRoutes } from "./voice-sessions.js"; + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => ({ getExperimental: async () => ({ enableChatConnectors: true }) }), +})); + +const companyId = "11111111-1111-4111-8111-111111111111"; +const endpointId = "22222222-2222-4222-8222-222222222222"; +const idempotencyKey = "33333333-3333-4333-8333-333333333333"; +const actor: Express.Request["actor"] = { + type: "board", source: "session", userId: "signed-in-user", companyIds: [companyId], +}; +const service = { history: vi.fn(), start: vi.fn() }; + +function app(currentActor = actor) { + const server = express(); + server.use(express.json()); + server.use((req, _res, next) => { req.actor = currentActor; next(); }); + server.use("/api", voiceSessionRoutes({} as Db, service as unknown as VoiceSessionService)); + server.use(errorHandler); + return server; +} + +beforeEach(() => { + vi.resetAllMocks(); + service.history.mockResolvedValue([]); + service.start.mockResolvedValue({ session: { id: "test-session" } }); +}); + +describe("voice caller authentication", () => { + it.each(["session", "cloud_tenant"] as const)("allows signed-in %s users to read calls and start a session", async (source) => { + const server = app({ ...actor, source }); + await request(server).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200, []); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "member" }); + await request(server).post(`/api/companies/${companyId}/voice-sessions`) + .send({ endpointId, idempotencyKey }).expect(200); + expect(service.start).toHaveBeenCalledWith(expect.objectContaining({ companyId, endpointId, caller: { id: actor.userId, authority: "member" } })); + }); + + it("preserves authenticated Cloud instance-admin authority", async () => { + await request(app({ ...actor, source: "cloud_tenant", isInstanceAdmin: true })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "instance_admin" }); + }); + + it("preserves local trusted callers", async () => { + await request(app({ type: "board", source: "local_implicit" })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: "local-board", authority: "local_board" }); + }); + + it.each(["board_key", "mcp_oauth", "cloud_control"] as const)("rejects %s credentials as private human callers", async (source) => { + await request(app({ ...actor, source })).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403); + expect(service.history).not.toHaveBeenCalled(); + }); + + it.each(["session", "cloud_tenant"] as const)("requires a user identity for %s callers", async (source) => { + await request(app({ ...actor, source, userId: undefined })).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403); + expect(service.history).not.toHaveBeenCalled(); + }); + + it("retains Cloud company isolation", async () => { + const server = app({ ...actor, source: "cloud_tenant", companyIds: [] }); + await request(server).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403); + await request(server).post(`/api/companies/${companyId}/voice-sessions`).send({ endpointId, idempotencyKey }).expect(403); + expect(service.history).not.toHaveBeenCalled(); + expect(service.start).not.toHaveBeenCalled(); + }); + + it("does not authenticate raw Cloud headers or agent callers", async () => { + await request(app({ type: "none", source: "none" })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`) + .set("x-paperclip-cloud-user-id", actor.userId!).expect(403); + await request(app({ type: "agent", source: "agent_key", companyId })) + .post(`/api/companies/${companyId}/voice-sessions`).send({ endpointId, idempotencyKey }).expect(403); + expect(service.history).not.toHaveBeenCalled(); + expect(service.start).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/voice-sessions.ts b/server/src/routes/voice-sessions.ts index ed16b415294..adc49244904 100644 --- a/server/src/routes/voice-sessions.ts +++ b/server/src/routes/voice-sessions.ts @@ -13,7 +13,7 @@ import { assertBoard, assertCompanyAccess } from "./authz.js"; function caller(req: Request): VoiceCaller { assertBoard(req); if (req.actor.source === "local_implicit") return { id: "local-board", authority: "local_board" }; - if (!req.actor.userId || req.actor.source !== "session") throw forbidden("Sign in to start a private voice conversation"); + if (!req.actor.userId || !["session", "cloud_tenant"].includes(req.actor.source ?? "")) throw forbidden("Sign in to start a private voice conversation"); return { id: req.actor.userId, authority: req.actor.isInstanceAdmin ? "instance_admin" : "member" }; } function company(req: Request, requireAccess = true) {