mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(voice): allow authenticated Cloud callers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b8ffeb973a
commit
c2531c98d9
3 files changed
+98
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user