diff --git a/Cargo.lock b/Cargo.lock index aa52530..de4eb68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1744,7 +1744,7 @@ dependencies = [ [[package]] name = "pubky-app-specs" -version = "0.4.5" +version = "0.5.0" dependencies = [ "anyhow", "base32", diff --git a/Cargo.toml b/Cargo.toml index 9a33fc3..2aebf57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pubky-app-specs" -version = "0.4.5" +version = "0.5.0" edition = "2021" rust-version = "1.89" description = "Pubky.app Data Model Specifications" diff --git a/README.md b/README.md index cf8ac18..d398964 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Pubky.app models are designed for decentralized content sharing. The system uses - `video` - `link` - `file` +- `collection` **Example: Valid Post** @@ -173,6 +174,27 @@ Pubky.app models are designed for decentralized content sharing. The system uses } ``` +**Note on `kind = collection`:** + +Collection posts use a typed JSON envelope as their `content`. The envelope shape is: + +```json +{ + "name": "AI papers", + "description": "Best stuff", + "items": [ + "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A", + "pubky://userB/pub/pubky.app/posts/0034A0X7NJ52B" + ] +} +``` + +- `name` — required, 1–100 unicode scalars, non-whitespace-only. +- `description` — optional, max 500 scalars. +- `items` — ordered list of URIs (≤ 100, each ≤ 300 chars, protocols in `[pubky, http, https]`). + +For `kind = collection`, `parent`, `embed`, and `post.attachments` must be unset. The `content` field is bounded by 40000 scalars instead of the regular short/long caps. + --- ### PubkyAppTag diff --git a/src/constants.rs b/src/constants.rs index 3609c9a..fb1b0eb 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,5 +1,5 @@ // Application version -pub static VERSION: &str = "0.4.5"; +pub static VERSION: &str = "0.5.0"; // Path constants pub static PUBLIC_PATH: &str = "/pub/"; diff --git a/src/lib.rs b/src/lib.rs index e84787d..c6e7351 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,12 +16,15 @@ pub use limits::*; // Re-export domain types pub use models::blob::PubkyAppBlob; pub use models::bookmark::PubkyAppBookmark; +pub use models::collection_pointer::PubkyAppCollectionPointer; pub use models::feed::{PubkyAppFeed, PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort}; pub use models::file::{PubkyAppFile, VALID_MIME_TYPES}; pub use models::follow::PubkyAppFollow; pub use models::last_read::PubkyAppLastRead; pub use models::mute::PubkyAppMute; -pub use models::post::{PubkyAppPost, PubkyAppPostEmbed, PubkyAppPostKind}; +pub use models::post::{ + PubkyAppCollectionContent, PubkyAppPost, PubkyAppPostEmbed, PubkyAppPostKind, +}; pub use models::tag::PubkyAppTag; pub use models::user::{PubkyAppUser, PubkyAppUserLink}; pub use models::PubkyAppObject; diff --git a/src/limits.rs b/src/limits.rs index 2f17cdf..3b6a263 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -57,6 +57,23 @@ pub struct ValidationLimits { pub post_attachment_url_max_length: usize, /// Allowed protocols for attachment URLs. pub post_allowed_attachment_protocols: &'static [&'static str], + /// Maximum scalar count (`chars().count()`, not bytes) for the JSON + /// envelope content of a Collection post. Sized to hold a + /// max-population envelope: 100 items × 300 chars + name + description + /// + JSON overhead + headroom for additive future fields. + pub collection_content_max_length: usize, + /// Minimum character count for a Collection name. The validator rejects + /// whitespace-only names separately, then counts the full string length. + pub collection_name_min_length: usize, + /// Maximum character count for a Collection name. Leading/trailing + /// whitespace counts toward the total (the validator does not trim). + pub collection_name_max_length: usize, + /// Maximum character count for a Collection description. + pub collection_description_max_length: usize, + /// Maximum number of items (attachment URIs) per Collection. + pub collection_items_max_count: usize, + /// Maximum character count for a single Collection item URI. + pub collection_item_uri_max_length: usize, /// Minimum file name length in characters. pub file_name_min_length: usize, /// Maximum file name length in characters. @@ -87,6 +104,12 @@ pub const VALIDATION_LIMITS: ValidationLimits = ValidationLimits { post_attachments_max_count: 4, post_attachment_url_max_length: 200, post_allowed_attachment_protocols: &["pubky", "http", "https"], + collection_content_max_length: 40_000, + collection_name_min_length: 1, + collection_name_max_length: 100, + collection_description_max_length: 500, + collection_items_max_count: 100, + collection_item_uri_max_length: 300, file_name_min_length: 1, file_name_max_length: 255, file_src_max_length: 1024, diff --git a/src/models/collection_pointer.rs b/src/models/collection_pointer.rs new file mode 100644 index 0000000..4803843 --- /dev/null +++ b/src/models/collection_pointer.rs @@ -0,0 +1,226 @@ +use crate::{ + common::timestamp, + traits::{HasIdPath, Validatable}, + APP_PATH, PUBLIC_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(target_arch = "wasm32")] +use crate::traits::Json; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// A user's pointer to a Collection post, identified by `(owner, post_id)`. +/// +/// One primitive, one path: +/// +/// `/pub/pubky.app/collections//` +/// +/// The role of a given pointer is **inferred at read time** by comparing the +/// path's `` to the homeserver user (the URI host): +/// +/// - `owner_id == homeserver_user` — **own-pointer**: a sovereign index entry +/// declaring "I created this collection." Lets a client list its own +/// collections by prefix-scan on its homeserver without a Nexus dependency. +/// +/// - `owner_id != homeserver_user` — **follow-pointer**: a subscription to +/// someone else's collection. Indexers (e.g. Nexus) materialize this as a +/// `(:User)-[:FOLLOWS_COLLECTION]->(:Post {kind:'collection'})` edge and +/// emit a follow-notification to the target's owner. Deleting the pointer +/// removes the edge; no unfollow-notification is fired. +/// +/// Body is intentionally minimal — `created_at` only — matching the +/// `PubkyAppFollow` precedent. The `(owner, post_id)` pair is fully encoded +/// in the path, so the spec primitive itself carries no role field, no +/// target field, and no subfolder discriminator. +/// +/// Example URIs: +/// +/// - own: `pubky://A/pub/pubky.app/collections/A/0034A0X7NJ52G` +/// - follow: `pubky://A/pub/pubky.app/collections/B/0034A0X7NJ52G` +#[cfg_attr(target_arch = "wasm32", wasm_bindgen)] +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppCollectionPointer { + pub created_at: i64, +} + +impl PubkyAppCollectionPointer { + /// Creates a new pointer with `created_at` set to the current timestamp. + pub fn new() -> Self { + Self { + created_at: timestamp(), + } + } + + /// Canonical builder. Works for **both** own-pointers (pass your own + /// `user_id` as `owner_id`) and follow-pointers (pass the target + /// collection's owner). Role is determined at read time by callers + /// comparing `owner_id` against the URI host. + pub fn create_path(owner_id: &str, post_id: &str) -> String { + [ + PUBLIC_PATH, + APP_PATH, + Self::PATH_SEGMENT, + owner_id, + "/", + post_id, + ] + .concat() + } +} + +impl HasIdPath for PubkyAppCollectionPointer { + const PATH_SEGMENT: &'static str = "collections/"; + + /// Trait form. `id` here is the composite `/`. + /// Provided so trait-generic call sites (e.g. `try_to_uri_str`) keep + /// working; new callers should prefer the two-arg + /// [`PubkyAppCollectionPointer::create_path`]. + fn create_path(id: &str) -> String { + [PUBLIC_PATH, APP_PATH, Self::PATH_SEGMENT, id].concat() + } +} + +#[cfg(target_arch = "wasm32")] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen)] +impl PubkyAppCollectionPointer { + #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))] + pub fn from_json(js_value: &JsValue) -> Result { + Self::import_json(js_value) + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))] + pub fn to_json(&self) -> Result { + self.export_json() + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = createPath))] + pub fn create_path_wasm(owner_id: &str, post_id: &str) -> String { + Self::create_path(owner_id, post_id) + } +} + +#[cfg(target_arch = "wasm32")] +impl Json for PubkyAppCollectionPointer {} + +impl Validatable for PubkyAppCollectionPointer { + fn validate(&self, _id: Option<&str>) -> Result<(), String> { + // Body is just `created_at`. Path components (owner pubkey, post_id) + // are pre-validated by the URI parser before this is reached. + // `created_at` bounds-checking matches the PubkyAppFollow precedent + // (a TODO there, deferred symmetrically here). + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + const OWNER: &str = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo"; + const POST_ID: &str = "0034A0X7NJ52G"; + + #[test] + fn test_new() { + let p = PubkyAppCollectionPointer::new(); + let now = timestamp(); + // within 1 second + assert!(p.created_at <= now && p.created_at >= now - 1_000_000); + } + + #[test] + fn test_create_path() { + let path = PubkyAppCollectionPointer::create_path(OWNER, POST_ID); + assert_eq!( + path, + "/pub/pubky.app/collections/operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/0034A0X7NJ52G" + ); + } + + #[test] + fn test_create_path_trait_form() { + // The HasIdPath trait form takes a composite "/" id. + let composite = format!("{OWNER}/{POST_ID}"); + let path = ::create_path(&composite); + let canonical = PubkyAppCollectionPointer::create_path(OWNER, POST_ID); + assert_eq!(path, canonical); + } + + #[test] + fn test_round_trip() { + let json = r#"{ "created_at": 1627849723 }"#; + let p = + ::try_from(json.as_bytes(), POST_ID).unwrap(); + assert_eq!(p.created_at, 1627849723); + } + + // ---------- WASM-target tests ---------- + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test::wasm_bindgen_test] + fn test_create_path_wasm_static() { + // WASM-bound static path builder mirrors the Rust `create_path`. + let path = PubkyAppCollectionPointer::create_path_wasm(OWNER, POST_ID); + assert_eq!( + path, + "/pub/pubky.app/collections/operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/0034A0X7NJ52G" + ); + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test::wasm_bindgen_test] + fn test_create_own_collection_pointer_wasm_builder() { + // Builder method that fills owner = self.pubky_id implicitly. + use crate::PubkySpecsBuilder; + let builder = + PubkySpecsBuilder::new(OWNER.to_string()).expect("Failed to construct builder"); + let result = builder + .create_own_collection_pointer(POST_ID.to_string()) + .expect("createOwnCollectionPointer should succeed"); + + let pointer = result.collection_pointer(); + let now = timestamp(); + assert!(pointer.created_at <= now && pointer.created_at >= now - 1_000_000); + + let meta = result.meta(); + let expected_path = format!("/pub/pubky.app/collections/{OWNER}/{POST_ID}"); + assert_eq!(meta.path(), expected_path); + // Composite id mirrors Resource::CollectionPointer.id() in uri_parser. + assert_eq!(meta.id(), format!("{OWNER}/{POST_ID}")); + // URL host is the builder's own pubky_id; for an own-pointer it + // matches the path's owner segment (which is the role-inference + // invariant tested in models/mod.rs). + let expected_url = format!("pubky://{OWNER}{expected_path}"); + assert_eq!(meta.url(), expected_url); + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test::wasm_bindgen_test] + fn test_create_followed_collection_pointer_wasm_builder() { + // Builder method that takes an explicit target owner. URI host is + // the builder's own pubky_id (the follower); path's owner segment + // is the target's. owner != user_id ⇒ follow-pointer. + use crate::PubkySpecsBuilder; + let follower = OWNER; + let target_owner = "pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy"; + let builder = + PubkySpecsBuilder::new(follower.to_string()).expect("Failed to construct builder"); + let result = builder + .create_followed_collection_pointer(target_owner.to_string(), POST_ID.to_string()) + .expect("createFollowedCollectionPointer should succeed"); + + let meta = result.meta(); + let expected_path = format!("/pub/pubky.app/collections/{target_owner}/{POST_ID}"); + assert_eq!(meta.path(), expected_path); + // URL host = follower (builder's own), not the target. + let expected_url = format!("pubky://{follower}{expected_path}"); + assert_eq!(meta.url(), expected_url); + // Composite id reflects the path's / segments. + assert_eq!(meta.id(), format!("{target_owner}/{POST_ID}")); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index debfdd2..10072ff 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,6 +2,7 @@ use crate::{traits::Validatable, ParsedUri, Resource}; pub mod blob; pub mod bookmark; +pub mod collection_pointer; pub mod feed; pub mod file; pub mod follow; @@ -12,8 +13,8 @@ pub mod tag; pub mod user; use super::{ - PubkyAppBlob, PubkyAppBookmark, PubkyAppFeed, PubkyAppFile, PubkyAppFollow, PubkyAppLastRead, - PubkyAppMute, PubkyAppPost, PubkyAppTag, PubkyAppUser, + PubkyAppBlob, PubkyAppBookmark, PubkyAppCollectionPointer, PubkyAppFeed, PubkyAppFile, + PubkyAppFollow, PubkyAppLastRead, PubkyAppMute, PubkyAppPost, PubkyAppTag, PubkyAppUser, }; /// A unified enum wrapping all PubkyApp objects. @@ -29,6 +30,7 @@ pub enum PubkyAppObject { Blob(blob::PubkyAppBlob), Feed(feed::PubkyAppFeed), LastRead(last_read::PubkyAppLastRead), + CollectionPointer(collection_pointer::PubkyAppCollectionPointer), } impl PubkyAppObject { @@ -85,6 +87,14 @@ impl PubkyAppObject { let last_read = ::try_from(blob, "")?; Ok(PubkyAppObject::LastRead(last_read)) } + Resource::CollectionPointer { owner: _, post_id } => { + // The owner is discarded at the spec layer — the body doesn't + // carry it — but stays available to consumers (e.g. the Nexus + // watcher) via the `Resource` variant. We hand the post_id + // segment to the validator as the canonical id. + let pointer = ::try_from(blob, post_id)?; + Ok(PubkyAppObject::CollectionPointer(pointer)) + } Resource::Unknown => Err(format!("Unrecognized resource {:?}", resource)), } } @@ -373,6 +383,83 @@ mod tests { } } + // ---------- CollectionPointer role-inference invariants ---------- + // + // The CollectionPointer primitive carries no role field. Whether a given + // pointer is an own-pointer (sovereign index entry for the user's own + // collection) or a follow-pointer (subscription to someone else's + // collection) is determined at read time by comparing the URI host + // (`ParsedUri::user_id`) against the path-encoded `owner`. These two + // tests encode that contract — anything that breaks the invariant + // breaks consumers downstream (Nexus watcher, frontends). + + #[test] + fn test_import_collection_pointer_own_role() { + // user pointing to a collection they own: owner == follower (URI host). + let user = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo".to_string(); + let uri = + collection_pointer_uri_builder(user.clone(), user.clone(), "0034A0X7NJ52G".into()); + let json = r#"{ "created_at": 1627849730 }"#; + + let parsed = ParsedUri::try_from(uri.as_str()).unwrap(); + if let Resource::CollectionPointer { ref owner, .. } = parsed.resource { + assert_eq!( + parsed.user_id.as_ref(), + owner.as_ref(), + "expected own-pointer (URI host == path owner)" + ); + } else { + panic!( + "Expected Resource::CollectionPointer, got {:?}", + parsed.resource + ); + } + + match PubkyAppObject::from_resource(&parsed.resource, json.as_bytes()).unwrap() { + PubkyAppObject::CollectionPointer(p) => assert_eq!(p.created_at, 1627849730), + other => panic!( + "Expected PubkyAppObject::CollectionPointer, got {:?}", + other + ), + } + } + + #[test] + fn test_import_collection_pointer_follow_role() { + // user pointing to a collection they DO NOT own: owner != follower. + let follower = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo".to_string(); + let target_owner = "pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy".to_string(); + let uri = collection_pointer_uri_builder( + follower.clone(), + target_owner.clone(), + "0034A0X7NJ52G".into(), + ); + let json = r#"{ "created_at": 1627849731 }"#; + + let parsed = ParsedUri::try_from(uri.as_str()).unwrap(); + if let Resource::CollectionPointer { ref owner, .. } = parsed.resource { + assert_ne!( + parsed.user_id.as_ref(), + owner.as_ref(), + "expected follow-pointer (URI host != path owner)" + ); + assert_eq!(owner.as_ref(), target_owner); + } else { + panic!( + "Expected Resource::CollectionPointer, got {:?}", + parsed.resource + ); + } + + match PubkyAppObject::from_resource(&parsed.resource, json.as_bytes()).unwrap() { + PubkyAppObject::CollectionPointer(p) => assert_eq!(p.created_at, 1627849731), + other => panic!( + "Expected PubkyAppObject::CollectionPointer, got {:?}", + other + ), + } + } + #[test] fn test_import_unknown_resource() { let uri = diff --git a/src/models/post.rs b/src/models/post.rs index d711a4f..d2d87cc 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -33,6 +33,7 @@ pub enum PubkyAppPostKind { Video, Link, File, + Collection, #[serde(other)] Unknown, } @@ -58,11 +59,26 @@ impl FromStr for PubkyAppPostKind { "video" => Ok(PubkyAppPostKind::Video), "link" => Ok(PubkyAppPostKind::Link), "file" => Ok(PubkyAppPostKind::File), + "collection" => Ok(PubkyAppPostKind::Collection), _ => Err(format!("Invalid content kind: {}", s)), } } } +impl PubkyAppPostKind { + /// Returns `true` for every spec-recognized variant, `false` for `Unknown`. + /// + /// `Unknown` is the forwards-compat catch-all variant (via `#[serde(other)]`) + /// that captures any post-kind string this version of the spec doesn't + /// recognize yet. Most consumers — indexers, stream filters, search ranking — + /// want to skip such posts, and this helper lets them write + /// `if kind.is_known() { ... }` rather than + /// `if !matches!(kind, PubkyAppPostKind::Unknown) { ... }`. + pub fn is_known(&self) -> bool { + !matches!(self, PubkyAppPostKind::Unknown) + } +} + /// Represents embedded content within a post #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)] @@ -91,10 +107,9 @@ impl PubkyAppPostEmbed { PubkyAppPostKind::Video => "Video".to_string(), PubkyAppPostKind::Link => "Link".to_string(), PubkyAppPostKind::File => "File".to_string(), + PubkyAppPostKind::Collection => "Collection".to_string(), PubkyAppPostKind::Unknown => "Unknown".to_string(), } - // pub fn kind(&self) -> PubkyAppPostKind { - // self.kind.clone() } #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))] @@ -103,6 +118,42 @@ impl PubkyAppPostEmbed { } } +/// Typed JSON envelope stored in `PubkyAppPost::content` when `kind == Collection`. +/// +/// A collection post curates an ordered list of URIs (via `items`) +/// under a `name` and optional `description`. The envelope is parsed and validated +/// by the spec but never re-serialized as a top-level homeserver object. +/// +/// **Construction**: this struct is deserialized from the post's `content` JSON +/// envelope during validation and is **not** intended to be constructed by +/// callers directly. It is re-exported publicly (via `lib.rs`) so SDK consumers +/// can inspect the envelope shape (OpenAPI schema, type definitions), but the +/// authoritative way to produce a Collection post is to author a `PubkyAppPost` +/// with `kind: Collection` and a `content` string that JSON-parses into this +/// shape. +/// +/// Forward-compat: `#[serde(deny_unknown_fields)]` is intentionally NOT used so +/// future minor versions can add fields (e.g. `cover_image`) without breaking +/// older parsers. New fields must be additive and ignorable. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +#[serde(rename_all = "snake_case")] +pub struct PubkyAppCollectionContent { + /// Display name of the collection. Length bounded by + /// `VALIDATION_LIMITS.collection_name_{min,max}_length` (unicode scalars). + /// Whitespace-only names are rejected separately by the validator. + pub name: String, + /// Optional human-readable description. Length bounded by + /// `VALIDATION_LIMITS.collection_description_max_length` (unicode scalars). + pub description: Option, + /// Ordered list of URIs this collection curates. Bounded by + /// `VALIDATION_LIMITS.collection_items_max_count` and + /// `VALIDATION_LIMITS.collection_item_uri_max_length`. Each URI must use + /// a protocol in `VALIDATION_LIMITS.post_allowed_attachment_protocols`. + #[serde(default)] + pub items: Vec, +} + /// Represents raw post in homeserver with content and kind /// URI: /pub/pubky.app/posts/:post_id /// Where post_id is CrockfordBase32 encoding of timestamp @@ -143,10 +194,9 @@ impl PubkyAppPost { PubkyAppPostKind::Video => "Video".to_string(), PubkyAppPostKind::Link => "Link".to_string(), PubkyAppPostKind::File => "File".to_string(), + PubkyAppPostKind::Collection => "Collection".to_string(), PubkyAppPostKind::Unknown => "Unknown".to_string(), } - // pub fn kind(&self) -> PubkyAppPostKind { - // self.kind.clone() } #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))] @@ -267,15 +317,100 @@ impl Validatable for PubkyAppPost { // `Unknown` is a serde catch-all for forwards-compat: older binaries can // deserialize events from newer clients without panicking, but such posts // must never pass spec validation. Same reasoning for `embed.kind`. - if matches!(self.kind, PubkyAppPostKind::Unknown) { + if !self.kind.is_known() { return Err("Validation Error: post kind is unknown".into()); } if let Some(ref embed) = self.embed { - if matches!(embed.kind, PubkyAppPostKind::Unknown) { + if !embed.kind.is_known() { return Err("Validation Error: embed kind is unknown".into()); } } + if matches!(self.kind, PubkyAppPostKind::Collection) { + if self.parent.is_some() || self.embed.is_some() { + return Err( + "Validation Error: Collection posts cannot have parent or embed".into(), + ); + } + // Anti-misuse guard: items belong in the envelope, not in + // `post.attachments`. Relax this when Collections gain real + // attachments (e.g. cover image). + if matches!(&self.attachments, Some(a) if !a.is_empty()) { + return Err( + "Validation Error: Collection posts must not use post.attachments — items belong in the content envelope" + .into(), + ); + } + if self.content.chars().count() > VALIDATION_LIMITS.collection_content_max_length { + return Err(format!( + "Validation Error: Collection content exceeds max length {}", + VALIDATION_LIMITS.collection_content_max_length + )); + } + let envelope: PubkyAppCollectionContent = + serde_json::from_str(&self.content).map_err(|e| { + format!( + "Validation Error: Collection content must be a valid JSON envelope: {}", + e + ) + })?; + if envelope.name.trim().is_empty() { + return Err( + "Validation Error: Collection name must contain non-whitespace characters" + .into(), + ); + } + let name_chars = envelope.name.chars().count(); + let name_min = VALIDATION_LIMITS.collection_name_min_length; + let name_max = VALIDATION_LIMITS.collection_name_max_length; + if !(name_min..=name_max).contains(&name_chars) { + return Err(format!( + "Validation Error: Collection name must be {}..={} characters", + name_min, name_max + )); + } + if let Some(desc) = &envelope.description { + if desc.chars().count() > VALIDATION_LIMITS.collection_description_max_length { + return Err(format!( + "Validation Error: Collection description exceeds {} characters", + VALIDATION_LIMITS.collection_description_max_length + )); + } + } + if envelope.items.len() > VALIDATION_LIMITS.collection_items_max_count { + return Err(format!( + "Validation Error: Collection cannot have more than {} items", + VALIDATION_LIMITS.collection_items_max_count + )); + } + for (index, uri) in envelope.items.iter().enumerate() { + if uri.chars().count() > VALIDATION_LIMITS.collection_item_uri_max_length { + return Err(format!( + "Validation Error: Collection item URI exceeds {} characters", + VALIDATION_LIMITS.collection_item_uri_max_length + )); + } + let parsed = Url::parse(uri) + .map_err(|_| format!("Validation Error: Invalid item URL: {}", uri))?; + if !VALIDATION_LIMITS + .post_allowed_attachment_protocols + .contains(&parsed.scheme()) + { + let allowed_protocols = VALIDATION_LIMITS + .post_allowed_attachment_protocols + .iter() + .map(|p| format!("{}://", p)) + .collect::>() + .join(", "); + return Err(format!( + "Validation Error: Collection item URL at index {} uses disallowed protocol '{}'; must use one of the allowed protocols: {}", + index, parsed.scheme(), allowed_protocols + )); + } + } + return Ok(()); + } + // Validate content length based on post kind let (max_length, kind_name) = match self.kind { PubkyAppPostKind::Short => (VALIDATION_LIMITS.post_short_content_max_length, "Short"), @@ -287,7 +422,9 @@ impl Validatable for PubkyAppPost { VALIDATION_LIMITS.post_short_content_max_length, "Image/Video/Link/File", ), - PubkyAppPostKind::Unknown => unreachable!("guarded by early-return above"), + PubkyAppPostKind::Collection | PubkyAppPostKind::Unknown => { + unreachable!("guarded by early-return above") + } }; if self.content.chars().count() > max_length { @@ -1007,7 +1144,20 @@ mod tests { // FromStr stays strict: it does NOT produce Unknown for arbitrary input. // Unknown is exclusively a serde catch-all. assert!(PubkyAppPostKind::from_str("foobar").is_err()); - assert!(PubkyAppPostKind::from_str("collection").is_err()); + assert!(PubkyAppPostKind::from_str("totally-new-kind").is_err()); + } + + #[test] + fn test_is_known_returns_true_for_all_recognized_variants() { + use PubkyAppPostKind::*; + for k in [Short, Long, Image, Video, Link, File, Collection] { + assert!(k.is_known(), "{k:?} should be known"); + } + } + + #[test] + fn test_is_known_returns_false_for_unknown() { + assert!(!PubkyAppPostKind::Unknown.is_known()); } #[test] @@ -1065,4 +1215,463 @@ mod tests { }; assert_eq!(post.kind(), "Unknown"); } + + // ----- v0.5.0 Collection variant + PubkyAppCollectionContent envelope ----- + + fn collection_envelope_json(name: &str, description: Option<&str>, items: &[String]) -> String { + serde_json::to_string(&PubkyAppCollectionContent { + name: name.to_string(), + description: description.map(|d| d.to_string()), + items: items.to_vec(), + }) + .unwrap() + } + + fn make_collection_post( + name: &str, + description: Option<&str>, + items: Option>, + ) -> PubkyAppPost { + let items = items.unwrap_or_default(); + PubkyAppPost::new( + collection_envelope_json(name, description, &items), + PubkyAppPostKind::Collection, + None, + None, + None, + ) + } + + #[test] + fn test_collection_post_roundtrip_valid() { + let post = make_collection_post( + "AI papers", + Some("Best stuff"), + Some(vec![ + "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string(), + "pubky://userB/pub/pubky.app/posts/0034A0X7NJ52B".to_string(), + "pubky://userC/pub/pubky.app/posts/0034A0X7NJ52C".to_string(), + ]), + ); + let id = post.create_id(); + let blob = serde_json::to_vec(&post).unwrap(); + let parsed = ::try_from(&blob, &id).unwrap(); + assert_eq!(parsed.kind, PubkyAppPostKind::Collection); + assert!(parsed.attachments.is_none()); + let envelope: PubkyAppCollectionContent = serde_json::from_str(&parsed.content).unwrap(); + assert_eq!(envelope.items.len(), 3); + } + + #[test] + fn test_collection_post_rejects_malformed_envelope() { + let post = PubkyAppPost::new( + "this is not JSON".to_string(), + PubkyAppPostKind::Collection, + None, + None, + None, + ); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("JSON envelope"), + "expected JSON envelope error, got: {}", + err + ); + } + + #[test] + fn test_collection_post_rejects_empty_name() { + let post = make_collection_post("", None, None); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn test_collection_post_rejects_oversized_name() { + // 101 grapheme-ish chars; mix in emoji to confirm we count by unicode scalars, not bytes. + let oversized = "a".repeat(99) + "🚀🚀"; + assert_eq!(oversized.chars().count(), 101); + let post = make_collection_post(&oversized, None, None); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn test_collection_post_accepts_max_name() { + let exactly_100 = "a".repeat(100); + assert_eq!(exactly_100.chars().count(), 100); + let post = make_collection_post(&exactly_100, None, None); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_collection_post_rejects_whitespace_only_name() { + // Whitespace-only names pass `min_length=1` purely by char count, so + // we reject them with a dedicated guard. Without that guard, a name + // of `" "` would be a 4-char valid name with no meaningful content. + let post = make_collection_post(" ", None, None); + let id = post.create_id(); + let err = post + .validate(Some(&id)) + .expect_err("whitespace-only name must fail validation"); + assert!( + err.contains("whitespace"), + "error should mention whitespace, got: {err}" + ); + } + + #[test] + fn test_collection_post_counts_whitespace_in_name_length() { + // Regression guard: the validator does NOT trim before counting. A + // 99-char name padded with one space on each side is 101 chars and + // must fail max=100. With the previous trim-then-count behavior this + // would have been 99 chars and passed. + let padded = format!(" {} ", "a".repeat(99)); + assert_eq!(padded.chars().count(), 101); + let post = make_collection_post(&padded, None, None); + let id = post.create_id(); + let err = post + .validate(Some(&id)) + .expect_err("101-char padded name must fail max length"); + assert!( + err.contains("1..=100"), + "error should report the length range, got: {err}" + ); + } + + #[test] + fn test_collection_post_rejects_oversized_description() { + let too_long = "a".repeat(501); + let post = make_collection_post("X", Some(&too_long), None); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("description")); + } + + #[test] + fn test_collection_post_accepts_empty_description() { + // Explicit empty-string description is valid (the field is optional and + // 0..=500 chars allowed). + let post = make_collection_post("X", Some(""), None); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_collection_post_accepts_max_description() { + let exactly_500 = "a".repeat(500); + assert_eq!(exactly_500.chars().count(), 500); + let post = make_collection_post("X", Some(&exactly_500), None); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_collection_post_rejects_missing_name() { + // Envelope JSON without a `name` field at all (description-only). + // Distinct from `test_collection_post_rejects_empty_name`, which sends + // an empty string; this sends a missing key entirely. + let envelope = r#"{ "description": "no name here" }"#.to_string(); + let post = PubkyAppPost::new(envelope, PubkyAppPostKind::Collection, None, None, None); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("name") || err.to_lowercase().contains("missing"), + "expected name-required error, got: {err}" + ); + } + + #[test] + fn test_collection_post_rejects_parent() { + let post = PubkyAppPost::new( + collection_envelope_json("X", None, &[]), + PubkyAppPostKind::Collection, + Some("pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string()), + None, + None, + ); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("parent or embed"), + "expected parent-or-embed error, got: {}", + err + ); + } + + #[test] + fn test_collection_post_rejects_embed() { + let post = PubkyAppPost::new( + collection_envelope_json("X", None, &[]), + PubkyAppPostKind::Collection, + None, + Some(PubkyAppPostEmbed { + kind: PubkyAppPostKind::Short, + uri: "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string(), + }), + None, + ); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("parent or embed")); + } + + #[test] + fn test_collection_post_accepts_100_items() { + let items: Vec = (0..100) + .map(|i| format!("pubky://userA/pub/pubky.app/posts/{:013}", i)) + .collect(); + let post = make_collection_post("Big list", None, Some(items)); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_collection_post_rejects_101_items() { + let items: Vec = (0..101) + .map(|i| format!("pubky://userA/pub/pubky.app/posts/{:013}", i)) + .collect(); + let post = make_collection_post("Too big", None, Some(items)); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("100 items")); + } + + #[test] + fn test_collection_post_accepts_300_char_uri() { + // Build a pubky URI with the user host + a long path filling out to exactly 300 chars. + let prefix = "pubky://userA/pub/pubky.app/posts/"; + let pad = "x".repeat(300 - prefix.chars().count()); + let uri = format!("{}{}", prefix, pad); + assert_eq!(uri.chars().count(), 300); + let post = make_collection_post("X", None, Some(vec![uri])); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_collection_post_rejects_301_char_uri() { + let prefix = "pubky://userA/pub/pubky.app/posts/"; + let pad = "x".repeat(301 - prefix.chars().count()); + let uri = format!("{}{}", prefix, pad); + assert_eq!(uri.chars().count(), 301); + let post = make_collection_post("X", None, Some(vec![uri])); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("URI exceeds")); + } + + #[test] + fn test_postkind_collection_display_lowercase() { + assert_eq!(PubkyAppPostKind::Collection.to_string(), "collection"); + } + + #[test] + fn test_postkind_fromstr_collection() { + assert_eq!( + PubkyAppPostKind::from_str("collection").unwrap(), + PubkyAppPostKind::Collection + ); + } + + #[test] + fn test_collection_post_accepts_zero_items() { + // Curators may create a draft and add items later via edits. + let post = make_collection_post("Drafts", None, None); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_collection_envelope_tolerates_extra_fields() { + // Forward-compat: the envelope intentionally does NOT use deny_unknown_fields, + // so future minor versions can add fields like `cover_image` without breaking + // older parsers. This test locks in that behavior. + let envelope_json = + r#"{"name":"X","description":"Y","cover_image":"https://example.com/x.png"}"#; + let post = PubkyAppPost::new( + envelope_json.to_string(), + PubkyAppPostKind::Collection, + None, + None, + None, + ); + let id = post.create_id(); + assert!( + post.validate(Some(&id)).is_ok(), + "extra envelope fields must be tolerated" + ); + } + + #[test] + fn test_collection_post_rejects_disallowed_item_protocol() { + let post = + make_collection_post("X", None, Some(vec!["ftp://example.com/file".to_string()])); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("must use one of the allowed protocols")); + } + + #[test] + fn test_collection_post_rejects_javascript_protocol() { + // XSS-vector defense: never accept `javascript:` URIs as attachments, + // even though they technically parse as URLs. + let post = make_collection_post("X", None, Some(vec!["javascript:alert(1)".to_string()])); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("must use one of the allowed protocols")); + } + + #[test] + fn test_collection_post_rejects_data_uri_protocol() { + // Same XSS-vector defense for `data:` URIs. + let post = make_collection_post("X", None, Some(vec!["data:text/plain,hello".to_string()])); + let id = post.create_id(); + let result = post.validate(Some(&id)); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("must use one of the allowed protocols")); + } + + #[test] + fn test_collection_post_rejects_non_empty_attachments() { + let post = PubkyAppPost::new( + collection_envelope_json("X", None, &[]), + PubkyAppPostKind::Collection, + None, + None, + Some(vec![ + "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string() + ]), + ); + let id = post.create_id(); + let err = post + .validate(Some(&id)) + .expect_err("Collection with non-empty post.attachments must be rejected"); + assert!( + err.contains("post.attachments"), + "expected anti-misuse error, got: {err}" + ); + } + + #[test] + fn test_collection_post_accepts_missing_items_field() { + let envelope_json = r#"{"name":"X"}"#; + let post = PubkyAppPost::new( + envelope_json.to_string(), + PubkyAppPostKind::Collection, + None, + None, + None, + ); + let id = post.create_id(); + assert!( + post.validate(Some(&id)).is_ok(), + "missing `items` field must deserialize as empty list via serde(default)" + ); + } + + #[test] + fn test_collection_post_envelope_at_max_size() { + let items: Vec = (0..VALIDATION_LIMITS.collection_items_max_count) + .map(|i| { + let prefix = format!("pubky://u{i:03}/pub/pubky.app/posts/"); + let pad_len = + VALIDATION_LIMITS.collection_item_uri_max_length - prefix.chars().count(); + format!("{}{}", prefix, "x".repeat(pad_len)) + }) + .collect(); + let max_name = "a".repeat(VALIDATION_LIMITS.collection_name_max_length); + let max_desc = "b".repeat(VALIDATION_LIMITS.collection_description_max_length); + let post = make_collection_post(&max_name, Some(&max_desc), Some(items)); + assert!( + post.content.chars().count() < VALIDATION_LIMITS.collection_content_max_length, + "envelope at max field sizes must fit under collection_content_max_length" + ); + let id = post.create_id(); + assert!(post.validate(Some(&id)).is_ok()); + } + + #[test] + fn test_existing_post_kinds_unchanged_with_collection() { + // Regression: each of the six legacy lowercase kinds still round-trips after + // adding Collection. Catches accidental ordering / serde changes. + for s in ["short", "long", "image", "video", "link", "file"] { + let json = format!( + r#"{{"content":"x","kind":"{}","parent":null,"embed":null,"attachments":null}}"#, + s + ); + let post: PubkyAppPost = serde_json::from_str(&json).unwrap(); + let re = serde_json::to_value(&post.kind).unwrap(); + assert_eq!(re.as_str(), Some(s), "kind={} did not round-trip", s); + } + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test::wasm_bindgen_test] + fn test_postkind_collection_wasm_getter() { + let post = PubkyAppPost { + content: collection_envelope_json("X", None, &[]), + kind: PubkyAppPostKind::Collection, + parent: None, + embed: None, + attachments: None, + }; + assert_eq!(post.kind(), "Collection"); + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test::wasm_bindgen_test] + fn test_create_collection_post_wasm_builder() { + // End-to-end via the JS-facing builder: + // PubkySpecsBuilder.createCollectionPost(name, description?, attachments?) + // builds the {name, description} envelope internally, packages it + // into a kind=Collection PubkyAppPost, and returns a PostResult + // ready to ship to the homeserver. JS callers don't have to + // JSON-stringify the envelope themselves. + use crate::PubkySpecsBuilder; + let pubky_id = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo".to_string(); + let builder = PubkySpecsBuilder::new(pubky_id).expect("Failed to construct builder"); + let result = builder + .create_collection_post( + "My favorites".to_string(), + Some("Best things".to_string()), + Some(vec![ + "pubky://operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0034A0X7NJ52A".to_string(), + ]), + ) + .expect("createCollectionPost should succeed"); + + let post = result.post(); + assert_eq!(post.kind, PubkyAppPostKind::Collection); + assert!(post.attachments.is_none()); + let envelope: PubkyAppCollectionContent = serde_json::from_str(&post.content) + .expect("Collection content must deserialize as PubkyAppCollectionContent"); + assert_eq!(envelope.name, "My favorites"); + assert_eq!(envelope.description.as_deref(), Some("Best things")); + assert_eq!(envelope.items.len(), 1); + } } diff --git a/src/uri_parser.rs b/src/uri_parser.rs index 1290964..3f55bb0 100644 --- a/src/uri_parser.rs +++ b/src/uri_parser.rs @@ -1,8 +1,8 @@ use crate::{ traits::{HasIdPath, HasPath}, - PubkyAppBlob, PubkyAppBookmark, PubkyAppFeed, PubkyAppFile, PubkyAppFollow, PubkyAppLastRead, - PubkyAppMute, PubkyAppPost, PubkyAppTag, PubkyAppUser, PubkyId, APP_PATH, PROTOCOL, - PUBLIC_PATH, + PubkyAppBlob, PubkyAppBookmark, PubkyAppCollectionPointer, PubkyAppFeed, PubkyAppFile, + PubkyAppFollow, PubkyAppLastRead, PubkyAppMute, PubkyAppPost, PubkyAppTag, PubkyAppUser, + PubkyId, APP_PATH, PROTOCOL, PUBLIC_PATH, }; use serde::{Deserialize, Serialize}; use std::convert::TryFrom; @@ -21,6 +21,13 @@ pub enum Resource { Blob(String), Feed(String), LastRead, + /// A pointer to a Collection post identified by `(owner, post_id)`. + /// The role (own vs follow) is inferred by callers comparing `owner` + /// against the URI host (`ParsedUri::user_id`). + CollectionPointer { + owner: PubkyId, + post_id: String, + }, #[default] Unknown, } @@ -40,6 +47,9 @@ impl fmt::Display for Resource { Resource::File(_) => PubkyAppFile::PATH_SEGMENT.trim_end_matches('/'), Resource::Blob(_) => PubkyAppBlob::PATH_SEGMENT.trim_end_matches('/'), Resource::Feed(_) => PubkyAppFeed::PATH_SEGMENT.trim_end_matches('/'), + Resource::CollectionPointer { .. } => { + PubkyAppCollectionPointer::PATH_SEGMENT.trim_end_matches('/') + } Resource::Unknown => "unknown", }; write!(f, "{}", name) @@ -59,6 +69,11 @@ impl Resource { Resource::File(id) => Some(id.clone()), Resource::Blob(id) => Some(id.clone()), Resource::Feed(id) => Some(id.clone()), + // Composite id reflecting the path between `collections/` and end. + // Consumers that need just the post_id can split on `/`. + Resource::CollectionPointer { owner, post_id } => { + Some(format!("{}/{}", owner.as_ref(), post_id)) + } // The following variants do not carry an id. Resource::User | Resource::LastRead | Resource::Unknown => None, } @@ -88,6 +103,9 @@ impl ParsedUri { Resource::File(id) => PubkyAppFile::create_path(id), Resource::Blob(id) => PubkyAppBlob::create_path(id), Resource::Feed(id) => PubkyAppFeed::create_path(id), + Resource::CollectionPointer { owner, post_id } => { + PubkyAppCollectionPointer::create_path(owner.as_ref(), post_id) + } Resource::Unknown => return Err("Cannot convert Unknown resource to URI".to_string()), }; @@ -148,6 +166,25 @@ impl TryFrom<&str> for ParsedUri { PubkyAppLastRead::PATH_SEGMENT => Resource::LastRead, _ => Resource::Unknown, }, + // Exactly three segments matching `collections//`. + // Must come BEFORE the generic `[res_type, id, ..]` arm so that + // collection-pointer URIs don't fall through to the registered- + // single-segment lookup (where "collections/" isn't a registered + // PATH_SEGMENT and would resolve to `Resource::Unknown`). + // + // If the guard fails (parent != "collections" or post_id empty), + // Rust falls through to the next arm. + [parent, owner_id, post_id] + if parent == PubkyAppCollectionPointer::PATH_SEGMENT.trim_end_matches('/') + && !owner_id.is_empty() + && !post_id.is_empty() => + { + let owner = PubkyId::try_from(owner_id)?; + Resource::CollectionPointer { + owner, + post_id: post_id.to_string(), + } + } // Two or more segments and the id is not empty. [res_type, id, ..] if !id.is_empty() => { let resource_type = format!("{}/", res_type); @@ -340,6 +377,56 @@ mod tests { assert_eq!(parsed.resource, Resource::Unknown); } + #[test] + fn test_valid_collection_pointer_uri() { + // Three-segment path: collections//. + // The parser produces Resource::CollectionPointer { owner, post_id } + // regardless of whether the URI host equals the owner; role inference + // is left to higher layers. + let target_owner = "pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy"; + let post_id = "0034A0X7NJ52G"; + let uri = + collection_pointer_uri_builder(USER_ID.into(), target_owner.into(), post_id.into()); + let parsed = + ParsedUri::try_from(uri.clone()).expect("Failed to parse valid collection-pointer URI"); + assert_eq!(parsed.user_id, PubkyId::try_from(USER_ID).unwrap()); + match &parsed.resource { + Resource::CollectionPointer { + owner, + post_id: pid, + } => { + assert_eq!(owner.as_ref(), target_owner); + assert_eq!(pid, post_id); + } + other => panic!("Expected CollectionPointer, got {other:?}"), + } + // Round-trip via try_to_uri_str. + let reconstructed = parsed + .try_to_uri_str() + .expect("Failed to convert CollectionPointer back to URI"); + assert_eq!(uri, reconstructed, "CollectionPointer URI roundtrip failed"); + } + + #[test] + fn test_collection_pointer_empty_post_id() { + // Trailing slash leaves post_id empty → fall-through to Unknown. + let uri = format!("pubky://{USER_ID}/pub/pubky.app/collections/{USER_ID}/"); + let parsed = ParsedUri::try_from(uri) + .expect("Parser must not error on empty post_id; should fall through to Unknown"); + assert_eq!(parsed.resource, Resource::Unknown); + } + + #[test] + fn test_collection_pointer_bad_owner() { + // Malformed owner segment → PubkyId::try_from fails → Err. + let uri = format!("pubky://{USER_ID}/pub/pubky.app/collections/not_a_valid_pubky/POSTID"); + let result = ParsedUri::try_from(uri); + assert!( + result.is_err(), + "Parser should reject a CollectionPointer URI with a malformed owner pubkey" + ); + } + // Failure cases #[test] diff --git a/src/utils.rs b/src/utils.rs index 3cfa70b..06928d8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,8 +1,8 @@ use crate::{ constants::{APP_PATH, PROTOCOL, PUBLIC_PATH}, traits::{HasIdPath, HasPath}, - PubkyAppBlob, PubkyAppBookmark, PubkyAppFeed, PubkyAppFile, PubkyAppFollow, PubkyAppMute, - PubkyAppPost, PubkyAppTag, PubkyAppUser, + PubkyAppBlob, PubkyAppBookmark, PubkyAppCollectionPointer, PubkyAppFeed, PubkyAppFile, + PubkyAppFollow, PubkyAppMute, PubkyAppPost, PubkyAppTag, PubkyAppUser, }; #[cfg(target_arch = "wasm32")] @@ -82,3 +82,22 @@ pub fn last_read_uri_builder(author_id: String) -> String { let last_read_path = [PUBLIC_PATH, APP_PATH, "last_read"].concat(); [PROTOCOL, &author_id, &last_read_path].concat() } + +/// Builds a Collection-Pointer URI of the form +/// `"pubky:///pub/pubky.app/collections//"`. +/// +/// Works for both own-pointers (`target_owner_id == follower_id`) and +/// follow-pointers (`target_owner_id != follower_id`). The role is inferred +/// at read time by callers comparing the URI host against the path's owner. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = collectionPointerUriBuilder) +)] +pub fn collection_pointer_uri_builder( + follower_id: String, + target_owner_id: String, + target_post_id: String, +) -> String { + let path = PubkyAppCollectionPointer::create_path(&target_owner_id, &target_post_id); + [PROTOCOL, &follower_id, &path].concat() +} diff --git a/src/wasm.rs b/src/wasm.rs index 42a00f7..e77304a 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -137,6 +137,11 @@ result_struct!(BookmarkResult, bookmark, PubkyAppBookmark); result_struct!(MuteResult, mute, PubkyAppMute); result_struct!(LastReadResult, last_read, PubkyAppLastRead); result_struct!(BlobResult, blob, PubkyAppBlob); +result_struct!( + CollectionPointerResult, + collection_pointer, + PubkyAppCollectionPointer +); #[wasm_bindgen] impl PubkySpecsBuilder { @@ -294,6 +299,41 @@ impl PubkySpecsBuilder { Ok(PostResult { post, meta }) } + /// Creates a `kind = Collection` post — a curated list of URIs under + /// a name and optional description. + /// + /// Convenience wrapper around `createPost` that builds the + /// `PubkyAppCollectionContent` envelope (`{ name, description, items }`) and + /// JSON-serializes it into `content` internally, so JS callers don't + /// have to stringify the envelope themselves. + /// + /// `parent` and `embed` are not supported for Collection posts — the + /// validator rejects them — so this helper omits those arguments. + #[wasm_bindgen(js_name = createCollectionPost)] + pub fn create_collection_post( + &self, + name: String, + description: Option, + items: Option>, + ) -> Result { + let envelope = PubkyAppCollectionContent { + name, + description, + items: items.unwrap_or_default(), + }; + let content = serde_json::to_string(&envelope) + .map_err(|e| format!("Failed to serialize Collection envelope: {e}"))?; + + let post = PubkyAppPost::new(content, PubkyAppPostKind::Collection, None, None, None); + let post_id = post.create_id(); + post.validate(Some(&post_id))?; + + let path = PubkyAppPost::create_path(&post_id); + let meta = Meta::from_object(Some(&post_id), self.pubky_id.clone(), path); + + Ok(PostResult { post, meta }) + } + // ----------------------------------------------------------------------------- // 5. PubkyAppTag // ----------------------------------------------------------------------------- @@ -393,6 +433,70 @@ impl PubkySpecsBuilder { Ok(BlobResult { blob, meta }) } + + // ----------------------------------------------------------------------------- + // 11. PubkyAppCollectionPointer + // ----------------------------------------------------------------------------- + // + // The spec primitive is unified: one struct, one path + // (/pub/pubky.app/collections//), one body + // ({ created_at: i64 }). The role (own vs follow) is inferred at + // read time by comparing the URI host to the path-encoded owner. + // + // For JS ergonomics we expose TWO builder methods that match the two + // call-sites a frontend actually distinguishes — without forcing the + // caller to type its own pubky_id redundantly in the own-case. + + /// Creates an **own-pointer** to one of this user's own Collection posts. + /// + /// Sovereign homeserver-side index entry that lets clients enumerate + /// the user's own collections via a prefix-scan on + /// `/pub/pubky.app/collections//`, with no Nexus dependency. + /// Indexers do nothing with own-pointers; the homeserver state is + /// enough. + /// + /// The owner is implicit (this builder's `pubky_id`); only the + /// `post_id` is needed. + #[wasm_bindgen(js_name = createOwnCollectionPointer)] + pub fn create_own_collection_pointer( + &self, + post_id: String, + ) -> Result { + let owner_id = self.pubky_id.to_string(); + self.create_followed_collection_pointer(owner_id, post_id) + } + + /// Creates a **follow-pointer** subscribing to another user's Collection + /// post. + /// + /// Indexers (e.g. Nexus) materialize this as a + /// `(:User)-[:FOLLOWS_COLLECTION]->(:Post {kind:'collection'})` graph + /// edge and emit a follow-notification to the target owner. + /// + /// Pass the same value for `target_owner_id` and this builder's + /// `pubky_id` and you get an own-pointer instead; the spec primitive + /// is unified, so the distinction is purely caller-side. + #[wasm_bindgen(js_name = createFollowedCollectionPointer)] + pub fn create_followed_collection_pointer( + &self, + target_owner_id: String, + target_post_id: String, + ) -> Result { + let collection_pointer = PubkyAppCollectionPointer::new(); + collection_pointer.validate(Some(&target_post_id))?; + + let path = PubkyAppCollectionPointer::create_path(&target_owner_id, &target_post_id); + // Composite "/" mirrors Resource::CollectionPointer.id() + // in uri_parser.rs and gives JS a unique handle for the pointer + // (callers that need just the post_id can split on '/'). + let composite_id = format!("{}/{}", target_owner_id, target_post_id); + let meta = Meta::from_object(Some(&composite_id), self.pubky_id.clone(), path); + + Ok(CollectionPointerResult { + collection_pointer, + meta, + }) + } } /// This object represents the result of parsing a Pubky URI. It contains: