diff --git a/apps/api/src/memory-provider-router-wiring.test.ts b/apps/api/src/memory-provider-router-wiring.test.ts new file mode 100644 index 0000000..cf738ce --- /dev/null +++ b/apps/api/src/memory-provider-router-wiring.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("memory provider config router wiring", () => { + it("routes providerConfig through owner-gated getMemoryProviderConfig", () => { + const routerSource = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), "router.ts"), + "utf8", + ); + expect(routerSource).toContain("getMemoryProviderConfig(deps, context.actor)"); + expect(routerSource).not.toMatch(/providerConfig:[\s\S]{0,240}findSpaceMemoryConfig/); + }); +}); diff --git a/apps/api/src/router.test.ts b/apps/api/src/router.test.ts index 6a7b874..9dc4a3d 100644 --- a/apps/api/src/router.test.ts +++ b/apps/api/src/router.test.ts @@ -804,6 +804,99 @@ describe("integration setup authorization", () => { }); }); +describe("memory provider config owner gate", () => { + function memoryProviderDeps(spaceOwner: boolean) { + const spaceMemoryConfigFindUnique = vi.fn().mockResolvedValue({ + provider: "supermemory", + settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, + defaultMemoryScope: "isolated", + updatedAt: new Date("2026-08-19T00:00:00.000Z"), + }); + const spaceMemberFindUnique = vi.fn().mockResolvedValue({ + role: spaceOwner ? "owner" : "member", + }); + const prisma = { + spaceMember: { findUnique: spaceMemberFindUnique }, + spaceMemoryConfig: { findUnique: spaceMemoryConfigFindUnique }, + user: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + email: "user@rakazo.test", + name: "Test User", + avatarStyle: "robot", + }), + }, + spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) }, + deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + } as unknown as PrismaClient; + const deps = { + prisma, + env: { + defaultProvider: "fake", + defaultModel: "fake-model", + webOrigin: "http://127.0.0.1:5173", + screenProxySecret: "fake-test-secret", + sandboxProvider: "fake", + }, + dataDir: "/tmp/rakazo-router-test", + } as unknown as RouterDeps; + const actor = { + spaceId: "workspace-1", + userId: "user-1", + email: spaceOwner ? "owner@rakazo.test" : "member@rakazo.test", + isDeploymentOwner: false, + } satisfies Actor; + return { + deps, + actor, + handler: new RPCHandler(createRouter(deps)), + spaceMemberFindUnique, + spaceMemoryConfigFindUnique, + }; + } + + it("forbids non-owners from reading provider configuration", async () => { + const { handler, actor, spaceMemberFindUnique, spaceMemoryConfigFindUnique } = + memoryProviderDeps(false); + + const { response } = await handler.handle( + new Request("http://127.0.0.1/rpc/memory/providerConfig", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ json: null }), + }), + { prefix: "/rpc", context: { actor } }, + ); + + expect(response.status).toBe(403); + expect(spaceMemberFindUnique).toHaveBeenCalled(); + expect(spaceMemoryConfigFindUnique).not.toHaveBeenCalled(); + }); + + it("returns provider configuration for Space owners", async () => { + const { handler, actor, spaceMemoryConfigFindUnique } = memoryProviderDeps(true); + + const { response } = await handler.handle( + new Request("http://127.0.0.1/rpc/memory/providerConfig", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ json: null }), + }), + { prefix: "/rpc", context: { actor } }, + ); + + expect(response.status).toBe(200); + expect(spaceMemoryConfigFindUnique).toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + json: { + provider: "supermemory", + settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, + defaultMemoryScope: "isolated", + updatedAt: "2026-08-19T00:00:00.000Z", + }, + }); + }); +}); + describe("interrupted computer reservation release", () => { function fixture() { const order: string[] = []; diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index b4b1328..dbdd0c5 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -114,7 +114,6 @@ import { findDefaultModelCredential, findDefaultVoiceCredential, findModelCredential, - findSpaceMemoryConfig, formatMessagingLinkCode, InvalidSpaceNameError, IsolationError, @@ -151,8 +150,8 @@ import { searchIntegrationCatalog } from "./integration-catalog.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js"; import { disconnectMemoryProvider, + getMemoryProviderConfig, persistMemoryProviderConfig, - serializeSpaceMemoryConfig, updateMemoryProviderDefaultScope, } from "./memory-provider-config.js"; import { @@ -2323,10 +2322,9 @@ export function createRouter(deps: RouterDeps) { }); return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n"); }), - providerConfig: authed.memory.providerConfig.handler(async ({ context }) => { - const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId); - return config ? serializeSpaceMemoryConfig(config) : null; - }), + providerConfig: authed.memory.providerConfig.handler(async ({ context }) => + getMemoryProviderConfig(deps, context.actor), + ), connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) => persistMemoryProviderConfig(deps, context.actor, input), ), diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 3fd6019..93b4a1a 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -126,6 +126,10 @@ describeWithDatabase("API authorization and resource isolation", () => { ["memory/list", {}], ["memory/update", { documentId: "missing-memory", content: "Nope" }], ["memory/exportMarkdown", {}], + ["memory/providerConfig"], + ["memory/connectProvider", { provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" }], + ["memory/setDefaultScope", { defaultMemoryScope: "shared" }], + ["memory/disconnectProvider"], ["routines/list", { botId: "missing-bot" }], ["routines/create", routineInput("missing-bot")], ["routines/update", { routineId: "missing-routine", name: "Nope" }], @@ -705,6 +709,80 @@ describeWithDatabase("API authorization and resource isolation", () => { await expectDenied(app, intruder, "bots/list", {}, support.id); }); + it("restricts memory provider configuration to Space owners", async () => { + const owner = await signup(app, `memory-provider-owner-${stamp}@rakazo.test`, "Memory Owner"); + const member = await signup( + app, + `memory-provider-member-${stamp}@rakazo.test`, + "Memory Member", + ); + const memberActor = await rpc(app, member, "me"); + + const shared = await rpc(app, owner, "spaces/create", { name: "Shared memory config" }); + const sharedRow = await handles.prisma.space.findUniqueOrThrow({ + where: { id: shared.id }, + select: { organizationId: true }, + }); + + await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); + await handles.prisma.member.create({ + data: { + id: `memory-provider-member-org-${stamp}`, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + await handles.prisma.spaceMember.create({ + data: { + id: `memory-provider-member-space-${stamp}`, + spaceId: shared.id, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + + const ownerActor = await rpc(app, owner, "me", {}, shared.id); + const secret = await handles.prisma.secret.create({ + data: { + userId: ownerActor.userId, + spaceId: shared.id, + kind: "memory-provider", + ciphertext: "encrypted-memory-provider-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", + }, + }); + + await expectForbidden(app, member, "memory/providerConfig", {}, shared.id); + await expectForbidden(app, member, "memory/connectProvider", { + provider: "supermemory", + settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, + credentials: { apiKey: "not-a-real-key" }, + defaultMemoryScope: "isolated", + }, shared.id); + await expectForbidden(app, member, "memory/setDefaultScope", { defaultMemoryScope: "shared" }, shared.id); + await expectForbidden(app, member, "memory/disconnectProvider", {}, shared.id); + + expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({ + provider: "supermemory", + settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, + defaultMemoryScope: "isolated", + updatedAt: expect.any(String), + }); + }); + it("enforces the space limit across concurrent creation requests", async () => { const cookie = await signup(app, `space-limit-${stamp}@rakazo.test`, "Space Limit"); const actor = await rpc(app, cookie, "me");