A holder is now somebody who can prove it. Schema v11 stores a sha256 of a per-holder token beside the directory row, so a copied database cannot replay it and the plaintext is shown exactly once. The operator token mints and revokes those tokens; a holder token reads that holder's own memories and nothing else; naming somebody else without the operator token is refused with both ids in the message. The visibility rule was leaking before this: browse had none at all, and the search rules short-circuited to the whole library whenever no holder was named. The rule is now written out in five copies with one parameter carried four times, and a test compares the copies so they cannot drift — semgrep rejects an interpolated constant, so the copies are not a choice. Eyes carry three states on purpose. NULL means the whole library, which is what a process that owns the store is entitled to; a holder id means that holder; the empty string means nobody, which is what a tokenless HTTP caller is. Making NULL mean nobody instead would have forced every internal caller — CLI, proofs, the compiler, the bench harnesses — to name eyes, contradicting the rule that a local process reads as its own trusted principal. The capability card answers "can this store answer my kind of question" before anything is sent. It is derived from what the store holds, so it cannot promise a modality the store lacks, and it carries no memory text, so it is safe to read before trusting the store. HTTP, CLI and one MCP tool all serve it without a token.
269 lines
12 KiB
Python
269 lines
12 KiB
Python
"""The sharing rule over HTTP: a token says who is asking, and the store answers to that person."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import ely_eye.api as api_module
|
|
from ely_eye.compiler import ContextCompiler
|
|
from ely_eye.config import Settings
|
|
from ely_eye.db import Database
|
|
from ely_eye.holder_store import HolderStore
|
|
from ely_eye.memory import MemoryService
|
|
from ely_eye.schemas import (
|
|
EvidenceAtom,
|
|
MemoryOrigin,
|
|
Modality,
|
|
RuntimeProfile,
|
|
SourceRecord,
|
|
TrustScores,
|
|
Visibility,
|
|
)
|
|
from ely_eye.storage import ObjectStore
|
|
|
|
OPERATOR = "the-operator-token"
|
|
AS_OPERATOR = {"Authorization": f"Bearer {OPERATOR}"}
|
|
PNG = b"\x89PNG\r\n\x1a\n"
|
|
KEYS = "我们的钥匙放在玄关抽屉里"
|
|
THERMOSTAT = "恒温器设在二十二度"
|
|
OUTAGE = "上周三的停电持续了四十分钟"
|
|
|
|
|
|
def _settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
|
resolved = Settings(home=tmp_path, default_holder="wife", auth_token=OPERATOR)
|
|
monkeypatch.setattr(api_module, "get_settings", lambda: resolved)
|
|
Database(resolved).init()
|
|
return resolved
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
|
return TestClient(api_module.create_app())
|
|
|
|
|
|
@pytest.fixture
|
|
def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
|
return _settings(tmp_path, monkeypatch)
|
|
|
|
|
|
def _token_for(client: TestClient, holder_id: str) -> dict[str, Any]:
|
|
answer = client.post(f"/api/holders/{holder_id}/token", headers=AS_OPERATOR)
|
|
assert answer.status_code == 200, answer.json()
|
|
return dict(answer.json())
|
|
|
|
|
|
def _remember(settings: Settings, text: str, holder_id: str, **kwargs: Any) -> EvidenceAtom:
|
|
return MemoryService(settings, Database(settings)).remember(
|
|
text, holder_id=holder_id, origin=MemoryOrigin.self_, **kwargs
|
|
)
|
|
|
|
|
|
def _hits(client: TestClient, query: str, headers: dict[str, str] | None = None, **body: Any) -> list[str]:
|
|
answer = client.post("/api/search", json={"query": query, **body}, headers=headers or {})
|
|
assert answer.status_code == 200, answer.json()
|
|
return [str(hit["atom"]["atom_id"]) for hit in answer.json()["hits"]]
|
|
|
|
|
|
def test_only_the_operator_mints_a_token(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife")
|
|
wife = _token_for(client, "wife")
|
|
assert client.post("/api/holders/husband/token").status_code == 401, "no token cannot mint one"
|
|
refused = client.post("/api/holders/husband/token", headers={"Authorization": f"Bearer {wife['token']}"})
|
|
assert refused.status_code == 401, "a holder token is not an operator token"
|
|
assert "Bearer" in refused.json()["detail"]
|
|
|
|
|
|
def test_the_token_is_shown_once_and_stored_as_a_hash(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife")
|
|
issued = _token_for(client, "wife")
|
|
token = str(issued["token"])
|
|
assert issued["holder_id"] == "wife" and issued["issued_at"]
|
|
|
|
with Database(settings).connect() as connection:
|
|
row = connection.execute("SELECT token_hash FROM holders WHERE holder_id = 'wife'").fetchone()
|
|
stored = str(row["token_hash"])
|
|
assert stored == hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
assert token not in stored and len(stored) == 64, "a copied database must not replay the token"
|
|
|
|
listed = client.get("/api/holders/wife", headers=AS_OPERATOR).json()
|
|
assert "token" not in listed and listed["token_issued_at"] == issued["issued_at"]
|
|
|
|
|
|
def test_a_holder_token_reads_as_that_holder_and_nobody_else(client: TestClient, settings: Settings) -> None:
|
|
mine = _remember(settings, KEYS, "wife")
|
|
wife = _token_for(client, "wife")
|
|
_token_for(client, "husband")
|
|
|
|
as_wife = _hits(client, KEYS, headers={"Authorization": f"Bearer {wife['token']}"})
|
|
as_husband = _hits(client, KEYS, headers={"Authorization": f"Bearer {wife['token']}"})
|
|
assert mine.atom_id in as_wife
|
|
assert as_husband == as_wife, "the same token reads the same way whatever the query says"
|
|
|
|
anonymous = _hits(client, KEYS)
|
|
assert mine.atom_id not in anonymous, "a caller with no token is nobody"
|
|
|
|
|
|
def test_an_unknown_token_is_refused_rather_than_treated_as_nobody(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife")
|
|
refused = client.post("/api/search", json={"query": KEYS}, headers={"Authorization": "Bearer not-a-token"})
|
|
assert refused.status_code == 401
|
|
detail = str(refused.json()["detail"])
|
|
assert "operator" in detail and "holder" in detail, "the refusal has to say which two things failed"
|
|
|
|
|
|
def test_a_public_memory_is_findable_by_anybody(client: TestClient, settings: Settings) -> None:
|
|
shared = _remember(settings, OUTAGE, "wife", visibility=Visibility(scope="everyone"))
|
|
assert shared.atom_id in _hits(client, OUTAGE), "no token, still visible"
|
|
|
|
|
|
def test_a_shared_memory_is_readable_only_by_the_holders_it_names(client: TestClient, settings: Settings) -> None:
|
|
private = _remember(settings, THERMOSTAT, "wife", visibility=Visibility(scope="visible_to", holders=["husband"]))
|
|
_remember(settings, KEYS, "husband")
|
|
wife = _token_for(client, "wife")
|
|
husband = _token_for(client, "husband")
|
|
|
|
assert private.atom_id in _hits(client, THERMOSTAT, headers={"Authorization": f"Bearer {husband['token']}"})
|
|
assert private.atom_id in _hits(client, THERMOSTAT, headers={"Authorization": f"Bearer {wife['token']}"})
|
|
assert private.atom_id not in _hits(client, THERMOSTAT)
|
|
|
|
outsider = _remember(settings, OUTAGE, "neighbour")
|
|
_token_for(client, "neighbour")
|
|
neighbour = _token_for(client, "neighbour")
|
|
assert outsider.atom_id
|
|
assert private.atom_id not in _hits(client, THERMOSTAT, headers={"Authorization": f"Bearer {neighbour['token']}"})
|
|
|
|
|
|
def test_naming_another_holder_is_a_claim_the_token_has_to_back(client: TestClient, settings: Settings) -> None:
|
|
mine = _remember(settings, KEYS, "wife")
|
|
wife = _token_for(client, "wife")
|
|
_token_for(client, "husband")
|
|
as_wife = {"Authorization": f"Bearer {wife['token']}"}
|
|
|
|
over = client.post("/api/search", json={"query": KEYS, "filters": {"as_holder": "husband"}}, headers=as_wife)
|
|
assert over.status_code == 403
|
|
detail = str(over.json()["detail"])
|
|
assert "wife" in detail and "husband" in detail, "a refusal that names neither side cannot be acted on"
|
|
|
|
anonymous = client.post("/api/search", json={"query": KEYS, "filters": {"as_holder": "wife"}})
|
|
assert anonymous.status_code == 403, "typing a name is not a token"
|
|
|
|
operator = client.post("/api/search", json={"query": KEYS, "filters": {"as_holder": "wife"}}, headers=AS_OPERATOR)
|
|
assert operator.status_code == 200
|
|
assert mine.atom_id in [str(hit["atom"]["atom_id"]) for hit in operator.json()["hits"]]
|
|
|
|
|
|
def test_revoking_a_token_stops_it_working(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife")
|
|
token = _token_for(client, "wife")["token"]
|
|
assert HolderStore(Database(settings)).verify_token(str(token)) == "wife"
|
|
|
|
revoked = client.delete("/api/holders/wife/token", headers=AS_OPERATOR)
|
|
assert revoked.status_code == 200 and revoked.json()["holder_id"] == "wife"
|
|
assert client.post("/api/search", json={"query": KEYS}, headers={"Authorization": f"Bearer {token}"}).status_code == 401
|
|
assert client.get("/api/holders/wife", headers=AS_OPERATOR).json()["token_issued_at"] is None
|
|
|
|
|
|
def test_a_working_set_refuses_to_belong_to_nobody(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife", session_id="kitchen-2026")
|
|
_remember(settings, THERMOSTAT, "husband", session_id="kitchen-2026")
|
|
|
|
anonymous = client.get("/api/memory/working", params={"session": "kitchen-2026"})
|
|
assert anonymous.status_code == 422
|
|
assert "somebody" in str(anonymous.json()["detail"])
|
|
|
|
wife = _token_for(client, "wife")
|
|
hers = client.get("/api/memory/working", params={"session": "kitchen-2026"}, headers={"Authorization": f"Bearer {wife['token']}"})
|
|
assert [atom["text"] for atom in hers.json()] == [KEYS], "a session sees its own lane, not the room's"
|
|
|
|
both = client.get("/api/memory/working", params={"session": "kitchen-2026", "holder": "husband"}, headers=AS_OPERATOR)
|
|
assert [atom["text"] for atom in both.json()] == [THERMOSTAT]
|
|
|
|
|
|
def test_a_conflict_list_refuses_to_belong_to_nobody(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, "恒温器放在书房,设定 22 度", "wife")
|
|
_remember(settings, "恒温器调到 24 度", "wife")
|
|
assert client.get("/api/memory/conflicts").status_code == 422
|
|
wife = _token_for(client, "wife")
|
|
hers = client.get("/api/memory/conflicts", headers={"Authorization": f"Bearer {wife['token']}"})
|
|
assert hers.status_code == 200 and len(hers.json()) == 1
|
|
|
|
|
|
def test_the_card_is_public_names_holders_and_carries_no_memory_text(client: TestClient, settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife", scenario="home")
|
|
_remember(settings, THERMOSTAT, "husband")
|
|
_token_for(client, "wife")
|
|
|
|
answer = client.get("/api/card")
|
|
assert answer.status_code == 200, "a capability card exists to be read by a stranger"
|
|
card = answer.json()
|
|
assert card["schema_version"] >= 11 and card["has_audio"] is False
|
|
named = {holder["holder_id"]: holder for holder in card["holders"]}
|
|
assert set(named) == {"wife", "husband"}
|
|
assert named["wife"]["memories"] == 1 and named["wife"]["token_issued"] is True
|
|
assert named["husband"]["token_issued"] is False
|
|
assert named["wife"]["scenarios"] == ["home"] and named["wife"]["modalities"] == {"text": 1}
|
|
assert named["wife"]["newest_memory"], "a stranger needs to know how stale the memory is"
|
|
assert KEYS not in answer.text and THERMOSTAT not in answer.text
|
|
|
|
one = client.get("/api/card/wife")
|
|
assert one.status_code == 200 and one.json()["holder_id"] == "wife"
|
|
assert client.get("/api/card/nobody").status_code == 404
|
|
|
|
|
|
def test_a_picture_is_served_only_to_eyes_that_may_see_it(client: TestClient, settings: Settings) -> None:
|
|
database = Database(settings)
|
|
stored = ObjectStore(settings).put_bytes(PNG, ".png", "image")
|
|
database.upsert_sources(
|
|
[
|
|
SourceRecord(
|
|
source_id=source,
|
|
path=f"file:///{source}.png",
|
|
kind="image",
|
|
mime="image/png",
|
|
sha256=source * 8,
|
|
size_bytes=len(PNG),
|
|
)
|
|
for source in ("hers", "ours")
|
|
]
|
|
)
|
|
database.upsert_atoms(
|
|
[
|
|
EvidenceAtom(
|
|
atom_id=f"{source}:frame",
|
|
modality=Modality.image,
|
|
source_id=source,
|
|
source=f"file:///{source}.png",
|
|
image_ref=stored,
|
|
trust=TrustScores(parser=1.0, ocr=0.0, model=0.0),
|
|
holder_id="wife" if source == "hers" else "",
|
|
)
|
|
for source in ("hers", "ours")
|
|
]
|
|
)
|
|
|
|
assert client.get("/api/atoms/ours:frame/image").status_code == 200, "nobody owns it, so anybody may look"
|
|
assert client.get("/api/atoms/hers:frame/image").status_code == 404, "the bytes are behind the same rule as the words"
|
|
assert client.get("/api/atoms", params={"modality": "image"}).json()["total"] == 1
|
|
wife = _token_for(client, "wife")
|
|
page = client.get("/api/atoms", params={"modality": "image"}, headers={"Authorization": f"Bearer {wife['token']}"})
|
|
assert page.json()["total"] == 2
|
|
|
|
|
|
def test_an_operator_and_a_stranger_do_not_share_one_compiled_context(settings: Settings) -> None:
|
|
_remember(settings, KEYS, "wife")
|
|
compiler = ContextCompiler(settings, Database(settings))
|
|
# The stranger compiles first: if the cache key ignored whose eyes were used, the operator would be served this.
|
|
stranger = compiler.compile(KEYS, RuntimeProfile.live_demo, as_holder="")
|
|
assert stranger.hits == [], "a memory nobody owns and nobody marked public is invisible to a stranger"
|
|
|
|
owner = compiler.compile(KEYS, RuntimeProfile.live_demo, as_holder="wife")
|
|
assert KEYS in owner.packed_text and owner.cache_trace_id != stranger.cache_trace_id
|
|
|
|
whole = compiler.compile(KEYS, RuntimeProfile.live_demo)
|
|
assert KEYS in whole.packed_text, "a process that owns the store still reads the whole library"
|