From 142490b059765fe553a5a8fe4bcad68a57193d80 Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sat, 1 Aug 2026 12:43:53 -0400 Subject: [PATCH] feat(cli): add administrator publication lifecycle --- README.md | 12 +- crates/frameshift-cli/src/cmd/moderation.rs | 386 ++++++++++++++++++- crates/frameshift-cli/src/main.rs | 123 +++++- crates/frameshift-client/src/moderation.rs | 402 +++++++++++++++++++- docs/wiki/CLI-Reference.md | 17 +- docs/wiki/Publishing-and-Moderation.md | 17 +- 6 files changed, 942 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4b74cb9..5e626ac 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ On registry install, the client verifies the pack signature against the exact ke ### Admin -The registry server exposes account-authenticated lifecycle controls. Publisher owners can withdraw eligible non-public submissions with `frameshift publication withdraw` and read their scoped immutable evidence with `frameshift publication decisions`. Active administrators can suspend a publisher with `POST /v1/admin/publishers/{publisher_id}/suspend` or tombstone an active release with `POST /v1/admin/packs/{name}/{version}/tombstone`. Each accepted transition and its reason are committed atomically to immutable audit evidence. Administrators can read the global stream at `GET /v1/admin/publication-decisions`. +The registry server exposes account-authenticated lifecycle controls. Publisher owners can withdraw eligible non-public submissions with `frameshift publication withdraw` and read their scoped immutable evidence with `frameshift publication decisions`. Active administrators can suspend a publisher with `frameshift moderation suspend-publisher`, tombstone an active release with `frameshift moderation tombstone`, and read the global stream with `frameshift moderation decisions`. Each accepted transition and its reason are committed atomically to immutable audit evidence. Active moderators and administrators can inspect a known quarantined submission with `frameshift moderation show`, download its exact archive with @@ -241,7 +241,7 @@ submission with `frameshift moderation show`, download its exact archive with `decide`, and publish an approved submission with `promote`. The server retains the role, lifecycle, and independent-review checks for every operation. -Publisher owners may file one appeal within 30 days of a `request_changes` or `reject` moderation decision with `frameshift publication appeal` and read private cases with `frameshift publication appeals`. Active administrators resolve appeals at `POST /v1/admin/publication-appeals/{appeal_id}/resolution`; an `overturn` approves the exact unchanged submission, while `uphold` preserves its adverse state. The original reviewer cannot resolve the appeal when another active administrator is available. A sole administrator must record a bounded separation exception. Filing and resolution require a caller-supplied UUID `x-request-id`, reject substituted retries, and retain immutable evidence. +Publisher owners may file one appeal within 30 days of a `request_changes` or `reject` moderation decision with `frameshift publication appeal` and read private cases with `frameshift publication appeals`. Active administrators list global cases with `frameshift moderation appeals` and resolve them with `frameshift moderation resolve-appeal`; an `overturn` approves the exact unchanged submission, while `uphold` preserves its adverse state. The original reviewer cannot resolve the appeal when another active administrator is available. A sole administrator must record a bounded separation exception. Filing and resolution require a caller-supplied UUID `x-request-id`, reject substituted retries, and retain immutable evidence. ### Registry safety controls @@ -449,6 +449,14 @@ frameshift moderation artifact --server --submission-id --out --submission-id Record approve, request-changes, or reject --action --reason-code frameshift moderation promote --server --submission-id Publish an approved submission +frameshift moderation suspend-publisher --server --publisher-id Suspend an approved publisher + --reason-code +frameshift moderation tombstone --server --name --version Tombstone one active public release + --reason +frameshift moderation decisions --server List global lifecycle evidence +frameshift moderation appeals --server List global private appeal cases +frameshift moderation resolve-appeal --server --appeal-id Resolve one publication appeal + --disposition --rationale frameshift search [QUERY] [--tag ] [--limit ] Search the registry frameshift project-id Print the hashed project ID ``` diff --git a/crates/frameshift-cli/src/cmd/moderation.rs b/crates/frameshift-cli/src/cmd/moderation.rs index 27c7619..db00588 100644 --- a/crates/frameshift-cli/src/cmd/moderation.rs +++ b/crates/frameshift-cli/src/cmd/moderation.rs @@ -7,11 +7,17 @@ use std::io::Write as _; use std::path::{Path, PathBuf}; +use chrono::{DateTime, Utc}; use clap::{Args, Subcommand, ValueEnum}; -use frameshift_catalog::PublicationModerationAction; +use frameshift_catalog::{ + PublicationAppealCursor, PublicationAppealDisposition, PublicationLifecycleCursor, + PublicationModerationAction, TombstoneReason, +}; use frameshift_client::moderation::{ - get_moderation_artifact, get_moderation_submission, moderate_publication_submission, - promote_publication_submission, + get_moderation_artifact, get_moderation_submission, list_administrator_publication_appeals, + list_administrator_publication_decisions, moderate_publication_submission, + promote_publication_submission, resolve_administrator_publication_appeal, + suspend_publication_publisher, tombstone_publication_release, }; use uuid::Uuid; @@ -89,6 +95,99 @@ pub enum ModerationCommand { #[arg(long)] request_id: Option, }, + /// Suspend one approved publisher under administrator authority. + SuspendPublisher { + /// Registry base URL. + #[arg(long)] + server: String, + /// Stable publisher profile UUID. + #[arg(long)] + publisher_id: Uuid, + /// Stable 1-64 character private reason code. + #[arg(long)] + reason_code: String, + /// Stable decision UUID to reuse after an ambiguous network failure. + #[arg(long)] + decision_id: Option, + /// Stable request UUID to reuse after an ambiguous network failure. + #[arg(long)] + request_id: Option, + }, + /// Tombstone one active public release under administrator authority. + Tombstone { + /// Registry base URL. + #[arg(long)] + server: String, + /// Public pack name. + #[arg(long)] + name: String, + /// Exact public semantic version. + #[arg(long)] + version: String, + /// Closed public takedown reason category. + #[arg(long, value_enum)] + reason: TombstoneReasonArg, + /// Stable decision UUID to reuse after an ambiguous network failure. + #[arg(long)] + decision_id: Option, + /// Stable request UUID to reuse after an ambiguous network failure. + #[arg(long)] + request_id: Option, + }, + /// List global immutable publication lifecycle evidence. + Decisions { + /// Registry base URL. + #[arg(long)] + server: String, + /// RFC 3339 timestamp from the final record of the preceding page. + #[arg(long, requires = "before_id")] + before_created_at: Option>, + /// UUID from the final record of the preceding page. + #[arg(long, requires = "before_created_at")] + before_id: Option, + /// Number of newest-first records to return. + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, + /// List global private publication appeal cases. + Appeals { + /// Registry base URL. + #[arg(long)] + server: String, + /// RFC 3339 timestamp from the final record of the preceding page. + #[arg(long, requires = "before_id")] + before_created_at: Option>, + /// UUID from the final record of the preceding page. + #[arg(long, requires = "before_created_at")] + before_id: Option, + /// Number of newest-first appeal cases to return. + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, + /// Resolve one publication appeal under administrator separation enforcement. + ResolveAppeal { + /// Registry base URL. + #[arg(long)] + server: String, + /// Stable appeal UUID. + #[arg(long)] + appeal_id: Uuid, + /// Final appeal disposition. + #[arg(long, value_enum)] + disposition: AppealDispositionArg, + /// Private administrator rationale of at most 4000 characters. + #[arg(long)] + rationale: String, + /// Audited reason for an unavoidable sole-administrator self-resolution. + #[arg(long)] + separation_exception_reason: Option, + /// Stable resolution UUID to reuse after an ambiguous network failure. + #[arg(long)] + resolution_id: Option, + /// Stable request UUID to reuse after an ambiguous network failure. + #[arg(long)] + request_id: Option, + }, } /// CLI spelling for the server's supported moderation actions. @@ -114,6 +213,49 @@ impl From for PublicationModerationAction { } } +/// CLI spelling for the closed public release tombstone reasons. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum TombstoneReasonArg { + /// The pack author requested removal. + AuthorRequest, + /// The pack violated platform terms of service. + TosViolation, + /// A DMCA takedown notice requires removal. + Dmca, +} + +/// Convert a CLI tombstone reason into the shared wire enum. +impl From for TombstoneReason { + /// Preserve the selected public reason category exactly. + fn from(value: TombstoneReasonArg) -> Self { + match value { + TombstoneReasonArg::AuthorRequest => Self::AuthorRequest, + TombstoneReasonArg::TosViolation => Self::TosViolation, + TombstoneReasonArg::Dmca => Self::Dmca, + } + } +} + +/// CLI spelling for administrator appeal dispositions. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum AppealDispositionArg { + /// Preserve the original adverse decision. + Uphold, + /// Reverse the adverse decision and approve the unchanged submission. + Overturn, +} + +/// Convert a CLI appeal disposition into the shared wire enum. +impl From for PublicationAppealDisposition { + /// Preserve the selected appeal outcome exactly. + fn from(value: AppealDispositionArg) -> Self { + match value { + AppealDispositionArg::Uphold => Self::Uphold, + AppealDispositionArg::Overturn => Self::Overturn, + } + } +} + /// Execute one role-gated moderation operation. pub fn run_moderation(args: ModerationArgs) -> Result<(), CliError> { match args.command { @@ -149,6 +291,54 @@ pub fn run_moderation(args: ModerationArgs) -> Result<(), CliError> { promotion_id, request_id, } => promote(&server, submission_id, promotion_id, request_id), + ModerationCommand::SuspendPublisher { + server, + publisher_id, + reason_code, + decision_id, + request_id, + } => suspend_publisher(&server, publisher_id, &reason_code, decision_id, request_id), + ModerationCommand::Tombstone { + server, + name, + version, + reason, + decision_id, + request_id, + } => tombstone_release(&server, &name, &version, reason, decision_id, request_id), + ModerationCommand::Decisions { + server, + before_created_at, + before_id, + limit, + } => administrator_decisions( + &server, + lifecycle_cursor(before_created_at, before_id)?, + limit, + ), + ModerationCommand::Appeals { + server, + before_created_at, + before_id, + limit, + } => administrator_appeals(&server, appeal_cursor(before_created_at, before_id)?, limit), + ModerationCommand::ResolveAppeal { + server, + appeal_id, + disposition, + rationale, + separation_exception_reason, + resolution_id, + request_id, + } => resolve_appeal( + &server, + appeal_id, + disposition, + &rationale, + separation_exception_reason.as_deref(), + resolution_id, + request_id, + ), } } @@ -235,6 +425,166 @@ fn promote( Ok(()) } +/// Suspend one publisher while preserving stable retry identifiers in failures. +fn suspend_publisher( + server: &str, + publisher_id: Uuid, + reason_code: &str, + decision_id: Option, + request_id: Option, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let decision_id = decision_id.unwrap_or_else(Uuid::new_v4); + let request_id = request_id.unwrap_or_else(Uuid::new_v4); + let record = suspend_publication_publisher( + server, + &token, + publisher_id, + decision_id, + request_id, + reason_code, + ) + .map_err(|error| { + mutation_error( + "publisher suspension", + "decision-id", + error, + decision_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +/// Tombstone one release while preserving stable retry identifiers in failures. +fn tombstone_release( + server: &str, + name: &str, + version: &str, + reason: TombstoneReasonArg, + decision_id: Option, + request_id: Option, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let decision_id = decision_id.unwrap_or_else(Uuid::new_v4); + let request_id = request_id.unwrap_or_else(Uuid::new_v4); + let record = tombstone_publication_release( + server, + &token, + name, + version, + decision_id, + request_id, + reason.into(), + ) + .map_err(|error| { + mutation_error( + "release tombstone", + "decision-id", + error, + decision_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +/// Print one bounded newest-first page of global lifecycle decisions. +fn administrator_decisions( + server: &str, + before: Option, + limit: u32, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let records = list_administrator_publication_decisions(server, &token, before, limit)?; + println!("{}", serde_json::to_string_pretty(&records)?); + Ok(()) +} + +/// Print one bounded newest-first page of global private appeal cases. +fn administrator_appeals( + server: &str, + before: Option, + limit: u32, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let records = list_administrator_publication_appeals(server, &token, before, limit)?; + println!("{}", serde_json::to_string_pretty(&records)?); + Ok(()) +} + +/// Resolve one appeal while preserving stable retry identifiers in failures. +#[allow(clippy::too_many_arguments)] +fn resolve_appeal( + server: &str, + appeal_id: Uuid, + disposition: AppealDispositionArg, + rationale: &str, + separation_exception_reason: Option<&str>, + resolution_id: Option, + request_id: Option, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let resolution_id = resolution_id.unwrap_or_else(Uuid::new_v4); + let request_id = request_id.unwrap_or_else(Uuid::new_v4); + let record = resolve_administrator_publication_appeal( + server, + &token, + appeal_id, + resolution_id, + request_id, + disposition.into(), + rationale, + separation_exception_reason, + ) + .map_err(|error| { + mutation_error( + "appeal resolution", + "resolution-id", + error, + resolution_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +/// Construct a lifecycle cursor after Clap has enforced paired flags. +fn lifecycle_cursor( + created_at: Option>, + id: Option, +) -> Result, CliError> { + match (created_at, id) { + (None, None) => Ok(None), + (Some(created_at), Some(id)) => Ok(Some(PublicationLifecycleCursor { created_at, id })), + _ => Err(CliError::Moderation( + "--before-created-at and --before-id must be supplied together".to_string(), + )), + } +} + +/// Construct an appeal cursor after Clap has enforced paired flags. +fn appeal_cursor( + created_at: Option>, + id: Option, +) -> Result, CliError> { + match (created_at, id) { + (None, None) => Ok(None), + (Some(created_at), Some(id)) => Ok(Some(PublicationAppealCursor { created_at, id })), + _ => Err(CliError::Moderation( + "--before-created-at and --before-id must be supplied together".to_string(), + )), + } +} + /// Persist artifact bytes atomically while refusing an existing destination. fn persist_artifact(path: &Path, bytes: &[u8]) -> Result<(), CliError> { let parent = path @@ -329,4 +679,34 @@ mod tests { PublicationModerationAction::Reject ); } + + /// CLI tombstone reasons map exactly to the shared public wire variants. + #[test] + fn tombstone_reason_mapping_preserves_all_variants() { + assert_eq!( + TombstoneReason::from(TombstoneReasonArg::AuthorRequest), + TombstoneReason::AuthorRequest + ); + assert_eq!( + TombstoneReason::from(TombstoneReasonArg::TosViolation), + TombstoneReason::TosViolation + ); + assert_eq!( + TombstoneReason::from(TombstoneReasonArg::Dmca), + TombstoneReason::Dmca + ); + } + + /// CLI appeal dispositions map exactly to the shared wire variants. + #[test] + fn appeal_disposition_mapping_preserves_all_variants() { + assert_eq!( + PublicationAppealDisposition::from(AppealDispositionArg::Uphold), + PublicationAppealDisposition::Uphold + ); + assert_eq!( + PublicationAppealDisposition::from(AppealDispositionArg::Overturn), + PublicationAppealDisposition::Overturn + ); + } } diff --git a/crates/frameshift-cli/src/main.rs b/crates/frameshift-cli/src/main.rs index 15b09be..2672aaf 100644 --- a/crates/frameshift-cli/src/main.rs +++ b/crates/frameshift-cli/src/main.rs @@ -127,7 +127,7 @@ enum Command { /// Manage Creator Studio submissions, owner lifecycle decisions, and appeals. Publication(PublicationArgs), - /// Inspect, decide, and promote quarantined publication submissions. + /// Moderate submissions and administer publication lifecycle controls. Moderation(ModerationArgs), /// Register this machine's author key under a handle at the registry. @@ -704,7 +704,9 @@ mod publication_cli_tests { #[cfg(test)] mod moderation_cli_tests { use super::*; - use cmd::moderation::{ModerationActionArg, ModerationCommand}; + use cmd::moderation::{ + AppealDispositionArg, ModerationActionArg, ModerationCommand, TombstoneReasonArg, + }; /// Decision parsing accepts the hyphenated request-changes action. #[test] @@ -748,6 +750,123 @@ mod moderation_cli_tests { ]); assert!(result.is_err()); } + + /// Publisher suspension parsing accepts stable retry identifiers. + #[test] + fn parses_administrator_publisher_suspension_retry() { + let cli = Cli::try_parse_from([ + "frameshift", + "moderation", + "suspend-publisher", + "--server", + "https://registry.example", + "--publisher-id", + "00000000-0000-0000-0000-000000000001", + "--reason-code", + "policy.abuse", + "--decision-id", + "00000000-0000-0000-0000-000000000002", + "--request-id", + "00000000-0000-0000-0000-000000000003", + ]) + .expect("publisher suspension arguments should parse"); + assert!(matches!( + cli.command, + Command::Moderation(ModerationArgs { + command: ModerationCommand::SuspendPublisher { .. } + }) + )); + } + + /// Tombstone parsing maps the public kebab-case reason spelling. + #[test] + fn parses_administrator_release_tombstone_reason() { + let cli = Cli::try_parse_from([ + "frameshift", + "moderation", + "tombstone", + "--server", + "https://registry.example", + "--name", + "reviewed-pack", + "--version", + "1.0.0", + "--reason", + "tos-violation", + ]) + .expect("release tombstone arguments should parse"); + assert!(matches!( + cli.command, + Command::Moderation(ModerationArgs { + command: ModerationCommand::Tombstone { + reason: TombstoneReasonArg::TosViolation, + .. + } + }) + )); + } + + /// Appeal resolution parsing preserves the disposition and retry identifiers. + #[test] + fn parses_administrator_appeal_resolution() { + let cli = Cli::try_parse_from([ + "frameshift", + "moderation", + "resolve-appeal", + "--server", + "https://registry.example", + "--appeal-id", + "00000000-0000-0000-0000-000000000001", + "--disposition", + "overturn", + "--rationale", + "Independent evidence supports reversal.", + "--resolution-id", + "00000000-0000-0000-0000-000000000002", + "--request-id", + "00000000-0000-0000-0000-000000000003", + ]) + .expect("appeal resolution arguments should parse"); + assert!(matches!( + cli.command, + Command::Moderation(ModerationArgs { + command: ModerationCommand::ResolveAppeal { + disposition: AppealDispositionArg::Overturn, + .. + } + }) + )); + } + + /// Administrator audit pagination requires both keyset cursor components. + #[test] + fn administrator_decisions_reject_partial_cursor() { + let result = Cli::try_parse_from([ + "frameshift", + "moderation", + "decisions", + "--server", + "https://registry.example", + "--before-id", + "00000000-0000-0000-0000-000000000001", + ]); + assert!(result.is_err()); + } + + /// Administrator appeal listing enforces the server's page-size bound locally. + #[test] + fn administrator_appeals_reject_oversized_page() { + let result = Cli::try_parse_from([ + "frameshift", + "moderation", + "appeals", + "--server", + "https://registry.example", + "--limit", + "101", + ]); + assert!(result.is_err()); + } } /// Unit tests for `conformance_upgrade_warning`'s per-variant messaging. diff --git a/crates/frameshift-client/src/moderation.rs b/crates/frameshift-client/src/moderation.rs index 037ea84..2830c0f 100644 --- a/crates/frameshift-client/src/moderation.rs +++ b/crates/frameshift-client/src/moderation.rs @@ -5,8 +5,11 @@ //! independent-review separation, lifecycle transitions, and promotion. use frameshift_catalog::{ - PublicationModerationAction, PublicationModerationDecisionRecord, PublicationPromotionRecord, - PublicationSubmissionRecord, + PublicationAppealCaseRecord, PublicationAppealCursor, PublicationAppealDisposition, + PublicationAppealResolutionRecord, PublicationLifecycleCursor, + PublicationLifecycleDecisionRecord, PublicationModerationAction, + PublicationModerationDecisionRecord, PublicationPromotionRecord, PublicationSubmissionRecord, + TombstoneReason, }; use secrecy::SecretString; use serde::Serialize; @@ -34,6 +37,37 @@ struct PromotePublicationRequest { id: Uuid, } +/// Caller-controlled fields for one administrator publisher suspension. +#[derive(Serialize)] +struct SuspendPublicationPublisherRequest<'a> { + /// Stable lifecycle decision identifier. + id: Uuid, + /// Stable bounded private reason code. + reason_code: &'a str, +} + +/// Caller-controlled fields for one administrator release tombstone. +#[derive(Serialize)] +struct TombstonePublicationReleaseRequest { + /// Stable lifecycle decision identifier. + id: Uuid, + /// Closed public tombstone reason category. + reason: TombstoneReason, +} + +/// Caller-controlled fields for one administrator appeal resolution. +#[derive(Serialize)] +struct ResolvePublicationAppealRequest<'a> { + /// Stable appeal-resolution identifier. + id: Uuid, + /// Final administrator disposition. + disposition: PublicationAppealDisposition, + /// Bounded private rationale for the disposition. + rationale: &'a str, + /// Bounded audited reason for an unavoidable self-resolution. + separation_exception_reason: Option<&'a str>, +} + /// Retrieve one role-gated publication submission for operator review. pub fn get_moderation_submission( server_url: &str, @@ -120,6 +154,123 @@ pub fn promote_publication_submission( ) } +/// Suspend one publisher under authenticated administrator authority. +pub fn suspend_publication_publisher( + server_url: &str, + access_token: &SecretString, + publisher_id: Uuid, + decision_id: Uuid, + request_id: Uuid, + reason_code: &str, +) -> Result { + validate_lifecycle_reason_code(reason_code)?; + let publisher = publisher_id.to_string(); + let url = admin_url(server_url, &["publishers", &publisher, "suspend"])?; + post_json_with_request_id( + &url, + access_token, + request_id, + &SuspendPublicationPublisherRequest { + id: decision_id, + reason_code, + }, + ) +} + +/// Tombstone one public release under authenticated administrator authority. +#[allow(clippy::too_many_arguments)] +pub fn tombstone_publication_release( + server_url: &str, + access_token: &SecretString, + pack_name: &str, + version: &str, + decision_id: Uuid, + request_id: Uuid, + reason: TombstoneReason, +) -> Result { + let url = admin_url(server_url, &["packs", pack_name, version, "tombstone"])?; + post_json_with_request_id( + &url, + access_token, + request_id, + &TombstonePublicationReleaseRequest { + id: decision_id, + reason, + }, + ) +} + +/// List global immutable publication lifecycle decisions for an administrator. +pub fn list_administrator_publication_decisions( + server_url: &str, + access_token: &SecretString, + before: Option, + limit: u32, +) -> Result, ClientError> { + let mut url = admin_url(server_url, &["publication-decisions"])?; + append_admin_page_query( + &mut url, + before.map(|cursor| (cursor.created_at, cursor.id)), + limit, + )?; + 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()) +} + +/// List global private publication appeal cases for an administrator. +pub fn list_administrator_publication_appeals( + server_url: &str, + access_token: &SecretString, + before: Option, + limit: u32, +) -> Result, ClientError> { + let mut url = admin_url(server_url, &["publication-appeals"])?; + append_admin_page_query( + &mut url, + before.map(|cursor| (cursor.created_at, cursor.id)), + limit, + )?; + 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()) +} + +/// Resolve one publication appeal under administrator separation enforcement. +#[allow(clippy::too_many_arguments)] +pub fn resolve_administrator_publication_appeal( + server_url: &str, + access_token: &SecretString, + appeal_id: Uuid, + resolution_id: Uuid, + request_id: Uuid, + disposition: PublicationAppealDisposition, + rationale: &str, + separation_exception_reason: Option<&str>, +) -> Result { + validate_appeal_text(rationale, "--rationale", 4_000)?; + if let Some(reason) = separation_exception_reason { + validate_appeal_text(reason, "--separation-exception-reason", 1_000)?; + } + let appeal = appeal_id.to_string(); + let url = admin_url(server_url, &["publication-appeals", &appeal, "resolution"])?; + post_json_with_request_id( + &url, + access_token, + request_id, + &ResolvePublicationAppealRequest { + id: resolution_id, + disposition, + rationale, + separation_exception_reason, + }, + ) +} + /// Build a moderation endpoint while preserving a registry base path. fn moderation_url(server_url: &str, suffix: &[&str]) -> Result { let mut segments = vec!["v1", "moderation", "publication-submissions"]; @@ -127,6 +278,64 @@ fn moderation_url(server_url: &str, suffix: &[&str]) -> Result Result { + let mut segments = vec!["v1", "admin"]; + segments.extend_from_slice(suffix); + crate::publisher::registry_endpoint_url(server_url, &segments) +} + +/// Append a validated newest-first administrator page query. +fn append_admin_page_query( + url: &mut url::Url, + before: Option<(chrono::DateTime, Uuid)>, + limit: u32, +) -> Result<(), ClientError> { + if !(1..=100).contains(&limit) { + return Err(ClientError::InvalidPublicationLifecycleInput { + detail: "--limit must be between 1 and 100".to_string(), + }); + } + let mut query = url.query_pairs_mut(); + if let Some((created_at, id)) = before { + query.append_pair("before_created_at", &created_at.to_rfc3339()); + query.append_pair("before_id", &id.to_string()); + } + query.append_pair("limit", &limit.to_string()); + drop(query); + Ok(()) +} + +/// Validate the server's stable lifecycle reason-code grammar before transport. +fn validate_lifecycle_reason_code(reason_code: &str) -> Result<(), ClientError> { + let reason = reason_code.as_bytes(); + let valid_head = !reason.is_empty() + && reason.len() <= 64 + && reason + .first() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + let valid_tail = reason.iter().skip(1).all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-') + }); + if valid_head && valid_tail { + return Ok(()); + } + Err(ClientError::InvalidPublicationLifecycleInput { + detail: "--reason-code must use 1-64 lowercase ASCII letters, digits, '.', '_', or '-'" + .to_string(), + }) +} + +/// Validate one bounded private administrator appeal field before transport. +fn validate_appeal_text(value: &str, flag: &str, maximum: usize) -> Result<(), ClientError> { + if !value.trim().is_empty() && value.chars().count() <= maximum { + return Ok(()); + } + Err(ClientError::InvalidPublicationLifecycleInput { + detail: format!("{flag} must be non-blank and at most {maximum} characters"), + }) +} + /// Send one bearer-authenticated idempotent JSON mutation. fn post_json_with_request_id( url: &url::Url, @@ -360,4 +569,193 @@ mod tests { let request = handle.join().expect("test server thread"); assert!(request.contains("/artifact HTTP/1.1")); } + + /// Administrator lifecycle mutations preserve path targets and retry identifiers. + #[test] + fn sends_administrator_lifecycle_mutations() { + let decision_id = Uuid::from_u128(20); + let request_id = Uuid::from_u128(21); + let response = serde_json::to_vec(&serde_json::json!({ + "id": decision_id, + "action": "suspend_publisher", + "actor_account_id": Uuid::from_u128(2), + "publisher_id": Uuid::from_u128(3), + "submission_id": null, + "pack_name": null, + "version": null, + "from_state": "approved", + "to_state": "suspended", + "reason_code": "policy.abuse", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize lifecycle fixture"); + let (server, handle) = serve_response("application/json", response); + let token = SecretString::new("administrator-token".to_string()); + let record = suspend_publication_publisher( + &server, + &token, + Uuid::from_u128(3), + decision_id, + request_id, + "policy.abuse", + ) + .expect("suspension response"); + assert_eq!(record.id, decision_id); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "POST /registry/v1/admin/publishers/00000000-0000-0000-0000-000000000003/suspend HTTP/1.1\r\n" + )); + assert!(request + .to_ascii_lowercase() + .contains(&format!("\r\nx-request-id: {request_id}\r\n"))); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + assert!(request.contains(&format!("\"id\":\"{decision_id}\""))); + assert!(request.contains("\"reason_code\":\"policy.abuse\"")); + + let response = serde_json::to_vec(&serde_json::json!({ + "id": decision_id, + "action": "tombstone_release", + "actor_account_id": Uuid::from_u128(2), + "publisher_id": Uuid::from_u128(3), + "submission_id": null, + "pack_name": "pack/name", + "version": "1.0.0+linux", + "from_state": "active", + "to_state": "tombstone", + "reason_code": "tos-violation", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize tombstone fixture"); + let (server, handle) = serve_response("application/json", response); + tombstone_publication_release( + &server, + &token, + "pack/name", + "1.0.0+linux", + decision_id, + request_id, + frameshift_catalog::TombstoneReason::TosViolation, + ) + .expect("tombstone response"); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "POST /registry/v1/admin/packs/pack%2Fname/1.0.0+linux/tombstone HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + assert!(request.contains(&format!("\"id\":\"{decision_id}\""))); + assert!(request.contains("\"reason\":\"tos-violation\"")); + } + + /// Administrator audit reads preserve bounded paired keyset cursors. + #[test] + fn lists_administrator_lifecycle_records() { + use chrono::TimeZone as _; + + let cursor = frameshift_catalog::PublicationLifecycleCursor { + created_at: chrono::Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(), + id: Uuid::from_u128(22), + }; + let token = SecretString::new("administrator-token".to_string()); + let (server, handle) = serve_response("application/json", b"[]".to_vec()); + let records = list_administrator_publication_decisions(&server, &token, Some(cursor), 25) + .expect("decision list"); + assert!(records.is_empty()); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with("GET /registry/v1/admin/publication-decisions?")); + assert!(request.contains("before_created_at=2026-01-02T03%3A04%3A05%2B00%3A00")); + assert!(request.contains("before_id=00000000-0000-0000-0000-000000000016")); + assert!(request.contains("limit=25")); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + + let appeal_cursor = frameshift_catalog::PublicationAppealCursor { + created_at: cursor.created_at, + id: cursor.id, + }; + let (server, handle) = serve_response("application/json", b"[]".to_vec()); + let records = + list_administrator_publication_appeals(&server, &token, Some(appeal_cursor), 100) + .expect("appeal list"); + assert!(records.is_empty()); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with("GET /registry/v1/admin/publication-appeals?")); + assert!(request.contains("limit=100")); + } + + /// Appeal resolution sends the exact private fields and stable retry identifiers. + #[test] + fn sends_administrator_appeal_resolution() { + let resolution_id = Uuid::from_u128(30); + let appeal_id = Uuid::from_u128(31); + let request_id = Uuid::from_u128(32); + let response = serde_json::to_vec(&serde_json::json!({ + "id": resolution_id, + "appeal_id": appeal_id, + "actor_account_id": Uuid::from_u128(2), + "disposition": "overturn", + "rationale": "Independent evidence supports reversal.", + "separation_exception_reason": "Only one administrator is active.", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize resolution fixture"); + let (server, handle) = serve_response("application/json", response); + let token = SecretString::new("administrator-token".to_string()); + let record = resolve_administrator_publication_appeal( + &server, + &token, + appeal_id, + resolution_id, + request_id, + frameshift_catalog::PublicationAppealDisposition::Overturn, + "Independent evidence supports reversal.", + Some("Only one administrator is active."), + ) + .expect("resolution response"); + assert_eq!(record.id, resolution_id); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "POST /registry/v1/admin/publication-appeals/00000000-0000-0000-0000-00000000001f/resolution HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + assert!(request.contains(&format!("\"id\":\"{resolution_id}\""))); + assert!(request.contains("\"disposition\":\"overturn\"")); + assert!(request + .contains("\"separation_exception_reason\":\"Only one administrator is active.\"")); + } + + /// Administrator lifecycle validation rejects malformed input before transport. + #[test] + fn rejects_invalid_administrator_lifecycle_input() { + let token = SecretString::new("administrator-token".to_string()); + let reason_error = suspend_publication_publisher( + "https://registry.example", + &token, + Uuid::nil(), + Uuid::nil(), + Uuid::nil(), + "Policy.Invalid", + ) + .expect_err("uppercase reason must fail"); + assert!(reason_error.to_string().contains("reason-code")); + + let rationale_error = resolve_administrator_publication_appeal( + "https://registry.example", + &token, + Uuid::nil(), + Uuid::nil(), + Uuid::nil(), + frameshift_catalog::PublicationAppealDisposition::Uphold, + " ", + None, + ) + .expect_err("blank rationale must fail"); + assert!(rationale_error.to_string().contains("rationale")); + + let limit_error = + list_administrator_publication_decisions("https://registry.example", &token, None, 101) + .expect_err("oversized page must fail"); + assert!(limit_error.to_string().contains("limit")); + } } diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index 61ffbd7..6fcddde 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -229,7 +229,9 @@ history use newest-first keyset pagination with a bounded `--limit`; supply Active moderators and administrators can inspect a known submission UUID with `show`, download its exact quarantine archive with `artifact`, apply `approve`, `request-changes`, or `reject` with `decide`, and publish an approved submission -with `promote`. The server enforces role membership, lifecycle transitions, and +with `promote`. Active administrators can also suspend publishers, tombstone +public releases, inspect global lifecycle and appeal evidence, and resolve +appeals. The server enforces role membership, lifecycle transitions, and independent-review separation. ```bash @@ -237,11 +239,18 @@ frameshift moderation show --server https://registry.example --submission-id --out submission.tar.gz frameshift moderation decide --server https://registry.example --submission-id --action approve --reason-code reviewed frameshift moderation promote --server https://registry.example --submission-id +frameshift moderation suspend-publisher --server https://registry.example --publisher-id --reason-code policy.abuse +frameshift moderation tombstone --server https://registry.example --name reviewed-pack --version 1.0.0 --reason tos-violation +frameshift moderation decisions --server https://registry.example +frameshift moderation appeals --server https://registry.example +frameshift moderation resolve-appeal --server https://registry.example --appeal-id --disposition overturn --rationale "Independent evidence supports reversal." ``` -`artifact` refuses to overwrite its destination. Decision and promotion -failures print the generated operation and request UUID flags so an ambiguous -request can be retried with the same identifiers. +`artifact` refuses to overwrite its destination. Mutation failures print the +generated operation and request UUID flags so an ambiguous request can be +retried with the same identifiers. Global decision and appeal listings use +newest-first pagination with a bounded `--limit`; supply `--before-created-at` +and `--before-id` together for the next page. ## Project configuration and vault diff --git a/docs/wiki/Publishing-and-Moderation.md b/docs/wiki/Publishing-and-Moderation.md index 24d54ab..800c3bb 100644 --- a/docs/wiki/Publishing-and-Moderation.md +++ b/docs/wiki/Publishing-and-Moderation.md @@ -96,11 +96,14 @@ IDs so retries cannot silently become different actions. ```bash frameshift publication appeal --server https://registry.example --publisher alice --decision-id --statement "The unchanged artifact meets policy." frameshift publication appeals --server https://registry.example --publisher alice +frameshift moderation appeals --server https://registry.example +frameshift moderation resolve-appeal --server https://registry.example --appeal-id --disposition overturn --rationale "Independent evidence supports reversal." ``` Appeal failures include the appeal and request UUID flags required for an exact -retry. Appeal history uses the same bounded newest-first pagination contract as -decision history. +retry. Resolution failures include the resolution and request UUID flags. +Owner and administrator appeal history use the same bounded newest-first +pagination contract as decision history. ## Suspension and tombstones @@ -112,6 +115,16 @@ audit evidence. Direct downloads stop serving the tombstoned version, and the catalog recomputes the latest version from the remaining active releases. Historical signer and decision records remain evidence of what happened. +```bash +frameshift moderation suspend-publisher --server https://registry.example --publisher-id --reason-code policy.abuse +frameshift moderation tombstone --server https://registry.example --name reviewed-pack --version 1.0.0 --reason tos-violation +frameshift moderation decisions --server https://registry.example +``` + +Suspension and tombstone failures include the decision and request UUID flags +required for an exact retry. The global decision stream uses bounded paired +keyset cursor flags. + ## Legacy CLI publication is different `frameshift publish --server ... --handle ...` is the older author-handle