From 40f901d2f62fe6e27d615d1b80f40c4f678e4e60 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Fri, 16 Oct 2026 12:52:00 +0200 Subject: [PATCH] A token reads a base's graph overview and neighbourhood Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bogdan Szabo --- crates/utopia-server/src/api/graph_routes.rs | 8 +++---- .../utopia-server/src/api/token_read_tests.rs | 11 +++++----- crates/utopia-server/src/auth.rs | 3 ++- ...ty-from-the-person-scope-from-the-token.md | 21 ++++++++++++------- docs/design/access-and-audit.md | 6 +++--- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index 3116474a2..8eb3e165f 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -71,11 +71,11 @@ pub struct OverviewQuery { pub async fn overview( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path(kb_id): Path, Query(q): Query, ) -> ApiResult> { - require_kb(&state, &user, kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, kb_id).await?; let at = parse_instant("at", q.at.as_deref())?; let as_of = parse_instant("as_of", q.as_of.as_deref())?; // 画多少个是渲染的事,库里有多少是知识库的事——两个数都回,界面才说得出 @@ -105,11 +105,11 @@ pub struct NeighborhoodQuery { pub async fn neighborhood( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path(kb_id): Path, Query(q): Query, ) -> ApiResult> { - require_kb(&state, &user, kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, kb_id).await?; let at = parse_instant("at", q.at.as_deref())?; let as_of = parse_instant("as_of", q.as_of.as_deref())?; let (nodes, edges) = utopia_store::graph::neighborhood( diff --git a/crates/utopia-server/src/api/token_read_tests.rs b/crates/utopia-server/src/api/token_read_tests.rs index 1855c84f1..b9d52d525 100644 --- a/crates/utopia-server/src/api/token_read_tests.rs +++ b/crates/utopia-server/src/api/token_read_tests.rs @@ -1,4 +1,4 @@ -//! 个人令牌读实体(0014 决定 7):搜实体、实体详情、认知历史、事实证据四条读路由 +//! 个人令牌读图(0014 决定 7):搜实体、实体详情、认知历史、事实证据、图总览、邻域六条读路由 //! 收任何一枚活着的令牌。角色照旧是上限,`kb_ids` 排除了的库答 404;改实体仍只认会话。 use crate::state::AppState; use axum::body::Body; @@ -81,13 +81,15 @@ impl Fixture { .1 } - fn read_paths(&self) -> [String; 4] { + fn read_paths(&self) -> [String; 6] { let (kb, entity, fact) = (self.kb, self.entity, self.fact); [ format!("/kbs/{kb}/entities?q=Zhang"), format!("/kbs/{kb}/entities/{entity}"), format!("/kbs/{kb}/entities/{entity}/history"), format!("/kbs/{kb}/facts/{fact}/evidence"), + format!("/kbs/{kb}/graph/overview"), + format!("/kbs/{kb}/graph/neighborhood?entity={entity}"), ] } @@ -109,8 +111,7 @@ impl Fixture { } #[tokio::test] -async fn a_read_token_of_a_base_viewer_reads_its_entities_facts_and_history() -> anyhow::Result<()> -{ +async fn a_read_token_of_a_base_viewer_gets_200_on_every_graph_read() -> anyhow::Result<()> { let Some(f) = Fixture::new().await? else { return Ok(()); }; @@ -128,7 +129,7 @@ async fn a_read_token_of_a_base_viewer_reads_its_entities_facts_and_history() -> } #[tokio::test] -async fn a_token_limited_to_another_base_gets_404_on_every_entity_read() -> anyhow::Result<()> { +async fn a_token_limited_to_another_base_gets_404_on_every_graph_read() -> anyhow::Result<()> { let Some(f) = Fixture::new().await? else { return Ok(()); }; diff --git a/crates/utopia-server/src/auth.rs b/crates/utopia-server/src/auth.rs index 5a7889866..f091f4f91 100644 --- a/crates/utopia-server/src/auth.rs +++ b/crates/utopia-server/src/auth.rs @@ -176,7 +176,8 @@ impl FromRequestParts for AuthUser { /// /// **不替换 `AuthUser`。** `AuthUser` 把每个 Bearer 都当 JWT 解,令牌在那几百条 /// 路由上一律是 401——那是有意的:哪条路由准令牌进来,要在 0014 里说出理由。 -/// 用这个提取器的只有文档原件(0052)、「把一个库搭起来」那几条和读实体那几条(决定 7)。 +/// 用这个提取器的只有文档原件(0052)、「把一个库搭起来」那几条和读图那几条:搜实体、 +/// 实体详情、认知历史、事实证据、图总览、邻域(决定 7)。 /// /// 令牌只收窄:`pat` 在的时候,路由先问 [`Self::require_write`] 或 /// [`Self::reaches`],角色照旧由 `require_kb` 与工作区角色判。 diff --git a/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md b/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md index 58cff2554..850e9015b 100644 --- a/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md +++ b/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md @@ -87,14 +87,18 @@ push documents in. 7. **A token reads the graph** (added 2026-10-16, #1262). A service that mirrors into a base reads the base back to check what landed, and a session JWT is the wrong credential for a backend - (decision 6). Four reads now also take any live token, acting as its owner: searching a base's + (decision 6). Six reads now also take any live token, acting as its owner: searching a base's entities (`GET /kbs/{id}/entities`), an entity's detail (`GET /kbs/{id}/entities/{eid}`), its - record-axis history (`…/history`), and a fact's evidence (`GET /kbs/{id}/facts/{fid}/evidence`). - Search finds the entity by name; the detail gives its facts, names and derivations; the - history says when we changed our mind; the evidence ties a fact to its sentence. Read is the - lowest scope, so any token qualifies; the owner's role is still the ceiling and Viewer - suffices; a token whose `kb_ids` exclude the base gets 404, as for document content (0052). - Changing an entity on the same path (`PATCH`) still takes a session. + record-axis history (`…/history`), a fact's evidence (`GET /kbs/{id}/facts/{fid}/evidence`), + the graph overview (`GET /kbs/{id}/graph/overview`) and an entity's neighbourhood + (`GET /kbs/{id}/graph/neighborhood`). Search finds the entity by name; the detail gives its + facts, names and derivations; the history says when we changed our mind; the evidence ties a + fact to its sentence; the overview gives the most connected entities with the base's totals, + so a client sees the shape of what it pushed; the neighbourhood gives the entities around one, + so it can follow what a statement touches, and one started from another base's entity comes + back empty. Read is the lowest scope, so any token qualifies; the owner's role is still the + ceiling and Viewer suffices; a token whose `kb_ids` exclude the base gets 404, as for document + content (0052). Changing an entity on the same path (`PATCH`) still takes a session. ## Dead ends @@ -121,7 +125,8 @@ push documents in. push-only as well; a backend mirroring several kinds of documents pushes them through one. A token now creates `api` sources too. - 2026-10-16: decision 7 opens four graph reads to any token; decision 6's "only these routes - and document content" now also counts them. + and document content" now also counts them. 2026-10-19: the graph overview and neighbourhood + join them. ## Open questions diff --git a/docs/design/access-and-audit.md b/docs/design/access-and-audit.md index 70770528c..86e533c38 100644 --- a/docs/design/access-and-audit.md +++ b/docs/design/access-and-audit.md @@ -19,9 +19,9 @@ writes an audit row `mcp.tool_called`; the tokens page shows the plaintext once snippet [0014]. A `write` token also sets up a base as its owner: creating a base and a `statements` or `api` source, reading the source's push key, importing an ontology and deciding a kind word, with the owner's role unchanged as the ceiling; any token lists a workspace's bases; a token limited by `kb_ids` -creates no base. These routes, document content and four graph reads (entity search, detail, -history, fact evidence; Viewer suffices, `kb_ids` answers 404) take `SessionOrToken`; apart from -MCP, every other route is session-only [0014 d6, d7]. `ingest_token` on a source is a different kind: plaintext, push-only [0014]. +creates no base. These routes, document content and six graph reads (entity search, detail, +history, fact evidence, overview, neighbourhood; Viewer suffices, `kb_ids` answers 404) take +`SessionOrToken`; apart from MCP, every other route is session-only [0014 d6, d7]. `ingest_token` on a source is a different kind: plaintext, push-only [0014]. **Another app can trade its user's fresh ID token for a session.** `POST /auth/oidc/exchange` accepts an ID token from the configured issuer whose audience is in