diff --git a/Cargo.lock b/Cargo.lock index 8ca0219..e8c0fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2088,6 +2088,7 @@ dependencies = [ "frameshift-vault-local", "rpassword", "secrecy", + "serde", "serde_json", "tempfile", "thiserror 2.0.18", diff --git a/README.md b/README.md index 9a89cf4..d68f7a9 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,13 @@ frameshift account revoke-role --server --account-id --role --account-id --status + +# Review account invitation requests as an administrator. +frameshift account invite-requests --server [--status ] +frameshift account review-invite-request --server --request-id --status + +# Issue an invitation and print its raw one-time token once. +frameshift account issue-invite --server --request-id ``` Deployments that register a different public OAuth client set @@ -147,6 +154,9 @@ interactive terminal and are read through hidden prompts. 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. +Invitation issuance returns durable metadata plus the raw registration token on +standard output exactly once. Deliver that token to the invitation-bound email; +the registry stores only its digest and cannot display it again. ## Automate mode @@ -449,6 +459,11 @@ frameshift account revoke-role --server --account-id Revok --role frameshift account set-status --server --account-id Set an account lifecycle state --status +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 + --status +frameshift account issue-invite --server --request-id Issue and display one invitation token frameshift register --server --handle [--display-name ] Claim an author handle frameshift publish --persona [--out ] Build a persona pack (add --server + --handle to sign and upload) [--server --handle ] diff --git a/crates/frameshift-cli/Cargo.toml b/crates/frameshift-cli/Cargo.toml index dca0c9d..f3bbbc8 100644 --- a/crates/frameshift-cli/Cargo.toml +++ b/crates/frameshift-cli/Cargo.toml @@ -33,6 +33,8 @@ chrono.workspace = true ed25519-dalek = { workspace = true } # Wraps the vault passphrase/value input so it is never accidentally logged. secrecy = { workspace = true } +# Serializes one-time invitation output directly to standard output. +serde = { workspace = true } serde_json = { workspace = true } # Persists reviewed quarantine artifacts atomically without overwriting operator files. tempfile = { workspace = true } diff --git a/crates/frameshift-cli/src/cmd/account.rs b/crates/frameshift-cli/src/cmd/account.rs index 0084ba2..6fe32ed 100644 --- a/crates/frameshift-cli/src/cmd/account.rs +++ b/crates/frameshift-cli/src/cmd/account.rs @@ -9,11 +9,13 @@ use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use clap::{Args, Subcommand, ValueEnum}; -use frameshift_catalog::{AccountStatus, PlatformRole}; +use frameshift_catalog::{AccountInviteStatus, AccountStatus, PlatformRole}; use frameshift_client::account::{ - assign_account_platform_role, get_account, get_auth_config, login_local_account, - logout_local_account, register_local_account, revoke_account_platform_role, set_account_status, - AccountView, LocalAccountSession, NativeAuthClient, + assign_account_platform_role, 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, }; use frameshift_client::session::{AuthenticatedSession, SessionClient, SessionClientConfig}; use frameshift_client::session_store::{ @@ -93,6 +95,39 @@ pub enum AccountCommand { #[arg(long, value_enum)] status: AccountStatusArg, }, + /// List administrator-visible account invitation requests. + InviteRequests { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Optional invitation review state filter. + #[arg(long, value_enum)] + status: Option, + /// Number of newest requests to return. + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=200))] + limit: u32, + }, + /// Transition one invitation request to a non-issued review state. + ReviewInviteRequest { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Stable invitation-request UUID. + #[arg(long)] + request_id: Uuid, + /// Pending, reviewing, or declined state to apply. + #[arg(long, value_enum)] + status: InviteReviewStatusArg, + }, + /// Issue one invitation and print its raw one-time token once. + IssueInvite { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Stable invitation-request UUID. + #[arg(long)] + request_id: Uuid, + }, } /// CLI spelling for global platform roles. @@ -138,6 +173,64 @@ impl From for AccountStatus { } } +/// CLI spelling for administrator invitation queue filters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum InviteQueueStatusArg { + /// Applications waiting for initial review. + Pending, + /// Applications actively under review. + Reviewing, + /// Applications whose one-time invitation was issued. + Invited, + /// Applications declined with their audit record retained. + Declined, +} + +/// Convert a CLI queue filter into the shared invitation status. +impl From for AccountInviteStatus { + /// Preserve the selected queue state exactly. + fn from(value: InviteQueueStatusArg) -> Self { + match value { + InviteQueueStatusArg::Pending => Self::Pending, + InviteQueueStatusArg::Reviewing => Self::Reviewing, + InviteQueueStatusArg::Invited => Self::Invited, + InviteQueueStatusArg::Declined => Self::Declined, + } + } +} + +/// CLI spelling for non-issued invitation review transitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum InviteReviewStatusArg { + /// Return an application to the initial review queue. + Pending, + /// Mark an application as actively under review. + Reviewing, + /// Decline an application while retaining its audit record. + Declined, +} + +/// Convert a CLI review transition into the restricted client input. +impl From for AccountInviteReviewStatus { + /// Preserve the selected non-issued review state exactly. + fn from(value: InviteReviewStatusArg) -> Self { + match value { + InviteReviewStatusArg::Pending => Self::Pending, + InviteReviewStatusArg::Reviewing => Self::Reviewing, + InviteReviewStatusArg::Declined => Self::Declined, + } + } +} + +/// Borrowed structured output for one newly issued invitation. +#[derive(serde::Serialize)] +struct IssuedAccountInviteOutput<'a> { + /// Durable non-secret invitation metadata. + invite: &'a frameshift_catalog::AccountInviteRecord, + /// Raw invitation token deliberately displayed exactly once. + token: &'a str, +} + /// Account login options. #[derive(Debug, Args)] pub struct AccountLoginArgs { @@ -214,6 +307,17 @@ pub fn run_account(args: AccountArgs) -> Result<(), CliError> { account_id, status, } => set_status(&server, account_id, status), + AccountCommand::InviteRequests { + server, + status, + limit, + } => invite_requests(&server, status, limit), + AccountCommand::ReviewInviteRequest { + server, + request_id, + status, + } => review_invite_request(&server, request_id, status), + AccountCommand::IssueInvite { server, request_id } => issue_invite(&server, request_id), } } @@ -247,6 +351,56 @@ fn set_status(server: &str, account_id: Uuid, status: AccountStatusArg) -> Resul Ok(()) } +/// Print one bounded administrator invitation queue. +fn invite_requests( + server: &str, + status: Option, + limit: u32, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let requests = list_account_invite_requests(server, &token, status.map(Into::into), limit) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&requests)?); + Ok(()) +} + +/// Transition one invitation request through administrator review. +fn review_invite_request( + server: &str, + request_id: Uuid, + status: InviteReviewStatusArg, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let request = review_account_invite_request(server, &token, request_id, status.into()) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&request)?); + Ok(()) +} + +/// Issue one invitation and deliberately write its one-time token to standard output. +fn issue_invite(server: &str, request_id: Uuid) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let issued = issue_account_invite(server, &token, request_id) + .map_err(|error| CliError::Account(error.to_string()))?; + print_issued_invite(&issued) +} + +/// Serialize one secret-bearing invitation without creating a debug representation. +fn print_issued_invite(issued: &IssuedAccountInvite) -> Result<(), CliError> { + let output = IssuedAccountInviteOutput { + invite: &issued.invite, + token: issued.token().expose_secret(), + }; + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + serde_json::to_writer_pretty(&mut writer, &output)?; + writeln!(writer)?; + Ok(()) +} + /// Authenticate through the selected provider and persist the resulting session. fn run_login(args: AccountLoginArgs) -> Result<(), CliError> { let server = args.server.clone().unwrap_or_else(registry_base_url); @@ -1059,6 +1213,151 @@ mod tests { ); } + /// Administrator invite queue parsing preserves the exact filter and bounded limit. + #[test] + fn parses_administrator_invite_queue() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "invite-requests", + "--server", + "https://registry.example", + "--status", + "invited", + "--limit", + "200", + ]) + .expect("invite queue arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::InviteRequests { + server, + status, + limit, + }, + }) = parsed.command + else { + panic!("expected administrator invite queue"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(status, Some(InviteQueueStatusArg::Invited)); + assert_eq!(limit, 200); + assert!(TestCli::try_parse_from([ + "frameshift", + "account", + "invite-requests", + "--server", + "https://registry.example", + "--limit", + "201", + ]) + .is_err()); + } + + /// Administrator invite review parsing excludes the issuance-only invited state. + #[test] + fn parses_administrator_invite_review() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "review-invite-request", + "--server", + "https://registry.example", + "--request-id", + "00000000-0000-0000-0000-000000000003", + "--status", + "declined", + ]) + .expect("invite review arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::ReviewInviteRequest { + server, + request_id, + status, + }, + }) = parsed.command + else { + panic!("expected administrator invite review"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(request_id, Uuid::from_u128(3)); + assert_eq!(status, InviteReviewStatusArg::Declined); + assert!(TestCli::try_parse_from([ + "frameshift", + "account", + "review-invite-request", + "--server", + "https://registry.example", + "--request-id", + "00000000-0000-0000-0000-000000000003", + "--status", + "invited", + ]) + .is_err()); + } + + /// Administrator invitation issuance parsing preserves the exact application target. + #[test] + fn parses_administrator_invite_issuance() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "issue-invite", + "--server", + "https://registry.example", + "--request-id", + "00000000-0000-0000-0000-000000000003", + ]) + .expect("invite issuance arguments"); + let TestCommand::Account(AccountArgs { + command: AccountCommand::IssueInvite { server, request_id }, + }) = parsed.command + else { + panic!("expected administrator invite issuance"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(request_id, Uuid::from_u128(3)); + } + + /// Every CLI invitation queue filter maps to its exact shared wire value. + #[test] + fn maps_all_administrator_invite_queue_statuses() { + assert_eq!( + AccountInviteStatus::from(InviteQueueStatusArg::Pending), + AccountInviteStatus::Pending + ); + assert_eq!( + AccountInviteStatus::from(InviteQueueStatusArg::Reviewing), + AccountInviteStatus::Reviewing + ); + assert_eq!( + AccountInviteStatus::from(InviteQueueStatusArg::Invited), + AccountInviteStatus::Invited + ); + assert_eq!( + AccountInviteStatus::from(InviteQueueStatusArg::Declined), + AccountInviteStatus::Declined + ); + } + + /// Every CLI invitation review state maps to its restricted client input. + #[test] + fn maps_all_administrator_invite_review_statuses() { + assert_eq!( + AccountInviteReviewStatus::from(InviteReviewStatusArg::Pending), + AccountInviteReviewStatus::Pending + ); + assert_eq!( + AccountInviteReviewStatus::from(InviteReviewStatusArg::Reviewing), + AccountInviteReviewStatus::Reviewing + ); + assert_eq!( + AccountInviteReviewStatus::from(InviteReviewStatusArg::Declined), + AccountInviteReviewStatus::Declined + ); + } + /// Registry matching ignores a cosmetic trailing slash only. #[test] fn normalizes_registry_trailing_slash_without_weakening_base_binding() { diff --git a/crates/frameshift-client/src/account.rs b/crates/frameshift-client/src/account.rs index c7ea916..030e2da 100644 --- a/crates/frameshift-client/src/account.rs +++ b/crates/frameshift-client/src/account.rs @@ -5,7 +5,8 @@ use chrono::{DateTime, Utc}; use frameshift_catalog::{ - AccountRecord, AccountStatus, PlatformRole, PlatformRoleRecord, PublisherMembershipRecord, + AccountInviteRecord, AccountInviteRequestRecord, AccountInviteStatus, AccountRecord, + AccountStatus, PlatformRole, PlatformRoleRecord, PublisherMembershipRecord, PublisherProfileRecord, }; use secrecy::{ExposeSecret as _, SecretString}; @@ -141,6 +142,75 @@ struct SetAccountStatusRequest { status: AccountStatus, } +/// Non-issued review states accepted by the administrator PATCH route. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountInviteReviewStatus { + /// Return an application to the initial review queue. + Pending, + /// Mark an application as actively under review. + Reviewing, + /// Decline an application while retaining its audit record. + Declined, +} + +/// Caller-controlled field for one administrator invite-request review. +#[derive(Serialize)] +struct ReviewAccountInviteRequest { + /// Non-issued state the application must hold after the transition. + status: AccountInviteReviewStatus, +} + +/// Wire response containing an invitation token that is wiped after conversion. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct IssuedAccountInviteResponse { + /// Durable non-secret invitation metadata. + invite: AccountInviteRecord, + /// Raw one-time token returned only at issuance. + token: Option, +} + +/// Wipe the raw invitation token when its temporary wire value leaves scope. +impl Drop for IssuedAccountInviteResponse { + /// Zero the optional raw token string. + fn drop(&mut self) { + use zeroize::Zeroize as _; + if let Some(token) = &mut self.token { + token.zeroize(); + } + } +} + +/// One newly issued invitation with its secret-bearing one-time token. +pub struct IssuedAccountInvite { + /// Durable invitation metadata safe for normal structured output. + pub invite: AccountInviteRecord, + /// Raw one-time token retained in secret memory. + token: SecretString, +} + +/// Redacted diagnostics for one newly issued account invitation. +impl std::fmt::Debug for IssuedAccountInvite { + /// Render invitation metadata while withholding the raw token. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IssuedAccountInvite") + .field("invite", &self.invite) + .field("token", &"[REDACTED]") + .finish() + } +} + +/// Expose the one-time token only to callers that deliberately deliver it. +impl IssuedAccountInvite { + /// Borrow the secret invitation token. + #[must_use] + pub fn token(&self) -> &SecretString { + &self.token + } +} + /// Authenticated account profile and its publisher memberships. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct AccountView { @@ -423,6 +493,100 @@ pub fn set_account_status( ) } +/// List administrator-visible account invitation requests. +/// +/// # Errors +/// +/// Returns an input-bound, registry URL, transport, status, size, or JSON +/// response error without including the bearer token in the diagnostic. +pub fn list_account_invite_requests( + server_url: &str, + access_token: &SecretString, + status: Option, + limit: u32, +) -> Result, ClientError> { + if !(1..=200).contains(&limit) { + return Err(ClientError::InvalidAccountInviteInput { + detail: "limit must be between 1 and 200".to_string(), + }); + } + let mut url = administrator_invite_url(server_url, &[])?; + let mut query = url.query_pairs_mut(); + if let Some(status) = status { + query.append_pair("status", account_invite_status_name(status)); + } + query.append_pair("limit", &limit.to_string()); + drop(query); + let request = crate::publisher::with_bearer( + crate::registry::http_agent().get(url.as_str()), + access_token, + ); + crate::publisher::send_and_decode(request.call(), url.as_str()) +} + +/// Transition one account invitation request to a non-issued review state. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, JSON serialization, or JSON +/// response error without including the bearer token in the diagnostic. +pub fn review_account_invite_request( + server_url: &str, + access_token: &SecretString, + request_id: Uuid, + status: AccountInviteReviewStatus, +) -> Result { + let request = request_id.to_string(); + let url = administrator_invite_url(server_url, &[&request])?; + send_account_json( + crate::registry::http_agent().request("PATCH", url.as_str()), + &url, + access_token, + &ReviewAccountInviteRequest { status }, + ) +} + +/// Issue one account invitation and retain its raw token as a secret. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, JSON, or token-validation +/// error without including either bearer or invitation token in the diagnostic. +pub fn issue_account_invite( + server_url: &str, + access_token: &SecretString, + request_id: Uuid, +) -> Result { + let request = request_id.to_string(); + let url = administrator_invite_url(server_url, &[&request, "invite"])?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent().post(url.as_str()), + access_token, + ); + let mut response: IssuedAccountInviteResponse = + crate::publisher::send_and_decode(request.call(), url.as_str())?; + let mut token = response + .token + .take() + .ok_or_else(|| invalid_invitation_token(&url))?; + let mut decoded = Zeroizing::new([0_u8; 32]); + let valid_token = base64::Engine::decode_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + token.as_bytes(), + decoded.as_mut(), + ) + .is_ok_and(|length| length == decoded.len()); + if !valid_token { + use zeroize::Zeroize as _; + token.zeroize(); + return Err(invalid_invitation_token(&url)); + } + Ok(IssuedAccountInvite { + invite: response.invite.clone(), + token: SecretString::new(token), + }) +} + /// Build one administrator account endpoint while preserving a registry base path. fn administrator_account_url( server_url: &str, @@ -435,6 +599,32 @@ fn administrator_account_url( crate::publisher::registry_endpoint_url(server_url, &segments) } +/// Build one administrator invitation endpoint while preserving a registry base path. +fn administrator_invite_url(server_url: &str, suffix: &[&str]) -> Result { + let mut segments = vec!["v1", "admin", "invite-requests"]; + segments.extend_from_slice(suffix); + crate::publisher::registry_endpoint_url(server_url, &segments) +} + +/// Render one invitation status in its exact query-string spelling. +const fn account_invite_status_name(status: AccountInviteStatus) -> &'static str { + match status { + AccountInviteStatus::Pending => "pending", + AccountInviteStatus::Reviewing => "reviewing", + AccountInviteStatus::Invited => "invited", + AccountInviteStatus::Declined => "declined", + } +} + +/// Build one bounded server-response error for a missing or malformed invitation token. +fn invalid_invitation_token(url: &url::Url) -> ClientError { + ClientError::RegistryRejected { + url: url.to_string(), + status: 502, + message: "registry returned an invalid one-time invitation token".to_string(), + } +} + /// Send one bearer-authenticated administrator account JSON mutation. fn send_account_json( request: ureq::Request, @@ -631,6 +821,12 @@ mod tests { "POST /registry/v1/admin/accounts/00000000-0000-0000-0000-000000000001/platform-roles HTTP/1.1\r\n" )); assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + let error = list_account_invite_requests(&server, &token, None, 201) + .expect_err("oversized invite queue"); + assert!(matches!( + error, + ClientError::InvalidAccountInviteInput { .. } + )); assert!(request.contains("\"role\":\"administrator\"")); let (server, handle) = serve_json_response(role_body); @@ -665,4 +861,73 @@ mod tests { assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); assert!(request.contains("\"status\":\"suspended\"")); } + + /// Administrator invitation controls preserve queue, review, issuance, and token boundaries. + #[test] + fn sends_administrator_invitation_controls() { + let request_body = r#"{"id":"00000000-0000-0000-0000-000000000003","normalized_email":"invitee@example.test","display_name":null,"intent":"publish_personas","statement":"I want to publish personas.","status":"reviewing","consented_at":"2026-01-01T00:00:00Z","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"#; + let (server, handle) = serve_json_response(format!("[{request_body}]")); + let server = format!("{server}/registry"); + let token = SecretString::new("administrator-token".to_string()); + let requests = list_account_invite_requests( + &server, + &token, + Some(frameshift_catalog::AccountInviteStatus::Reviewing), + 25, + ) + .expect("invite queue response"); + assert_eq!(requests.len(), 1); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "GET /registry/v1/admin/invite-requests?status=reviewing&limit=25 HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + + let declined_body = request_body.replace("\"reviewing\"", "\"declined\""); + let (server, handle) = serve_json_response(declined_body); + let server = format!("{server}/registry"); + let reviewed = review_account_invite_request( + &server, + &token, + uuid::Uuid::from_u128(3), + AccountInviteReviewStatus::Declined, + ) + .expect("invite review response"); + assert_eq!( + reviewed.status, + frameshift_catalog::AccountInviteStatus::Declined + ); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "PATCH /registry/v1/admin/invite-requests/00000000-0000-0000-0000-000000000003 HTTP/1.1\r\n" + )); + assert!(request.contains("\"status\":\"declined\"")); + + let raw_invitation_token = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + [7_u8; 32], + ); + let issued_body = format!( + r#"{{"invite":{{"id":"00000000-0000-0000-0000-000000000004","request_id":"00000000-0000-0000-0000-000000000003","normalized_email":"invitee@example.test","token_digest":"AQID","issued_by_account_id":"00000000-0000-0000-0000-000000000002","is_bootstrap":false,"expires_at":"2026-01-08T00:00:00Z","consumed_at":null,"revoked_at":null,"created_at":"2026-01-01T00:00:00Z"}},"token":"{raw_invitation_token}"}}"# + ); + let (server, handle) = serve_json_response(issued_body.clone()); + let server = format!("{server}/registry"); + let issued = issue_account_invite(&server, &token, uuid::Uuid::from_u128(3)) + .expect("invite issuance response"); + assert_eq!(issued.token().expose_secret(), &raw_invitation_token); + assert!(!format!("{issued:?}").contains(&raw_invitation_token)); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "POST /registry/v1/admin/invite-requests/00000000-0000-0000-0000-000000000003/invite HTTP/1.1\r\n" + )); + assert!(!request.contains(&raw_invitation_token)); + + let malformed_body = issued_body.replace(&raw_invitation_token, "malformed-token"); + let (server, handle) = serve_json_response(malformed_body); + let server = format!("{server}/registry"); + let error = issue_account_invite(&server, &token, uuid::Uuid::from_u128(3)) + .expect_err("malformed invitation token"); + assert!(!error.to_string().contains("malformed-token")); + let _request = handle.join().expect("test server thread"); + } } diff --git a/crates/frameshift-client/src/error.rs b/crates/frameshift-client/src/error.rs index 892ea06..edc7e7a 100644 --- a/crates/frameshift-client/src/error.rs +++ b/crates/frameshift-client/src/error.rs @@ -293,6 +293,13 @@ pub enum ClientError { detail: String, }, + /// An account-invitation argument violates a stable client-visible server bound. + #[error("invalid account invitation input: {detail}")] + InvalidAccountInviteInput { + /// Actionable description of the rejected argument and its accepted shape. + detail: String, + }, + #[error("author_pubkey is not a supported ed25519 public key encoding: {0}")] InvalidAuthorPublicKey(String), diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index e7985a4..937693b 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -65,10 +65,15 @@ registry and do not accept a token argument. frameshift account grant-role --server --account-id --role frameshift account revoke-role --server --account-id --role frameshift account set-status --server --account-id --status +frameshift account invite-requests --server [--status ] [--limit <1-200>] +frameshift account review-invite-request --server --request-id --status +frameshift account issue-invite --server --request-id ``` The registry prevents revoking, suspending, or disabling the last active -administrator. +administrator. `issue-invite` prints durable invitation metadata and the raw +registration token exactly once. The registry retains only the token digest and +cannot display the raw value again. ### `frameshift install [OPTIONS] ` diff --git a/docs/wiki/Operations-and-Observability.md b/docs/wiki/Operations-and-Observability.md index 0f13f01..bf62f51 100644 --- a/docs/wiki/Operations-and-Observability.md +++ b/docs/wiki/Operations-and-Observability.md @@ -227,18 +227,26 @@ reads `0`; that is the intended fail-closed behavior, not an outage. | `DELETE /v1/admin/accounts/{account_id}/platform-roles/{role}` | Revoke a role, retaining it as auditable history | | `PATCH /v1/admin/accounts/{account_id}/status` | Set `active`, `suspended`, or `disabled` | -The account-control routes are available through the CLI with registry-bound -bearer authority and closed role and status values: +The administrator routes are available through the CLI with registry-bound +bearer authority and closed review, role, and status values: ```bash +frameshift account invite-requests --server [--status ] [--limit <1-200>] +frameshift account review-invite-request --server --request-id --status +frameshift account issue-invite --server --request-id frameshift account grant-role --server --account-id --role frameshift account revoke-role --server --account-id --role frameshift account set-status --server --account-id --status ``` -All three require an active administrator and return `403` with a fixed body to -anyone else, including for a target account that does not exist, so the routes -cannot be used to test whether an account is present. +Invitation issuance prints the raw registration token exactly once. The +registry persists only its digest, so the raw value cannot be recovered through +a later registry read. + +Every listed route requires an active administrator. Account role and status +routes return `403` with a fixed body to anyone else, including for a target +account that does not exist, so those routes cannot be used to test whether an +account is present. Revocation never deletes an assignment. The row is marked `revoked` and keeps its original grant time and assigning account. Granting a revoked role again