Skip to main content

embacle_server/uhp/
share.rs

1// ABOUTME: Session sharing — mint, read back and revoke a public read-only view of a session
2// ABOUTME: A share id opens the view and nothing else; it is never a credential for the API
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Sharing a session.
8//!
9//! Sharing publishes a **view**, not access. Three properties make that true,
10//! and each is a separate way to get this wrong:
11//!
12//! The link alone opens it. A share only its minter can open has not been
13//! published to anyone, so the view sits outside the auth layer.
14//!
15//! The share id is not a credential. Presenting it as a bearer token against
16//! the API must fail exactly as any other unknown token does — a link handed to
17//! a reader must never become a key to the account that minted it.
18//!
19//! The view is read-only. It answers `GET` and refuses every method that would
20//! change anything, because a reader with a link is not a principal.
21
22use std::collections::HashMap;
23use std::sync::RwLock;
24
25use axum::extract::{Path, State};
26use axum::Json;
27use serde::Serialize;
28use uuid::Uuid;
29
30use super::error::UhpFailure;
31use super::sessions::Turn;
32use super::state::UhpState;
33
34/// Shares this server has minted.
35///
36/// LIMITATION(registre#410): `Shares` is per-process, so a link minted on one instance
37/// does not open on another.
38#[derive(Debug, Default)]
39pub struct Shares {
40    by_share: RwLock<HashMap<String, String>>,
41    by_session: RwLock<HashMap<String, String>>,
42}
43
44impl Shares {
45    /// Mint a share for a session, or return the one it already has.
46    ///
47    /// Minting twice returns the same link rather than a second one, so a
48    /// client retrying after a dropped connection does not scatter live links
49    /// it cannot then revoke.
50    pub fn mint(&self, session_id: &str) -> String {
51        if let Some(existing) = self.for_session(session_id) {
52            return existing;
53        }
54        let share_id = format!("share_{}", Uuid::new_v4().simple());
55        if let Ok(mut by_share) = self.by_share.write() {
56            by_share.insert(share_id.clone(), session_id.to_owned());
57        }
58        if let Ok(mut by_session) = self.by_session.write() {
59            by_session.insert(session_id.to_owned(), share_id.clone());
60        }
61        share_id
62    }
63
64    /// The share a session already has, if any.
65    pub fn for_session(&self, session_id: &str) -> Option<String> {
66        self.by_session.read().ok()?.get(session_id).cloned()
67    }
68
69    /// The session a share opens.
70    pub fn session_of(&self, share_id: &str) -> Option<String> {
71        self.by_share.read().ok()?.get(share_id).cloned()
72    }
73
74    /// Revoke a session's share, reporting whether there was one.
75    ///
76    /// Revocation kills the link itself, not one holder's copy of it: the id is
77    /// removed, so every link ever minted for this session stops opening.
78    pub fn revoke(&self, session_id: &str) -> bool {
79        let share_id = self
80            .by_session
81            .write()
82            .map_or(None, |mut by_session| by_session.remove(session_id));
83        let Some(share_id) = share_id else {
84            return false;
85        };
86        if let Ok(mut by_share) = self.by_share.write() {
87            by_share.remove(&share_id);
88        }
89        true
90    }
91}
92
93/// A minted share.
94#[derive(Debug, Clone, Serialize)]
95pub struct Share {
96    /// The share id, which appears in the link.
97    pub id: String,
98    /// Always `session.share`.
99    pub object: &'static str,
100    /// The session it opens.
101    pub session_id: String,
102    /// Where to open it, relative to this server's UHP base.
103    pub url: String,
104}
105
106/// The public path a share id opens at.
107fn share_path(share_id: &str) -> String {
108    format!("/share/{share_id}")
109}
110
111/// Handle `POST /v1/sessions/{session_id}/share`.
112///
113/// Takes no body, as the specification describes.
114///
115/// # Errors
116///
117/// Answers `404 session_not_found` when this server holds no such session.
118pub async fn create(
119    State(state): State<UhpState>,
120    Path(session_id): Path<String>,
121) -> Result<Json<Share>, UhpFailure> {
122    if state.store.session_turns(&session_id).is_none() {
123        return Err(UhpFailure::not_found(
124            "session_not_found",
125            "no session with that id exists",
126        ));
127    }
128    let id = state.shares.mint(&session_id);
129    Ok(Json(Share {
130        url: share_path(&id),
131        id,
132        object: "session.share",
133        session_id,
134    }))
135}
136
137/// Handle `GET /v1/sessions/{session_id}/share`.
138///
139/// # Errors
140///
141/// Answers `404 share_not_found` when the session has no live share.
142pub async fn get(
143    State(state): State<UhpState>,
144    Path(session_id): Path<String>,
145) -> Result<Json<Share>, UhpFailure> {
146    let id = state.shares.for_session(&session_id).ok_or_else(|| {
147        UhpFailure::not_found("share_not_found", "this session has no shared view")
148    })?;
149    Ok(Json(Share {
150        url: share_path(&id),
151        id,
152        object: "session.share",
153        session_id,
154    }))
155}
156
157/// Handle `DELETE /v1/sessions/{session_id}/share`.
158///
159/// # Errors
160///
161/// Answers `404 share_not_found` when the session has no live share.
162pub async fn revoke(
163    State(state): State<UhpState>,
164    Path(session_id): Path<String>,
165) -> Result<Json<super::sessions::Deleted>, UhpFailure> {
166    if state.shares.revoke(&session_id) {
167        Ok(Json(super::sessions::Deleted {
168            id: session_id,
169            object: "session.share.deleted",
170            deleted: true,
171        }))
172    } else {
173        Err(UhpFailure::not_found(
174            "share_not_found",
175            "this session has no shared view",
176        ))
177    }
178}
179
180/// What a reader sees when they open a link.
181///
182/// Turns only. No harness configuration, no MCP server URLs, no bearer token,
183/// nothing about the principal who minted it — a reader with a link is not a
184/// principal, and the view must not become a way to learn what one holds.
185#[derive(Debug, Clone, Serialize)]
186pub struct SharedView {
187    /// Always `session.shared_view`.
188    pub object: &'static str,
189    /// The session being read.
190    pub session_id: String,
191    /// Its turns, oldest first.
192    pub turns: Vec<Turn>,
193    /// Stated so a reader knows the view will refuse anything else.
194    pub read_only: bool,
195}
196
197/// Handle `GET /share/{share_id}` — served without a credential.
198///
199/// # Errors
200///
201/// Answers `404 share_not_found` for a revoked or unknown link, which is the
202/// same answer either way: a revoked link must not be distinguishable from one
203/// that never existed.
204pub async fn view(
205    State(state): State<UhpState>,
206    Path(share_id): Path<String>,
207) -> Result<Json<SharedView>, UhpFailure> {
208    let session_id = state.shares.session_of(&share_id).ok_or_else(|| {
209        UhpFailure::not_found("share_not_found", "this link does not open anything")
210    })?;
211    let turns = state.store.session_turns(&session_id).ok_or_else(|| {
212        UhpFailure::not_found("share_not_found", "this link does not open anything")
213    })?;
214    Ok(Json(SharedView {
215        object: "session.shared_view",
216        session_id,
217        turns: turns.iter().map(Turn::from).collect(),
218        read_only: true,
219    }))
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn minting_twice_returns_one_link() {
228        // Otherwise a client retrying after a dropped connection scatters live
229        // links it can no longer revoke.
230        let shares = Shares::default();
231        let first = shares.mint("sess_1");
232        let second = shares.mint("sess_1");
233        assert_eq!(first, second);
234        assert!(first.starts_with("share_"));
235    }
236
237    #[test]
238    fn revocation_kills_the_link_itself() {
239        let shares = Shares::default();
240        let id = shares.mint("sess_1");
241        assert_eq!(shares.session_of(&id).as_deref(), Some("sess_1"));
242
243        assert!(shares.revoke("sess_1"));
244        assert!(
245            shares.session_of(&id).is_none(),
246            "a revoked link must stop opening for every holder, not just the minter"
247        );
248        assert!(shares.for_session("sess_1").is_none());
249        assert!(!shares.revoke("sess_1"), "revoking twice reports false");
250    }
251
252    #[test]
253    fn a_share_id_is_not_a_session_id() {
254        // R-03 presents the share id as a bearer token. It must be worthless
255        // there, and it is: it names nothing the API looks up.
256        let shares = Shares::default();
257        let id = shares.mint("sess_1");
258        assert_ne!(id, "sess_1");
259        assert!(id.starts_with("share_"), "distinct namespace from sess_");
260    }
261}