diff --git a/apps/api/src/ai-consent.test.ts b/apps/api/src/ai-consent.test.ts index b6e0536..8b767a0 100644 --- a/apps/api/src/ai-consent.test.ts +++ b/apps/api/src/ai-consent.test.ts @@ -21,7 +21,11 @@ function setup() { findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })), findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]), }, - spaceMemoryConfig: { findUnique: vi.fn(async () => null) }, + spaceMemoryConfig: { + findUnique: vi.fn(async () => null), + count: vi.fn(async () => 0), + }, + spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) }, deploymentSettings: { findUnique: vi.fn(async () => null) }, aiDataConsent: { findMany: vi.fn(async () => []), upsert }, $transaction: vi.fn(async (queries) => Promise.all(queries)), @@ -65,6 +69,29 @@ describe("consent grants", () => { expect(status.recipients[0]?.name).toBe("OpenAI"); expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled(); }); + it("discloses memory provider details to space owners", async () => { + const { deps, actor } = setup(); + vi.mocked(deps.prisma.spaceMemoryConfig.findUnique).mockResolvedValue({ + provider: "supermemory", + settings: { baseUrl: "http://127.0.0.1:8123" }, + } as never); + const status = await aiConsentStatus(deps, actor, { uses: ["memory"] }); + expect(status.recipients).toHaveLength(1); + expect(status.recipients[0]?.name).toContain("Supermemory"); + expect(status.recipients[0]?.name).toContain("127.0.0.1:8123"); + expect(deps.prisma.spaceMemoryConfig.count).not.toHaveBeenCalled(); + }); + it("redacts space memory provider details for non-owner members", async () => { + const { deps, actor } = setup(); + vi.mocked(deps.prisma.spaceMember.findUnique).mockResolvedValue({ role: "member" } as never); + vi.mocked(deps.prisma.spaceMemoryConfig.count).mockResolvedValue(1); + const status = await aiConsentStatus(deps, actor, { uses: ["memory"] }); + expect(status.recipients).toHaveLength(1); + expect(status.recipients[0]?.name).toBe("Space memory"); + expect(JSON.stringify(status.recipients)).not.toContain("127.0.0.1"); + expect(JSON.stringify(status.recipients)).not.toContain("supermemory"); + expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled(); + }); it("returns an operator policy without requiring a hosted provider", async () => { const { deps, actor } = setup(); deps.env.privacyPolicyUrl = "https://example.com/privacy"; diff --git a/apps/api/src/ai-consent.ts b/apps/api/src/ai-consent.ts index 1df8172..76d0440 100644 --- a/apps/api/src/ai-consent.ts +++ b/apps/api/src/ai-consent.ts @@ -8,15 +8,25 @@ import { toStringRecord, } from "@rakazo/adapters"; import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts"; -import { AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts"; +import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts"; import { findDefaultModelCredential, findDefaultVoiceCredential, findModelCredential, } from "@rakazo/db"; +import { isSpaceOwner } from "./memory-provider-config.js"; import type { RouterDeps } from "./router.js"; import { resolveThreadTarget } from "./thread-target.js"; +function genericSpaceMemoryRecipient(): Omit { + return { + key: createHash("sha256").update(JSON.stringify(["memory", "space-memory"])).digest("hex"), + name: "Space memory", + use: "memory", + detail: AI_DATA_DISCLOSURES.memory, + }; +} + export async function aiConsentStatus( deps: RouterDeps, actor: Actor, @@ -31,7 +41,8 @@ export async function aiConsentStatus( ? [target.botId] : target.memberBotIds : undefined; - const [preferences, defaultCredential, bots, voices, memory, settings, consents] = + const includesMemory = uses.includes("memory"); + const [preferences, defaultCredential, bots, voices, memoryOwner, settings, consents] = await Promise.all([ modelsEnabled ? deps.prisma.spaceModelPreference.findMany({ @@ -61,9 +72,7 @@ export async function aiConsentStatus( include: { credential: true }, }) : [], - uses.includes("memory") - ? deps.prisma.spaceMemoryConfig.findUnique({ where: { spaceId: actor.spaceId } }) - : null, + includesMemory ? isSpaceOwner(deps.prisma, actor) : false, modelsEnabled ? deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }) : null, @@ -161,14 +170,25 @@ export async function aiConsentStatus( } for (const voice of voices) add(aiRecipient({ provider: voice.credential.provider, use: "voice" })); - if (memory) - add( - aiRecipient({ - provider: memory.provider, - use: "memory", - baseUrl: toStringRecord(memory.settings).baseUrl, - }), - ); + if (includesMemory) { + if (memoryOwner) { + const memory = await deps.prisma.spaceMemoryConfig.findUnique({ + where: { spaceId: actor.spaceId }, + }); + if (memory) + add( + aiRecipient({ + provider: memory.provider, + use: "memory", + baseUrl: toStringRecord(memory.settings).baseUrl, + }), + ); + } else if ( + (await deps.prisma.spaceMemoryConfig.count({ where: { spaceId: actor.spaceId } })) > 0 + ) { + add(genericSpaceMemoryRecipient()); + } + } if ( uses.includes("model") && deps.cloudAgent && diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 93b4a1a..ced0fec 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator } from "@rakazo/adapters"; -import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts"; +import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts"; import { claimEmptySpaceDeletionForMember, deleteEmptySpaceForMember, @@ -585,6 +585,78 @@ describeWithDatabase("API authorization and resource isolation", () => { ).not.toBeNull(); }); + it("redacts memory provider details in aiConsent/status for non-owner members", async () => { + const owner = await signup(app, `ai-consent-owner-${stamp}@rakazo.test`, "Consent Owner"); + const ownerActor = await rpc(app, owner, "me"); + const shared = await rpc(app, owner, "spaces/create", { name: "Consent shared" }); + const sharedRow = await handles.prisma.space.findUniqueOrThrow({ + where: { id: shared.id }, + select: { organizationId: true }, + }); + const secret = await handles.prisma.secret.create({ + data: { + userId: ownerActor.userId, + spaceId: shared.id, + kind: "memory-provider", + ciphertext: "encrypted-test-key", + }, + }); + await handles.prisma.spaceMemoryConfig.create({ + data: { + spaceId: shared.id, + userId: ownerActor.userId, + provider: "supermemory", + settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, + secretId: secret.id, + defaultMemoryScope: "isolated", + }, + }); + + const member = await signup(app, `ai-consent-member-${stamp}@rakazo.test`, "Consent Member"); + const memberActor = await rpc(app, member, "me"); + await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); + await handles.prisma.member.create({ + data: { + id: `ai-consent-org-member-${stamp}`, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + await handles.prisma.spaceMember.create({ + data: { + id: `ai-consent-space-member-${stamp}`, + spaceId: shared.id, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + + const ownerStatus = await rpc( + app, + owner, + "aiConsent/status", + { uses: ["memory"] }, + shared.id, + ); + const memberStatus = await rpc( + app, + member, + "aiConsent/status", + { uses: ["memory"] }, + shared.id, + ); + const ownerMemory = ownerStatus.recipients.find((recipient) => recipient.use === "memory"); + const memberMemory = memberStatus.recipients.find((recipient) => recipient.use === "memory"); + expect(ownerMemory?.name).toContain("127.0.0.1:8123"); + expect(memberMemory?.name).toBe("Space memory"); + expect(JSON.stringify(memberStatus.recipients)).not.toContain("127.0.0.1"); + expect(JSON.stringify(memberStatus.recipients)).not.toMatch(/supermemory/i); + }); + it("keeps space data and computers behind the selected space boundary", async () => { const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner"); const original = await rpc(app, cookie, "me");