From 024e868849e1f39cfc3ccca91bf3040fc0aaae66 Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sat, 1 Aug 2026 13:51:32 -0400 Subject: [PATCH] feat(cli): add account and publisher profiles --- README.md | 18 ++ crates/frameshift-cli/src/cmd/account.rs | 256 ++++++++++++++++++- crates/frameshift-client/src/account.rs | 199 ++++++++++++++ crates/frameshift-client/src/error.rs | 7 + docs/wiki/Accounts-and-Publisher-Identity.md | 32 ++- docs/wiki/CLI-Reference.md | 18 +- 6 files changed, 521 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d68f7a9..cb8cc85 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,13 @@ frameshift account login --first-party # Confirm the server-validated account and publisher memberships. frameshift account status +# Update account metadata. Supply at least one field. +frameshift account update-profile --server [--email ] [--display-name ] + +# Create an owned publisher profile, then update its public metadata when needed. +frameshift account create-publisher --server --handle --display-name [--biography ] +frameshift account update-publisher --server --handle --display-name [--biography | --clear-biography] + # Revoke the provider session, then erase the exact local credential and metadata. frameshift account logout @@ -151,6 +158,11 @@ callback. Login never accepts a bearer token through arguments, environment variables, or command-line values. First-party credentials require an interactive terminal and are read through hidden prompts. +Account and publisher profile commands reuse the saved session for the exact +registry. New publisher profiles begin in pending moderation and automatically +grant the creating account an active owner membership. Publisher profile updates +require that active owner membership and a fresh authentication session. + Administrator account controls resolve bearer authority for the exact registry without accepting a token argument. The registry rejects non-administrators and prevents revoking, suspending, or disabling the last active administrator. @@ -459,6 +471,12 @@ frameshift account revoke-role --server --account-id Revok --role frameshift account set-status --server --account-id Set an account lifecycle state --status +frameshift account update-profile --server Update account profile metadata + [--email ] [--display-name ] +frameshift account create-publisher --server --handle Create an owned publisher profile + --display-name [--biography ] +frameshift account update-publisher --server --handle Update an owned publisher profile + --display-name [--biography | --clear-biography] frameshift account invite-requests --server List invitation requests [--status ] [--limit <1-200>] frameshift account review-invite-request --server --request-id Transition an invitation review state diff --git a/crates/frameshift-cli/src/cmd/account.rs b/crates/frameshift-cli/src/cmd/account.rs index 6fe32ed..422ddc5 100644 --- a/crates/frameshift-cli/src/cmd/account.rs +++ b/crates/frameshift-cli/src/cmd/account.rs @@ -8,14 +8,15 @@ use std::io::{IsTerminal as _, Read as _, Write as _}; use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use clap::{Args, Subcommand, ValueEnum}; +use clap::{ArgGroup, Args, Subcommand, ValueEnum}; use frameshift_catalog::{AccountInviteStatus, AccountStatus, PlatformRole}; use frameshift_client::account::{ - assign_account_platform_role, get_account, get_auth_config, issue_account_invite, - list_account_invite_requests, login_local_account, logout_local_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, AccountInviteReviewStatus, AccountView, IssuedAccountInvite, - LocalAccountSession, NativeAuthClient, + 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::{ @@ -59,6 +60,52 @@ pub enum AccountCommand { Status, /// Revoke the provider session when supported and erase local credentials. Logout, + /// Update mutable metadata for the authenticated account. + #[command(group(ArgGroup::new("account_profile").required(true).args(["email", "display_name"])))] + UpdateProfile { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Replacement account email metadata. + #[arg(long)] + email: Option, + /// Replacement account display name. + #[arg(long)] + display_name: Option, + }, + /// Create a pending publisher profile owned by the authenticated account. + CreatePublisher { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Unique lowercase public publisher handle. + #[arg(long)] + handle: String, + /// Public publisher display name. + #[arg(long)] + display_name: String, + /// Optional public publisher biography. + #[arg(long)] + biography: Option, + }, + /// Update a publisher profile under active-owner authority. + UpdatePublisher { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Existing public publisher handle. + #[arg(long)] + handle: String, + /// Replacement public display name. + #[arg(long)] + display_name: String, + /// Replacement public biography. + #[arg(long, conflicts_with = "clear_biography")] + biography: Option, + /// Remove the existing public biography. + #[arg(long)] + clear_biography: bool, + }, /// Grant one global platform role under administrator authority. GrantRole { /// Registry API base URL. @@ -292,6 +339,30 @@ pub fn run_account(args: AccountArgs) -> Result<(), CliError> { AccountCommand::Register(args) => run_register(args), AccountCommand::Status => run_status(), AccountCommand::Logout => run_logout(), + AccountCommand::UpdateProfile { + server, + email, + display_name, + } => update_profile(&server, email.as_deref(), display_name.as_deref()), + AccountCommand::CreatePublisher { + server, + handle, + display_name, + biography, + } => create_publisher(&server, &handle, &display_name, biography.as_deref()), + AccountCommand::UpdatePublisher { + server, + handle, + display_name, + biography, + clear_biography, + } => update_publisher( + &server, + &handle, + &display_name, + biography.as_deref(), + clear_biography, + ), AccountCommand::GrantRole { server, account_id, @@ -321,6 +392,58 @@ pub fn run_account(args: AccountArgs) -> Result<(), CliError> { } } +/// Update the current account profile through its exact authenticated session. +fn update_profile( + server: &str, + email: Option<&str>, + display_name: Option<&str>, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let account = update_account_profile(server, &token, email, display_name) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&account)?); + Ok(()) +} + +/// Create one publisher profile through the current authenticated session. +fn create_publisher( + server: &str, + handle: &str, + display_name: &str, + biography: Option<&str>, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let publisher = create_publisher_profile(server, &token, handle, display_name, biography) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&publisher)?); + Ok(()) +} + +/// Update one owned publisher profile through the current authenticated session. +fn update_publisher( + server: &str, + handle: &str, + display_name: &str, + biography: Option<&str>, + clear_biography: bool, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let publisher = update_publisher_profile( + server, + &token, + handle, + display_name, + biography, + clear_biography, + ) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&publisher)?); + Ok(()) +} + /// Grant one role through the exact authenticated registry session. fn grant_role(server: &str, account_id: Uuid, role: PlatformRoleArg) -> Result<(), CliError> { validate_server_url(server)?; @@ -1073,6 +1196,129 @@ mod tests { .is_err()); } + /// Account profile parsing requires at least one replacement field. + #[test] + fn parses_account_profile_update() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "update-profile", + "--server", + "https://registry.example", + "--display-name", + "Alice Example", + ]) + .expect("account profile arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::UpdateProfile { + server, + email, + display_name, + }, + }) = parsed.command + else { + panic!("expected account profile update"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(email, None); + assert_eq!(display_name.as_deref(), Some("Alice Example")); + assert!(TestCli::try_parse_from([ + "frameshift", + "account", + "update-profile", + "--server", + "https://registry.example", + ]) + .is_err()); + } + + /// Publisher creation parsing preserves its public profile fields. + #[test] + fn parses_publisher_profile_creation() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "create-publisher", + "--server", + "https://registry.example", + "--handle", + "gatekeeper", + "--display-name", + "Gatekeeper", + "--biography", + "Verifies releases.", + ]) + .expect("publisher creation arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::CreatePublisher { + server, + handle, + display_name, + biography, + }, + }) = parsed.command + else { + panic!("expected publisher profile creation"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(handle, "gatekeeper"); + assert_eq!(display_name, "Gatekeeper"); + assert_eq!(biography.as_deref(), Some("Verifies releases.")); + } + + /// Publisher updates expose explicit biography replacement or removal. + #[test] + fn parses_publisher_profile_update() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "update-publisher", + "--server", + "https://registry.example", + "--handle", + "gatekeeper", + "--display-name", + "Release Gatekeeper", + "--clear-biography", + ]) + .expect("publisher update arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::UpdatePublisher { + server, + handle, + display_name, + biography, + clear_biography, + }, + }) = parsed.command + else { + panic!("expected publisher profile update"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(handle, "gatekeeper"); + assert_eq!(display_name, "Release Gatekeeper"); + assert_eq!(biography, None); + assert!(clear_biography); + assert!(TestCli::try_parse_from([ + "frameshift", + "account", + "update-publisher", + "--server", + "https://registry.example", + "--handle", + "gatekeeper", + "--display-name", + "Gatekeeper", + "--biography", + "Replacement", + "--clear-biography", + ]) + .is_err()); + } + /// Administrator role grant parsing preserves the exact target and closed role value. #[test] fn parses_administrator_role_grant() { diff --git a/crates/frameshift-client/src/account.rs b/crates/frameshift-client/src/account.rs index 030e2da..fed3950 100644 --- a/crates/frameshift-client/src/account.rs +++ b/crates/frameshift-client/src/account.rs @@ -142,6 +142,37 @@ struct SetAccountStatusRequest { status: AccountStatus, } +/// Mutable authenticated-account profile fields serialized at the HTTP boundary. +#[derive(Serialize)] +struct UpdateAccountProfileRequest<'a> { + /// Replacement email metadata when supplied. + email: Option<&'a str>, + /// Replacement display name when supplied. + display_name: Option<&'a str>, +} + +/// New publisher profile fields serialized at the HTTP boundary. +#[derive(Serialize)] +struct CreatePublisherProfileRequest<'a> { + /// Unique public publisher handle. + handle: &'a str, + /// Public publisher display name. + display_name: &'a str, + /// Optional public biography. + biography: Option<&'a str>, +} + +/// Mutable publisher profile fields serialized at the HTTP boundary. +#[derive(Serialize)] +struct UpdatePublisherProfileRequest<'a> { + /// Replacement public display name. + display_name: &'a str, + /// Replacement biography when supplied. + biography: Option<&'a str>, + /// Whether to remove an existing biography. + clear_biography: bool, +} + /// Non-issued review states accepted by the administrator PATCH route. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -427,6 +458,99 @@ pub fn get_account( } } +/// Update mutable metadata for the authenticated account. +/// +/// Omitted fields retain their current values. Text validation remains owned +/// by the registry so its public bounds cannot drift from the client. +/// +/// # Errors +/// +/// Returns an input-shape, registry URL, transport, status, size, JSON +/// serialization, or JSON response error without including the bearer token. +pub fn update_account_profile( + server_url: &str, + access_token: &SecretString, + email: Option<&str>, + display_name: Option<&str>, +) -> Result { + if email.is_none() && display_name.is_none() { + return Err(ClientError::InvalidAccountProfileInput { + detail: "email or display_name must be supplied".to_string(), + }); + } + let url = crate::publisher::registry_endpoint_url(server_url, &["v1", "account"])?; + send_account_json( + crate::registry::http_agent().request("PATCH", url.as_str()), + &url, + access_token, + &UpdateAccountProfileRequest { + email, + display_name, + }, + ) +} + +/// Create a pending publisher profile owned by the authenticated account. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, JSON serialization, or JSON +/// response error without including the bearer token. +pub fn create_publisher_profile( + server_url: &str, + access_token: &SecretString, + handle: &str, + display_name: &str, + biography: Option<&str>, +) -> Result { + let url = crate::publisher::registry_endpoint_url(server_url, &["v1", "publishers"])?; + send_account_json( + crate::registry::http_agent().post(url.as_str()), + &url, + access_token, + &CreatePublisherProfileRequest { + handle, + display_name, + biography, + }, + ) +} + +/// Update a publisher profile under active-owner authority. +/// +/// An omitted biography retains its current value. `clear_biography` removes +/// it, and cannot be combined with a replacement biography. +/// +/// # Errors +/// +/// Returns an input-shape, registry URL, transport, status, size, JSON +/// serialization, or JSON response error without including the bearer token. +pub fn update_publisher_profile( + server_url: &str, + access_token: &SecretString, + handle: &str, + display_name: &str, + biography: Option<&str>, + clear_biography: bool, +) -> Result { + if biography.is_some() && clear_biography { + return Err(ClientError::InvalidAccountProfileInput { + detail: "biography and clear_biography cannot be supplied together".to_string(), + }); + } + let url = crate::publisher::registry_endpoint_url(server_url, &["v1", "publishers", handle])?; + send_account_json( + crate::registry::http_agent().request("PATCH", url.as_str()), + &url, + access_token, + &UpdatePublisherProfileRequest { + display_name, + biography, + clear_biography, + }, + ) +} + /// Grant one global platform role under authenticated administrator authority. /// /// # Errors @@ -712,6 +836,11 @@ mod tests { r#"{"id":"00000000-0000-0000-0000-000000000001","issuer":"https://issuer.example","subject":"subject-1","email":"alice@example.com","display_name":"Alice","status":"active","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"# } + /// Return one stable publisher profile JSON object for mutation responses. + fn publisher_json() -> &'static str { + r#"{"id":"00000000-0000-0000-0000-000000000002","handle":"gatekeeper","display_name":"Gatekeeper","biography":"Verifies releases.","moderation_status":"pending","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"# + } + /// Account lookup sends the bearer only in the authorization header. #[test] fn fetches_account_with_bearer_header() { @@ -727,6 +856,76 @@ mod tests { assert_eq!(request.matches("test-access-token").count(), 1); } + /// Profile mutations preserve base paths, bearer authority, and exact JSON fields. + #[test] + fn sends_account_and_publisher_profile_mutations() { + let token = SecretString::new("profile-token".to_string()); + + let (server, handle) = serve_json_response(account_json()); + let server = format!("{server}/registry"); + let account = + update_account_profile(&server, &token, Some("new@example.test"), Some("New Name")) + .expect("account profile response"); + assert_eq!(account.id, Uuid::from_u128(1)); + let request = handle.join().expect("account request thread"); + assert!(request.starts_with("PATCH /registry/v1/account HTTP/1.1\r\n")); + assert!(request.contains("\r\nAuthorization: Bearer profile-token\r\n")); + assert!(request.contains(r#"{"email":"new@example.test","display_name":"New Name"}"#)); + assert_eq!(request.matches("profile-token").count(), 1); + + let (server, handle) = serve_json_response(publisher_json()); + let server = format!("{server}/registry"); + let publisher = create_publisher_profile( + &server, + &token, + "gatekeeper", + "Gatekeeper", + Some("Verifies releases."), + ) + .expect("publisher creation response"); + assert_eq!(publisher.handle, "gatekeeper"); + let request = handle.join().expect("publisher creation thread"); + assert!(request.starts_with("POST /registry/v1/publishers HTTP/1.1\r\n")); + assert!(request.contains(r#"{"handle":"gatekeeper","display_name":"Gatekeeper","biography":"Verifies releases."}"#)); + assert_eq!(request.matches("profile-token").count(), 1); + + let (server, handle) = serve_json_response(publisher_json()); + let server = format!("{server}/registry"); + update_publisher_profile(&server, &token, "gatekeeper", "Gatekeeper", None, true) + .expect("publisher update response"); + let request = handle.join().expect("publisher update thread"); + assert!(request.starts_with("PATCH /registry/v1/publishers/gatekeeper HTTP/1.1\r\n")); + assert!(request + .contains(r#"{"display_name":"Gatekeeper","biography":null,"clear_biography":true}"#)); + assert_eq!(request.matches("profile-token").count(), 1); + } + + /// Structurally ambiguous profile mutations fail before transport. + #[test] + fn rejects_ambiguous_profile_mutations() { + let token = SecretString::new("profile-token".to_string()); + let empty = update_account_profile("https://registry.example", &token, None, None) + .expect_err("empty account profile update"); + assert!(matches!( + empty, + ClientError::InvalidAccountProfileInput { .. } + )); + + let conflicting = update_publisher_profile( + "https://registry.example", + &token, + "gatekeeper", + "Gatekeeper", + Some("New biography"), + true, + ) + .expect_err("conflicting biography update"); + assert!(matches!( + conflicting, + ClientError::InvalidAccountProfileInput { .. } + )); + } + /// First-party login sends credentials only in JSON and redacts its bearer result. #[test] fn logs_in_with_native_bearer_without_debug_disclosure() { diff --git a/crates/frameshift-client/src/error.rs b/crates/frameshift-client/src/error.rs index edc7e7a..f01d5ce 100644 --- a/crates/frameshift-client/src/error.rs +++ b/crates/frameshift-client/src/error.rs @@ -300,6 +300,13 @@ pub enum ClientError { detail: String, }, + /// An account or publisher profile mutation is structurally ambiguous. + #[error("invalid account profile input: {detail}")] + InvalidAccountProfileInput { + /// Actionable description of the rejected mutation shape. + detail: String, + }, + #[error("author_pubkey is not a supported ed25519 public key encoding: {0}")] InvalidAuthorPublicKey(String), diff --git a/docs/wiki/Accounts-and-Publisher-Identity.md b/docs/wiki/Accounts-and-Publisher-Identity.md index fd64e91..5dad8ff 100644 --- a/docs/wiki/Accounts-and-Publisher-Identity.md +++ b/docs/wiki/Accounts-and-Publisher-Identity.md @@ -42,6 +42,7 @@ frameshift account register frameshift account login frameshift account login --first-party frameshift account status +frameshift account update-profile --server https://frameshift-api.syntheos.dev --display-name "YOUR NAME" ``` `frameshift account register` redeems a single-use invitation through hidden @@ -73,9 +74,34 @@ Its handle is the public name; its UUID is the stable identity used for ownership checks even if display metadata changes. Publisher operations require an active account membership with the appropriate -role. The current key CLI assumes that the publisher profile and your -membership already exist. Use `frameshift account status` to confirm membership -before enrolling a device. +role. Create the profile from the authenticated CLI session: + +```bash +frameshift account create-publisher \ + --server https://frameshift-api.syntheos.dev \ + --handle YOUR_PUBLISHER_HANDLE \ + --display-name "YOUR PUBLISHER NAME" \ + --biography "OPTIONAL PUBLIC BIOGRAPHY" +``` + +The profile begins in pending moderation, and the registry creates an active +owner membership for the authenticated account. Confirm both records with +`frameshift account status` before enrolling a device. + +Owners can replace public metadata later. The display name is required on every +update. Omit both biography options to keep the existing biography, provide +`--biography` to replace it, or use `--clear-biography` to remove it. + +```bash +frameshift account update-publisher \ + --server https://frameshift-api.syntheos.dev \ + --handle YOUR_PUBLISHER_HANDLE \ + --display-name "UPDATED PUBLISHER NAME" \ + --clear-biography +``` + +Publisher updates require an active owner membership and a fresh authentication +session. ## Create and enroll a device key diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index 937693b..145e96b 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -19,7 +19,7 @@ The binary is named `frameshift`. | Command | Purpose | |---|---| -| `account` | Register, log in, inspect, or end an account session | +| `account` | Manage account sessions, profiles, publishers, and administrator controls | | `install` | Install a persona pack into the central store | | `activate` | Activate an installed persona for the current project | | `uninstall` | Remove an installed persona from the current project | @@ -57,6 +57,22 @@ session. Registration and first-party login collect secrets only through hidden 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. + +```bash +frameshift account update-profile --server [--email ] [--display-name ] +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. + Active administrators can grant or revoke global platform roles and transition account lifecycle states. These commands resolve bearer authority for the exact registry and do not accept a token argument.