Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 31 additions & 14 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)."""
Expand All @@ -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 ──────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion app/tools/notion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}",
Expand Down
6 changes: 3 additions & 3 deletions tests/test_concurrency_and_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)])
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 12 additions & 13 deletions tests/test_oauth_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<mod>.get_tokens), not in
app.services.token_store.
"""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
Expand Down Expand Up @@ -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():
Expand Down
6 changes: 4 additions & 2 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 6 additions & 8 deletions tests/test_user_journeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,38 +392,36 @@ 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="<h1>Hello</h1>", title="Test", format="html", uid=uid,
)

# 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="<h1>Hi</h1>", 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


Expand Down