diff --git a/agents.md b/agents.md index 3ac972fa3eed2c9f3dd44908db22c2875d376100..5ee37ec83766aed6f2e47bfb84c50145a616823a 100644 --- a/agents.md +++ b/agents.md @@ -52,7 +52,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (GitHub resolver + import card JSON); ontology item pages and the **`GET /vote/compare`** left/right columns use **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, which calls **`server/src/resolvers/mod.rs::try_render_resolver_item_body`** before falling back to the usual **`
`** linkified view.
   - **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports.
   - **Issues:** each open issue is its own **`system:github-resolver`** ingest in that repo import thread (so a later redact removes that issue from the garden). Refresh pages **all** open issues from the GitHub API (up to the page safety cap), keeps still-open single-issue posts, and **`SystemRedact`s** posts for closed/missing issues (and multi-issue bulk posts, which are re-imported as singles). The `slug-github-card` fence payload is **base64(JSON)** so markdown fences in issue bodies cannot terminate the DSL toggle-fence; decode yields the real excerpt string for rich/markdown rendering.
-
+  - **External URL wire form:** Canonical garden paths are **`/-/https://host/…`**. Legacy **`/-/host/…`** and collapsed **`/-/https:/host/…`** permanently redirect to the canonical form. Action `next` fields always use the canonical path.
 - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
 
 - **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.
diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs
index 7c08edc94669ecd521c784502a193df7f00329d2..1a674ae19f633e42ba3717d51a97d0efe5132cfe 100644
--- a/server/src/html/breadcrumb_path.rs
+++ b/server/src/html/breadcrumb_path.rs
@@ -90,8 +90,9 @@ fn external_ancestor_chain(item: &ItemId) -> Vec {
 impl ExternalOntologyPath {
     pub(super) fn from_input(path: &str) -> Self {
         let p = path.trim_start_matches('/');
+        let p = slug_types::repair_collapsed_http_scheme(p);
         let raw = if p.starts_with("http://") || p.starts_with("https://") {
-            p.to_string()
+            p
         } else if p.is_empty() {
             "-/.".to_string()
         } else {
@@ -103,6 +104,33 @@ impl ExternalOntologyPath {
         Self::from_item(parsed)
     }
 
+    /// When the request path is legacy `host/path` (or a collapsed `https:/…`), return the
+    /// canonical garden local path `/-/https://host/path` for a redirect.
+    pub(super) fn legacy_redirect_target(request_path: &str) -> Option {
+        let p = request_path.trim_start_matches('/');
+        if p.is_empty() {
+            return None;
+        }
+        let repaired = slug_types::repair_collapsed_http_scheme(p);
+        // Already canonical full-URL wire form.
+        if repaired.starts_with("http://") || repaired.starts_with("https://") {
+            // Collapsed scheme was repaired — still redirect so the address bar shows `https://`.
+            if repaired.as_str() != p {
+                let item = ItemId::parse(&repaired)?;
+                let disp = item.display_path();
+                return Some(format!("/{}", disp.trim_start_matches('/')));
+            }
+            return None;
+        }
+        // Legacy host-first: `github.com/org/repo`
+        let item = ItemId::parse(&format!("-/{repaired}"))?;
+        if !(item.as_str().starts_with("https://") || item.as_str().starts_with("http://")) {
+            return None;
+        }
+        let disp = item.display_path();
+        Some(format!("/{}", disp.trim_start_matches('/')))
+    }
+
     pub(super) fn from_item(item: ItemId) -> Self {
         let chain = external_ancestor_chain(&item);
         Self { item, chain }
@@ -158,4 +186,26 @@ mod tests {
             "https://example.com/a/b"
         );
     }
+
+    #[test]
+    fn legacy_host_path_redirects_to_https_wire_form() {
+        assert_eq!(
+            ExternalOntologyPath::legacy_redirect_target("github.com/org/repo").as_deref(),
+            Some("/-/https://github.com/org/repo")
+        );
+        assert_eq!(
+            ExternalOntologyPath::legacy_redirect_target("https://github.com/org/repo").as_deref(),
+            None
+        );
+    }
+
+    #[test]
+    fn collapsed_https_scheme_redirects_to_repaired_canonical() {
+        assert_eq!(
+            ExternalOntologyPath::legacy_redirect_target("https:/github.com/org/repo").as_deref(),
+            Some("/-/https://github.com/org/repo")
+        );
+        let repaired = ExternalOntologyPath::from_input("https:/github.com/org/repo");
+        assert_eq!(repaired.as_str(), "https://github.com/org/repo");
+    }
 }
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index a6b4963c61c67d96e9445ef9fe52e300e77b6d6f..dfda796835a35a0204252ee1445afe884c1b3bc1 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -57,11 +57,14 @@ pub(super) async fn render_scope_view(
     let external_href = external_source_href(&model.item);
     let external_can_embed = external_frame_allowed(&model.item);
     let (garden_room, garden_prefix) = garden_layout_meta(&nav);
-    let next_for_pin = uri
-        .path_and_query()
-        .map(|pq| pq.as_str().to_string())
-        .filter(|s| !s.is_empty())
-        .unwrap_or_else(|| "/".to_string());
+    // Canonical `/-/https://…` (or room-scoped) path — not the raw request URI (legacy host-first).
+    let next_for_pin = {
+        let base = item_href(&model.item, &nav);
+        match uri.query() {
+            Some(q) if !q.is_empty() && !base.contains('?') => format!("{base}?{q}"),
+            _ => base,
+        }
+    };
 
     let url_key = canonical_view_url(&uri);
     let view_count = state.views.get_views(&url_key);
diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs
index 84f995db56e488b5438315b74d2c51e187e91c58..b6fd4f8d573b45493bf6596ee833eec1b3917476 100644
--- a/server/src/html/garden/routes.rs
+++ b/server/src/html/garden/routes.rs
@@ -213,6 +213,13 @@ pub async fn external_ontology_path(
     jar: CookieJar,
     uri: Uri,
 ) -> impl IntoResponse {
+    if let Some(canonical) = ExternalOntologyPath::legacy_redirect_target(&path) {
+        let loc = match uri.query() {
+            Some(q) if !q.is_empty() => format!("{canonical}?{q}"),
+            _ => canonical,
+        };
+        return axum::response::Redirect::permanent(&loc).into_response();
+    }
     let path = ExternalOntologyPath::from_input(&path);
     render_scope_view(
         state,
@@ -222,6 +229,7 @@ pub async fn external_ontology_path(
         uri,
     )
     .await
+    .into_response()
 }
 
 pub async fn room_garden_index(
@@ -370,8 +378,22 @@ pub async fn room_external_ontology_path(
         return room_not_found_page(&jar, &uri).into_response();
     }
     drop(reduced);
+    if let Some(canonical_tail) = ExternalOntologyPath::legacy_redirect_target(&path) {
+        // canonical_tail is `/-/https://…`; under a room it becomes `/r/{seg}/-/https://…`
+        let tail = canonical_tail
+            .strip_prefix("/-/")
+            .unwrap_or(canonical_tail.trim_start_matches('/'));
+        let base = format!("{}-/{tail}", nav.garden_root_url().trim_end_matches('~'));
+        let loc = match uri.query() {
+            Some(q) if !q.is_empty() => format!("{base}?{q}"),
+            _ => base,
+        };
+        return axum::response::Redirect::permanent(&loc).into_response();
+    }
     let path = ExternalOntologyPath::from_input(&path);
-    render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri).await
+    render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri)
+        .await
+        .into_response()
 }
 
 pub async fn room_ontology_path(
diff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj
index 8f43039931d4a4b9cd6272462912dc1393361ccf..d7d5ed5645e8cf461ad37708019b819d1922fa27 100644
--- a/test/browser_github_resolver.clj
+++ b/test/browser_github_resolver.clj
@@ -130,6 +130,14 @@
                (page/navigate pg (str base-url "/login"))
                (is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
 
+               (page/navigate pg (str base-url "/-/github.com/octo/hello"))
+               ;; Legacy host-first URL should land on canonical /-/https://…
+               (is (wait-for-text pg "body" "GitHub resolver" 15000)
+                   "legacy URL reaches resolver page after redirect")
+               (let [url (page/url pg)]
+                 (is (str/includes? url "/-/https://github.com/octo/hello")
+                     (str "address bar uses canonical https wire form, got " url)))
+
                (page/navigate pg (str base-url "/-/https://github.com/octo"))
                (is (wait-for-text pg "#external-resolver-panel" "GitHub resolver" 15000)
                    "user page shows resolver panel")
diff --git a/types/src/item_wire.rs b/types/src/item_wire.rs
index 73447a6ca6d6c937ceb0c6c7ddf506c21c6ab452..5c823f403320998642725d3c583940c88902091d 100644
--- a/types/src/item_wire.rs
+++ b/types/src/item_wire.rs
@@ -23,6 +23,21 @@ fn finalize_external_identity_url(s: String) -> String {
     strip_redundant_root_slash(&normalized).unwrap_or(normalized)
 }
 
+/// Some clients/proxies collapse `https://` → `https:/` inside a path. Repair before parsing.
+pub fn repair_collapsed_http_scheme(s: &str) -> String {
+    if let Some(rest) = s.strip_prefix("https:/") {
+        if !rest.starts_with('/') {
+            return format!("https://{rest}");
+        }
+    }
+    if let Some(rest) = s.strip_prefix("http:/") {
+        if !rest.starts_with('/') {
+            return format!("http://{rest}");
+        }
+    }
+    s.to_string()
+}
+
 /// `url::Url` serializes bare hosts with a `/` path; we keep host-only items slash-free for stable
 /// keys matching the pre-normalizer spellings.
 fn strip_redundant_root_slash(s: &str) -> Option {
@@ -46,15 +61,15 @@ pub fn canonicalize_item(input: &str) -> String {
     }
 
     if let Some(rest) = s.strip_prefix("-/") {
-        let rest = rest.trim().trim_start_matches('/');
-        // New wire form: `/-/https://host/path` (full URL after the dash prefix).
+        let rest = repair_collapsed_http_scheme(rest.trim().trim_start_matches('/'));
+        // Canonical wire form: `/-/https://host/path` (full URL after the dash prefix).
         if rest.starts_with("http://") || rest.starts_with("https://") {
-            return finalize_external_identity_url(rest.to_string());
+            return finalize_external_identity_url(rest);
         }
         // Legacy wire form: `/-/host/path` (host-first segments).
         let (host, tail) = rest
             .split_once('/')
-            .map_or((rest, ""), |(h, t)| (h, t));
+            .map_or((rest.as_str(), ""), |(h, t)| (h, t));
         let host = host.trim().to_lowercase();
         if host.is_empty() {
             return String::new();
@@ -83,6 +98,8 @@ pub fn canonicalize_item(input: &str) -> String {
         };
     }
 
+    let s = repair_collapsed_http_scheme(s);
+
     if let Some(rest) = s.strip_prefix("https://") {
         let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t));
         let host = host.trim().to_lowercase();
@@ -103,7 +120,7 @@ pub fn canonicalize_item(input: &str) -> String {
     }
 
     let is_tilde = s.starts_with("~/");
-    let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(s);
+    let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(&s);
 
     let tail = rest
         .split('/')
diff --git a/types/src/lib.rs b/types/src/lib.rs
index 717c6b3fa6e11d6ca54f4143a69e4c666d73bd82..8435ef8b19318b0acb9ba99ed10202cc6d80b157 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -10,7 +10,7 @@ pub mod thread_xml;
 pub use item_id::ItemId;
 pub use item_wire::{
     canonicalize_item, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url,
-    SLUG_TILDE_ONTOLOGY_ROOT,
+    repair_collapsed_http_scheme, SLUG_TILDE_ONTOLOGY_ROOT,
 };
 pub use paths::{
     canonicalize_tag, ForumThreadUrl, GardenItemUrl, RelativePath,
diff --git a/types/src/paths.rs b/types/src/paths.rs
index 0b72582de4764e56356867279bf4eb340af2e82a..e80697ed35132685f655827d6f497a446ba4e616 100644
--- a/types/src/paths.rs
+++ b/types/src/paths.rs
@@ -516,6 +516,26 @@ mod tests {
         );
     }
 
+    #[test]
+    fn canonicalize_repairs_collapsed_https_scheme_in_dash_path() {
+        assert_eq!(
+            canonicalize_item("-/https:/github.com/org/repo"),
+            "https://github.com/org/repo"
+        );
+        assert_eq!(
+            canonicalize_item("https:/github.com/org/repo"),
+            "https://github.com/org/repo"
+        );
+    }
+
+    #[test]
+    fn legacy_and_https_dash_forms_share_storage_identity() {
+        assert_eq!(
+            canonicalize_item("-/github.com/org/repo"),
+            canonicalize_item("-/https://github.com/org/repo")
+        );
+    }
+
     #[test]
     fn item_id_parent_external_strips_last_segment() {
         let c = ItemId::parse("https://spotify.com/track/1").unwrap();