diff --git a/Makefile b/Makefile index 9802a4d..1d7c8b2 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,9 @@ SSHCMD := ssh -i $(SSH_KEY) -o StrictHostKeyChecking=no deploy: ## rsync app+sql to prod, apply schema (idempotent), rebuild, health-check rsync -az -e "$(SSHCMD)" --exclude .git --exclude __pycache__ --exclude .venv --exclude .env \ - app/ ubuntu@$(EC2_HOST):/home/ubuntu/zynd/app/ - rsync -az -e "$(SSHCMD)" sql/ ubuntu@$(EC2_HOST):/home/ubuntu/zynd/sql/ - $(SSHCMD) ubuntu@$(EC2_HOST) 'cd ~/zynd && \ + app/ ubuntu@$(EC2_HOST):/home/ubuntu/memory-layer/app/ + rsync -az -e "$(SSHCMD)" sql/ ubuntu@$(EC2_HOST):/home/ubuntu/memory-layer/sql/ + $(SSHCMD) ubuntu@$(EC2_HOST) 'cd ~/memory-layer && \ sudo docker compose -f docker-compose.prod.yml exec -T postgres psql -U zynd -d zynd -v ON_ERROR_STOP=1 < sql/schema.sql && \ sudo docker compose -f docker-compose.prod.yml up -d --build api worker mcp' @echo "deployed — health:" && sleep 6 && curl -fsS https://api.zynd.ai/health && echo diff --git a/app/main.py b/app/main.py index cf29d49..378372b 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from app.auth import issue_personal_token, verify_access_claims, verify_access_token from app.config import settings from app.db import close_pool, get_pool, init_pool -from app.models import AssertionView, ConnectRequest, ContextRequest, DeclareRequest, FactRef, IngestRequest, IngestResponse, PublishPageRequest, SocialLinks, UpdatePageRequest +from app.models import AssertionView, ConnectRequest, ContextRequest, DeclareBatchRequest, DeclareRequest, FactRef, IngestRequest, IngestResponse, PublishPageRequest, SocialLinks, UpdatePageRequest from app.services.ingest import ingest_turns from app.connect import router as connect_router from app.docs import router as docs_router @@ -263,9 +263,8 @@ async def my_context(k: int = 20, user_id: str = Depends(current_user)) -> dict: @app.get("/users/{user_id}/graph", response_model=list[AssertionView]) async def get_graph(user_id: str, auth_user: str = Depends(current_user)) -> list[AssertionView]: - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only read your own graph") - return await _active_graph(user_id) + # Use auth_user (resolved internal ID) — path param may be a Supabase UUID. + return await _active_graph(auth_user) @app.get("/me/matches") @@ -355,11 +354,9 @@ async def get_match( auth_user: str = Depends(current_user), ) -> list[dict]: """Top-N users whose `cluster_type` vector is nearest to this user's.""" - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only query your own matches") from app.services.matching import match_users try: - return await match_users(get_pool(), user_id, cluster_type, limit) + return await match_users(get_pool(), auth_user, cluster_type, limit) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -407,6 +404,25 @@ async def declare_findability(req: DeclareRequest, user_id: str = Depends(curren return {"status": "declared", "predicate": req.predicate, "value": req.value} +@app.post("/me/findability/declare-batch") +async def declare_findability_batch(req: DeclareBatchRequest, user_id: str = Depends(current_user)) -> dict: + """Declare multiple public findability facts in one call (used by @zynd/ctx sync). + + Processes each declaration independently — invalid items are skipped with a reason, + valid ones are written. Never aborts the whole batch on a single bad item. + """ + from app.services.findability import declare + declared: list[dict] = [] + skipped: list[dict] = [] + for item in req.declarations: + try: + await declare(get_pool(), user_id, item.predicate, item.value) + declared.append({"predicate": item.predicate, "value": item.value}) + except ValueError as exc: + skipped.append({"predicate": item.predicate, "value": item.value, "reason": str(exc)}) + return {"status": "ok", "declared": declared, "skipped": skipped} + + @app.post("/me/memory/declare") async def declare_memory_fact(req: DeclareRequest, user_id: str = Depends(current_user)) -> dict: """User explicitly adds a PRIVATE memory fact (stays private, never matched).""" @@ -421,21 +437,22 @@ async def declare_memory_fact(req: DeclareRequest, user_id: str = Depends(curren @app.get("/export/{user_id}") async def export_context(user_id: str, auth_user: str = Depends(current_user)) -> dict: """Full active context as a portable JSON-LD packet (brief §11.1).""" - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only export your own context") from app.services.export import build_jsonld_export - return await build_jsonld_export(get_pool(), user_id) + return await build_jsonld_export(get_pool(), auth_user) @app.post("/context/{user_id}") async def context_packet( user_id: str, req: ContextRequest, auth_user: str = Depends(current_user) ) -> list[dict]: - """Top-K assertions relevant to a topic — the MCP slice over HTTP (brief §11.2).""" - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only query your own context") + """Top-K assertions relevant to a topic — the MCP slice over HTTP (brief §11.2). + + `user_id` in the path may be a Supabase UUID; `current_user` translates it + to the internal memory-layer ID. Always query with `auth_user` so Supabase-UUID + callers (agent-persona) hit the same namespace as MCP/ChatGPT callers. + """ from app.services.export import context_slice - return await context_slice(get_pool(), user_id, req.topic, req.k) + return await context_slice(get_pool(), auth_user, req.topic, req.k) # ── Shareable page hosting ────────────────────────────────────────────── diff --git a/app/tools/notion.py b/app/tools/notion.py index 57c3b9e..b1414ee 100644 --- a/app/tools/notion.py +++ b/app/tools/notion.py @@ -13,7 +13,7 @@ def _notion_request(method: str, path: str, user_id: str, payload: dict = None) -> dict: tokens = get_tokens(user_id=user_id, provider="notion") if not tokens: - raise ValueError("Notion not connected. Please connect your Notion account.") + return {"success": False, "error": "Notion not connected. Please connect your Notion account."} headers = { "Authorization": f"Bearer {tokens['access_token']}", diff --git a/tests/test_concurrency_and_isolation.py b/tests/test_concurrency_and_isolation.py index 080a0d7..0aa90a0 100644 --- a/tests/test_concurrency_and_isolation.py +++ b/tests/test_concurrency_and_isolation.py @@ -71,7 +71,7 @@ async def _fake_create_pool(*args, **kwargs): return pool # when — 10 coroutines all call _get_pool() simultaneously - with patch("app.mcp_http.asyncpg.create_pool", side_effect=_fake_create_pool) as cp: + with patch("app.mcp_http.asyncpg.create_pool", new_callable=AsyncMock, side_effect=_fake_create_pool) as cp: results = await asyncio.gather(*[m._get_pool() for _ in range(10)]) # then — create_pool ran exactly once and everyone got the SAME pool object @@ -90,7 +90,7 @@ async def test_second_pool_call_reuses_cached_pool_without_locking(): async def _fake_create_pool(*args, **kwargs): return sentinel - with patch("app.mcp_http.asyncpg.create_pool", side_effect=_fake_create_pool) as cp: + with patch("app.mcp_http.asyncpg.create_pool", new_callable=AsyncMock, side_effect=_fake_create_pool) as cp: first = await m._get_pool() # when — many more callers arrive after init again = await asyncio.gather(*[m._get_pool() for _ in range(20)]) @@ -112,7 +112,7 @@ async def _fake_create(*args, **kwargs): return MagicMock(name="arq-pool") # when — 10 coroutines race to lazily create the arq pool - with patch("app.mcp_http.create_pool", side_effect=_fake_create) as cp: + with patch("app.mcp_http.create_pool", new_callable=AsyncMock, side_effect=_fake_create) as cp: results = await asyncio.gather(*[m._get_arq() for _ in range(10)]) # then — exactly one arq pool exists and is shared by all callers diff --git a/tests/test_export.py b/tests/test_export.py index 5689ad5..4abe578 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -97,5 +97,6 @@ async def test_export_and_context_endpoints(client): assert ctx.status_code == 200 assert isinstance(ctx.json(), list) - forbidden = await client.get("/export/00000000-0000-0000-0000-000000000000", headers=AUTH) - assert forbidden.status_code == 403 + # Passing a different user_id uses auth_user from JWT — returns caller's own export, not 403. + other_path = await client.get("/export/00000000-0000-0000-0000-000000000000", headers=AUTH) + assert other_path.status_code == 200 diff --git a/tests/test_matching.py b/tests/test_matching.py index 4dc48af..69a1cde 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -127,8 +127,9 @@ async def test_match_endpoint_auth_and_validation(client): assert ok.status_code == 200 assert isinstance(ok.json(), list) - forbidden = await client.get("/match/00000000-0000-0000-0000-000000000000", headers=AUTH) - assert forbidden.status_code == 403 + # Passing a different user_id uses auth_user from JWT — returns caller's own matches, not 403. + other_path = await client.get("/match/00000000-0000-0000-0000-000000000000", headers=AUTH) + assert other_path.status_code == 200 bad_cluster = await client.get(f"/match/{dev_id}?cluster_type=bogus", headers=AUTH) assert bad_cluster.status_code == 200 # v2: unknown cluster falls back to full findability card diff --git a/tests/test_oauth_integrations.py b/tests/test_oauth_integrations.py index 0764618..91413e3 100644 --- a/tests/test_oauth_integrations.py +++ b/tests/test_oauth_integrations.py @@ -9,22 +9,16 @@ - /connect password signup + sign-in flow Design notes / findings: - - Every "not connected" tool degrades GRACEFULLY: the credential helpers - (get_google_creds / twitter._get_client / linkedin._get_headers / - notion._notion_request) raise ValueError when get_tokens() returns None, but - each tool wraps its body in try/except and returns {"success": False, - "error": ...}. So the observable contract is a DICT, not an exception. These - tests assert that contract. If any tool is ever changed to let the ValueError - escape, the corresponding test here will fail (it awaits the tool and asserts - a dict) — which is the intended regression guard. + - Notion/_notion_request returns {"success": False, "error": ...} when no tokens + (not a ValueError). All other tools follow the same pattern. - LinkedIn DM send/read are hard-coded placeholders (Partner Program gated); - they return an error dict WITHOUT consulting tokens, so they are "not - connected"-safe by construction. + they return an error dict WITHOUT consulting tokens. - Google/Twitter/LinkedIn/Notion tools import `get_tokens` by symbol, so each is patched in its own module namespace (app.tools..get_tokens), not in app.services.token_store. """ import json +from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -391,13 +385,18 @@ async def test_list_my_pages_empty_for_new_user(): # ── Group 8: Connect flow (password auth) ─────────────────────────────────────── -def _connect_client(mock_pool): +@asynccontextmanager +async def _connect_client(mock_pool): """ASGI client over app.main with app.db.get_pool patched to a mock pool. - Group 8 exercises the /connect HTTP surface without a live Postgres.""" + Group 8 exercises the /connect HTTP surface without a live Postgres. + Patch must remain active for the duration of each request, hence the + asynccontextmanager wrapper — the plain `with patch(...)` form exits before + the ASGI transport makes its first DB call.""" with patch("app.connect.get_pool", return_value=mock_pool): from app.main import app transport = httpx.ASGITransport(app=app) - return httpx.AsyncClient(transport=transport, base_url="http://test") + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c async def test_connect_creates_new_user_and_issues_token(): diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 7e49ceb..16a41db 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -111,6 +111,8 @@ async def test_graph_endpoint_returns_active_assertions(client): predicates = {a["predicate"] for a in r.json()} assert predicates == {"is_learning", "is_building"} - # Cannot read another user's graph. + # Passing a different user_id in the path still returns the caller's own graph + # (auth_user from JWT is used, not the path param — no cross-user data exposure). other = await client.get("/users/00000000-0000-0000-0000-000000000000/graph", headers=AUTH) - assert other.status_code == 403 + assert other.status_code == 200 + assert other.json() == r.json() # same data as the direct /users/{uid}/graph call diff --git a/tests/test_user_journeys.py b/tests/test_user_journeys.py index f331145..1a13238 100644 --- a/tests/test_user_journeys.py +++ b/tests/test_user_journeys.py @@ -392,14 +392,13 @@ async def test_user_publishes_an_html_page_and_gets_a_live_url(): pool = MagicMock() pool.fetchrow = AsyncMock(return_value={"supabase_user_id": "suid-xyz"}) - fake_pages = MagicMock() - fake_pages.create_page = AsyncMock(return_value={ + create_page_mock = AsyncMock(return_value={ "success": True, "url": "https://zynd.io/p/abc123", "slug": "abc123", "title": "Test", }) # when — she asks to publish some HTML as a shareable page with patch("app.mcp_http._get_pool", AsyncMock(return_value=pool)), \ - patch.dict("sys.modules", {"app.services.pages_agent": fake_pages}): + patch("app.services.pages_agent.create_page", create_page_mock): result = await m.publish_page( content="

Hello

", title="Test", format="html", uid=uid, ) @@ -407,23 +406,22 @@ async def test_user_publishes_an_html_page_and_gets_a_live_url(): # then — she gets a live public URL back assert result["success"] is True assert result["url"] == "https://zynd.io/p/abc123" - fake_pages.create_page.assert_awaited_once() + create_page_mock.assert_awaited_once() async def test_anonymous_user_publishes_an_expiring_page(): # given — no signed-in user (uid resolves to None via _uid_opt) - fake_pages = MagicMock() - fake_pages.create_page = AsyncMock(return_value={ + create_page_mock = AsyncMock(return_value={ "success": True, "url": "https://zynd.io/p/temp42", "slug": "temp42", "title": "Temp", }) # when — an anonymous caller publishes a page - with patch.dict("sys.modules", {"app.services.pages_agent": fake_pages}): + with patch("app.services.pages_agent.create_page", create_page_mock): result = await m.publish_page(content="

Hi

", title="Temp", uid=None) # then — the page is hosted, and it was created with the anonymous 5-hour TTL assert result["success"] is True - _, kwargs = fake_pages.create_page.call_args + _, kwargs = create_page_mock.call_args assert kwargs.get("expires_in_hours") == m.PUBLIC_PAGE_TTL_HOURS