diff --git a/crates/frameshift-cli/src/cmd/account.rs b/crates/frameshift-cli/src/cmd/account.rs index 422ddc5..5036626 100644 --- a/crates/frameshift-cli/src/cmd/account.rs +++ b/crates/frameshift-cli/src/cmd/account.rs @@ -12,11 +12,11 @@ use clap::{ArgGroup, Args, Subcommand, ValueEnum}; use frameshift_catalog::{AccountInviteStatus, AccountStatus, PlatformRole}; use frameshift_client::account::{ assign_account_platform_role, create_publisher_profile, get_account, get_auth_config, - issue_account_invite, list_account_invite_requests, login_local_account, logout_local_account, - register_local_account, review_account_invite_request, revoke_account_platform_role, - set_account_status, update_account_profile, update_publisher_profile, - AccountInviteReviewStatus, AccountView, IssuedAccountInvite, LocalAccountSession, - NativeAuthClient, + get_publisher_profile, issue_account_invite, list_account_invite_requests, login_local_account, + logout_local_account, register_local_account, review_account_invite_request, + revoke_account_platform_role, set_account_status, update_account_profile, + update_publisher_profile, AccountInviteReviewStatus, AccountView, IssuedAccountInvite, + LocalAccountSession, NativeAuthClient, }; use frameshift_client::session::{AuthenticatedSession, SessionClient, SessionClientConfig}; use frameshift_client::session_store::{ @@ -73,6 +73,15 @@ pub enum AccountCommand { #[arg(long)] display_name: Option, }, + /// Fetch and print one public publisher profile without authentication. + ShowPublisher { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Existing public publisher handle. + #[arg(long)] + handle: String, + }, /// Create a pending publisher profile owned by the authenticated account. CreatePublisher { /// Registry API base URL. @@ -344,6 +353,7 @@ pub fn run_account(args: AccountArgs) -> Result<(), CliError> { email, display_name, } => update_profile(&server, email.as_deref(), display_name.as_deref()), + AccountCommand::ShowPublisher { server, handle } => show_publisher(&server, &handle), AccountCommand::CreatePublisher { server, handle, @@ -406,6 +416,15 @@ fn update_profile( Ok(()) } +/// Print one public publisher profile without resolving an account session. +fn show_publisher(server: &str, handle: &str) -> Result<(), CliError> { + validate_server_url(server)?; + let publisher = get_publisher_profile(server, handle) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&publisher)?); + Ok(()) +} + /// Create one publisher profile through the current authenticated session. fn create_publisher( server: &str, @@ -1233,6 +1252,29 @@ mod tests { .is_err()); } + /// Public publisher lookup parsing requires only registry and handle metadata. + #[test] + fn parses_public_publisher_profile_lookup() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "show-publisher", + "--server", + "https://registry.example", + "--handle", + "gatekeeper", + ]) + .expect("publisher lookup arguments"); + let TestCommand::Account(AccountArgs { + command: AccountCommand::ShowPublisher { server, handle }, + }) = parsed.command + else { + panic!("expected publisher profile lookup"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(handle, "gatekeeper"); + } + /// Publisher creation parsing preserves its public profile fields. #[test] fn parses_publisher_profile_creation() { diff --git a/crates/frameshift-client/src/account.rs b/crates/frameshift-client/src/account.rs index fed3950..8f2c5aa 100644 --- a/crates/frameshift-client/src/account.rs +++ b/crates/frameshift-client/src/account.rs @@ -458,6 +458,38 @@ pub fn get_account( } } +/// Fetch one public publisher profile by handle. +/// +/// This endpoint is intentionally unauthenticated because publisher profiles +/// are public registry metadata. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, or JSON error. +pub fn get_publisher_profile( + server_url: &str, + handle: &str, +) -> Result { + let url = crate::publisher::registry_endpoint_url(server_url, &["v1", "publishers", handle])?; + let request = ureq::AgentBuilder::new() + .redirects(0) + .timeout(std::time::Duration::from_secs(15)) + .build() + .get(url.as_str()); + match request.call() { + Ok(response) => crate::registry::response_json_bounded(response, url.as_str()), + Err(ureq::Error::Status(status, response)) => Err(ClientError::RegistryRejected { + url: url.to_string(), + status, + message: crate::registry::response_text_bounded(response, url.as_str()), + }), + Err(error) => Err(ClientError::RegistryHttp { + url: url.to_string(), + detail: error.to_string(), + }), + } +} + /// Update mutable metadata for the authenticated account. /// /// Omitted fields retain their current values. Text validation remains owned @@ -856,6 +888,19 @@ mod tests { assert_eq!(request.matches("test-access-token").count(), 1); } + /// Public publisher lookup preserves base paths without adding authority. + #[test] + fn fetches_public_publisher_profile_without_authorization() { + let (server, handle) = serve_json_response(publisher_json()); + let server = format!("{server}/registry"); + let publisher = + get_publisher_profile(&server, "gatekeeper").expect("publisher profile response"); + assert_eq!(publisher.handle, "gatekeeper"); + let request = handle.join().expect("publisher request thread"); + assert!(request.starts_with("GET /registry/v1/publishers/gatekeeper HTTP/1.1\r\n")); + assert!(!request.to_ascii_lowercase().contains("authorization:")); + } + /// Profile mutations preserve base paths, bearer authority, and exact JSON fields. #[test] fn sends_account_and_publisher_profile_mutations() { diff --git a/docs/wiki/Accounts-and-Publisher-Identity.md b/docs/wiki/Accounts-and-Publisher-Identity.md index 5dad8ff..feb1458 100644 --- a/docs/wiki/Accounts-and-Publisher-Identity.md +++ b/docs/wiki/Accounts-and-Publisher-Identity.md @@ -73,6 +73,14 @@ A publisher profile is the durable owner shown with account-backed releases. Its handle is the public name; its UUID is the stable identity used for ownership checks even if display metadata changes. +Anyone can inspect the public profile without an account session: + +```bash +frameshift account show-publisher \ + --server https://frameshift-api.syntheos.dev \ + --handle PUBLISHER_HANDLE +``` + Publisher operations require an active account membership with the appropriate role. Create the profile from the authenticated CLI session: diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index 145e96b..dd33728 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -58,20 +58,22 @@ interactive prompts. `login --first-party` selects password login when the registry also advertises OIDC. Use `update-profile` to replace account metadata. At least one of `--email` or -`--display-name` is required. Use `create-publisher` to create a pending public -profile and active owner membership, then use `update-publisher` to replace its -display metadata. Omit biography options to retain the current biography, or -use `--clear-biography` to remove it. +`--display-name` is required. `show-publisher` reads public profile metadata and +does not require a saved account session. Use `create-publisher` to create a +pending public profile and active owner membership, then use `update-publisher` +to replace its display metadata. Omit biography options to retain the current +biography, or use `--clear-biography` to remove it. ```bash frameshift account update-profile --server [--email ] [--display-name ] +frameshift account show-publisher --server --handle frameshift account create-publisher --server --handle --display-name [--biography ] frameshift account update-publisher --server --handle --display-name [--biography | --clear-biography] ``` -These profile commands resolve the saved bearer authority for the exact -registry and do not accept a token argument. Publisher updates require an -active owner membership and a fresh authentication session. +The write commands resolve the saved bearer authority for the exact registry +and do not accept a token argument. Publisher updates require an active owner +membership and a fresh authentication session. Active administrators can grant or revoke global platform roles and transition account lifecycle states. These commands resolve bearer authority for the exact