diff --git a/packages/database/core/migrate/idempotent/003_tree_access.sql b/packages/database/core/migrate/idempotent/003_tree_access.sql index 0fbbd52c..c4cdb6ae 100644 --- a/packages/database/core/migrate/idempotent/003_tree_access.sql +++ b/packages/database/core/migrate/idempotent/003_tree_access.sql @@ -286,6 +286,7 @@ as $func$ ( 'tree_path', x.tree_path , 'access', x.access ) + order by x.tree_path, x.access ), '[]'::jsonb) from ( diff --git a/packages/engine/core/db.integration.test.ts b/packages/engine/core/db.integration.test.ts index 245011be..e18ab34c 100644 --- a/packages/engine/core/db.integration.test.ts +++ b/packages/engine/core/db.integration.test.ts @@ -87,6 +87,21 @@ test("grant + buildTreeAccess returns the search_memory jsonb shape", async () = expect(ta).toHaveLength(2); }); +test("buildTreeAccess returns grants in tree-path order", async () => { + const spaceId = await db.createSpace(randomSlug(), "Ordered"); + const userId = await newUserId(); + await db.createUser(userId, `ordered_${userId.slice(0, 8)}`); + await db.addPrincipalToSpace(spaceId, userId); + await db.grantTreeAccess(spaceId, userId, "zeta", 2); + await db.grantTreeAccess(spaceId, userId, "alpha", 1); + + expect(await db.buildTreeAccess(userId, spaceId)).toEqual([ + { tree_path: "alpha", access: 1 }, + { tree_path: `home.${userId.replace(/-/g, "")}`, access: 3 }, + { tree_path: "zeta", access: 2 }, + ]); +}); + test("group access flows through buildTreeAccess; removeGroupMember revokes it", async () => { const spaceId = await db.createSpace(randomSlug(), "T"); const userId = await newUserId(); diff --git a/packages/server/router.integration.test.ts b/packages/server/router.integration.test.ts new file mode 100644 index 00000000..71d83ba6 --- /dev/null +++ b/packages/server/router.integration.test.ts @@ -0,0 +1,124 @@ +// Integration coverage for user-RPC context assembly at the router boundary. +// +// A restricted PAT's metadata originates in authenticateUser, passes through +// createRouter, and must reach both space.list and the management-method gate. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { bootstrapSpaceDatabase, migrateCore } from "@memory.build/database"; +import type { EmbeddingConfig } from "@memory.build/embedding"; +import { + ACCESS, + type CoreStore, + coreStore, + formatApiKey, +} from "@memory.build/engine/core"; +import type { Sql } from "postgres"; +import postgres from "postgres"; +import { MIN_CLIENT_VERSION, SERVER_VERSION } from "../../version"; +import type { Auth } from "./auth/betterauth"; +import type { ServerContext } from "./context"; +import { createRouter, type Router } from "./router"; + +const URL = + process.env.TEST_DATABASE_URL ?? + "postgresql://postgres@127.0.0.1:5432/postgres"; + +const rand = (n: number) => { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = crypto.getRandomValues(new Uint8Array(n)); + let result = ""; + for (const byte of bytes) result += alphabet[byte % alphabet.length]; + return result; +}; + +let sql: Sql; +let core: CoreStore; +let coreSchema: string; +let router: Router; + +function userRpcRequest(token: string, method: string, params: unknown = {}) { + return new Request("http://localhost/api/v1/user/rpc", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }), + }); +} + +beforeAll(async () => { + sql = postgres(URL, { onnotice: () => {} }); + coreSchema = `core_test_${rand(8)}`; + await bootstrapSpaceDatabase(sql); + await migrateCore(sql, { schema: coreSchema }); + core = coreStore(sql, coreSchema); + + const context: ServerContext = { + db: sql, + // The test uses only the API-key branch, which never reads better-auth. + betterAuth: {} as Auth, + verifyOAuthToken: async () => null, + getUserEmailVerified: async () => true, + core, + authSchema: "auth", + coreSchema, + embeddingConfig: { + provider: "openai", + model: "text-embedding-3-small", + dimensions: 1536, + apiKey: "test-key", + } as EmbeddingConfig, + apiBaseUrl: "https://test.example.com", + webDist: "packages/web/dist", + webAllowedOrigins: ["https://test.example.com"], + serverVersion: SERVER_VERSION, + minClientVersion: MIN_CLIENT_VERSION, + }; + router = createRouter(context); +}); + +afterAll(async () => { + await sql.unsafe(`drop schema if exists ${coreSchema} cascade`); + await sql.end(); +}); + +test("a restricted PAT keeps its space scope and cannot manage the account through the router", async () => { + const [row] = await sql`select uuidv7() as id`; + const userId = row?.id as string; + await core.createUser(userId, `u_${rand(8)}@example.com`); + + const declaredSpaceId = await core.createSpace(rand(12), "Declared"); + const undeclaredSpaceId = await core.createSpace(rand(12), "Undeclared"); + await core.addPrincipalToSpace(declaredSpaceId, userId); + await core.addPrincipalToSpace(undeclaredSpaceId, userId); + + const key = await core.createApiKey(userId, "scoped-pat", { + access: [ + { + spaceId: declaredSpaceId, + grants: [{ treePath: "share", access: ACCESS.read }], + }, + ], + }); + const token = formatApiKey(key.lookupId, key.secret); + + const listResponse = await router.handleRequest( + userRpcRequest(token, "space.list"), + ); + expect(listResponse.status).toBe(200); + const listBody = (await listResponse.json()) as { + result: { spaces: Array<{ id: string }> }; + }; + expect(listBody.result.spaces.map((space) => space.id)).toEqual([ + declaredSpaceId, + ]); + + const managementResponse = await router.handleRequest( + userRpcRequest(token, "agent.list"), + ); + expect(managementResponse.status).toBe(200); + const managementBody = (await managementResponse.json()) as { + error?: { data?: { code?: string } }; + }; + expect(managementBody.error?.data?.code).toBe("FORBIDDEN"); +}); diff --git a/packages/server/router.ts b/packages/server/router.ts index 7126bbed..b0276edd 100644 --- a/packages/server/router.ts +++ b/packages/server/router.ts @@ -196,6 +196,8 @@ export function createRouter(ctx: ServerContext): Router { name, emailVerified, viaApiKey, + apiKeyId, + apiKeyRestricted, authenticatedAs, } = result.context; // Lazy first-login provisioning: stand up the core principal the first time @@ -226,6 +228,8 @@ export function createRouter(ctx: ServerContext): Router { db, coreSchema, viaApiKey, + apiKeyId, + apiKeyRestricted, authenticatedAs, }; });