From fc2ee07076eb397b09db7b2681e5213002df0d70 Mon Sep 17 00:00:00 2001 From: Paul Hernandez <60959+phernandez@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:01:17 -0500 Subject: [PATCH] feat(api): expose owning project on entity resolve Expose project_external_id on v2 entity resolve responses so cloud callers can authorize cross-project targets without a second tenant DB lookup. --- .../api/v2/routers/knowledge_router.py | 11 ++++ src/basic_memory/schemas/v2/entity.py | 1 + tests/api/v2/test_knowledge_router.py | 50 ++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/api/v2/routers/knowledge_router.py b/src/basic_memory/api/v2/routers/knowledge_router.py index a29fb976..7876b202 100644 --- a/src/basic_memory/api/v2/routers/knowledge_router.py +++ b/src/basic_memory/api/v2/routers/knowledge_router.py @@ -18,6 +18,7 @@ from basic_memory.deps import ( EntityServiceV2ExternalDep, SearchServiceV2ExternalDep, LinkResolverV2ExternalDep, + ProjectRepositoryDep, ProjectConfigV2ExternalDep, AppConfigDep, EntityRepositoryV2ExternalDep, @@ -152,6 +153,7 @@ async def resolve_identifier( data: EntityResolveRequest, link_resolver: LinkResolverV2ExternalDep, entity_repository: EntityRepositoryV2ExternalDep, + project_repository: ProjectRepositoryDep, ) -> EntityResolveResponse: """Resolve a string identifier (external_id, permalink, title, or path) to entity info. @@ -175,6 +177,7 @@ async def resolve_identifier( { "external_id": "550e8400-e29b-41d4-a716-446655440000", "entity_id": 123, + "project_external_id": "4b9b7a10-7a63-48d2-ae3f-0d6a2c69313f", "permalink": "specs/search", "file_path": "specs/search.md", "title": "Search Specification", @@ -209,9 +212,17 @@ async def resolve_identifier( if not entity: raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'") + owner_project = await project_repository.get_by_id(entity.project_id) + if not owner_project: # pragma: no cover + raise HTTPException( + status_code=500, + detail="Resolved entity references an unknown project", + ) + result = EntityResolveResponse( external_id=entity.external_id, entity_id=entity.id, + project_external_id=owner_project.external_id, permalink=entity.permalink, file_path=entity.file_path, title=entity.title, diff --git a/src/basic_memory/schemas/v2/entity.py b/src/basic_memory/schemas/v2/entity.py index a2f3a11d..18946617 100644 --- a/src/basic_memory/schemas/v2/entity.py +++ b/src/basic_memory/schemas/v2/entity.py @@ -45,6 +45,7 @@ class EntityResolveResponse(BaseModel): external_id: str = Field(..., description="External UUID (primary API identifier)") entity_id: int = Field(..., description="Numeric entity ID (internal identifier)") + project_external_id: str = Field(..., description="External UUID of the owning project") permalink: Optional[str] = Field(None, description="Entity permalink") file_path: str = Field(..., description="Relative file path") title: str = Field(..., description="Entity title") diff --git a/tests/api/v2/test_knowledge_router.py b/tests/api/v2/test_knowledge_router.py index 08148c87..156b48bf 100644 --- a/tests/api/v2/test_knowledge_router.py +++ b/tests/api/v2/test_knowledge_router.py @@ -1,11 +1,14 @@ """Tests for V2 knowledge graph API routes (ID-based endpoints).""" +from datetime import datetime, timezone import uuid import pytest from httpx import AsyncClient -from basic_memory.models import Project +from basic_memory.models import Entity as EntityModel, Project +from basic_memory.repository.entity_repository import EntityRepository +from basic_memory.repository.project_repository import ProjectRepository from basic_memory.schemas import DeleteEntitiesResponse from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse @@ -40,10 +43,55 @@ async def test_resolve_identifier_by_permalink( assert response.status_code == 200 resolved = EntityResolveResponse.model_validate(response.json()) assert resolved.entity_id == entity_id + assert resolved.project_external_id == test_project.external_id assert resolved.permalink == created_entity.permalink assert resolved.resolution_method == "permalink" +@pytest.mark.asyncio +async def test_resolve_identifier_returns_target_project_external_id_for_cross_project_link( + client: AsyncClient, + session_maker, + tmp_path, + v2_project_url, +): + """Cross-project resolves should expose the owning project external ID.""" + project_repository = ProjectRepository(session_maker) + other_project = await project_repository.create( + { + "name": "other-project", + "description": "Secondary project", + "path": str(tmp_path / "other-project"), + "is_active": True, + "is_default": False, + } + ) + now = datetime.now(timezone.utc) + other_entity_repository = EntityRepository(session_maker, project_id=other_project.id) + target = await other_entity_repository.add( + EntityModel( + title="Cross Project Note", + note_type="note", + content_type="text/markdown", + file_path="docs/Cross Project Note.md", + permalink=f"{other_project.permalink}/docs/cross-project-note", + created_at=now, + updated_at=now, + project_id=other_project.id, + ) + ) + + response = await client.post( + f"{v2_project_url}/knowledge/resolve", + json={"identifier": "other-project::Cross Project Note", "strict": True}, + ) + + assert response.status_code == 200 + resolved = EntityResolveResponse.model_validate(response.json()) + assert resolved.entity_id == target.id + assert resolved.project_external_id == other_project.external_id + + @pytest.mark.asyncio async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url): """Test resolving a non-existent identifier returns 404."""