diff --git a/agents.md b/agents.md index 28d8ddb25c65ff778acd0c01624ed68afa4c31f9..2b55bfb0cf2486186f98db7fd30c3708e73c2226 100644 --- a/agents.md +++ b/agents.md @@ -56,9 +56,9 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`ThreadGraduate` / `GraduateThread`:** Private-room forum threads with **Manage** can be published to the public site under the same tag. The writer replays non-redacted ingests into **`room: public`** (chronological order), then appends a durable **`ThreadGraduated`** marker. Graduated private threads show a banner linking to public **`/t/:tag`**, block further private posts, and cannot be graduated twice. CLI: **`npx slugsocial private forum graduate `**; RPC: **`ThreadGraduate`**. - **`ResolveExternal`:** Resolver buttons (GitHub, are.na) 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/`** (per-domain resolver + import card JSON); **`mod.rs::resolve_external_children`** dispatches on URL host and **`try_render_resolver_item_body`** tries each card renderer before falling back to the usual **`
`** linkified view (called from **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, used by ontology item pages and the **`GET /vote/compare`** left/right columns). The resolver panel (**`external_resolver_controls`** in **`server/src/html/garden/external.rs`**) renders per-host ("GitHub resolver" / "Are.na resolver"); only GitHub items get a siblings button (are.na block URLs are not path-children of their channel).
-  - **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports. Are.na: one thread per channel (`import:https:::www.are.na:channel:{slug}`).
+  - **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports. Are.na: one thread per channel (`import:https:::www.are.na:channel:{slug}`) or per user profile (`import:https:::www.are.na:{user}`).
   - **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.
-  - **Are.na channels:** each channel entry (block or nested channel) is its own **`system:arena-resolver`** ingest in the channel import thread, with the same refresh semantics as issues: page **`GET /v3/channels/{slug}/contents`** (`per=100`, page cap 25), keep still-connected posts, redact removed ones. Block URLs (`/block/:id`) are **not** path-children of the channel, so membership is an **explicit containment claim** (`block <: channel` with a leading `{ … }` explanation) in the same post as the block's `slug-arena-card` body; blocks keep cross-channel identity (one item, multiple scopes). Validation requires containment sides to have bodies, so a ghost channel (never pasted with a body) gets a one-time channel-card definition ingest before block posts (**`ensure_channel_body`**). Cards carry `image_url` / `source_url` and render an `` for image blocks. Env: **`SLUG_ARENA_API_BASE_URL`** (default `https://api.are.na`, v3), optional **`SLUG_ARENA_TOKEN`**, **`SLUG_ARENA_RESOLVER_COOLDOWN_MS`**. Card CSS classes are resolver-agnostic (`import-card__*`, shared with GitHub cards).
+  - **Are.na channels and users:** each channel entry (block or nested channel) is its own **`system:arena-resolver`** ingest in the channel import thread, with the same refresh semantics as issues: page **`GET /v3/channels/{slug}/contents`** (`per=100`, page cap 25), keep still-connected posts, redact removed ones. User profile URLs (`https://www.are.na/:user`) import the user's channels via **`GET /v3/users/{user}/contents`** (non-channel entries skipped). Legacy **`/:user/:channel`** URLs name the same channel as **`/channel/:slug`** (slugs are globally unique): the resolver collapses them onto the canonical channel item (**`canonical_arena_item`**), and the panel's `item_storage` / `next` point at the canonical page. Block URLs (`/block/:id`) are **not** path-children of the channel, so membership is an **explicit containment claim** (`block <: channel` with a leading `{ … }` explanation) in the same post as the block's `slug-arena-card` body; blocks keep cross-channel identity (one item, multiple scopes). Validation requires containment sides to have bodies, so a ghost parent (channel or user, never pasted with a body) gets a one-time card definition ingest before child posts (**`ensure_scope_body`**). Cards carry `image_url` / `source_url` and render an `` for image blocks. Env: **`SLUG_ARENA_API_BASE_URL`** (default `https://api.are.na`, v3), optional **`SLUG_ARENA_TOKEN`**, **`SLUG_ARENA_RESOLVER_COOLDOWN_MS`**. Card CSS classes are resolver-agnostic (`import-card__*`, shared with GitHub cards).
   - **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.
   - **Tilde garden URLs / leaf identity:** Leaf pages are canonical (`GET /~/luke` renders the item/scope page). Nested `GET /~/x/luke` permanently redirects (308, query preserved) to the leaf — same precedent as `/-/` legacy redirects (`OntologyPath::nested_redirect_target`). Trailing slashes on the garden roots and leaf pages also 308 to the slash-free form (`/~/` → `/~`, `/~/luke/` → `/~/luke`; same for `/-/` and `/r/:room_key/~/` / `/r/:room_key/-/`). Room-scoped `/r/:room/~/*path` follows the same rule (`/r/…/~/x/luke` → `/r/…/~/luke`). **`GET /~`** is the root-electorate index (via the root scope view). `~/x/luke` and `~/y/luke` are the same item `~luke`. Breadcrumbs walk **containment** (strongest parent weight), not the typed URL path; other active scopes are listed as alternates on the item page.
 - **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.
diff --git a/server/src/html/garden/external.rs b/server/src/html/garden/external.rs
index 39177205bee0ded1f253b41c7205147266918d53..cccfe2717260cea6ba7b437c41fceed5c626a10a 100644
--- a/server/src/html/garden/external.rs
+++ b/server/src/html/garden/external.rs
@@ -54,6 +54,19 @@ pub(super) fn external_resolver_controls(
         "www.are.na" | "are.na" => ("Are.na", "arena"),
         _ => return None,
     };
+    // Legacy are.na `/:user/:channel` URLs name the same channel as the
+    // canonical `/channel/:slug` form; resolve and land on the canonical item.
+    let canonical = crate::resolvers::arena::canonical_arena_item(&item_id)
+        .filter(|c| c.as_str() != item_id.as_str());
+    let item_id = canonical.clone().unwrap_or(item_id);
+    let next_owned;
+    let next = match &canonical {
+        Some(c) => {
+            next_owned = super::item::item_href(c.as_str(), nav);
+            next_owned.as_str()
+        }
+        None => next,
+    };
     // are.na block URLs are not path-children of their channel, so sibling
     // resolution is meaningless there; only GitHub items get a siblings button.
     let siblings_allowed = host == "github.com";
@@ -166,3 +179,37 @@ fn resolve_status_text(stats: crate::resolvers::ResolveStats, source: &str) -> S
         parts.join(" ")
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn legacy_arena_channel_url_resolves_onto_canonical_item() {
+        let nav = ThreadNav::public();
+        let markup = external_resolver_controls(
+            "https://www.are.na/jake-chvatal/item-industrial",
+            &nav,
+            "/-/https://www.are.na/jake-chvatal/item-industrial",
+        )
+        .expect("are.na panel renders");
+        let html = markup.into_string();
+        // Payload targets the canonical channel item and lands on its page.
+        assert!(html.contains("https://www.are.na/channel/item-industrial"));
+        assert!(html.contains("/-/https://www.are.na/channel/item-industrial"));
+        assert!(!html.contains("jake-chvatal"));
+    }
+
+    #[test]
+    fn canonical_arena_channel_url_keeps_page_as_next() {
+        let nav = ThreadNav::public();
+        let markup = external_resolver_controls(
+            "https://www.are.na/channel/arena-influences",
+            &nav,
+            "/-/https://www.are.na/channel/arena-influences",
+        )
+        .expect("are.na panel renders");
+        let html = markup.into_string();
+        assert!(html.contains("https://www.are.na/channel/arena-influences"));
+    }
+}
diff --git a/server/src/resolvers/arena.rs b/server/src/resolvers/arena.rs
index afaac209d056c113160b8032865f6d5c384e27eb..9f8bdf62d99a9b878b9abeb949ba33ff78364810 100644
--- a/server/src/resolvers/arena.rs
+++ b/server/src/resolvers/arena.rs
@@ -1,9 +1,11 @@
-//! Are.na resolver: imports channel contents (blocks + nested channels) as garden items.
+//! Are.na resolver: imports channel contents (blocks + nested channels) and user
+//! profiles (their channels) as garden items.
 //!
 //! Unlike GitHub issues, are.na block URLs (`/block/:id`) are not path-children of the
 //! channel URL, so membership is emitted as an explicit containment claim
 //! (`block <: channel`) alongside the card body. Blocks keep cross-channel identity:
 //! the same block connected in two imported channels is one item with two scopes.
+//! Legacy `/:user/:channel` URLs resolve onto the canonical `/channel/:slug` item.
 
 use async_trait::async_trait;
 use maud::html;
@@ -34,6 +36,7 @@ fn arena_cooldown_ms() -> i64 {
 #[serde(rename_all = "snake_case")]
 pub enum ArenaImportKind {
     Channel,
+    User,
     Text,
     Image,
     Link,
@@ -108,13 +111,14 @@ impl ArenaResolver {
     }
 
     pub fn can_resolve_children(&self, item: &ItemId) -> bool {
-        arena_channel_slug(item).is_some()
+        arena_target(item).is_some()
     }
 
     pub async fn list_children(&self, item: &ItemId) -> Result, String> {
-        let slug =
-            arena_channel_slug(item).ok_or_else(|| "not an are.na channel URL".to_string())?;
-        self.list_channel_contents(&slug).await
+        match arena_target(item).ok_or_else(|| "not an are.na channel or user URL".to_string())? {
+            ArenaTarget::Channel(slug) => self.list_channel_contents(&slug).await,
+            ArenaTarget::User(user) => self.list_user_channels(&user).await,
+        }
     }
 
     async fn get_json(&self, path: &str) -> Result {
@@ -140,16 +144,34 @@ impl ArenaResolver {
     }
 
     async fn list_channel_contents(&self, slug: &str) -> Result, String> {
+        self.collect_paged(&format!("/v3/channels/{slug}/contents"), false)
+            .await
+    }
+
+    /// A user's channels via `/v3/users/:id/contents` (non-channel entries skipped).
+    async fn list_user_channels(&self, user: &str) -> Result, String> {
+        self.collect_paged(&format!("/v3/users/{user}/contents"), true)
+            .await
+    }
+
+    async fn collect_paged(
+        &self,
+        path: &str,
+        channels_only: bool,
+    ) -> Result, String> {
         let mut out = Vec::new();
         for page in 1..=ARENA_MAX_PAGES {
             let value = self
-                .get_json(&format!("/v3/channels/{slug}/contents?per=100&page={page}"))
+                .get_json(&format!("{path}?per=100&page={page}"))
                 .await?;
             let arr = value
                 .get("data")
                 .and_then(|d| d.as_array())
                 .ok_or_else(|| "are.na response missing data array".to_string())?;
             for entry in arr {
+                if channels_only && entry.get("type").and_then(|v| v.as_str()) != Some("Channel") {
+                    continue;
+                }
                 if let Some(child) = arena_child_from_entry(entry) {
                     out.push(child);
                 }
@@ -193,19 +215,74 @@ fn arena_segments(item: &ItemId) -> Option> {
     }
 }
 
-fn arena_channel_slug(item: &ItemId) -> Option {
+/// Are.na site sections that must not be mistaken for a user slug in
+/// `/:user/...` URLs.
+const ARENA_RESERVED_SEGMENTS: &[&str] = &[
+    "about",
+    "api",
+    "blog",
+    "block",
+    "channel",
+    "developers",
+    "explore",
+    "gift",
+    "getting-started",
+    "log-in",
+    "login",
+    "premium",
+    "search",
+    "settings",
+    "sign-up",
+    "signup",
+    "tools",
+];
+
+/// What a pasted are.na URL points at. Channel slugs are globally unique, so
+/// the legacy `/:user/:channel` form names the same channel as
+/// `/channel/:slug` (are.na itself redirects the legacy form).
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum ArenaTarget {
+    Channel(String),
+    User(String),
+}
+
+fn arena_target(item: &ItemId) -> Option {
     let segs = arena_segments(item)?;
     match segs.as_slice() {
-        [channel, slug] if channel == "channel" && !slug.is_empty() => Some(slug.clone()),
+        [first, slug] if first == "channel" && !slug.is_empty() => {
+            Some(ArenaTarget::Channel(slug.clone()))
+        }
+        [user, slug] if !slug.is_empty() && !ARENA_RESERVED_SEGMENTS.contains(&user.as_str()) => {
+            Some(ArenaTarget::Channel(slug.clone()))
+        }
+        [user] if !ARENA_RESERVED_SEGMENTS.contains(&user.as_str()) => {
+            Some(ArenaTarget::User(user.clone()))
+        }
         _ => None,
     }
 }
 
-/// Stable import thread for an are.na URL: one thread per channel.
+fn canonical_channel_item(slug: &str) -> ItemId {
+    ItemId::parse(&format!("https://www.are.na/channel/{slug}"))
+        .expect("channel slug came from a parsed URL")
+        .normalized_storage()
+}
+
+/// Item the resolver attaches children to: collapses legacy `/:user/:channel`
+/// URLs onto the canonical `/channel/:slug` item so one channel is one item
+/// no matter which URL form was pasted.
+pub fn canonical_arena_item(item: &ItemId) -> Option {
+    match arena_target(item)? {
+        ArenaTarget::Channel(slug) => Some(canonical_channel_item(&slug)),
+        ArenaTarget::User(_) => Some(item.clone().normalized_storage()),
+    }
+}
+
+/// Stable import thread for an are.na URL: one thread per channel or per user.
 fn resolver_thread_tag(item: &ItemId) -> String {
-    let path = match arena_channel_slug(item) {
-        Some(slug) => format!("https://www.are.na/channel/{slug}"),
-        None => item.display_path().trim_start_matches("-/").to_string(),
+    let path = match arena_target(item) {
+        Some(ArenaTarget::Channel(slug)) => format!("https://www.are.na/channel/{slug}"),
+        _ => item.display_path().trim_start_matches("-/").to_string(),
     };
     let tail = path.replace(['/', '?'], ":");
     format!("import:{tail}")
@@ -382,30 +459,33 @@ fn sanitize_explanation_text(raw: &str) -> String {
         .replace(['\n', '\r'], " ")
 }
 
-fn child_to_dsl(channel: &ItemId, child: &ArenaChild) -> String {
+fn child_to_dsl(parent: &ItemId, child: &ArenaChild) -> String {
     let payload = card_payload_for_dsl_fence(&child.card);
     let inner = format!("```slug-arena-card\n{payload}\n```");
-    let channel_url = channel.clone().normalized_storage();
-    let label = sanitize_explanation_text(channel_url.as_str());
+    let parent_url = parent.clone().normalized_storage();
+    let label = sanitize_explanation_text(parent_url.as_str());
     // Item body first: validation requires containment sides to be defined
-    // (here or previously) before the claim. The channel side is guaranteed by
-    // `ensure_channel_body`, not by re-declaring it here — re-declaring would
-    // clobber a user-written channel body on every block import.
+    // (here or previously) before the claim. The parent side is guaranteed by
+    // `ensure_scope_body`, not by re-declaring it here — re-declaring would
+    // clobber a user-written parent body on every child import.
     format!(
-        "{child_url} {{\n{inner}\n}}\n\n{{ Connected in are.na channel {label}. }}\n{child_url} <: {channel_url}\n",
+        "{child_url} {{\n{inner}\n}}\n\n{{ Connected in are.na {label}. }}\n{child_url} <: {parent_url}\n",
         child_url = child.url,
-        channel_url = channel_url.as_str(),
+        parent_url = parent_url.as_str(),
     )
 }
 
-/// Channel items are usually created (with a body) when the user pastes the URL.
-/// When the channel only exists as a ghost path-parent, define it once so the
-/// containment claims validate — and so the channel page renders a rich card.
-async fn ensure_channel_body(
+/// Parent items (channel or user profile) are usually created (with a body)
+/// when the user pastes the URL. When the parent only exists as a ghost,
+/// define it once so the containment claims validate — and so the parent page
+/// renders a rich card.
+async fn ensure_scope_body(
     state: &AppState,
     room: &str,
     thread_tag: &str,
-    channel: &ItemId,
+    parent: &ItemId,
+    kind: ArenaImportKind,
+    headline: String,
 ) -> Result<(), String> {
     use crate::reducer::scope_from_room_wire;
 
@@ -414,30 +494,29 @@ async fn ensure_channel_body(
         let scope = scope_from_room_wire(room);
         let in_room = reduced
             .content_for_scope(&scope)
-            .map(|c| c.item_bodies.contains_key(channel))
+            .map(|c| c.item_bodies.contains_key(parent))
             .unwrap_or(false);
-        let in_public = reduced.public().item_bodies.contains_key(channel);
+        let in_public = reduced.public().item_bodies.contains_key(parent);
         !in_room && !in_public
     };
     if !needs_body {
         return Ok(());
     }
-    let slug = arena_channel_slug(channel).unwrap_or_else(|| channel.last_segment().to_string());
-    let card = ArenaImportCard::new(ArenaImportKind::Channel, channel.as_str().to_string(), slug);
+    let card = ArenaImportCard::new(kind, parent.as_str().to_string(), headline);
     let payload = card_payload_for_dsl_fence(&card);
     let text = format!(
         "{url} {{\n```slug-arena-card\n{payload}\n```\n}}\n",
-        url = channel.as_str()
+        url = parent.as_str()
     );
     system_ingest(state, room, thread_tag, text, ARENA_SYSTEM_PRINCIPAL).await
 }
 
-/// Child URLs declared via containment claims into `channel` within one ingest body.
-fn arena_urls_declared_in_ingest(raw: &str, channel: &ItemId) -> Vec {
+/// Child URLs declared via containment claims into `parent` within one ingest body.
+fn arena_urls_declared_in_ingest(raw: &str, parent: &ItemId) -> Vec {
     let Ok(doc) = crate::dsl::parse_full(raw) else {
         return Vec::new();
     };
-    let parent = channel.clone().normalized_storage();
+    let parent = parent.clone().normalized_storage();
     let mut out = Vec::new();
     for stmt in doc.statements {
         let crate::dsl::Stmt::Containment {
@@ -467,12 +546,12 @@ fn arena_urls_declared_in_ingest(raw: &str, channel: &ItemId) -> Vec {
     out
 }
 
-/// One post per channel entry; redact posts for entries removed from the channel
+/// One post per entry; redact posts for entries removed from the parent scope
 /// (and legacy multi-entry bulk posts).
-async fn resolve_arena_channel_contents(
+async fn resolve_arena_scope_contents(
     state: &AppState,
     room: &str,
-    channel: &ItemId,
+    parent: &ItemId,
     children: Vec,
 ) -> Result {
     use crate::canonical_path::canonicalize_tag;
@@ -484,7 +563,7 @@ async fn resolve_arena_channel_contents(
         .filter_map(|c| super::normalized_child_url(&c.url))
         .collect();
 
-    let thread_tag = resolver_thread_tag(channel);
+    let thread_tag = resolver_thread_tag(parent);
     let scope = scope_from_room_wire(room);
     let tag = canonicalize_tag(&thread_tag);
 
@@ -507,7 +586,7 @@ async fn resolve_arena_channel_contents(
             if ing.principal != ARENA_SYSTEM_PRINCIPAL {
                 continue;
             }
-            let declared = arena_urls_declared_in_ingest(&ing.raw, channel);
+            let declared = arena_urls_declared_in_ingest(&ing.raw, parent);
             if declared.is_empty() {
                 continue;
             }
@@ -541,7 +620,7 @@ async fn resolve_arena_channel_contents(
             state,
             room,
             &thread_tag,
-            child_to_dsl(channel, child),
+            child_to_dsl(parent, child),
             ARENA_SYSTEM_PRINCIPAL,
         )
         .await?;
@@ -560,11 +639,12 @@ pub async fn resolve_arena_children(
     room: &str,
     item: &ItemId,
 ) -> Result {
-    if !state.arena_resolver.can_resolve_children(item) {
+    let Some(target) = arena_target(item) else {
         return Err(
-            "are.na resolver handles channel URLs (https://www.are.na/channel/…) only".to_string(),
+            "are.na resolver handles channel URLs (https://www.are.na/channel/…) and user profiles (https://www.are.na/:user) only"
+                .to_string(),
         );
-    }
+    };
 
     let key = format!("arena:{}:{}", room.trim(), item.as_str());
     let now = now_ms();
@@ -582,11 +662,15 @@ pub async fn resolve_arena_children(
         runs.insert(key, now);
     }
 
-    let channel = item.clone().normalized_storage();
-    let children = state.arena_resolver.list_children(&channel).await?;
-    let thread_tag = resolver_thread_tag(&channel);
-    ensure_channel_body(state, room, &thread_tag, &channel).await?;
-    resolve_arena_channel_contents(state, room, &channel, children).await
+    let parent = canonical_arena_item(item).expect("arena_target matched");
+    let children = state.arena_resolver.list_children(item).await?;
+    let thread_tag = resolver_thread_tag(&parent);
+    let (kind, headline) = match &target {
+        ArenaTarget::Channel(slug) => (ArenaImportKind::Channel, slug.clone()),
+        ArenaTarget::User(user) => (ArenaImportKind::User, user.clone()),
+    };
+    ensure_scope_body(state, room, &thread_tag, &parent, kind, headline).await?;
+    resolve_arena_scope_contents(state, room, &parent, children).await
 }
 
 fn parse_arena_import_from_body(body: &str) -> Option {
@@ -598,6 +682,7 @@ fn parse_arena_import_from_body(body: &str) -> Option {
 fn kind_badge(kind: &ArenaImportKind) -> &'static str {
     match kind {
         ArenaImportKind::Channel => "Are.na · channel",
+        ArenaImportKind::User => "Are.na · user",
         ArenaImportKind::Text => "Are.na · text",
         ArenaImportKind::Image => "Are.na · image",
         ArenaImportKind::Link => "Are.na · link",
@@ -696,19 +781,61 @@ mod tests {
     }
 
     #[test]
-    fn arena_segments_parse_channel_urls() {
+    fn arena_target_parses_channel_urls() {
         let item = ItemId::parse("https://www.are.na/channel/arena-influences").unwrap();
         assert_eq!(
             arena_segments(&item),
             Some(vec!["channel".to_string(), "arena-influences".to_string()])
         );
+        assert_eq!(
+            arena_target(&item),
+            Some(ArenaTarget::Channel("arena-influences".to_string()))
+        );
         let bare = ItemId::parse("https://are.na/channel/arena-influences").unwrap();
         assert_eq!(
-            arena_channel_slug(&bare),
-            Some("arena-influences".to_string())
+            arena_target(&bare),
+            Some(ArenaTarget::Channel("arena-influences".to_string()))
         );
         let block = ItemId::parse("https://www.are.na/block/123").unwrap();
-        assert_eq!(arena_channel_slug(&block), None);
+        assert_eq!(arena_target(&block), None);
+    }
+
+    #[test]
+    fn arena_target_parses_legacy_user_channel_urls() {
+        let legacy = ItemId::parse("https://www.are.na/jake-chvatal/item-industrial").unwrap();
+        assert_eq!(
+            arena_target(&legacy),
+            Some(ArenaTarget::Channel("item-industrial".to_string()))
+        );
+        // Legacy URLs collapse onto the canonical channel item.
+        let canonical = canonical_arena_item(&legacy).expect("legacy channel URL");
+        assert_eq!(
+            canonical.as_str(),
+            "https://www.are.na/channel/item-industrial"
+        );
+        // Same thread as the canonical form.
+        assert_eq!(
+            resolver_thread_tag(&legacy),
+            "import:https:::www.are.na:channel:item-industrial"
+        );
+        // Reserved site sections are not user slugs.
+        let blog = ItemId::parse("https://www.are.na/blog/some-post").unwrap();
+        assert_eq!(arena_target(&blog), None);
+    }
+
+    #[test]
+    fn arena_target_parses_user_profile_urls() {
+        let user = ItemId::parse("https://www.are.na/jake-chvatal").unwrap();
+        assert_eq!(
+            arena_target(&user),
+            Some(ArenaTarget::User("jake-chvatal".to_string()))
+        );
+        assert_eq!(
+            resolver_thread_tag(&user),
+            "import:https:::www.are.na:jake-chvatal"
+        );
+        let reserved = ItemId::parse("https://www.are.na/explore").unwrap();
+        assert_eq!(arena_target(&reserved), None);
     }
 
     #[test]
@@ -835,7 +962,7 @@ mod tests {
         });
         let child = arena_child_from_entry(&entry).expect("text entry");
         // Containment sides must be defined with bodies: in production the channel
-        // is defined by `ensure_channel_body` (or the user's original paste); here
+        // is defined by `ensure_scope_body` (or the user's original paste); here
         // we prepend an equivalent channel definition to the same document.
         let text = format!(
             "{channel} {{are.na channel}}\n\n{child_dsl}",
@@ -920,4 +1047,53 @@ mod tests {
         let _ = tx.send(());
         let _ = server.await;
     }
+
+    #[tokio::test]
+    async fn list_user_channels_keeps_channels_skips_blocks() {
+        use axum::{routing::get, Json, Router};
+        use tokio::sync::oneshot;
+
+        let (tx, rx) = oneshot::channel::<()>();
+        let app = Router::new().route(
+            "/v3/users/jake-chvatal/contents",
+            get(|| async move {
+                Json(serde_json::json!({
+                    "meta": {"current_page": 1, "has_more_pages": false},
+                    "data": [
+                        {"id": 11, "type": "Channel", "title": "item/industrial", "slug": "item-industrial", "counts": {"contents": 1900}},
+                        {"id": 12, "type": "Text", "content": {"markdown": "loose block"}},
+                        {"id": 13, "type": "Channel", "title": "notes", "slug": "notes-abc"}
+                    ],
+                }))
+            }),
+        );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        let server = tokio::spawn(async move {
+            axum::serve(listener, app)
+                .with_graceful_shutdown(async {
+                    let _ = rx.await;
+                })
+                .await
+                .unwrap();
+        });
+        let resolver = ArenaResolver {
+            client: reqwest::Client::new(),
+            api_base_url: format!("http://{addr}"),
+            token: None,
+        };
+        let user = ItemId::parse("https://www.are.na/jake-chvatal").unwrap();
+        assert!(resolver.can_resolve_children(&user));
+        let kids = resolver.list_children(&user).await.unwrap();
+        assert_eq!(kids.len(), 2);
+        assert!(kids
+            .iter()
+            .any(|k| k.url == "https://www.are.na/channel/item-industrial"));
+        assert!(kids
+            .iter()
+            .any(|k| k.url == "https://www.are.na/channel/notes-abc"));
+        assert!(!kids.iter().any(|k| k.url.contains("/block/")));
+        let _ = tx.send(());
+        let _ = server.await;
+    }
 }
diff --git a/test/browser_arena_resolver.clj b/test/browser_arena_resolver.clj
index 71dbb44157c81bea725c5f5dc730571e4cdef772..760237731bf6b74d072772466b8de7ec2ad32f0f 100644
--- a/test/browser_arena_resolver.clj
+++ b/test/browser_arena_resolver.clj
@@ -152,7 +152,15 @@
                (is (wait-for-http-text (str base-url "/-/https://www.are.na/channel/my-chan")
                                        "-/https://www.are.na/block/1"
                                        15000)
-                   "still-connected block kept after refresh")))))
+                   "still-connected block kept after refresh")
+
+               ;; Legacy /:user/:channel URLs resolve onto the canonical channel item.
+               (page/navigate pg (str base-url "/-/https://www.are.na/some-user/my-chan"))
+               (is (wait-for-text pg "#external-resolver-panel" "Are.na resolver" 15000)
+                   "legacy channel URL shows are.na resolver panel")
+               (locator/click (page/locator pg "[data-testid=\"arena-resolve-children\"]"))
+               (is (wait-for-text pg "body" "-/https://www.are.na/block/1" 15000)
+                   "legacy URL import lands on canonical channel page with children")))))
 
        (is (some #{"/v3/channels/my-chan/contents"} @(:paths @!arena))
            "mock are.na saw channel contents request")