diff --git a/CHANGELOG.md b/CHANGELOG.md index f149f47aa9..a0bd674145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -277,6 +277,16 @@ See [STATUS.md](server/STATUS.md) to learn more about which features will remain without `--base-domain`'s other effect of also becoming the store's base domain (which changes how subjects are normalized and migrated, and is not something to switch on for a server that already holds data). + +- The `/version` and `/all-versions` endpoints are removed. Resource history + is read from the Loro oplog on the client now (`resource.getLoroHistory()`, + with signers from `/history-attribution`), which is what the History route + has used for a while; these endpoints were the older server-side path to the + same data, reachable only from a single "All versions" link. `atomic_lib` + loses `history::encode_version_id` and `history::decode_version_id` with + them -- they existed to carry an opaque `VersionID` through an HTTP query + parameter. `history::versions`, `history::at_version`, + `Resource::get_history` and `Resource::view_at` are unaffected. - The outbox drains over a live Iroh link too (`sync::peer::LivePeerCommitTransport`): a device with no hub in reach delivers its queued writes to a paired peer as signed `COMMIT` frames, which the peer validates and applies like a hub diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index cd19569c3e..db59e8ddb4 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -367,6 +367,20 @@ This changelog covers all five packages, as they are (for now) updated as a whol - Fix: `Resource.remove()` and `Resource.push()` emit `LocalChange` like `set()` does, so `useValue` / `useArray` re-render when a field is cleared or an item is appended. `useValue`'s setter clears a previously reported validation error when called with `undefined`. `useArray`'s `push` saves through the same debounced scheduler and error handling (`handleValidationError` / `store.notifyError`) as `set`, instead of an immediate `resource.save()` that only logged failures. `StoreContext` no longer defaults to a phantom `Store`: `useStore()` throws when no `` is mounted. - `@tomic/lib`: one list of server-managed properties (`server-managed-props.ts`) replaces the three copies in `resource.ts` and `store.ts` that had drifted apart; the OPFS cold-load guard now also treats an inline `genesis` certificate as skeleton, not content. One serializer (`Resource.toClientDbJsonAd()`) writes the local-database row from both `Store.addResource` and `Resource.persistToClientDb`, and a durable save now records its write in the store's dedup stamp so the next ingress does not rewrite the same row. - Comment on a table row, like in Notion. Every row's gutter carries a comment bubble: it shows up on hover for a row with nothing on it yet, and stays put — with the count, highlighted while unseen — for a row that has comments. Clicking it opens that row's thread in the Comments panel, headed by the row's title; another row's bubble switches threads, and the same row's closes the panel. A row is a resource of its own, so its thread is the ordinary one (Messages whose `about` points at the row) and needs no setup. The Comments panel can now be aimed at a resource inside the page rather than only at the page's own; the navbar button keeps opening the page's thread. +- Fix: the fork bar no longer fetches the server root on every resource page. + It renders above every resource and only returns `null` for a non-fork after + its hooks have run, so `useResource(originalSubject ?? '')` fired for all of + them; an empty subject resolves against the page origin, which serves no + resource (no Drive is created at `/`), so each page load spent a request on a + 404 and logged a console error. It asks for `unknownSubject` instead, which + the store answers from memory without touching the network. This is what made + the `offline-persistence` and `offline-tables` e2e specs fail: they assert on + unexpected browser console errors, and with the WebSocket disconnected the + miss surfaced over HTTP as a logged 404 rather than a silent protocol answer. +- The "All versions" link is gone from the version scroller. It pointed at the + server's `/all-versions` endpoint, which rendered the same history the + scroller was already showing, paginated and without attribution. Both + endpoints behind it are removed. - Fork bar: "Review changes" opens the per-property diff (the original's current value against the fork's) and names the properties the original also changed since the fork, so a reviewer sees what a merge writes over instead of a count. - A dashboard is reachable from its table: "Add view" offers **Dashboard**, which creates an empty Dashboard as a child of the table and shows it as a tab (`view-kind: dashboard`, `view-dashboard`). Switching an existing tab to Dashboard does the same. The Dashboard stays a resource of its own, so a Drive page or a document can still embed it. - Fix: creating a second table column with a name that already exists in the drive's ontology (e.g. two "Status" columns) no longer mints a colliding property shortname that silently corrupts the ontology. A compatible existing property is reused instead; an incompatible one gets a disambiguated shortname (`status-2`) ([#1504](https://github.com/ontola/atomic-server/issues/1504)). diff --git a/browser/data-browser/src/helpers/navigation.tsx b/browser/data-browser/src/helpers/navigation.tsx index fbdf6a68aa..4a2bf18018 100644 --- a/browser/data-browser/src/helpers/navigation.tsx +++ b/browser/data-browser/src/helpers/navigation.tsx @@ -101,18 +101,6 @@ export function historyURL(subject: string): string { return constructURL(paths.history, { subject }); } -/** - * Constructs the URL for the `all-versions` endpoint. Assumes the current URL - * supports that endpoint - */ -export function versionsURL(subject: string, baseURL: string): string { - const url = new URL(baseURL); - url.pathname = paths.allVersions; - url.searchParams.append('subject', subject); - - return constructOpenURL(url.toString()); -} - /** Takes the cursor position, finds the nearest `about=` attributes in DOM nodes */ export function getSubjectFromDom(): string | null { const found: string[] = []; diff --git a/browser/data-browser/src/routes/History/HistoryDesktopView.tsx b/browser/data-browser/src/routes/History/HistoryDesktopView.tsx index 88a97bf33a..b36aeb738a 100644 --- a/browser/data-browser/src/routes/History/HistoryDesktopView.tsx +++ b/browser/data-browser/src/routes/History/HistoryDesktopView.tsx @@ -92,7 +92,6 @@ export function HistoryDesktopView({ void; onNextItem?: () => void; onPreviousItem?: () => void; @@ -26,13 +23,11 @@ export function VersionScroller({ selectedVersion, title, className, - subject, persistSelection = false, onNextItem, onPreviousItem, onSelectVersion, }: VersionScrollerProps): JSX.Element { - const store = useStore(); const scrollerTitleId = useId(); return ( @@ -69,10 +64,6 @@ export function VersionScroller({ )} - - All versions - - ); } @@ -88,18 +79,6 @@ const ScrollerSection = styled.section` position: relative; `; -const AllVersionsLink = styled(AtomicLink)` - position: absolute; - bottom: -2rem; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5ch; - vertical-align: middle; - line-height: 1; -`; - const Scroller = styled.div` padding: 1rem; overflow: auto; diff --git a/browser/data-browser/src/routes/paths.tsx b/browser/data-browser/src/routes/paths.tsx index 4c430c5922..b4cbe46664 100644 --- a/browser/data-browser/src/routes/paths.tsx +++ b/browser/data-browser/src/routes/paths.tsx @@ -22,7 +22,6 @@ export const pathNames = { import: '/import', onboarding: '/onboarding', history: '/history', - allVersions: '/all-versions', sandbox: '/sandbox', fetchBookmark: '/fetch-bookmark', pruneTests: '/prunetests', @@ -52,7 +51,6 @@ export const paths = { import: `${pathNames.app}${pathNames.import}`, onboarding: `${pathNames.app}${pathNames.onboarding}`, history: `${pathNames.app}${pathNames.history}`, - allVersions: `${pathNames.app}${pathNames.allVersions}`, sandbox: `${pathNames.app}${pathNames.sandbox}`, fetchBookmark: pathNames.fetchBookmark, pruneTests: `${pathNames.app}${pathNames.pruneTests}`, diff --git a/docs/src/interoperability/vc-old.md b/docs/src/interoperability/vc-old.md index 52a9919e02..fb18537d4e 100644 --- a/docs/src/interoperability/vc-old.md +++ b/docs/src/interoperability/vc-old.md @@ -24,8 +24,7 @@ This is by design, as storing signed state changes allows for fully verifiable a If you want to know whether a specific value that you see is signed by a specific Agent, you need to find the Commit that created the value. -This can be achieved by using a Collection. -The easiest way to do this, is by using the [`/all-versions` Endpoint](https://atomicdata.dev/all-versions) and finding the Signer of the version that is relevant to your question. +This can be achieved by using the `/history-attribution` endpoint, which returns the verified signer for each change in a resource's history, as far as this node retained the signed envelopes. In the near future, we will introduce a `/verify` Endpoint that will allow you to verify a specific value. diff --git a/lib/src/history.rs b/lib/src/history.rs index 4d19161d84..d2d84f0086 100644 --- a/lib/src/history.rs +++ b/lib/src/history.rs @@ -4,26 +4,9 @@ //! //! Prefer this over [crate::loro] in app code — it keeps the CRDT details here. -use base64::{engine::general_purpose, Engine}; - pub use crate::loro::{VersionID, VersionMetadata}; use crate::{errors::AtomicResult, Resource}; -/// A [VersionID] as a URL-safe string, for addressing a version over HTTP. -/// The id is opaque (encoded Loro Frontiers), so it is carried as base64url -/// rather than given a readable structure clients might parse. -pub fn encode_version_id(id: &VersionID) -> String { - general_purpose::URL_SAFE_NO_PAD.encode(id.bytes()) -} - -pub fn decode_version_id(encoded: &str) -> AtomicResult { - let bytes = general_purpose::URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|e| format!("Not a valid version id: {e}"))?; - - Ok(VersionID::from_bytes(bytes)) -} - /// The resource's document, as a value this module may read freely. fn doc_of(resource: &Resource) -> AtomicResult { let Some(snapshot) = resource.materialized_state() else { @@ -160,15 +143,4 @@ mod test { assert_eq!(versions(&resource).unwrap().len(), 1); } - - #[test] - fn version_ids_survive_a_url() { - let id = VersionID::from_bytes(vec![0, 1, 2, 250, 255]); - let encoded = encode_version_id(&id); - assert!( - !encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='), - "must be URL-safe without padding: {encoded}" - ); - assert_eq!(decode_version_id(&encoded).unwrap(), id); - } } diff --git a/server/src/appstate.rs b/server/src/appstate.rs index c443800e9f..7e27a4e49b 100644 --- a/server/src/appstate.rs +++ b/server/src/appstate.rs @@ -130,8 +130,6 @@ impl AppState { store.add_endpoint(plugins::server_info::server_info_endpoint( server_info.clone(), ))?; - store.add_endpoint(plugins::versioning::version_endpoint())?; - store.add_endpoint(plugins::versioning::all_versions_endpoint())?; store.add_endpoint(plugins::did::did_endpoint())?; store.add_endpoint(plugins::did::resource_endpoint())?; store.add_endpoint(plugins::did::atomic_endpoint())?; diff --git a/server/src/plugins/mod.rs b/server/src/plugins/mod.rs index 3577f90c9b..e2d3b79a07 100644 --- a/server/src/plugins/mod.rs +++ b/server/src/plugins/mod.rs @@ -85,7 +85,6 @@ pub mod test_fixture; pub mod triggers; #[cfg(feature = "vector-search")] pub mod vector_search; -pub mod versioning; pub mod view_token; #[cfg(feature = "wasm-plugins")] pub mod wasm; diff --git a/server/src/plugins/versioning.rs b/server/src/plugins/versioning.rs deleted file mode 100644 index e441bd8d52..0000000000 --- a/server/src/plugins/versioning.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Versioning endpoints: a resource's history, and any past version of it. -//! -//! Both read the resource's Loro oplog (see [atomic_lib::history]). They used to -//! replay the Commit log instead, from before the move to Loro: every edit is -//! already a change in the document, so there is nothing to replay and no -//! separate log to keep consistent with the state it describes. -//! -//! A version is addressed as `/version?subject=&version-id=`, with -//! ids handed out by `/all-versions?subject=`. The id is opaque — -//! encoded Loro Frontiers — so it is not something clients construct themselves. - -use atomic_lib::{ - agents::ForAgent, - collections::Collection, - endpoints::{BoxFuture, Endpoint, HandleGetContext}, - errors::AtomicResult, - history, - storelike::ResourceResponse, - urls, Db, Resource, Storelike, -}; - -const SUBJECT_KEY: &str = "subject"; -const VERSION_ID_KEY: &str = "version-id"; -const CURRENT_PAGE_KEY: &str = "current-page"; -const PAGE_SIZE: usize = 30; - -pub fn version_endpoint() -> Endpoint { - Endpoint::builder("/version") - .shortname("versions") - .params([urls::SUBJECT, urls::VERSION_ID]) - .description( - "Returns a resource as it was at one version of its history. \ - Version ids come from the `all-versions` endpoint.", - ) - .form_when_missing([SUBJECT_KEY, VERSION_ID_KEY]) - .handle(handle_version_request) - .build() -} - -pub fn all_versions_endpoint() -> Endpoint { - Endpoint::builder("/all-versions") - .params([urls::SUBJECT, urls::COLLECTION_CURRENT_PAGE]) - .description("Lists every version of a resource, newest first.") - .form_when_missing([SUBJECT_KEY]) - .handle(handle_all_versions_request) - .build() -} - -fn query_param(url: &url::Url, key: &str) -> Option { - url.query_pairs() - .find(|(k, _)| k == key) - .map(|(_, v)| v.to_string()) -} - -/// The resource whose history is being asked about, if the agent may read it. -async fn readable_resource( - subject: &str, - store: &Db, - for_agent: &ForAgent, -) -> AtomicResult { - let resource = store.get_resource(&subject.into()).await?; - atomic_lib::hierarchy::check_read(store, &resource, for_agent).await?; - - Ok(resource) -} - -fn version_url(store: &Db, target: &str, id: &history::VersionID) -> AtomicResult { - let base = store.get_base_domain().ok_or("No base domain set")?; - let mut url = url::Url::parse(&format!("{base}/version"))?; - url.query_pairs_mut() - .append_pair(SUBJECT_KEY, target) - .append_pair(VERSION_ID_KEY, &history::encode_version_id(id)); - - Ok(url.to_string()) -} - -fn handle_version_request<'a>( - context: HandleGetContext<'a>, -) -> BoxFuture<'a, AtomicResult> { - Box::pin(async move { - let HandleGetContext { - store, - for_agent, - subject, - } = context; - - // `form_when_missing` guarantees both keys here. - let target = query_param(&subject, SUBJECT_KEY).ok_or("No subject query parameter")?; - let version_id = - query_param(&subject, VERSION_ID_KEY).ok_or("No version-id query parameter")?; - - let resource = readable_resource(&target, store, for_agent).await?; - let version_id = history::decode_version_id(&version_id)?; - let mut version = history::at_version(&resource, &version_id)?; - // Addressed by its version URL, so it does not masquerade as the live resource. - version.set_subject(subject.to_string()); - - Ok(ResourceResponse::Resource(version)) - }) -} - -fn handle_all_versions_request<'a>( - context: HandleGetContext<'a>, -) -> BoxFuture<'a, AtomicResult> { - Box::pin(async move { - let HandleGetContext { - store, - for_agent, - subject, - } = context; - - // `form_when_missing` guarantees a `subject` here. - let target = query_param(&subject, SUBJECT_KEY).ok_or("No subject query parameter")?; - let resource = readable_resource(&target, store, for_agent).await?; - - let versions = history::versions(&resource)?; - let total_items = versions.len(); - let total_pages = total_items.div_ceil(PAGE_SIZE); - let current_page = query_param(&subject, CURRENT_PAGE_KEY) - .and_then(|p| p.parse::().ok()) - .unwrap_or(0); - - let members = versions - .iter() - .skip(current_page * PAGE_SIZE) - .take(PAGE_SIZE) - .map(|version| version_url(store, &target, &version.id)) - .collect::>>()?; - - let collection = Collection { - subject: subject.to_string(), - property: None, - value: None, - members, - referenced_resources: None, - sort_by: None, - sort_desc: false, - page_size: PAGE_SIZE, - current_page, - total_items, - total_pages, - name: Some(format!("Versions of {target}")), - include_nested: false, - include_external: false, - aggregates: Vec::new(), - }; - - collection.to_resource(store).await - }) -} diff --git a/server/src/tests.rs b/server/src/tests.rs index 3b846bcfb5..0dc7b6cde8 100644 --- a/server/src/tests.rs +++ b/server/src/tests.rs @@ -1246,113 +1246,3 @@ async fn server_info_endpoint_with_home_drive() { "homeDrive should be the configured drive: {body}" ); } - -/// The versioning round trip: `/all-versions` lists a resource's versions, and -/// each link it hands out resolves to that resource as it was then. Both read -/// the Loro oplog. Nothing covered either endpoint before, which is how they -/// stayed broken (links pointed at `/versioning`, a path that never existed). -#[actix_rt::test] -async fn version_endpoints() { - let unique_string = atomic_lib::utils::random_string(10); - use clap::Parser; - let opts = Opts::parse_from([ - "atomic-server", - "--initialize", - "--data-dir", - &format!("./.temp/{}/db", unique_string), - "--config-dir", - &format!("./.temp/{}/config", unique_string), - ]); - - let mut config = config::build_config(opts).expect("failed init config"); - config.search_index_path = format!("./.temp/{}/search_index", unique_string).into(); - let appstate = crate::appstate::AppState::init(config.clone()) - .await - .expect("failed init appstate"); - - let data = Data::new(appstate.clone()); - let app = test::init_service( - App::new() - .app_data(data) - .configure(crate::routes::config_routes), - ) - .await; - - // A resource renamed once, each edit committed as its own change the way a - // client authors them — otherwise the two collapse into one Loro change, - // and there is no history to travel. - let agent = appstate.store.get_default_agent().unwrap().subject; - let subject = format!("{}/version-test", appstate.config.get_origin()); - - let doc = atomic_lib::loro::AtomicLoroDoc::new(); - doc.set_property(urls::READ, &vec![agent.clone()].into()) - .unwrap(); - doc.set_property(urls::WRITE, &vec![agent].into()).unwrap(); - doc.set_property(urls::NAME, &"first".to_string().into()) - .unwrap(); - doc.commit_with_message("e-1"); - - doc.set_property(urls::NAME, &"second".to_string().into()) - .unwrap(); - doc.commit_with_message("e-2"); - - let mut resource = atomic_lib::Resource::new(subject.as_str().into()); - resource.apply_state_doc(doc).unwrap(); - appstate - .store - .add_resource_opts(&resource, false, true, true) - .await - .unwrap(); - - let req = build_request_authenticated( - &format!("/all-versions?subject={}", urlencoding::encode(&subject)), - &appstate, - ) - .to_request(); - let resp = test::call_service(&app, req).await; - let status = resp.status(); - let body = get_body(resp); - assert!(status.is_success(), "/all-versions status {status}: {body}"); - - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - let members = json[urls::COLLECTION_MEMBERS] - .as_array() - .cloned() - .unwrap_or_default(); - assert!( - members.len() >= 2, - "two edits should be two versions: {body}" - ); - - // Newest first, so the last member is the resource as first written. - let oldest = members.last().unwrap().as_str().unwrap(); - assert!( - oldest.contains("/version?subject=") && oldest.contains("version-id="), - "a version link must address a subject at a version: {oldest}" - ); - - let path = &oldest[oldest.find("/version?").unwrap()..]; - let req = build_request_authenticated(path, &appstate).to_request(); - let resp = test::call_service(&app, req).await; - let status = resp.status(); - let body = get_body(resp); - assert!(status.is_success(), "{path} status {status}: {body}"); - - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!( - json[urls::NAME].as_str(), - Some("first"), - "the oldest version should read as the resource was first written: {body}" - ); - - // ...while the resource itself is still at its latest value. - let req = build_request_authenticated("/version-test", &appstate).to_request(); - let resp = test::call_service(&app, req).await; - let body = get_body(resp); - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!( - json[urls::NAME].as_str(), - Some("second"), - "reading a version must not move the live resource: {body}" - ); -}