diff --git a/README.md b/README.md index 8caeb99..e18018a 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,12 @@ On registry install, the client verifies the pack signature against the exact ke The registry server exposes account-authenticated lifecycle controls. Publisher owners can withdraw eligible non-public submissions with `POST /v1/publication-submissions/{id}/withdraw`. 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. Publisher owners can read their scoped evidence at `GET /v1/publishers/{handle}/publication-decisions`; administrators can read the global stream at `GET /v1/admin/publication-decisions`. +Active moderators and administrators can inspect a known quarantined +submission with `frameshift moderation show`, download its exact archive with +`artifact`, record an `approve`, `request-changes`, or `reject` decision 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 using `POST /v1/publishers/{handle}/publication-decisions/{decision_id}/appeal`. 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. Owners read their private cases at `GET /v1/publishers/{handle}/publication-appeals`; administrators use `GET /v1/admin/publication-appeals`. ### Registry safety controls @@ -429,6 +435,14 @@ frameshift verify (--persona | --bundle ) Run c 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 ] +frameshift publication review --draft --server --publisher Review an exact Creator Studio snapshot +frameshift publication submit --draft --server --publisher Submit the confirmed signed snapshot +frameshift publication status --server --submission-id Inspect an account-backed submission +frameshift moderation show --server --submission-id Inspect a quarantined submission +frameshift moderation artifact --server --submission-id --out Download its exact archive without overwriting +frameshift moderation decide --server --submission-id Record approve, request-changes, or reject + --action --reason-code +frameshift moderation promote --server --submission-id Publish an approved submission frameshift search [QUERY] [--tag ] [--limit ] Search the registry frameshift project-id Print the hashed project ID ``` diff --git a/crates/frameshift-cli/Cargo.toml b/crates/frameshift-cli/Cargo.toml index 3e815fd..02ae47b 100644 --- a/crates/frameshift-cli/Cargo.toml +++ b/crates/frameshift-cli/Cargo.toml @@ -32,6 +32,8 @@ ed25519-dalek = { workspace = true } # Wraps the vault passphrase/value input so it is never accidentally logged. secrecy = { workspace = true } serde_json = { workspace = true } +# Persists reviewed quarantine artifacts atomically without overwriting operator files. +tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } # Structured logging for best-effort paths (e.g. telemetry/selection-history @@ -50,6 +52,3 @@ webbrowser = { workspace = true } # Semantic selection via a local candle sentence-embedding model. Off by # default so the standard build stays free of the ML dependency stack. embeddings = ["dep:frameshift-embed-candle"] - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/crates/frameshift-cli/src/cmd/mod.rs b/crates/frameshift-cli/src/cmd/mod.rs index 7c3eeb4..870970c 100644 --- a/crates/frameshift-cli/src/cmd/mod.rs +++ b/crates/frameshift-cli/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod feedback; pub mod grow; pub mod keys; pub mod migrate; +pub mod moderation; pub mod prefs; pub mod publication; pub mod publish; diff --git a/crates/frameshift-cli/src/cmd/moderation.rs b/crates/frameshift-cli/src/cmd/moderation.rs new file mode 100644 index 0000000..27c7619 --- /dev/null +++ b/crates/frameshift-cli/src/cmd/moderation.rs @@ -0,0 +1,332 @@ +//! Human-operated publication moderation and promotion commands. +//! +//! The CLI reuses the authenticated account session for the exact registry. +//! Authorization, independent-review separation, state transitions, and +//! promotion integrity remain enforced by the server. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use clap::{Args, Subcommand, ValueEnum}; +use frameshift_catalog::PublicationModerationAction; +use frameshift_client::moderation::{ + get_moderation_artifact, get_moderation_submission, moderate_publication_submission, + promote_publication_submission, +}; +use uuid::Uuid; + +use crate::cmd::keys::resolve_access_token; +use crate::util::{validate_server_url, CliError}; + +/// Arguments for the `moderation` command group. +#[derive(Debug, Args)] +pub struct ModerationArgs { + /// Moderation operation to execute. + #[command(subcommand)] + pub command: ModerationCommand, +} + +/// Supported role-gated publication moderation operations. +#[derive(Debug, Subcommand)] +pub enum ModerationCommand { + /// Display one quarantined submission and its server validation report. + Show { + /// Registry base URL. + #[arg(long)] + server: String, + /// Stable submission UUID returned to the publisher. + #[arg(long)] + submission_id: Uuid, + }, + /// Download one exact quarantine artifact without overwriting an existing file. + Artifact { + /// Registry base URL. + #[arg(long)] + server: String, + /// Stable submission UUID returned to the publisher. + #[arg(long)] + submission_id: Uuid, + /// New destination path for the reviewed `.tar.gz` archive. + #[arg(long)] + out: PathBuf, + }, + /// Apply one review decision to a quarantined submission. + Decide { + /// Registry base URL. + #[arg(long)] + server: String, + /// Stable submission UUID returned to the publisher. + #[arg(long)] + submission_id: Uuid, + /// Review action to apply. + #[arg(long, value_enum)] + action: ModerationActionArg, + /// Stable bounded private reason code. + #[arg(long)] + reason_code: String, + /// Optional bounded private explanation for the publisher. + #[arg(long)] + private_explanation: Option, + /// 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, + }, + /// Promote one approved submission into the public registry. + Promote { + /// Registry base URL. + #[arg(long)] + server: String, + /// Approved submission UUID. + #[arg(long)] + submission_id: Uuid, + /// Stable promotion UUID to reuse after an ambiguous network failure. + #[arg(long)] + promotion_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. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ModerationActionArg { + /// Approve the exact reviewed artifact without making it public yet. + Approve, + /// Keep the artifact private and request a replacement submission. + RequestChanges, + /// Reject the exact reviewed artifact. + Reject, +} + +/// Convert the CLI action spelling into the shared wire action. +impl From for PublicationModerationAction { + /// Map one CLI action to the catalog wire enum without changing semantics. + fn from(value: ModerationActionArg) -> Self { + match value { + ModerationActionArg::Approve => Self::Approve, + ModerationActionArg::RequestChanges => Self::RequestChanges, + ModerationActionArg::Reject => Self::Reject, + } + } +} + +/// Execute one role-gated moderation operation. +pub fn run_moderation(args: ModerationArgs) -> Result<(), CliError> { + match args.command { + ModerationCommand::Show { + server, + submission_id, + } => show(&server, submission_id), + ModerationCommand::Artifact { + server, + submission_id, + out, + } => artifact(&server, submission_id, &out), + ModerationCommand::Decide { + server, + submission_id, + action, + reason_code, + private_explanation, + decision_id, + request_id, + } => decide( + &server, + submission_id, + action, + &reason_code, + private_explanation.as_deref(), + decision_id, + request_id, + ), + ModerationCommand::Promote { + server, + submission_id, + promotion_id, + request_id, + } => promote(&server, submission_id, promotion_id, request_id), + } +} + +/// Print one role-gated submission record as structured JSON. +fn show(server: &str, submission_id: Uuid) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let submission = get_moderation_submission(server, &token, submission_id)?; + println!("{}", serde_json::to_string_pretty(&submission)?); + Ok(()) +} + +/// Download one bounded quarantine artifact to a new atomic destination. +fn artifact(server: &str, submission_id: Uuid, out: &Path) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let bytes = get_moderation_artifact(server, &token, submission_id)?; + persist_artifact(out, &bytes)?; + println!("saved reviewed artifact to {}", out.display()); + Ok(()) +} + +/// Apply one decision while preserving stable retry identifiers in failures. +#[allow(clippy::too_many_arguments)] +fn decide( + server: &str, + submission_id: Uuid, + action: ModerationActionArg, + reason_code: &str, + private_explanation: Option<&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 decision = moderate_publication_submission( + server, + &token, + submission_id, + decision_id, + request_id, + action.into(), + reason_code, + private_explanation, + ) + .map_err(|error| { + mutation_error( + "moderation decision", + "decision-id", + error, + decision_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&decision)?); + Ok(()) +} + +/// Promote one approved submission while preserving retry identifiers in failures. +fn promote( + server: &str, + submission_id: Uuid, + promotion_id: Option, + request_id: Option, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let promotion_id = promotion_id.unwrap_or_else(Uuid::new_v4); + let request_id = request_id.unwrap_or_else(Uuid::new_v4); + let promotion = + promote_publication_submission(server, &token, submission_id, promotion_id, request_id) + .map_err(|error| { + mutation_error( + "publication promotion", + "promotion-id", + error, + promotion_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&promotion)?); + Ok(()) +} + +/// Persist artifact bytes atomically while refusing an existing destination. +fn persist_artifact(path: &Path, bytes: &[u8]) -> Result<(), CliError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { + CliError::Moderation(format!( + "failed to create artifact staging file beside {}: {error}", + path.display() + )) + })?; + temporary.write_all(bytes).map_err(|error| { + CliError::Moderation(format!( + "failed to write artifact staging file beside {}: {error}", + path.display() + )) + })?; + temporary.as_file().sync_all().map_err(|error| { + CliError::Moderation(format!( + "failed to sync artifact staging file beside {}: {error}", + path.display() + )) + })?; + temporary.persist_noclobber(path).map_err(|error| { + CliError::Moderation(format!( + "refusing to overwrite artifact destination {}: {}", + path.display(), + error.error + )) + })?; + Ok(()) +} + +/// Preserve both mutation identifiers so an ambiguous request can be retried exactly. +fn mutation_error( + stage: &str, + operation_flag: &str, + error: frameshift_client::ClientError, + operation_id: Uuid, + request_id: Uuid, +) -> CliError { + CliError::Moderation(format!( + "{stage} failed: {error}; retry with --{operation_flag} {operation_id} --request-id {request_id}" + )) +} + +#[cfg(test)] +/// Moderation command policy regression tests. +mod tests { + use super::*; + + /// Artifact persistence creates the requested file with exact bytes. + #[test] + fn artifact_persistence_writes_exact_bytes() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let destination = temporary.path().join("submission.tar.gz"); + persist_artifact(&destination, b"exact reviewed bytes").expect("persist artifact"); + assert_eq!( + std::fs::read(destination).expect("read artifact"), + b"exact reviewed bytes" + ); + } + + /// Artifact persistence never replaces an existing operator file. + #[test] + fn artifact_persistence_refuses_existing_destination() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let destination = temporary.path().join("submission.tar.gz"); + std::fs::write(&destination, b"operator data").expect("write existing file"); + let error = persist_artifact(&destination, b"replacement").expect_err("no overwrite"); + assert!(error.to_string().contains("refusing to overwrite")); + assert_eq!( + std::fs::read(destination).expect("read existing file"), + b"operator data" + ); + } + + /// CLI moderation actions map exactly to their shared wire variants. + #[test] + fn action_mapping_preserves_all_variants() { + assert_eq!( + PublicationModerationAction::from(ModerationActionArg::Approve), + PublicationModerationAction::Approve + ); + assert_eq!( + PublicationModerationAction::from(ModerationActionArg::RequestChanges), + PublicationModerationAction::RequestChanges + ); + assert_eq!( + PublicationModerationAction::from(ModerationActionArg::Reject), + PublicationModerationAction::Reject + ); + } +} diff --git a/crates/frameshift-cli/src/main.rs b/crates/frameshift-cli/src/main.rs index 748a5ad..bcd6516 100644 --- a/crates/frameshift-cli/src/main.rs +++ b/crates/frameshift-cli/src/main.rs @@ -23,6 +23,7 @@ use cmd::feedback::FeedbackArgs; use cmd::grow::GrowArgs; use cmd::keys::KeysArgs; use cmd::migrate::MigrateArgs; +use cmd::moderation::ModerationArgs; use cmd::prefs::PrefsArgs; use cmd::publication::PublicationArgs; use cmd::publish::PublishArgs; @@ -126,6 +127,9 @@ enum Command { /// Review and submit Creator Studio drafts through moderated publication. Publication(PublicationArgs), + /// Inspect, decide, and promote quarantined publication submissions. + Moderation(ModerationArgs), + /// Register this machine's author key under a handle at the registry. Register(RegisterArgs), @@ -493,6 +497,7 @@ fn run() -> Result<(), RunError> { Command::Publication(args) => { cmd::publication::run_publication(args).map_err(RunError::from) } + Command::Moderation(args) => cmd::moderation::run_moderation(args).map_err(RunError::from), Command::Register(args) => cmd::register::run_register(args).map_err(RunError::from), Command::Keys(args) => cmd::keys::run_keys(args).map_err(RunError::from), Command::Search(args) => cmd::search::run_search(args).map_err(RunError::from), @@ -634,6 +639,56 @@ mod publication_cli_tests { } } +/// CLI parsing regressions for the role-gated moderation surface. +#[cfg(test)] +mod moderation_cli_tests { + use super::*; + use cmd::moderation::{ModerationActionArg, ModerationCommand}; + + /// Decision parsing accepts the hyphenated request-changes action. + #[test] + fn parses_moderation_request_changes_decision() { + let cli = Cli::try_parse_from([ + "frameshift", + "moderation", + "decide", + "--server", + "https://registry.example", + "--submission-id", + "00000000-0000-0000-0000-000000000001", + "--action", + "request-changes", + "--reason-code", + "metadata", + ]) + .expect("moderation decision arguments should parse"); + assert!(matches!( + cli.command, + Command::Moderation(ModerationArgs { + command: ModerationCommand::Decide { + action: ModerationActionArg::RequestChanges, + .. + } + }) + )); + } + + /// Artifact parsing requires a destination path. + #[test] + fn moderation_artifact_requires_destination() { + let result = Cli::try_parse_from([ + "frameshift", + "moderation", + "artifact", + "--server", + "https://registry.example", + "--submission-id", + "00000000-0000-0000-0000-000000000001", + ]); + assert!(result.is_err()); + } +} + /// Unit tests for `conformance_upgrade_warning`'s per-variant messaging. #[cfg(test)] mod conformance_warning_tests { diff --git a/crates/frameshift-cli/src/util.rs b/crates/frameshift-cli/src/util.rs index 5724616..0df3447 100644 --- a/crates/frameshift-cli/src/util.rs +++ b/crates/frameshift-cli/src/util.rs @@ -104,6 +104,10 @@ pub enum CliError { #[error("{0}")] Account(String), + /// Role-gated publication moderation or promotion error. + #[error("{0}")] + Moderation(String), + /// `frameshift config` error: unknown key or a value that fails to parse /// for the requested key's type. #[error("{0}")] diff --git a/crates/frameshift-client/src/lib.rs b/crates/frameshift-client/src/lib.rs index 58df01a..147164c 100644 --- a/crates/frameshift-client/src/lib.rs +++ b/crates/frameshift-client/src/lib.rs @@ -6,6 +6,8 @@ mod error; /// Versioned local publisher-key metadata and secret storage. pub mod identity; mod model; +/// Account-authenticated publication moderation and promotion operations. +pub mod moderation; /// Explicit authenticated Creator Studio quarantine publication operations. pub mod publication; /// Registry publish implementation: pack, sign, and HTTP upload. diff --git a/crates/frameshift-client/src/moderation.rs b/crates/frameshift-client/src/moderation.rs new file mode 100644 index 0000000..037ea84 --- /dev/null +++ b/crates/frameshift-client/src/moderation.rs @@ -0,0 +1,363 @@ +//! Account-authenticated publication moderation and promotion transport. +//! +//! These operations expose the existing role-gated server boundary without +//! weakening it. The server remains authoritative for reviewer roles, +//! independent-review separation, lifecycle transitions, and promotion. + +use frameshift_catalog::{ + PublicationModerationAction, PublicationModerationDecisionRecord, PublicationPromotionRecord, + PublicationSubmissionRecord, +}; +use secrecy::SecretString; +use serde::Serialize; +use uuid::Uuid; + +use crate::error::ClientError; + +/// Caller-controlled fields for one idempotent moderation decision. +#[derive(Serialize)] +struct ModeratePublicationRequest<'a> { + /// Stable decision identifier. + id: Uuid, + /// Review action applied to the path-bound submission. + action: PublicationModerationAction, + /// Stable bounded private reason code. + reason_code: &'a str, + /// Optional bounded private explanation for the publisher. + private_explanation: Option<&'a str>, +} + +/// Caller-controlled identity for one idempotent promotion. +#[derive(Serialize)] +struct PromotePublicationRequest { + /// Stable promotion identifier. + id: Uuid, +} + +/// Retrieve one role-gated publication submission for operator review. +pub fn get_moderation_submission( + server_url: &str, + access_token: &SecretString, + submission_id: Uuid, +) -> Result { + let id = submission_id.to_string(); + let url = moderation_url(server_url, &[&id])?; + 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()) +} + +/// Retrieve one exact quarantine archive under the shared compressed-body cap. +pub fn get_moderation_artifact( + server_url: &str, + access_token: &SecretString, + submission_id: Uuid, +) -> Result, ClientError> { + let id = submission_id.to_string(); + let url = moderation_url(server_url, &[&id, "artifact"])?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent().get(url.as_str()), + access_token, + ); + match request.call() { + Ok(response) => crate::registry::response_archive_bytes_bounded(response, url.as_str()), + Err(ureq::Error::Status(status, response)) => Err(ClientError::RegistryRejected { + url: url.to_string(), + status, + message: crate::registry::response_text_bounded(response, url.as_str()), + }), + Err(error) => Err(ClientError::RegistryHttp { + url: url.to_string(), + detail: error.to_string(), + }), + } +} + +/// Record one idempotent role-gated moderation decision. +#[allow(clippy::too_many_arguments)] +pub fn moderate_publication_submission( + server_url: &str, + access_token: &SecretString, + submission_id: Uuid, + decision_id: Uuid, + request_id: Uuid, + action: PublicationModerationAction, + reason_code: &str, + private_explanation: Option<&str>, +) -> Result { + let id = submission_id.to_string(); + let url = moderation_url(server_url, &[&id, "decisions"])?; + post_json_with_request_id( + &url, + access_token, + request_id, + &ModeratePublicationRequest { + id: decision_id, + action, + reason_code, + private_explanation, + }, + ) +} + +/// Promote one approved submission using the server-verified quarantine bytes. +pub fn promote_publication_submission( + server_url: &str, + access_token: &SecretString, + submission_id: Uuid, + promotion_id: Uuid, + request_id: Uuid, +) -> Result { + let id = submission_id.to_string(); + let url = moderation_url(server_url, &[&id, "promotion"])?; + post_json_with_request_id( + &url, + access_token, + request_id, + &PromotePublicationRequest { id: promotion_id }, + ) +} + +/// 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"]; + segments.extend_from_slice(suffix); + crate::publisher::registry_endpoint_url(server_url, &segments) +} + +/// Send one bearer-authenticated idempotent JSON mutation. +fn post_json_with_request_id( + url: &url::Url, + access_token: &SecretString, + request_id: Uuid, + body: &T, +) -> Result { + let bytes = + serde_json::to_vec(body).map_err(|error| ClientError::JsonSerialize(error.to_string()))?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent() + .post(url.as_str()) + .set("Content-Type", "application/json") + .set("x-request-id", &request_id.to_string()), + access_token, + ); + crate::publisher::send_and_decode(request.send_bytes(&bytes), url.as_str()) +} + +#[cfg(test)] +/// Moderation HTTP client regression tests. +mod tests { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + use std::thread; + use std::time::Duration; + + use frameshift_catalog::PublicationSubmissionState; + + use super::*; + + /// Read one complete bounded HTTP request including its declared body. + fn read_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set read timeout"); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let count = stream.read(&mut chunk).expect("read request"); + if count == 0 { + break; + } + request.extend_from_slice(&chunk[..count]); + let Some(headers_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + else { + continue; + }; + let headers_end = headers_end + 4; + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + }) + .unwrap_or(0); + if request.len() >= headers_end + content_length { + break; + } + } + String::from_utf8(request).expect("request UTF-8") + } + + /// Serve one fixed response and return the captured request. + fn serve_response( + content_type: &'static str, + body: Vec, + ) -> (String, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let request = read_request(&mut stream); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(headers.as_bytes()).expect("write headers"); + stream.write_all(&body).expect("write response body"); + request + }); + (format!("http://{address}/registry"), handle) + } + + /// Return one complete quarantined-submission wire fixture. + fn submission_json() -> Vec { + serde_json::to_vec(&serde_json::json!({ + "id": Uuid::from_u128(1), + "intent_id": Uuid::from_u128(2), + "account_id": Uuid::from_u128(3), + "publisher_id": Uuid::from_u128(4), + "publisher_key_id": Uuid::from_u128(5), + "archive_hash": "0101010101010101010101010101010101010101010101010101010101010101", + "manifest_hash": "0202020202020202020202020202020202020202020202020202020202020202", + "file_inventory_hash": "0303030303030303030303030303030303030303030303030303030303030303", + "scan_schema_version": 1, + "scan_report": { + "schema_version": 1, + "valid": true, + "inventory_hash": "inventory", + "inventory": [], + "findings": [] + }, + "state": "quarantined", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize submission fixture") + } + + /// Submission retrieval preserves the registry base path and bearer boundary. + #[test] + fn retrieves_submission_with_bearer_header() { + let (server, handle) = serve_response("application/json", submission_json()); + let token = SecretString::new("moderator-token".to_string()); + let submission = get_moderation_submission(&server, &token, Uuid::from_u128(1)) + .expect("submission response"); + assert_eq!(submission.state, PublicationSubmissionState::Quarantined); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "GET /registry/v1/moderation/publication-submissions/00000000-0000-0000-0000-000000000001 HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer moderator-token\r\n")); + assert_eq!(request.matches("moderator-token").count(), 1); + } + + /// Decision transport binds the path, stable IDs, action, and private reason fields. + #[test] + fn sends_idempotent_moderation_decision() { + let decision_id = Uuid::from_u128(6); + let request_id = Uuid::from_u128(7); + let response = serde_json::to_vec(&serde_json::json!({ + "id": decision_id, + "submission_id": Uuid::from_u128(1), + "actor_account_id": Uuid::from_u128(3), + "action": "request_changes", + "from_state": "quarantined", + "to_state": "needs_review", + "reason_code": "metadata", + "private_explanation": "clarify the description", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize decision fixture"); + let (server, handle) = serve_response("application/json", response); + let token = SecretString::new("moderator-token".to_string()); + let decision = moderate_publication_submission( + &server, + &token, + Uuid::from_u128(1), + decision_id, + request_id, + PublicationModerationAction::RequestChanges, + "metadata", + Some("clarify the description"), + ) + .expect("decision response"); + assert_eq!(decision.id, decision_id); + let request = handle.join().expect("test server thread"); + let lowercase_request = request.to_ascii_lowercase(); + assert!(request.starts_with( + "POST /registry/v1/moderation/publication-submissions/00000000-0000-0000-0000-000000000001/decisions HTTP/1.1\r\n" + )); + assert!(lowercase_request.contains(&format!("\r\nx-request-id: {request_id}\r\n"))); + assert!(request.contains(&format!("\"id\":\"{decision_id}\""))); + assert!(request.contains("\"action\":\"request_changes\"")); + assert!(request.contains("\"reason_code\":\"metadata\"")); + } + + /// Promotion transport binds a separate stable promotion and request identifier. + #[test] + fn sends_idempotent_promotion() { + let promotion_id = Uuid::from_u128(8); + let request_id = Uuid::from_u128(9); + let response = serde_json::to_vec(&serde_json::json!({ + "id": promotion_id, + "submission_id": Uuid::from_u128(1), + "actor_account_id": Uuid::from_u128(3), + "pack_name": "reviewed-pack", + "version": "1.0.0", + "content_hash": "0404040404040404040404040404040404040404040404040404040404040404", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize promotion fixture"); + let (server, handle) = serve_response("application/json", response); + let token = SecretString::new("moderator-token".to_string()); + let promotion = promote_publication_submission( + &server, + &token, + Uuid::from_u128(1), + promotion_id, + request_id, + ) + .expect("promotion response"); + assert_eq!(promotion.id, promotion_id); + let request = handle.join().expect("test server thread"); + let lowercase_request = request.to_ascii_lowercase(); + assert!(lowercase_request.contains(&format!("\r\nx-request-id: {request_id}\r\n"))); + assert!(request.contains(&format!("\"id\":\"{promotion_id}\""))); + } + + /// Artifact transport returns exact bytes under the shared archive cap. + #[test] + fn retrieves_exact_artifact_bytes() { + let expected = b"reviewed archive bytes".to_vec(); + let (server, handle) = serve_response("application/gzip", expected.clone()); + let token = SecretString::new("moderator-token".to_string()); + let actual = get_moderation_artifact(&server, &token, Uuid::from_u128(1)) + .expect("artifact response"); + assert_eq!(actual, expected); + let request = handle.join().expect("test server thread"); + assert!(request.contains("/artifact HTTP/1.1")); + } + + /// Artifact transport rejects a response larger than the shared archive cap. + #[test] + fn rejects_oversized_artifact_response() { + let oversized = vec![0_u8; 16 * 1024 * 1024 + 1]; + let (server, handle) = serve_response("application/gzip", oversized); + let token = SecretString::new("moderator-token".to_string()); + let error = get_moderation_artifact(&server, &token, Uuid::from_u128(1)) + .expect_err("oversized artifact should fail"); + assert!(error + .to_string() + .contains("response exceeds maximum allowed size")); + let request = handle.join().expect("test server thread"); + assert!(request.contains("/artifact HTTP/1.1")); + } +} diff --git a/crates/frameshift-client/src/registry.rs b/crates/frameshift-client/src/registry.rs index 4906bd5..2a8beaf 100644 --- a/crates/frameshift-client/src/registry.rs +++ b/crates/frameshift-client/src/registry.rs @@ -991,6 +991,14 @@ pub(crate) fn response_json_bounded( }) } +/// Read a registry archive response under the shared compressed-body limit. +pub(crate) fn response_archive_bytes_bounded( + response: ureq::Response, + url: &str, +) -> Result, ClientError> { + response_bytes_bounded(response, url, MAX_ARCHIVE_BYTES) +} + /// Read a bounded registry error body as UTF-8 text. pub(crate) fn response_text_bounded(response: ureq::Response, url: &str) -> String { response_bytes_bounded(response, url, MAX_ERROR_RESPONSE_BYTES) diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index 46c953f..73f38c8 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -35,6 +35,8 @@ The binary is named `frameshift`. | `grow` | Append, inspect, or summarize local growth | | `verify` | Run persona conformance checks | | `publish` | Package a persona locally or publish it to a registry | +| `publication` | Review, submit, and inspect account-backed publications | +| `moderation` | Inspect, decide, and promote quarantined submissions | | `register` | Register this machine's author key under a registry handle | | `keys` | Manage local and account-enrolled publisher keys | | `search` | Search the registry pack catalog | @@ -200,6 +202,33 @@ mutation. Package a persona into `--out ` or publish it through `--server ` under `--handle `. +### `frameshift publication ` + +Use `review` to bind human confirmation to an exact Creator Studio snapshot, +`submit` to create the authenticated publication intent and upload that signed +snapshot, and `status` to inspect the resulting submission. The submit command +requires separate confirmations for the archive hash, publisher, signer key, +and submission intent. + +### `frameshift moderation ` + +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 +independent-review separation. + +```bash +frameshift moderation show --server https://registry.example --submission-id +frameshift moderation artifact --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 +``` + +`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. + ## Project configuration and vault ### `frameshift config get|set` diff --git a/docs/wiki/Publishing-and-Moderation.md b/docs/wiki/Publishing-and-Moderation.md index 96ab16e..8170e2c 100644 --- a/docs/wiki/Publishing-and-Moderation.md +++ b/docs/wiki/Publishing-and-Moderation.md @@ -21,9 +21,9 @@ token and an Ed25519-signed request. A local manifest without a valid signature cannot enter the account-backed publication pipeline, and the selected signer must match the manifest's `author_pubkey`. -The account-backed intent and submission functions are available in the core -client library for a human-facing client. They are not exposed through the -current CLI or MCP server. +The `frameshift publication review`, `submit`, and `status` commands expose the +account-backed workflow through the CLI. Final submission is not exposed +through the MCP server. ## Quarantine and review @@ -46,6 +46,20 @@ public release. A request for changes or rejection does not mutate the submitted artifact; the publisher prepares and submits a new exact snapshot when content must change. +The role-gated CLI accepts the submission UUID returned to the publisher: + +```bash +frameshift moderation show --server https://registry.example --submission-id +frameshift moderation artifact --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 +``` + +The artifact command writes to a new destination and refuses to replace an +existing file. The server still enforces active moderator or administrator +membership and requires an independent reviewer for artifact access and +promotion. + ## Withdrawal and decisions A publisher owner can withdraw an eligible non-public submission. Withdrawal