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 ff6f06e3d9f..2867fd32d55 100644 --- a/doc/plans/2026-09-11-speko-voice-e2e-runbook.md +++ b/doc/plans/2026-09-11-speko-voice-e2e-runbook.md @@ -21,7 +21,9 @@ PSTN/interruption qualification. 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, +company scope. Cloud owners use current company membership even when temporary +Cloud administrator access is enabled; they do not need a persisted local +instance-admin role. 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 diff --git a/server/src/__tests__/voice-session-store.integration.test.ts b/server/src/__tests__/voice-session-store.integration.test.ts index 07cbfa2e92c..276bdb353f4 100644 --- a/server/src/__tests__/voice-session-store.integration.test.ts +++ b/server/src/__tests__/voice-session-store.integration.test.ts @@ -1,3 +1,7 @@ +import express from "express"; +import request from "supertest"; +import { errorHandler } from "../middleware/error-handler.js"; +import { voiceSessionRoutes } from "../routes/voice-sessions.js"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -7,7 +11,7 @@ import { VOICE_RESULT_NOTIFICATION } from "@paperclipai/shared"; import { createHash, createHmac, randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { chatActions, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db"; +import { chatActions, instanceUserRoles, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db"; import { configureSpekoSessionTools } from "../services/voice/speko-tool-setup.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { chatChannelService } from "../services/chat-channels.js"; @@ -522,6 +526,34 @@ const support = await getEmbeddedPostgresTestSupport(); return { ...f, service, transport, credentials, input }; } + it("loads history and starts calls for Cloud owners without local admin roles, retaining membership revocation", async () => { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + const f = await nativeServiceFixture(); + await db.update(companyMemberships).set({ membershipRole: "owner" }).where(eq(companyMemberships.principalId, f.callerId)); + expect(await db.select().from(instanceUserRoles).where(eq(instanceUserRoles.userId, f.callerId))).toEqual([]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "cloud_tenant", userId: f.callerId, isInstanceAdmin: true, companyIds: [f.companyId] }; + next(); + }); + app.use("/api", voiceSessionRoutes(db, f.service)); + app.use(errorHandler); + const historyPath = `/api/companies/${f.companyId}/voice-history/${f.endpointId}`; + const history = await request(app).get(historyPath).expect(200); + expect(history.body).toHaveLength(1); + const started = await request(app).post(`/api/companies/${f.companyId}/voice-sessions`) + .send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(201); + const [persisted] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.body.session.id)); + expect(persisted).toMatchObject({ callerId: f.callerId, callerAuthority: "member", companyId: f.companyId }); + expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1); + await db.update(companyMemberships).set({ status: "removed" }).where(eq(companyMemberships.principalId, f.callerId)); + await request(app).get(historyPath).expect(403); + await request(app).post(`/api/companies/${f.companyId}/voice-sessions`) + .send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(403); + expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1); + }); + async function pushFixture() { await instanceSettingsService(db).updateExperimental({enableChatConnectors: true}); const f = await nativeServiceFixture(); diff --git a/server/src/routes/voice-sessions.auth.test.ts b/server/src/routes/voice-sessions.auth.test.ts index 4bf3f98ee09..99a9ed7a196 100644 --- a/server/src/routes/voice-sessions.auth.test.ts +++ b/server/src/routes/voice-sessions.auth.test.ts @@ -43,9 +43,15 @@ describe("voice caller authentication", () => { expect(service.start).toHaveBeenCalledWith(expect.objectContaining({ companyId, endpointId, caller: { id: actor.userId, authority: "member" } })); }); - it("preserves authenticated Cloud instance-admin authority", async () => { + it("keeps Cloud owners company-scoped without a persisted local admin role", 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: "member" }); + }); + + it("preserves local authenticated administrator authority", async () => { + await request(app({ ...actor, isInstanceAdmin: true })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200); expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "instance_admin" }); }); diff --git a/server/src/routes/voice-sessions.ts b/server/src/routes/voice-sessions.ts index adc49244904..add9b278395 100644 --- a/server/src/routes/voice-sessions.ts +++ b/server/src/routes/voice-sessions.ts @@ -14,7 +14,7 @@ function caller(req: Request): VoiceCaller { assertBoard(req); if (req.actor.source === "local_implicit") return { id: "local-board", authority: "local_board" }; 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" }; + return { id: req.actor.userId, authority: req.actor.source === "session" && req.actor.isInstanceAdmin ? "instance_admin" : "member" }; } function company(req: Request, requireAccess = true) { const id = req.params.companyId as string;