diff --git a/Cargo.lock b/Cargo.lock index 498589380..0de39f7c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "clap" version = "4.6.1" @@ -568,6 +578,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "chrono-tz", "croner", "email_address", "getrandom 0.2.17", @@ -2963,13 +2974,22 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", "serde", ] @@ -2980,7 +3000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2990,7 +3010,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", ] [[package]] @@ -4429,7 +4458,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -4440,7 +4469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -5517,7 +5546,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/Cargo.toml b/Cargo.toml index e537e3a37..a29c714d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio", "metrics", "te opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "metrics"] } sqlx = { version = "0.8", features = ["sqlite", "postgres", "migrate", "runtime-tokio", "chrono"] } chrono = "0.4" +chrono-tz = "0.10.4" croner = "2.2" tempfile = "3" rstest = "0.26" diff --git a/common/Cargo.toml b/common/Cargo.toml index e503fa3de..2e261d875 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -11,10 +11,11 @@ async-trait.workspace = true base64.workspace = true email_address.workspace = true chrono = { workspace = true, features = ["serde"] } +chrono-tz.workspace = true croner.workspace = true html5ever = { workspace = true, optional = true } macros = { path = "../macros" } -orgize = { workspace = true, optional = true } +orgize.workspace = true percent-encoding.workspace = true pulldown-cmark = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } @@ -49,13 +50,11 @@ sqlx = ["dep:sqlx"] # `html5ever` rides the same flag: `extract_media_refs` re-parses the sanitized output # (#711), so it is meaningless without the sanitizer and must stay out of the wasm graph # for the same reason `ammonia` does. -# -# `orgize` and `pulldown-cmark` ride it too (#836): the only code that calls them is -# `render_markdown`/`render_org` inside the `sanitize`-gated `sanitized` module, so -# declaring them non-optionally made them permanent members of every graph that never -# uses them. LTO already kept their code out of the wasm *binary* — this is manifest -# hygiene, so the dependency list stops claiming a coupling that does not exist. -sanitize = ["dep:ammonia", "dep:html5ever", "dep:orgize", "dep:pulldown-cmark"] +# `pulldown-cmark` rides this flag (#836): the only code that calls it is +# `render_markdown` inside the `sanitize`-gated `sanitized` module, so declaring it +# non-optionally made it a permanent member of every graph that never uses it. Org +# normalization uses `orgize` independently, so it remains a direct dependency. +sanitize = ["dep:ammonia", "dep:html5ever", "dep:pulldown-cmark"] test-utils = ["cheap-kdf"] cheap-kdf = [] # Compiles `common::test_support` (shared test fixtures) for downstream test crates; diff --git a/common/src/atompub/entry.rs b/common/src/atompub/entry.rs index f380a3634..9ce33b65b 100644 --- a/common/src/atompub/entry.rs +++ b/common/src/atompub/entry.rs @@ -158,22 +158,43 @@ fn prune_empty_extensions(entry: &mut Entry) { // Draft flag (app:control/app:draft) helpers // --------------------------------------------------------------------------- +/// Returns the explicit `app:control/app:draft` marker when present. +/// +/// `Some(true)` is RFC 5023's `yes` value; any other explicit marker value is +/// `Some(false)`. This preserves the distinction between an explicit +/// non-draft marker and no marker, which callers that merge lifecycle sources +/// need. Multiple valid markers retain [`is_draft`]'s established meaning: +/// `yes` wins. +#[must_use] +pub fn draft_marker(entry: &Entry) -> Option { + markers_in(entry, APP_NS, "control") + .filter_map(|(_, control)| control_draft_marker(&entry.namespaces, control)) + .reduce(|draft, marker| draft || marker) +} + /// Returns true when the entry carries `app:control/app:draft = yes`. #[must_use] pub fn is_draft(entry: &Entry) -> bool { - markers_in(entry, APP_NS, "control") - .any(|(_, control)| control_marks_draft(&entry.namespaces, control)) + draft_marker(entry).unwrap_or(false) } -fn control_marks_draft(namespaces: &BTreeMap, control: &Extension) -> bool { - control.children.get("draft").is_some_and(|drafts| { - drafts.iter().any(|d| { - child_in_namespace(namespaces, &control.attrs, d, APP_NS) - && d.value - .as_deref() - .is_some_and(|v| v.trim().eq_ignore_ascii_case("yes")) - }) - }) +fn control_draft_marker( + namespaces: &BTreeMap, + control: &Extension, +) -> Option { + let drafts = control.children.get("draft")?; + let mut found = false; + let mut is_draft = false; + for draft in drafts { + if child_in_namespace(namespaces, &control.attrs, draft, APP_NS) { + found = true; + is_draft |= draft + .value + .as_deref() + .is_some_and(|v| v.trim().eq_ignore_ascii_case("yes")); + } + } + found.then_some(is_draft) } /// Sets or clears the `app:control/app:draft` marker on an entry. @@ -804,6 +825,33 @@ mod tests { ); } + #[test] + fn draft_marker_preserves_explicit_non_draft_presence() { + let absent = r#"T"# + .parse::() + .expect("parse"); + let explicit_no = + r#" + T + no +"# + .parse::() + .expect("parse"); + let explicit_yes = + r#" + T + yes +"# + .parse::() + .expect("parse"); + + assert_eq!(draft_marker(&absent), None); + assert_eq!(draft_marker(&explicit_no), Some(false)); + assert_eq!(draft_marker(&explicit_yes), Some(true)); + assert!(!is_draft(&explicit_no)); + assert!(is_draft(&explicit_yes)); + } + #[test] fn a_control_element_outside_the_app_namespace_is_not_a_draft_flag() { // `app` is a conventional prefix, not a reserved one: the same spelling bound diff --git a/common/src/atompub/mod.rs b/common/src/atompub/mod.rs index a0fc4f3dd..ca0980b72 100644 --- a/common/src/atompub/mod.rs +++ b/common/src/atompub/mod.rs @@ -22,8 +22,8 @@ pub use title::{CollectionFeedTitle, CollectionTitle, WorkspaceTitle}; pub mod entry; pub use entry::{ - FeedMeta, MediaLinkEntry, entry_to_xml, is_draft, j_slug, render_feed, render_media_link_entry, - set_draft, set_j_slug, + FeedMeta, MediaLinkEntry, draft_marker, entry_to_xml, is_draft, j_slug, render_feed, + render_media_link_entry, set_draft, set_j_slug, }; pub mod service; diff --git a/common/src/etag.rs b/common/src/etag.rs index 6ef5eae27..ef7a75548 100644 --- a/common/src/etag.rs +++ b/common/src/etag.rs @@ -15,10 +15,14 @@ use std::str::FromStr; use macros::StrNewtype; +use serde::Serialize; use sha2::{Digest, Sha256}; use thiserror::Error; -use crate::media::ContentHash; +use crate::{ + media::ContentHash, post_body::PostBody, post_summary::PostSummary, post_title::PostTitle, + render::PostFormat, tag::TagLabel, +}; /// A strong HTTP `ETag` — a double-quoted opaque validator (RFC 7232). The wrapped /// `String` is private, so the only ways in are the validating [`FromStr`] (for stored / @@ -129,6 +133,43 @@ impl ETag { } } +/// Computes the canonical strong validator for a Post's mutable content. +/// +/// This deliberately serializes the historical `AtomPub` projection byte-for-byte: +/// field order, `format`'s display spelling, tag order, and the boolean draft +/// projection are part of the validator contract. Storage-specific ids and +/// timestamps are absent so an idempotent publish leaves the value unchanged. +#[must_use] +pub fn post_content_etag<'a>( + title: Option<&'a PostTitle>, + body: &'a PostBody, + format: &'a PostFormat, + summary: Option<&'a PostSummary>, + tags: impl IntoIterator, + draft: bool, +) -> ETag { + #[derive(Serialize)] + struct Content<'a> { + title: Option<&'a PostTitle>, + body: &'a PostBody, + format: String, + summary: Option<&'a PostSummary>, + tags: Vec<&'a TagLabel>, + draft: bool, + } + + let content = Content { + title, + body, + format: format.to_string(), + summary, + tags: tags.into_iter().collect(), + draft, + }; + let bytes = serde_json::to_vec(&content).unwrap_or_else(|_| Vec::new()); + ETag::sha256_of(bytes) +} + #[cfg(test)] mod tests { use super::*; diff --git a/common/src/lib.rs b/common/src/lib.rs index 07bda6f4e..71c943b81 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -24,6 +24,7 @@ pub mod local_storage_key; pub mod mailbox; pub mod mailer; pub mod media; +pub mod org; pub mod pagination; pub mod password; pub mod pg_identifier; diff --git a/common/src/org.rs b/common/src/org.rs new file mode 100644 index 000000000..d1c7775e3 --- /dev/null +++ b/common/src/org.rs @@ -0,0 +1,933 @@ +//! Pure normalization of an Org post's leading Jaunder metadata block. + +use std::str::FromStr; + +use chrono::{LocalResult, NaiveDate, NaiveDateTime, TimeZone}; +use chrono_tz::Tz; +use orgize::{ + Org, + ast::{Document, Keyword}, + rowan::ast::AstNode, +}; +use thiserror::Error; + +use crate::{ + etag::ETag, + ids::{AudienceId, PostId}, + post_body::PostBody, + post_summary::PostSummary, + post_title::PostTitle, + render::{PostFormat, canonicalize_body, derive_post_naming}, + slug::Slug, + tag::{TagLabel, parse_and_validate_tags}, + time::UtcInstant, + visibility::AudienceTarget, +}; + +/// Whether an ingress explicitly supplied a field. `Present(Vec::new())` is +/// deliberately distinct from [`Absent`](Self::Absent): an empty collection is +/// an instruction, not an omitted field. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Presence { + #[default] + Absent, + // rendered-html-from-trusted:allow generic presence holds caller-validated values and never mints or decodes HTML (#77) + Present(T), +} + +/// The lifecycle is a single input unit: callers cannot accidentally combine a +/// transport status with an Org publication time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublicationState { + Draft, + Scheduled(UtcInstant), + Published(UtcInstant), +} + +/// Structured values supplied by an ingress before Org headers are considered. +/// +/// An absent scalar is omitted; scalar clearing is a surface concern after +/// normalization rather than a second `Option` state here. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OrgStructuredMetadata { + pub title: Presence, + pub summary: Presence, + pub tags: Presence>, + pub audiences: Presence>, + pub lifecycle: Presence, +} + +/// The operation-specific identity available to pure metadata validation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrgOperation { + Create, + Update { post_id: PostId }, +} + +/// Bookkeeping parsed from non-authoritative `JAUNDER_*` properties. Final +/// comparisons against storage intentionally happen outside this module. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OrgBookkeeping { + pub slug: Option, + pub format: Option, + pub post_id: Option, + pub synced: Option, + pub synced_at: Option, + pub date_utc: Option, +} + +/// The effective fields after per-field structured/header precedence is applied. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OrgEffectiveMetadata { + pub title: Presence, + pub summary: Presence, + pub tags: Presence>, + pub audiences: Presence>, + pub lifecycle: Presence, +} + +/// Successful pure Org normalization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OrgNormalization { + pub body: PostBody, + pub metadata: OrgEffectiveMetadata, + pub bookkeeping: OrgBookkeeping, +} + +/// A rejected Org metadata block. The caller maps this single semantic failure +/// into its transport's error vocabulary without re-parsing source text. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum OrgMetadataError { + #[error("invalid Org metadata: {0}")] + Invalid(String), + #[error("Org body contains metadata but no content")] + MetadataOnly, +} + +/// Normalize source and resolve its recognized leading metadata block. +/// +/// `request_clock` is injected exactly once by the orchestration layer. Audience +/// ownership and comparisons against final stored values are intentionally not +/// dependencies of this pure function. +/// +/// # Errors +/// +/// Returns [`OrgMetadataError`] when recognized metadata is malformed, +/// contradictory, invalid for the operation, or leaves no Post body. +pub fn normalize_org( + source: &str, + structured: OrgStructuredMetadata, + operation: OrgOperation, + request_clock: UtcInstant, +) -> Result { + validate_lifecycle(&structured.lifecycle, request_clock)?; + let document = Org::parse(source); + let parsed = parse_leading_block(source, &document, request_clock)?; + validate_operation(&parsed.bookkeeping, operation)?; + + let (body, heading_title) = + canonical_body(&parsed.body, matches!(&parsed.title, Presence::Present(_)))?; + let title = match choose(structured.title, parsed.title) { + Presence::Absent => heading_title.map_or(Presence::Absent, Presence::Present), + title @ Presence::Present(_) => title, + }; + let summary = choose(structured.summary, parsed.summary); + let tags = choose(structured.tags, parsed.tags); + let audiences = choose(structured.audiences, parsed.audiences); + validate_audiences(&audiences)?; + let lifecycle = choose(structured.lifecycle, parsed.lifecycle); + + Ok(OrgNormalization { + body, + metadata: OrgEffectiveMetadata { + title, + summary, + tags, + audiences, + lifecycle, + }, + bookkeeping: parsed.bookkeeping, + }) +} + +#[derive(Default)] +struct ParsedBlock { + body: String, + title: Presence, + summary: Presence, + tags: Presence>, + audiences: Presence>, + lifecycle: Presence, + bookkeeping: OrgBookkeeping, +} +#[derive(Default)] +struct HeaderFields { + titles: Vec, + summaries: Vec, + tags: Vec, + audiences: Vec, + date: Option, + timezone: Option, + status: Option, + bookkeeping: OrgBookkeeping, +} + +enum HeaderKeyword<'a> { + Title(&'a str), + Description(&'a str), + Keywords(&'a str), + Date(&'a str), + Property(&'a str), +} + +#[derive(Clone, Copy)] +enum PropertyName { + DateTimezone, + Status, + Audience, + Slug, + Format, + Id, + Synced, + SyncedAt, + DateUtc, +} + +enum PropertyLine<'a> { + Unknown, + Recognized(PropertyName, &'a str), +} + +fn parse_leading_block( + source: &str, + document: &Org, + request_clock: UtcInstant, +) -> Result { + let header_end = leading_keyword_end(document); + let mut body = Vec::new(); + let mut offset = 0; + let mut fields = HeaderFields::default(); + + for source_line in source.split_inclusive('\n') { + let line = source_line.strip_suffix('\n').unwrap_or(source_line); + let handled = offset < header_end && parse_header_keyword(line, &mut fields)?; + if !handled { + body.push(line); + } + offset += source_line.len(); + } + + let mut parsed = ParsedBlock { + body: body.join("\n"), + bookkeeping: fields.bookkeeping, + ..ParsedBlock::default() + }; + if !fields.titles.is_empty() { + parsed.title = Presence::Present( + fields + .titles + .join("\n") + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid TITLE".into()))?, + ); + } + if !fields.summaries.is_empty() { + parsed.summary = Presence::Present( + fields + .summaries + .join("\n") + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid DESCRIPTION".into()))?, + ); + } + if !fields.tags.is_empty() { + parsed.tags = Presence::Present( + parse_and_validate_tags(fields.tags) + .map_err(|_| OrgMetadataError::Invalid("invalid KEYWORDS".into()))?, + ); + } + if !fields.audiences.is_empty() { + parsed.audiences = Presence::Present(fields.audiences); + } + parsed.lifecycle = parse_lifecycle(fields.status, fields.date, fields.timezone, request_clock)?; + Ok(parsed) +} + +fn parse_header_keyword(line: &str, fields: &mut HeaderFields) -> Result { + let Some(keyword) = header_keyword(line) else { + return Ok(false); + }; + match keyword { + HeaderKeyword::Title(value) => fields.titles.push(nonblank(value, "TITLE")?.to_owned()), + HeaderKeyword::Description(value) => fields + .summaries + .push(nonblank(value, "DESCRIPTION")?.to_owned()), + HeaderKeyword::Keywords(value) => fields.tags.extend(parse_keywords(value)?), + HeaderKeyword::Date(value) => set_once( + &mut fields.date, + nonblank(value, "DATE")?.to_owned(), + "DATE", + )?, + HeaderKeyword::Property(value) => match property_line(value)? { + PropertyLine::Unknown => return Ok(false), + PropertyLine::Recognized(name, value) => { + parse_property(name, value, fields)?; + } + }, + } + Ok(true) +} + +fn header_keyword(line: &str) -> Option> { + let (name, value) = keyword(line)?; + match name.as_str() { + "title" => Some(HeaderKeyword::Title(value)), + "description" => Some(HeaderKeyword::Description(value)), + "keywords" => Some(HeaderKeyword::Keywords(value)), + "date" => Some(HeaderKeyword::Date(value)), + "property" => Some(HeaderKeyword::Property(value)), + _ => None, + } +} + +fn parse_keywords(value: &str) -> Result, OrgMetadataError> { + let terms: Vec<_> = value + .split(',') + .map(str::trim) + .filter(|term| !term.is_empty()) + .collect(); + if terms.is_empty() { + return invalid("KEYWORDS must contain a tag"); + } + terms + .into_iter() + .map(|term| { + term.parse() + .map_err(|_| OrgMetadataError::Invalid("invalid KEYWORDS tag".into())) + }) + .collect() +} + +fn leading_keyword_end(document: &Org) -> usize { + let Some(section) = document + .first_node::() + .and_then(|doc| doc.section()) + else { + return 0; + }; + + section + .syntax() + .children() + .take_while(|node| Keyword::cast(node.clone()).is_some()) + .filter_map(Keyword::cast) + .last() + .map_or(0, |keyword| { + u32::from(keyword.syntax().text_range().end()) as usize + }) +} + +fn keyword(line: &str) -> Option<(String, &str)> { + let (key, value) = line.trim_start().split_once(':')?; + let key = key.strip_prefix("#+")?; + Some((key.to_ascii_lowercase(), value.trim())) +} + +fn property_line(value: &str) -> Result, OrgMetadataError> { + let Some((name, value)) = value.split_once(char::is_whitespace) else { + return if property_name(value).is_some() { + invalid("PROPERTY must name a value") + } else { + Ok(PropertyLine::Unknown) + }; + }; + let Some(name) = property_name(name) else { + return Ok(PropertyLine::Unknown); + }; + Ok(PropertyLine::Recognized( + name, + nonblank(value.trim(), "PROPERTY")?, + )) +} + +fn property_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "jaunder_date_tz" => Some(PropertyName::DateTimezone), + "jaunder_status" => Some(PropertyName::Status), + "jaunder_audience" => Some(PropertyName::Audience), + "jaunder_slug" => Some(PropertyName::Slug), + "jaunder_format" => Some(PropertyName::Format), + "jaunder_id" => Some(PropertyName::Id), + "jaunder_synced" => Some(PropertyName::Synced), + "jaunder_synced_at" => Some(PropertyName::SyncedAt), + "jaunder_date_utc" => Some(PropertyName::DateUtc), + _ => None, + } +} + +fn nonblank<'a>(value: &'a str, field: &str) -> Result<&'a str, OrgMetadataError> { + if value.is_empty() { + invalid(&format!("{field} must not be blank")) + } else { + Ok(value) + } +} +fn invalid(message: &str) -> Result { + Err(OrgMetadataError::Invalid(message.into())) +} +fn set_once(slot: &mut Option, value: String, name: &str) -> Result<(), OrgMetadataError> { + if slot.replace(value).is_some() { + invalid(&format!("duplicate {name}")) + } else { + Ok(()) + } +} + +fn parse_property( + name: PropertyName, + value: &str, + fields: &mut HeaderFields, +) -> Result<(), OrgMetadataError> { + match name { + PropertyName::DateTimezone => { + set_once(&mut fields.timezone, value.to_owned(), "JAUNDER_DATE_TZ") + } + PropertyName::Status => set_once(&mut fields.status, value.to_owned(), "JAUNDER_STATUS"), + PropertyName::Audience => { + fields.audiences.push(parse_audience(value)?); + Ok(()) + } + PropertyName::Slug => set_bookkeeping( + &mut fields.bookkeeping.slug, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder slug".into()))?, + "JAUNDER_SLUG", + ), + PropertyName::Format => set_bookkeeping( + &mut fields.bookkeeping.format, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder format".into()))?, + "JAUNDER_FORMAT", + ), + PropertyName::Id => set_bookkeeping( + &mut fields.bookkeeping.post_id, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder ID".into()))?, + "JAUNDER_ID", + ), + PropertyName::Synced => set_bookkeeping( + &mut fields.bookkeeping.synced, + ETag::from_str(value) + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder sync ETag".into()))?, + "JAUNDER_SYNCED", + ), + PropertyName::SyncedAt => set_bookkeeping( + &mut fields.bookkeeping.synced_at, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder sync time".into()))?, + "JAUNDER_SYNCED_AT", + ), + PropertyName::DateUtc => set_bookkeeping( + &mut fields.bookkeeping.date_utc, + value.parse().map_err(|_| { + OrgMetadataError::Invalid("invalid Jaunder publication time".into()) + })?, + "JAUNDER_DATE_UTC", + ), + } +} +fn set_bookkeeping(slot: &mut Option, value: T, name: &str) -> Result<(), OrgMetadataError> { + if slot.replace(value).is_some() { + invalid(&format!("duplicate {name}")) + } else { + Ok(()) + } +} + +fn parse_audience(value: &str) -> Result { + match value { + "public" => Ok(AudienceTarget::Public), + "subscribers" => Ok(AudienceTarget::Subscribers), + "private" => Ok(AudienceTarget::Private), + _ => value + .strip_prefix("named:") + .and_then(|id| id.parse::().ok()) + .map(AudienceTarget::Named) + .ok_or_else(|| OrgMetadataError::Invalid("invalid JAUNDER_AUDIENCE".into())), + } +} + +fn parse_lifecycle( + status: Option, + date: Option, + timezone: Option, + clock: UtcInstant, +) -> Result, OrgMetadataError> { + let Some(status) = status else { + if date.is_some() || timezone.is_some() { + return invalid("DATE and JAUNDER_DATE_TZ require JAUNDER_STATUS"); + } + return Ok(Presence::Absent); + }; + let instant = match (date, timezone) { + (None, None) => None, + (Some(date), Some(tz)) => Some(parse_org_date(&date, &tz)?), + _ => return invalid("DATE and JAUNDER_DATE_TZ must occur together"), + }; + let state = match (status.to_ascii_lowercase().as_str(), instant) { + ("draft", None) => PublicationState::Draft, + ("scheduled", Some(at)) if at.value() > clock.value() => PublicationState::Scheduled(at), + ("published", at) => PublicationState::Published(at.unwrap_or(clock)), + _ => return invalid("invalid JAUNDER_STATUS lifecycle"), + }; + let lifecycle = Presence::Present(state); + validate_lifecycle(&lifecycle, clock)?; + Ok(lifecycle) +} + +fn parse_org_date(value: &str, timezone: &str) -> Result { + let Some(value) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else { + return invalid("DATE must be an inactive Org timestamp"); + }; + let parts: Vec<_> = value.split_whitespace().collect(); + if parts.len() != 3 { + return invalid("invalid DATE"); + } + let date = NaiveDate::parse_from_str(parts[0], "%Y-%m-%d") + .map_err(|_| OrgMetadataError::Invalid("invalid DATE".into()))?; + if date.format("%a").to_string() != parts[1] { + return invalid("DATE weekday does not match date"); + } + let time = + NaiveDateTime::parse_from_str(&format!("{} {}", parts[0], parts[2]), "%Y-%m-%d %H:%M") + .map_err(|_| OrgMetadataError::Invalid("invalid DATE".into()))?; + let tz: Tz = timezone + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder timezone".into()))?; + match tz.from_local_datetime(&time) { + LocalResult::Single(at) => Ok(at.with_timezone(&chrono::Utc).into()), + LocalResult::Ambiguous(earlier, _) => Ok(earlier.with_timezone(&chrono::Utc).into()), + LocalResult::None => invalid("DATE is in a DST gap"), + } +} + +fn choose(structured: Presence, header: Presence) -> Presence { + match structured { + Presence::Present(value) => Presence::Present(value), + Presence::Absent => header, + } +} +fn validate_lifecycle( + lifecycle: &Presence, + clock: UtcInstant, +) -> Result<(), OrgMetadataError> { + match lifecycle { + Presence::Present(PublicationState::Published(at)) if at.value() > clock.value() => { + invalid("published instant must not be future") + } + _ => Ok(()), + } +} +fn validate_audiences(audiences: &Presence>) -> Result<(), OrgMetadataError> { + let Presence::Present(audiences) = audiences else { + return Ok(()); + }; + if audiences + .iter() + .any(|audience| matches!(audience, AudienceTarget::Private)) + && audiences.len() != 1 + { + return invalid("private audience cannot be combined"); + } + Ok(()) +} + +fn canonical_body( + body: &str, + header_supplies_title: bool, +) -> Result<(PostBody, Option), OrgMetadataError> { + // The canonicalizer's title-header state decides whether a following level-one + // heading is content. Retain that state while the parser removes recognized + // metadata by presenting an otherwise-discarded marker to the one canonical + // format-aware body door. + let source = if header_supplies_title { + format!("#+TITLE:\n{body}") + } else { + body.to_owned() + }; + let body: PostBody = source.parse().map_err(|_| OrgMetadataError::MetadataOnly)?; + let heading_title = derive_post_naming(None, &body, &PostFormat::Org).0; + canonicalize_body(&body, &PostFormat::Org) + .map(|body| (body, heading_title)) + .map_err(|_| OrgMetadataError::MetadataOnly) +} +fn validate_operation( + bookkeeping: &OrgBookkeeping, + operation: OrgOperation, +) -> Result<(), OrgMetadataError> { + match operation { + OrgOperation::Create + if bookkeeping.post_id.is_some() + || bookkeeping.synced.is_some() + || bookkeeping.synced_at.is_some() => + { + invalid("create cannot include ID or sync bookkeeping") + } + OrgOperation::Update { post_id } if bookkeeping.post_id.is_some_and(|id| id != post_id) => { + invalid("JAUNDER_ID does not match update target") + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn clock() -> UtcInstant { + "2026-08-26T12:00:00Z".parse().expect("valid fixed clock") + } + + fn normalize(source: &str) -> OrgNormalization { + normalize_org( + source, + OrgStructuredMetadata::default(), + OrgOperation::Create, + clock(), + ) + .expect("valid Org metadata") + } + + fn invalid(source: &str) { + assert!(matches!( + normalize_org( + source, + OrgStructuredMetadata::default(), + OrgOperation::Create, + clock(), + ), + Err(OrgMetadataError::Invalid(_)) + )); + } + + #[test] + fn honors_the_ast_header_boundary_and_preserves_unknown_whitespace() { + let normalized = normalize( + "\n#+tItLe: A title\n\n#+AUTHOR: Author\n indented content\n#+KEYWORDS: later\n", + ); + + assert_eq!( + normalized.metadata.title, + Presence::Present("A title".parse().unwrap()) + ); + assert_eq!( + normalized.body.to_string(), + "#+AUTHOR: Author\n indented content\n#+KEYWORDS: later\n" + ); + assert_eq!(normalized.metadata.tags, Presence::Absent); + } + + #[test] + fn preserves_unrecognized_header_keywords_and_properties() { + let normalized = normalize( + "#+AUTHOR: Author\n#+PROPERTY: EXPORT_FILE_NAME example\n#+PROPERTY: EXPORT_FILE_NAME\n#+NOT_A_KEYWORD: retained\nBody", + ); + + assert_eq!( + normalized.body.to_string(), + "#+AUTHOR: Author\n#+PROPERTY: EXPORT_FILE_NAME example\n#+PROPERTY: EXPORT_FILE_NAME\n#+NOT_A_KEYWORD: retained\nBody\n" + ); + } + + #[test] + fn composes_repeated_text_and_keywords_with_tag_identity_order_and_cap() { + let normalized = normalize( + "#+TITLE: First\n#+TITLE: Second\n#+DESCRIPTION: One\n#+DESCRIPTION: Two\n#+KEYWORDS: Rust, , Emacs\n#+KEYWORDS: rust, Lisp\nBody", + ); + + assert_eq!( + normalized.metadata.title, + Presence::Present("First\nSecond".parse().unwrap()) + ); + assert_eq!( + normalized.metadata.summary, + Presence::Present("One\nTwo".parse().unwrap()) + ); + assert_eq!( + normalized.metadata.tags, + Presence::Present(vec![ + "Rust".parse().unwrap(), + "Emacs".parse().unwrap(), + "Lisp".parse().unwrap() + ]) + ); + invalid("#+KEYWORDS: , ,\nBody"); + invalid("#+KEYWORDS: rust/lang\nBody"); + } + + #[test] + fn structured_presence_wins_without_an_explicit_clear_state() { + let normalized = normalize_org( + "#+TITLE: Header\n#+DESCRIPTION: Header summary\n#+KEYWORDS: rust\nBody", + OrgStructuredMetadata { + title: Presence::Present("Structured".parse().unwrap()), + summary: Presence::Present("Structured summary".parse().unwrap()), + tags: Presence::Present(vec![]), + ..OrgStructuredMetadata::default() + }, + OrgOperation::Create, + clock(), + ) + .expect("explicit structured metadata wins"); + + assert_eq!( + normalized.metadata.title, + Presence::Present("Structured".parse().unwrap()) + ); + assert_eq!( + normalized.metadata.summary, + Presence::Present("Structured summary".parse().unwrap()) + ); + assert_eq!(normalized.metadata.tags, Presence::Present(vec![])); + assert_eq!(normalized.body.to_string(), "Body\n"); + } + + #[test] + fn preserves_heading_title_behavior_and_strips_once() { + let normalized = normalize("* Heading\n\n content\n"); + assert_eq!( + normalized.metadata.title, + Presence::Present("Heading".parse().unwrap()) + ); + assert_eq!(normalized.body.to_string(), " content\n"); + + let header_wins = normalize("#+TITLE: Header\n* Heading\nBody"); + assert_eq!( + header_wins.metadata.title, + Presence::Present("Header".parse().unwrap()) + ); + assert_eq!(header_wins.body.to_string(), "* Heading\nBody\n"); + + let normalized_twice = normalize(normalized.body.as_ref()); + assert_eq!(normalized_twice.body, normalized.body); + assert_eq!(normalized_twice.metadata.title, Presence::Absent); + } + + #[test] + fn validates_lifecycle_combinations_clock_and_civil_time() { + assert_eq!( + normalize("#+PROPERTY: jaunder_status draft\nBody") + .metadata + .lifecycle, + Presence::Present(PublicationState::Draft) + ); + assert_eq!( + normalize( + "#+DATE: [2026-08-26 Wed 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody" + ) + .metadata + .lifecycle, + Presence::Present(PublicationState::Published(clock())) + ); + assert_eq!( + normalize( + "#+DATE: [2026-11-01 Sun 01:30]\n#+PROPERTY: JAUNDER_DATE_TZ America/New_York\n#+PROPERTY: JAUNDER_STATUS scheduled\nBody" + ) + .metadata + .lifecycle, + Presence::Present(PublicationState::Scheduled( + "2026-11-01T05:30:00Z".parse().unwrap() + )) + ); + invalid("#+PROPERTY: JAUNDER_STATUS scheduled\nBody"); + invalid( + "#+DATE: [2026-08-26 Tue 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody", + ); + invalid( + "#+DATE: [2026-03-08 Sun 02:30]\n#+PROPERTY: JAUNDER_DATE_TZ America/New_York\n#+PROPERTY: JAUNDER_STATUS published\nBody", + ); + invalid("#+DATE: [2026-08-26 Wed 12:00]\nBody"); + invalid("#+DATE: [2026-08-26 Wed 12:00]\n#+PROPERTY: JAUNDER_STATUS published\nBody"); + invalid("#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody"); + invalid( + "#+DATE: <2026-08-26 Wed 12:00>\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody", + ); + invalid( + "#+DATE: [2026-08-26 Wed]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody", + ); + } + + #[test] + fn validates_displaced_header_lifecycle_before_precedence() { + let future_published = "#+DATE: [2026-08-27 Thu 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody"; + + for lifecycle in [ + PublicationState::Draft, + PublicationState::Published(clock()), + ] { + assert!(matches!( + normalize_org( + future_published, + OrgStructuredMetadata { + lifecycle: Presence::Present(lifecycle), + ..OrgStructuredMetadata::default() + }, + OrgOperation::Create, + clock(), + ), + Err(OrgMetadataError::Invalid(_)) + )); + } + + let normalized = normalize_org( + "#+DATE: [2026-08-25 Tue 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody", + OrgStructuredMetadata { + lifecycle: Presence::Present(PublicationState::Draft), + ..OrgStructuredMetadata::default() + }, + OrgOperation::Create, + clock(), + ) + .expect("a valid displaced header lifecycle is accepted"); + assert_eq!( + normalized.metadata.lifecycle, + Presence::Present(PublicationState::Draft) + ); + } + + #[test] + fn validates_audience_and_singleton_metadata() { + assert_eq!( + normalize( + "#+PROPERTY: jaunder_audience named:42\n#+PROPERTY: JAUNDER_AUDIENCE subscribers\nBody" + ) + .metadata + .audiences, + Presence::Present(vec![ + AudienceTarget::Named(AudienceId::from(42)), + AudienceTarget::Subscribers + ]) + ); + invalid("#+PROPERTY: JAUNDER_AUDIENCE private\n#+PROPERTY: JAUNDER_AUDIENCE public\nBody"); + invalid("#+PROPERTY: JAUNDER_AUDIENCE named:invalid\nBody"); + invalid("#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: Jaunder_Status draft\nBody"); + invalid("#+DATE: [2026-08-26 Wed 12:00]\n#+DATE: [2026-08-26 Wed 12:00]\nBody"); + invalid("#+PROPERTY: JAUNDER_STATUS\nBody"); + } + + #[test] + fn validates_bookkeeping_grammar_duplicates_and_operation_identity() { + let normalized = normalize( + "#+PROPERTY: JAUNDER_FORMAT org\n#+PROPERTY: JAUNDER_SLUG example\n#+PROPERTY: JAUNDER_DATE_UTC 2026-08-26T12:00:00+00:00\nBody", + ); + assert_eq!(normalized.bookkeeping.format, Some(PostFormat::Org)); + assert_eq!( + normalized.bookkeeping.date_utc, + Some("2026-08-26T12:00:00Z".parse().unwrap()) + ); + invalid("#+PROPERTY: JAUNDER_FORMAT org\n#+PROPERTY: JAUNDER_FORMAT org\nBody"); + invalid("#+PROPERTY: JAUNDER_SYNCED weak\nBody"); + invalid("#+PROPERTY: JAUNDER_ID 7\nBody"); + invalid("#+PROPERTY: JAUNDER_DATE_UTC not-a-time\nBody"); + assert!(matches!( + normalize_org( + "#+PROPERTY: JAUNDER_ID 7\nBody", + OrgStructuredMetadata::default(), + OrgOperation::Update { + post_id: PostId::from(8) + }, + clock(), + ), + Err(OrgMetadataError::Invalid(_)) + )); + } + + #[test] + fn rejects_blank_text_and_more_than_the_tag_cap() { + invalid("#+TITLE: \nBody"); + invalid("#+DESCRIPTION: \nBody"); + + let tags = (0..=crate::tag::MAX_TAGS_PER_POST) + .map(|index| format!("tag{index}")) + .collect::>() + .join(", "); + invalid(&format!("#+KEYWORDS: {tags}\nBody")); + } + + #[test] + fn rejects_every_invalid_lifecycle_presence_combination() { + assert_eq!( + normalize("#+PROPERTY: JAUNDER_STATUS published\nBody") + .metadata + .lifecycle, + Presence::Present(PublicationState::Published(clock())) + ); + invalid( + "#+DATE: [2026-08-27 Thu 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS draft\nBody", + ); + invalid( + "#+DATE: [2026-08-27 Thu 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS published\nBody", + ); + invalid( + "#+DATE: [2026-08-26 Wed 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n#+PROPERTY: JAUNDER_STATUS scheduled\nBody", + ); + invalid("#+PROPERTY: JAUNDER_DATE_TZ UTC\nBody"); + } + + #[test] + fn parses_sync_bookkeeping_without_changing_instant_identity() { + let normalized = normalize_org( + "#+PROPERTY: JAUNDER_SYNCED \"sha256-abc\"\n#+PROPERTY: JAUNDER_SYNCED_AT 2026-08-26T08:00:00-04:00\nBody", + OrgStructuredMetadata::default(), + OrgOperation::Update { + post_id: PostId::from(7), + }, + clock(), + ) + .expect("valid update sync bookkeeping"); + assert_eq!( + normalized.bookkeeping.synced, + Some("\"sha256-abc\"".parse().unwrap()) + ); + assert_eq!(normalized.bookkeeping.synced_at, Some(clock())); + assert!(matches!( + normalize_org( + "#+PROPERTY: JAUNDER_SYNCED_AT 2026-08-26T12:00:00Z\n#+PROPERTY: JAUNDER_SYNCED_AT 2026-08-26T12:00:00Z\nBody", + OrgStructuredMetadata::default(), + OrgOperation::Update { + post_id: PostId::from(7), + }, + clock(), + ), + Err(OrgMetadataError::Invalid(_)) + )); + + let matching = normalize_org( + "#+PROPERTY: JAUNDER_ID 7\nBody", + OrgStructuredMetadata::default(), + OrgOperation::Update { + post_id: PostId::from(7), + }, + clock(), + ) + .expect("matching update identity is valid"); + assert_eq!(matching.bookkeeping.post_id, Some(PostId::from(7))); + } + + #[test] + fn rejects_metadata_only_source() { + assert_eq!( + normalize_org( + "#+TITLE: Only metadata", + OrgStructuredMetadata::default(), + OrgOperation::Create, + clock(), + ), + Err(OrgMetadataError::MetadataOnly) + ); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57b741fef..7cc76b55b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -342,16 +342,33 @@ passthrough), `Markdown` and `Org` drop leading all-whitespace lines, `trim_end()`, then re-append one newline. Interior blank lines and leading horizontal whitespace are never touched — both are significant to CommonMark. -Every write path converges on **one canonical stored body** -([ADR-0024](adr/0024-server-side-org-canonicalization.md)): `canonicalize_body` -additionally strips the Org title source, so headers the server stores -structurally (today `#+TITLE:`) do not survive in the body while unrecognized -`#+FOO:` lines round-trip verbatim; clients synthesize their own header block on -the way out. `perform_post_update` (`storage/src/post_service.rs:236`, naming -block `:251-267`) and `perform_post_creation` (`:401`, block `:417-424`) derive -naming from the _original_ body via `derive_post_naming` -(`common/src/render.rs:618`) before canonicalizing, because canonicalization -removes the Org title line. +Every write path converges on **one canonical stored body**. For Org, every +create and update parses the complete leading, case-insensitive Org/Jaunder +metadata block through its first non-keyword top-level element and merges +recognized metadata with structured input. After the whole write is accepted, +every recognized header is removed, including valid mutable metadata displaced +by structured input. Structured presence resolves per field, with lifecycle as +one indivisible merge unit; transport defaults do not manufacture presence. +Structured values win, headers fill only absence, and omission retains the +surface's existing update/default semantics. Unknown Org directives remain body +content. Parsing, precedence, audience authorization, lifecycle and bookkeeping +validation, and stripping are one atomic decision: malformed, conflicting, +foreign-audience, stale, or metadata-only input saves nothing. Recognized +mutable metadata covers title, tags, summary, lifecycle/date-zone data, and +audience targets; identity and sync bookkeeping is checked against +derived/current values, never trusted as input. The full policy is +[server-side Org metadata block canonicalization](adr/drafts/server-side-org-metadata-block.md), +which evolves [ADR-0024](adr/0024-server-side-org-canonicalization.md). Clients +synthesize presentation headers on output. + +The deep normalization interface is `common::org::normalize_org`: it owns the +Org element boundary, typed metadata parsing, field/lifecycle precedence, date +conversion, and canonical stripping, and returns effective metadata plus +non-authoritative bookkeeping. Web and AtomPub adapters map their wire presence +into that interface; `perform_post_creation`/`perform_post_update` then persist +its canonical result, with SQLite/PostgreSQL checking final slug/format/time and +the pre-write content `ETag` inside the write transaction before commit or +revision creation. **`RenderedHtml` guarantees "contains no active markup", through two named doors** ([ADR-0079](adr/0079-rendered-html-sanitization.md)). @@ -663,6 +680,13 @@ whole policy is the `format_wire` seam: the two private pure functions server crate; only the namespace and `j:slug` definitions it works against live in `common/src/atompub`. +For `text/org` entries, AtomPub create and update use that same full-block +metadata interpretation and canonical metadata-free body as every other Org +ingress; Atom elements remain structured input, not a competing canonical +representation. In particular, explicit Atom metadata wins over a header and the +header can supply only its absence. The authoritative invariant is +[server-side Org metadata block canonicalization](adr/drafts/server-side-org-metadata-block.md). + Two Jaunder wire extensions ride the namespace `https://jaunder.org/ns/atompub` (`J_NS`, `common/src/atompub/ns.rs:6`; [ADR-0023](adr/0023-atompub-jaunder-wire-extensions.md)): a read-only `j:slug` diff --git a/docs/adr/drafts/server-side-org-metadata-block.md b/docs/adr/drafts/server-side-org-metadata-block.md new file mode 100644 index 000000000..16cd137ad --- /dev/null +++ b/docs/adr/drafts/server-side-org-metadata-block.md @@ -0,0 +1,92 @@ +# ADR-DRAFT: Server-Side Org Metadata Block Canonicalization + +- Status: proposed +- Date: 2026-08-26 +- Issue: [#77](https://github.com/jaunder-org/jaunder/issues/77) + +## Context + +[ADR-0024](../0024-server-side-org-canonicalization.md) established that every +Org write reaches one metadata-free stored body, but deliberately deferred +full-header parsing. That limit leaves a raw-Org create or update unable to +express the same structured post metadata as other authoring surfaces, and would +make each surface responsible for a different interpretation of the same source. + +The server must accept Org as a first-class authoring representation without +letting metadata live independently in both the header and structured post +fields. It must also distinguish recognized Jaunder metadata from arbitrary Org +directives, preserve the latter as author content, and reject an invalid write +before it can partially alter either metadata or body. + +## Decision + +This ADR evolves ADR-0024: on every Org create and update, the server parses the +complete leading Org/Jaunder metadata block, case-insensitively. The block ends +immediately before the first top-level Org element that is not a keyword. After +the whole write is accepted, every recognized header is removed before body +canonicalization, including valid mutable metadata displaced by structured +input; unrecognized Org directives remain in the canonical body. Consequently, +stored Org bodies contain no recognized mutable metadata or bookkeeping and +every creation/update surface converges on the same structured post plus +canonical metadata-free source. + +Recognized mutable metadata is `#+TITLE`, repeated or comma-separated +`#+KEYWORDS`, repeated `#+DESCRIPTION`, `#+DATE`, and `#+PROPERTY` values for +`JAUNDER_DATE_TZ`, `JAUNDER_STATUS`, and repeated `JAUNDER_AUDIENCE`. Text and +list values compose by field; date, lifecycle, timezone, and bookkeeping values +are singletons. Values pass through the existing typed title, summary, tag, +slug, ID, and timestamp boundaries. Blank recognized values reject except that +each `KEYWORDS` occurrence drops empty comma terms and must retain at least one +valid term. Audience values are exactly `public`, `subscribers`, `private`, or +`named:`; `private` cannot combine, and named IDs must belong to the +author. + +Structured presence is resolved per field. A supplied valid scalar or +collection, including an empty collection, wins; otherwise the header may fill +the field. When both omit it, the surface keeps its existing update omission or +create default semantics. Lifecycle status and publication time merge as one +unit from one source, never as independently selected fields; transport defaults +do not manufacture presence. + +`DATE` is Emacs's inactive `[YYYY-MM-DD Ddd HH:MM]` form with a matching weekday +and required IANA `JAUNDER_DATE_TZ`. Ambiguous DST folds choose the earlier +instant; nonexistent local times reject. One request clock classifies the +result, with equality non-future. A header lifecycle always includes +case-insensitive `JAUNDER_STATUS`: `draft` permits neither date nor timezone; +`scheduled` requires both and a future instant; `published` permits neither and +uses the request clock, or requires both and a non-future instant. Date and +timezone never appear independently. + +`JAUNDER_FORMAT`, `JAUNDER_SLUG`, `JAUNDER_ID`, `JAUNDER_SYNCED`, +`JAUNDER_SYNCED_AT`, and `JAUNDER_DATE_UTC` are singleton `#+PROPERTY` +bookkeeping, not input authority. Create rejects ID/sync fields; slug, format, +and publication UTC must match the final stored representation. Update requires +ID to match the target, slug/format/publication UTC to match final effective +stored values, and `JAUNDER_SYNCED` to match the current pre-write content ETag; +`JAUNDER_SYNCED_AT` is syntax-only. Times compare as RFC 3339 instants. + +Parsing, merging, authorization, derived-field checks, and body stripping form +one atomic acceptance decision. Malformed or conflicting metadata, a foreign or +invalid audience, and metadata-only content reject without stripping or saving. +Web uses Validation except Conflict for stale sync; AtomPub uses 400 except 412 +for stale sync, without revealing whether a foreign audience exists. + +## Consequences + +- Good: raw Org gains the full structured metadata vocabulary on every ingress + path, while headers cannot silently outrank explicit structured requests. +- Good: the server owns one deterministic interpretation and stored body form; + clients can synthesize presentation headers without preserving duplicate + authoritative state. +- Good: unknown directives remain round-trippable author content, while + validation prevents stale synchronization bookkeeping and foreign audience + references from becoming persisted state. +- Cost: the server now owns a strict, format-aware parser and a field-specific + duplicate and precedence policy rather than treating Org headers as opaque + text. +- Cost: clients must provide internally consistent bookkeeping when they choose + to send it; malformed metadata rejects the entire write instead of degrading + to a partially applied post. +- Ruled out: parsing only `TITLE`, treating headers as an alternate + authoritative post representation, accepting a partial metadata block, or + stripping headers before all validation succeeds. diff --git a/docs/archive/2026-08-26-issue-77-org-header-block-plan.md b/docs/archive/2026-08-26-issue-77-org-header-block-plan.md new file mode 100644 index 000000000..e03cb142a --- /dev/null +++ b/docs/archive/2026-08-26-issue-77-org-header-block-plan.md @@ -0,0 +1,134 @@ +# Org Header Metadata Block Implementation Outline + +> Execute with `jaunder-iterate`, delegating individual tasks with +> `jaunder-dispatch`. This outline exists because issue #77 changes AtomPub and +> web write contracts, canonical Post storage, and cross-backend slug +> concurrency. + +## Scope + +In: + +- One shared Org normalization interface for every create/update ingress. +- Atomic persistence-dependent bookkeeping checks on SQLite and PostgreSQL. +- Web and AtomPub adapters, native errors, integration coverage, and browser + flows. +- Compatibility checks for Emacs-generated and pulled Org metadata. + +Out: + +- Non-Org formats, new metadata aliases, schema migrations, and changing the + canonical metadata-free body decision. +- Making bookkeeping authoritative or exposing foreign named audiences. +- Rewriting accepted ADR-0024; the proposed draft and architecture projection + record the evolved decision. + +## Task outline + +- [x] Task 1: Normalize Org source and metadata behind one deep interface + - Contract: a `common` Org module accepts source, structured field/lifecycle + presence, operation context, and one injected request clock; it returns a + typed effective metadata set, canonical metadata-free `PostBody`, and typed + bookkeeping or one `OrgMetadataError`. Parsing, merge order, lifecycle/date + conversion, stripping, and metadata-only rejection are not separate caller + interfaces. + - Contract: use `orgize` for the leading element boundary; reuse `PostTitle`, + `PostSummary`, `TagLabel`, ID/slug/time types; add pinned IANA timezone data + through the workspace dependency set. Audience ownership and final stored + value checks stay outside `common`. + - Contract: extract the existing content ETag projection into one reusable, + transport-neutral interface consumed by normalization/finalization and + AtomPub response/precondition handling; preserve its byte algorithm. + - Migration: replace the Org-specific sequencing of + `derive_post_naming`/`canonicalize_body`; preserve their non-Org behavior + and migrate every reference using LSP before removing obsolete Org helpers. + - Verification: focused `common` tests cover byte grammar, precedence and + presence, duplicate/empty rules, unknown preservation, idempotent stripping, + tag rules, the full lifecycle matrix, weekday/DST edges, one-clock equality, + and metadata-only rejection. + +- [x] Task 2: Make persistence-dependent finalization atomic on both backends + - Depends on: Task 1 typed normalization/bookkeeping result. + - Contract: shared post orchestration captures exactly one request clock and + passes that instant through normalization and explicit persistence values; + ingress adapters and storage adapters never capture another publication + clock for the same write. + - Contract: creation carries optional expected bookkeeping slug, format, and + publication UTC. Each candidate is inserted inside the existing transaction; + after a successful `INSERT`, compare the final slug/format/publication + instant before child writes or commit. A mismatch rolls back and proves the + first free candidate differed; a unique conflict before the expected slug + retries, while conflict at the expected candidate fails. No expectations + preserve current suffix retries. + - Contract: update checks expected target ID, frozen/overridden final slug, + format, publication instant, and current ETag after the ownership lock but + before revision insertion or mutation. Mutable metadata and named audiences + are fully parsed/authorized before either create or update persistence. + - Verification: `#[apply(backends)]` tests prove first-free slug matching, + rollback on earlier-free or occupied-expected candidates, create + format/publication-UTC mismatch rollback, published slug freeze, no + revision/body mutation on update mismatch, unchanged no-expectation retry + behavior, explicit publication-clock persistence, and identical + SQLite/PostgreSQL results. + +- [x] Task 3: Adapt web create/update without duplicating Org policy + - Depends on: Tasks 1-2. + - Contract: `PostInputs` mapping preserves per-field collection presence and + treats lifecycle as one source; transport defaults do not become header + presence. Resolve `named:` through the author-scoped `AudienceStorage` + interface and pass only typed context to shared orchestration, which owns + the request clock and content ETag comparison. + - Contract: consume the transport-neutral content ETag projection from Task 1; + map metadata failures to existing web Validation and stale sync to Conflict. + - Verification: extend backend-parametrized web create/update integration + suites for precedence, explicit empty collections, current omission/default + behavior, audiences, bookkeeping, atomic rejection, and error kinds. + +- [x] Task 4: Adapt AtomPub POST/PUT and preserve protocol behavior + - Depends on: Tasks 1-2; may execute alongside Task 3. + - Contract: `entry_to_post_fields` preserves actual Atom element/lifecycle + presence and remains only a wire adapter. Inject `AudienceStorage` into + `PostServices`; resolve named IDs author-scoped; preserve create default + audience and update audience when both Atom and header omit it. + - Contract: explicit Atom fields win, header bookkeeping validates against + final values/current ETag, `If-Match` remains an independent precondition, + metadata errors map to 400, and stale sync maps to 412. + - Verification: extend existing AtomPub POST/PUT/ETag integration cases for + full Org metadata, precedence, audience masking, collision-resolved slug, + successful canonical native-source reads, 400/412 failures, and no mutation. + +- [x] Task 5: Prove client and browser compatibility; finish projections + - Depends on: Tasks 3-4. + - Contract: Emacs pull/publish output remains accepted by the stricter server; + change Emacs serialization only where the approved grammar requires it. Keep + `CONTEXT.md` unchanged unless implementation reveals new domain language. + - Verification: add focused browser cases to `posts.spec.ts` and + `atompub.spec.ts` that exercise Org-header precedence, canonical + metadata-free readback, and atomic validation/stale-sync errors on + create/update. Run focused Emacs publish/pull integration coverage, + `devtool run -- cargo xtask e2e-local posts.spec.ts`, and + `devtool run -- cargo xtask e2e-local atompub.spec.ts`, then the applicable + branch gate through `jaunder-commit`. + - Documentation: reconcile the already-authored ADR draft and + `docs/ARCHITECTURE.md` projection with final symbol names; do not edit + `docs/README.md` or promote/number the draft on this feature branch. + +## Risk checks + +- One parser/normalizer interface owns ordering; web and Atom adapters contain + no Org grammar or canonicalization copies. +- All exported-symbol changes begin with LSP references and migrate every + caller; obsolete Org helpers and duplicate ETag implementations are removed. +- The unique index and transaction, not a preflight existence query, arbitrate + final create slugs on both backends. +- Every validation mismatch is checked before a commit or revision; malformed + metadata cannot leave a Post or revision behind. +- Audience lookup is author-scoped and errors do not distinguish foreign from + nonexistent IDs. +- One injected clock governs lifecycle comparison and persisted timing; DST fold + and gap behavior is deterministic. +- Existing web omission/default, Atom create-default/update-preserve audience, + published-slug freeze, ETag projection, and non-Org behavior remain covered. +- Each task certifies with `devtool run -- cargo xtask precommit` before commit; + no lint suppression lands without explicit approval and no commit carries a + `Co-Authored-By` trailer. diff --git a/docs/archive/2026-08-26-issue-77-org-header-block-spec.md b/docs/archive/2026-08-26-issue-77-org-header-block-spec.md new file mode 100644 index 000000000..b9199a2f1 --- /dev/null +++ b/docs/archive/2026-08-26-issue-77-org-header-block-spec.md @@ -0,0 +1,99 @@ +# Issue #77 — Org header metadata block + +## Outcome + +Jaunder treats the complete leading Org/Jaunder metadata block as structured +input on every Org create and update. Once the whole write is accepted, every +recognized header is removed from the canonical body, including valid mutable +metadata displaced by structured input; unsupported Org directives remain body +content. + +## Load-bearing decisions + +- The header is the leading sequence of top-level Org keyword elements, ending + before the first non-keyword top-level element. Org keyword/property names are + case-insensitive; Org's element parser owns separator and whitespace syntax. +- Structured presence is resolved per field. A supplied valid scalar or + collection (including an empty collection) wins; otherwise the header may + supply that field. After both sources are merged, omission retains that + surface's existing clear, preserve, or default behavior. +- Lifecycle is one merge unit: structured status/publication time or header + `JAUNDER_STATUS`/`DATE`/`JAUNDER_DATE_TZ` supplies the whole unit, never a + mixture. Transport defaults do not manufacture structured presence. +- Mutable headers are `#+TITLE`; repeated/comma-separated `#+KEYWORDS`; repeated + `#+DESCRIPTION`; `#+DATE`; and repeated `#+PROPERTY` values for + `JAUNDER_AUDIENCE`, plus singleton `JAUNDER_STATUS` and `JAUNDER_DATE_TZ`. +- `TITLE` lines join with newlines then use `PostTitle` validation; +- `DESCRIPTION` lines join with newlines then use `PostSummary` validation. +- Each `KEYWORDS` occurrence flattens its comma-separated terms, drops empty +- comma terms, and must retain at least one valid term; the resulting terms use +- existing `TagLabel` validation, slug deduplication, order, and tag cap. Other +- blank recognized values are invalid. +- Audience values are exactly `public`, `subscribers`, `private`, or + `named:`. `private` cannot combine with another target; named IDs + must exist and belong to the author. +- Duplicate text/list headers compose as above. Duplicate `DATE`, status, + timezone, or bookkeeping properties reject the request. +- `DATE` is exactly Emacs's inactive `[YYYY-MM-DD Ddd HH:MM]` form with a valid, + matching weekday and a required IANA `JAUNDER_DATE_TZ`. The earlier instant + wins at an ambiguous DST fold; a nonexistent DST-gap time rejects. One request + clock determines future/non-future, with equality non-future. +- A header-sourced lifecycle unit always includes `JAUNDER_STATUS`. `draft` + permits neither `DATE` nor `JAUNDER_DATE_TZ`; `scheduled` requires both and a + future instant; `published` either has neither and publishes at the request + clock, or has both and requires a non-future instant. `DATE` and timezone + never appear independently. UTC bookkeeping without an effective publication + instant is invalid. +- `JAUNDER_FORMAT`, `JAUNDER_SLUG`, `JAUNDER_ID`, `JAUNDER_SYNCED`, + `JAUNDER_SYNCED_AT`, and `JAUNDER_DATE_UTC` are singleton `#+PROPERTY` + bookkeeping, validated but never authoritative. IDs and slugs use their + existing typed grammar; ETags compare exactly; sync/UTC times parse as RFC + 3339 instants, with offset spelling irrelevant to instant equality. +- Create rejects `JAUNDER_ID`, `JAUNDER_SYNCED`, and `JAUNDER_SYNCED_AT`. + `JAUNDER_SLUG` must equal the final stored slug after collision handling, + `JAUNDER_FORMAT` must be `org`, and `JAUNDER_DATE_UTC` must equal the final + publication instant. +- Update requires `JAUNDER_ID` to match the target Post, + `JAUNDER_SLUG`/`FORMAT`/`DATE_UTC` to match the final effective stored values, + and `JAUNDER_SYNCED` to match the current pre-write content ETag; + `JAUNDER_SYNCED_AT` is syntax-only. AtomPub `If-Match` remains independently + required when supplied. +- Malformed/conflicting metadata, a foreign audience, and metadata-only input + reject atomically. Web reports Validation, except stale sync is Conflict; + AtomPub reports 400, except stale sync is 412. Errors do not reveal whether a + foreign named audience exists. +- This evolves ADR-0024's deliberate no-full-header-parsing decision. + +## Acceptance + +- Creating or updating an Org Post recognizes every supported keyword or + property in its leading metadata block regardless of case, and does not treat + a keyword after the first non-keyword top-level element as header metadata. +- For each mutable field, an explicitly structured value wins over header input; + header input fills only its absence; and omission from both retains the + existing surface-specific update/default result. +- Valid title, keywords, description, date/time zone, status, and audience + headers produce the corresponding Post state, including the duplicate and + multiline composition rules above. +- After the whole request succeeds, every recognized header is absent from the + saved canonical body, including valid mutable metadata that lost precedence; + unknown Org directives remain unchanged. A rejected request leaves the prior + Post unchanged. +- Invalid date/time-zone or status/date combinations, prohibited duplicate + bookkeeping/singleton fields, invalid audience syntax, private-plus-other + audiences, missing/foreign named audiences, and invalid bookkeeping fail the + request atomically. +- Create rejects `JAUNDER_ID`, `JAUNDER_SYNCED`, or `JAUNDER_SYNCED_AT`; rejects + `JAUNDER_SLUG`, `JAUNDER_FORMAT`, or `JAUNDER_DATE_UTC` when it disagrees with + the effective derived value; and update rejects any mismatched derivable + current/target metadata, stale ETag, or invalid sync time. +- Metadata-only Org input is rejected without changing stored content. +- Existing title, summary, tag, and audience omission semantics remain + unchanged. + +## Boundaries + +- No new domain vocabulary, metadata aliases, compatibility paths, or changes to + non-Org create/update formats are introduced. +- This spec records behavior only; it does not prescribe private module seams or + implementation order. diff --git a/elisp/test/jaunder-pull-integration.el b/elisp/test/jaunder-pull-integration.el index 9574334eb..a1cdeabde 100644 --- a/elisp/test/jaunder-pull-integration.el +++ b/elisp/test/jaunder-pull-integration.el @@ -84,6 +84,24 @@ bytes)) (should (string-suffix-p server-body bytes)) (should (string-match-p (regexp-quote media-url) bytes)) + ;; A pulled file carries the canonical Org metadata + ;; block. Re-publishing it exercises the real + ;; client mapping and strict AtomPub update path. + (let ((pulled-buffer (find-file-noselect path))) + (unwind-protect + (with-current-buffer pulled-buffer + (jaunder-publish) + (setq bytes (buffer-string)) + (should (equal + (jaunder--buffer-property "JAUNDER_ID") + id)) + (should (jaunder--buffer-property + "JAUNDER_SYNCED"))) + (when (buffer-live-p pulled-buffer) + (with-current-buffer pulled-buffer + (set-buffer-modified-p nil))) + (when (buffer-live-p pulled-buffer) + (kill-buffer pulled-buffer)))) (let ((blocked (jaunder--pull-member root member))) (should (eq (jaunder-pull-result-status blocked) 'blocked)) (should (equal (jaunder-pull-result-path blocked) path)) diff --git a/elisp/test/jaunder-pull-test.el b/elisp/test/jaunder-pull-test.el index 21190ac72..65cdb7c49 100644 --- a/elisp/test/jaunder-pull-test.el +++ b/elisp/test/jaunder-pull-test.el @@ -74,11 +74,21 @@ "#+PROPERTY: JAUNDER_SYNCED \"sha256-test\"\n" "#+PROPERTY: JAUNDER_SYNCED_AT 2026-08-25T12:00:00Z\n" "\n# Body\nhttps://h/media/y.png"))) + ;; The complete pull header is consumable by publishing without teaching this + ;; test the server's Org grammar: assert only the client's outgoing entry. (with-temp-buffer (insert org) (org-mode) - (should (equal (jaunder-entry-title (jaunder--org->atom)) - "Line one\nLine two"))))) + (let ((entry (jaunder--org->atom))) + (should (equal (jaunder-entry-title entry) "Line one\nLine two")) + (should (equal (jaunder-entry-categories entry) '("alpha" "beta"))) + (should (equal (jaunder-entry-summary entry) "First\nSecond")) + (should-not (jaunder-entry-draft entry)) + (should (equal (jaunder-entry-content-type entry) "text/org")) + (should (equal (jaunder-entry-published entry) "2026-08-26T11:00:00Z")) + ;; Republish sends only native content; the locally generated DATE, + ;; status, and bookkeeping block stays structured client-side. + (should (equal (jaunder-entry-body entry) "# Body\nhttps://h/media/y.png")))))) (ert-deftest jaunder-atom->org-published-html-and-xhtml-bodies () ;; Escaped HTML remains source text; XHTML drops only its required wrapper and diff --git a/end2end/tests/atompub.spec.ts b/end2end/tests/atompub.spec.ts index b4c1e06f1..a6b9e7cc9 100644 --- a/end2end/tests/atompub.spec.ts +++ b/end2end/tests/atompub.spec.ts @@ -113,7 +113,7 @@ test("an app password can be revoked from the sessions page", async ({ expect(response.status()).toBe(401); }); -test("full AtomPub publishing flow over HTTP with an app password", async ({ +test("full AtomPub Org publishing flow over HTTP with an app password", async ({ page, request, }) => { @@ -133,29 +133,50 @@ test("full AtomPub publishing flow over HTTP with an app password", async ({ expect(service.status()).toBe(200); expect(await service.text()).toContain("app:service"); - // 2. Create a post. + // 2. Create an Org post. Atom title wins over the header, while omitted + // categories and summary are supplied by its Org metadata. const created = await request.post(`${BASE_URL}/atompub/${username}/posts`, { headers: xml, data: ` - E2E Post - <p>hello from e2e</p> - + Atom title + #+TITLE: Header title +#+KEYWORDS: header-category +#+DESCRIPTION: Header summary +#+PROPERTY: JAUNDER_STATUS draft +#+UNKNOWN: preserved + +Org body `, }); expect(created.status()).toBe(201); + const createdBody = await created.text(); + const createdEtag = created.headers()["etag"]; const memberUrl = onServer(created.headers()["location"]); + const createdId = memberUrl.match(/\/posts\/(\d+)$/)?.[1]; + const createdSlug = createdBody.match(/([^<]+)<\/j:slug>/)?.[1]; + expect(createdEtag).toBeTruthy(); + expect(createdId).toBeTruthy(); + expect(createdSlug).toBeTruthy(); expect(memberUrl).toContain(`/atompub/${username}/posts/`); - // 3. Fetch the member entry (native-source HTML form, with the category). + // 3. Fetch the native Org source. Known metadata is canonicalized away, but + // unrecognized Org directives remain source content. const member = await request.get(memberUrl, { headers: { authorization: auth }, }); expect(member.status()).toBe(200); const memberBody = await member.text(); - expect(memberBody).toContain('type="html"'); - expect(memberBody).toContain("hello from e2e"); - expect(memberBody).toContain('term="e2e"'); + expect(memberBody).toContain("Atom title"); + expect(memberBody).toContain('term="header-category"'); + expect(memberBody).toContain("Header summary"); + expect(memberBody).toContain('type="text/org"'); + expect(memberBody).toContain("#+UNKNOWN: preserved"); + expect(memberBody).toContain("Org body"); + expect(memberBody).not.toContain("#+TITLE: Header title"); + expect(memberBody).not.toContain("#+KEYWORDS:"); + expect(memberBody).not.toContain("#+DESCRIPTION:"); + expect(memberBody).not.toContain("JAUNDER_STATUS"); // 4. List the collection feed. const list = await request.get(`${BASE_URL}/atompub/${username}/posts`, { @@ -166,17 +187,66 @@ test("full AtomPub publishing flow over HTTP with an app password", async ({ expect(listBody).toContain(" - E2E Post edited - <p>edited body</p> + Atom edited + + Atom summary + #+TITLE: Header edited +#+KEYWORDS: header-edited +#+DESCRIPTION: Header edited summary +#+PROPERTY: JAUNDER_STATUS draft +#+PROPERTY: JAUNDER_FORMAT org +#+PROPERTY: JAUNDER_SLUG ${editedSlug} +#+PROPERTY: JAUNDER_ID ${createdId} +#+PROPERTY: JAUNDER_SYNCED ${createdEtag} +#+PROPERTY: JAUNDER_SYNCED_AT 2026-08-26T12:00:00Z +#+UNKNOWN: preserved + +Edited Org body `, }); expect(edited.status()).toBe(200); - expect(await edited.text()).toContain("edited body"); + const editedBody = await edited.text(); + const editedEtag = edited.headers()["etag"]; + expect(editedEtag).toBeTruthy(); + expect(editedBody).toContain("Atom edited"); + expect(editedBody).toContain(`${editedSlug}`); + expect(editedBody).toContain('term="atom-category"'); + expect(editedBody).toContain("Atom summary"); + expect(editedBody).toContain("#+UNKNOWN: preserved"); + expect(editedBody).toContain("Edited Org body"); + expect(editedBody).not.toContain("JAUNDER_SYNCED"); + + // The fresh transport precondition isolates the stale in-body sync marker. + // A rejected PUT must leave the accepted revision unchanged. + const stale = await request.put(memberUrl, { + headers: { ...xml, "if-match": editedEtag }, + data: ` + + Should not persist + #+PROPERTY: JAUNDER_STATUS draft +#+PROPERTY: JAUNDER_FORMAT org +#+PROPERTY: JAUNDER_ID ${createdId} +#+PROPERTY: JAUNDER_SYNCED "stale" +#+PROPERTY: JAUNDER_SYNCED_AT 2026-08-26T12:00:00Z + +Rejected body +`, + }); + expect(stale.status()).toBe(412); + const unchanged = await request.get(memberUrl, { + headers: { authorization: auth }, + }); + expect(unchanged.status()).toBe(200); + const unchangedBody = await unchanged.text(); + expect(unchangedBody).toContain("Edited Org body"); + expect(unchangedBody).not.toContain("Rejected body"); // 6. Upload media (raw bytes + Slug). const media = await request.post(`${BASE_URL}/atompub/${username}/media`, { diff --git a/end2end/tests/posts.spec.ts b/end2end/tests/posts.spec.ts index 5ef1e85e5..326daf97b 100644 --- a/end2end/tests/posts.spec.ts +++ b/end2end/tests/posts.spec.ts @@ -13,6 +13,7 @@ import { signInAsNewUser, failServerFn, stallServerFn, + expectFlash, } from "./helpers"; import { createPerfProbe } from "./perf"; import { @@ -110,6 +111,164 @@ test("authenticated user can create a post through the UI", async ({ "Slug: playwright-post", ); }); +// #77: the full leading Org metadata block is normalized at the write boundary, +// while the form's explicit lifecycle remains authoritative. This follows the +// saved post back into its editor so the assertion covers the stored canonical +// source, rather than just a successful request. +test("Org header metadata round-trips through the composer as canonical source", async ({ + registeredPage, +}) => { + const page = await registeredPage("/posts/new"); + const title = "Browser Org header title"; + const summary = "Browser Org header description"; + const unknownDirective = "#+AUTHOR: Browser compatibility"; + + await click(page, SEL.formatButton("Org")); + await page.fill( + SEL.postBody, + `#+TITLE: ${title} +#+DESCRIPTION: ${summary} +#+KEYWORDS: browserorgone, browserorgtwo +#+PROPERTY: JAUNDER_STATUS published +${unknownDirective} + +Canonical Org body`, + ); + + // The explicit "Save draft" control is structured lifecycle presence, so it + // must override the otherwise-valid header request to publish. + await click(page, SEL.publishButton("false")); + await expectFlash(page, "Draft saved."); + await waitForSelector(page, SEL.saveSummary); + + await followPermalink(page, page.locator(SEL.saveSummary)); + await expect(page.locator("article .j-post-title")).toHaveText(title); + await expect(page.locator("article.j-post")).toContainText(summary); + + await openEditor(page); + await expect(page.locator(SEL.postSummary)).toHaveValue(summary); + await expect( + page.locator('.j-tag-chip-label:has-text("#browserorgone")'), + ).toBeVisible(); + await expect( + page.locator('.j-tag-chip-label:has-text("#browserorgtwo")'), + ).toBeVisible(); + + const canonicalBody = page.locator(SEL.postBody); + await expect(canonicalBody).toHaveValue( + new RegExp(`^${unknownDirective.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), + ); + const canonicalSource = await canonicalBody.inputValue(); + expect(canonicalSource).not.toContain("#+TITLE:"); + expect(canonicalSource).not.toContain("#+DESCRIPTION:"); + expect(canonicalSource).not.toContain("#+KEYWORDS:"); + expect(canonicalSource).not.toContain("#+PROPERTY: JAUNDER_STATUS"); + expect(canonicalSource).toContain("Canonical Org body"); +}); + +// #77: editor updates apply the same Org normalization and precedence as the +// composer. A stale sync marker is a write precondition, so it must reject +// atomically after a previously accepted editor update. +test("Org editor update preserves canonical state when a stale sync marker rejects", async ({ + registeredPage, +}) => { + const page = await registeredPage("/posts/new"); + const initialSummary = await composePost(page, { + body: "# Initial browser post\n\nInitial body", + publish: false, + }); + + await followPermalink(page, initialSummary); + await openEditor(page); + + const acceptedTitle = "Accepted browser Org title"; + const acceptedSummary = "Structured editor summary"; + const acceptedSlug = "structured-editor-slug"; + const unknownDirective = "#+AUTHOR: Editor compatibility"; + const acceptedBody = "Accepted editor Org body"; + await click(page, SEL.formatButton("Org")); + await page.fill( + SEL.postBody, + `#+TITLE: ${acceptedTitle} +#+DESCRIPTION: Header summary must lose +#+KEYWORDS: editororg +#+PROPERTY: JAUNDER_STATUS published +#+PROPERTY: JAUNDER_SLUG ${acceptedSlug} +${unknownDirective} + +${acceptedBody}`, + ); + await page.fill(SEL.postSummary, acceptedSummary); + await page.fill(SEL.postSlug, acceptedSlug); + + // Structured form fields remain authoritative over mutable metadata: save + // stays a draft and the structured summary displaces its header. The + // non-authoritative slug bookkeeping must agree with the structured slug. + await click(page, SEL.publishButton("false")); + await expectFlash(page, "Draft saved."); + await expect(page.locator(SEL.saveSummary)).toContainText( + `Slug: ${acceptedSlug}`, + ); + + await followPermalink(page, page.locator(SEL.saveSummary)); + await expect(page.locator("article .j-post-title")).toHaveText(acceptedTitle); + await expect(page.locator("article.j-post")).toContainText(acceptedSummary); + + await openEditor(page); + await expect( + page.locator('.j-tag-chip-label:has-text("#editororg")'), + ).toBeVisible(); + await expect(page.locator(SEL.postSummary)).toHaveValue(acceptedSummary); + await expect(page.locator(SEL.postSlug)).toHaveValue(acceptedSlug); + const canonicalBody = page.locator(SEL.postBody); + const canonicalSource = await canonicalBody.inputValue(); + expect(canonicalSource).toContain(unknownDirective); + expect(canonicalSource).toContain(acceptedBody); + expect(canonicalSource).not.toContain("#+TITLE:"); + expect(canonicalSource).not.toContain("#+DESCRIPTION:"); + expect(canonicalSource).not.toContain("#+KEYWORDS:"); + expect(canonicalSource).not.toContain("#+PROPERTY: JAUNDER_STATUS"); + expect(canonicalSource).not.toContain("#+PROPERTY: JAUNDER_SLUG"); + + const editorUrl = page.url(); + await page.fill( + SEL.postBody, + `#+PROPERTY: JAUNDER_SYNCED "stale" +#+TITLE: Rejected browser Org title + +Rejected editor Org body`, + ); + await click(page, SEL.publishButton("false")); + await expect(page.locator(SEL.error)).toHaveText("post content has changed"); + await expect(page).toHaveURL(editorUrl); + + await openPostFromDrafts(page, acceptedTitle); + await expect(page.locator(SEL.postSummary)).toHaveValue(acceptedSummary); + await expect(page.locator(SEL.postSlug)).toHaveValue(acceptedSlug); + await expect(page.locator(SEL.postBody)).toHaveValue( + new RegExp(acceptedBody), + ); + await expect(page.locator(SEL.postBody)).not.toHaveValue( + /Rejected editor Org body/, + ); +}); + +test("metadata-only Org composer input reports validation without saving", async ({ + registeredPage, +}) => { + const page = await registeredPage("/posts/new"); + const composerUrl = page.url(); + + await click(page, SEL.formatButton("Org")); + await page.fill(SEL.postBody, "#+TITLE: No content"); + await click(page, SEL.publishButton("false")); + + await expect(page.locator(SEL.error)).toHaveText( + "Org body contains metadata but no content", + ); + await expect(page).toHaveURL(composerUrl); + await expect(page.locator(SEL.saveSummary)).toHaveCount(0); +}); // #58: list_mine failure used to collapse into [], making the picker look // successfully empty and leaving Publish enabled. Intercept the real server-fn diff --git a/server/src/atompub/error.rs b/server/src/atompub/error.rs index 051f2583b..f5702684c 100644 --- a/server/src/atompub/error.rs +++ b/server/src/atompub/error.rs @@ -121,6 +121,14 @@ impl From for HandlerError { } } +impl From for HandlerError { + /// The parsed Org metadata is request input; neither malformed metadata nor + /// a metadata-only document may reach persistence. + fn from(_: common::org::OrgMetadataError) -> Self { + HandlerError::BadRequest + } +} + impl From for HandlerError { /// An entry whose content is nothing but blank lines describes no post, so it is /// the client's error — the same `400` the service layer's `EmptyPost` earns @@ -134,7 +142,7 @@ impl From for HandlerError { fn from(err: storage::PerformCreationError) -> Self { use storage::PerformCreationError as E; match err { - E::EmptyPost | E::InvalidSlug(_) => HandlerError::BadRequest, + E::EmptyPost | E::InvalidSlug(_) | E::BookkeepingMismatch => HandlerError::BadRequest, // Exhausted/CreatedNotFound/Storage are all internal failures. error => internal(error), } @@ -145,7 +153,8 @@ impl From for HandlerError { fn from(err: storage::PerformUpdateError) -> Self { use storage::PerformUpdateError as E; match err { - E::EmptyPost => HandlerError::BadRequest, + E::EmptyPost | E::BookkeepingMismatch => HandlerError::BadRequest, + E::StaleContent => HandlerError::PreconditionFailed, E::NotFound | E::Unauthorized => HandlerError::NotFound, error @ E::Storage(_) => internal(error), } @@ -254,6 +263,10 @@ mod tests { status(PerformCreationError::InvalidSlug(common::slug::InvalidSlug).into()), StatusCode::BAD_REQUEST ); + assert_eq!( + status(PerformCreationError::BookkeepingMismatch.into()), + StatusCode::BAD_REQUEST + ); assert_eq!( status(PerformCreationError::CreatedNotFound.into()), StatusCode::INTERNAL_SERVER_ERROR @@ -270,6 +283,14 @@ mod tests { status(PerformUpdateError::EmptyPost.into()), StatusCode::BAD_REQUEST ); + assert_eq!( + status(PerformUpdateError::BookkeepingMismatch.into()), + StatusCode::BAD_REQUEST + ); + assert_eq!( + status(PerformUpdateError::StaleContent.into()), + StatusCode::PRECONDITION_FAILED + ); assert_eq!( status(PerformUpdateError::NotFound.into()), StatusCode::NOT_FOUND diff --git a/server/src/atompub/mapping.rs b/server/src/atompub/mapping.rs index 2b6935e5a..b236644e1 100644 --- a/server/src/atompub/mapping.rs +++ b/server/src/atompub/mapping.rs @@ -6,7 +6,10 @@ //! (collection member) operations. use chrono::Utc; -use common::atompub::{Category, Content, Entry, Link, Text, is_draft, set_draft, set_j_slug}; +use common::atompub::{ + Category, Content, Entry, Link, Text, draft_marker, is_draft, set_draft, set_j_slug, +}; +use common::org::{Presence, PublicationState}; use common::post_body::{InvalidPostBody, PostBody}; use common::post_summary::PostSummary; use common::post_title::PostTitle; @@ -27,14 +30,16 @@ pub struct PostFields { /// Optional summary/excerpt (validated `PostSummary`; an over-cap wire value /// is dropped on ingest, mirroring the lenient category handling below). pub summary: Option, - /// Categories/tags extracted from the entry (author labels). - pub categories: Vec, - /// Whether the entry is marked as draft. + /// Categories/tags extracted from the entry, preserving whether any category + /// elements were supplied so an explicit empty collection remains distinct + /// from omission when Org headers are normalized. + pub categories: Presence>, + /// The entry's explicit lifecycle source. A draft marker or `` + /// element wins over Org metadata; their absence remains absence. + pub lifecycle: Presence, + /// Legacy Atom lifecycle fallback used after Org normalization when neither + /// wire nor header metadata supplied a lifecycle. pub is_draft: bool, - /// Explicit publication time from the entry's `` element - /// (`None` when absent). A future time schedules the post; a past time - /// backdates it. The inverse of [`post_to_entry`]'s `published` mapping. - pub published: Option, } /// The wire `atom:content` `type` for a post format (ADR-0023). `Html` uses the @@ -67,6 +72,16 @@ fn wire_to_format(content_type: Option<&str>, default: PostFormat) -> PostFormat } } +/// Classifies an Atom publication instant against the single clock captured for +/// the handler request. +fn classify_published(published: UtcInstant, request_clock: UtcInstant) -> PublicationState { + if published.value() > request_clock.value() { + PublicationState::Scheduled(published) + } else { + PublicationState::Published(published) + } +} + /// Maps an incoming `AtomPub` `Entry` to Jaunder post fields. /// /// Per ADR-0023, the entry's content `type` carries the storage format as a media @@ -86,6 +101,7 @@ fn wire_to_format(content_type: Option<&str>, default: PostFormat) -> PostFormat pub fn entry_to_post_fields( entry: &Entry, default_format: PostFormat, + request_clock: UtcInstant, ) -> Result { let (ctype, value) = entry .content() @@ -111,32 +127,42 @@ pub fn entry_to_post_fields( // boundary where a conforming term becomes a `TagLabel`. `entry_to_post_fields` // is infallible, so an invalid term is silently skipped here: dropping a // malformed term keeps one bad category from failing the whole entry (R5). - // Skipping is the *only* policy applied here — the set is neither deduped nor - // capped. The handlers run `common::tag::parse_and_validate_tags` on this vec - // before any write, so an over-cap entry is a `400` rather than an unbounded - // batched tag write (#771 D9, ADR-0092). let categories = entry .categories() .iter() .filter_map(|c| c.term().parse::().ok()) .collect(); let is_draft = is_draft(entry); - // Any incoming `j:slug` is deliberately ignored (ADR-0023): the slug is a - // read-only server property, derived here from the title/body, never the wire. - // Inverse of `post_to_entry`'s `published: post.published_at.map(fixed_offset)`: - // read the entry's `` (a fixed-offset datetime) back to UTC. + // A declared `app:draft` is an explicit Atom lifecycle source, including + // `no`; only a genuinely absent marker leaves room for Org metadata. let published = entry .published() - .map(|d| UtcInstant::from(d.with_timezone(&Utc))); + .map(|published| UtcInstant::from(published.with_timezone(&Utc))); + let lifecycle = match draft_marker(entry) { + Some(true) => Presence::Present(PublicationState::Draft), + Some(false) => Presence::Present(classify_published( + published.unwrap_or(request_clock), + request_clock, + )), + None => published + .map(|published| classify_published(published, request_clock)) + .map_or(Presence::Absent, Presence::Present), + }; + // Any incoming `j:slug` is deliberately ignored (ADR-0023): the slug is a + // read-only server property, derived here from the title/body, never the wire. Ok(PostFields { title, body, format, summary, - categories, + categories: if entry.categories().is_empty() { + Presence::Absent + } else { + Presence::Present(categories) + }, + lifecycle, is_draft, - published, }) } @@ -285,7 +311,9 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.format, PostFormat::Html); assert_eq!(fields.body, "

HTML content

"); @@ -303,7 +331,9 @@ mod tests {
"#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.format, PostFormat::Html); } @@ -319,7 +349,9 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.format, PostFormat::Markdown); assert_eq!(fields.body, "# Markdown"); @@ -337,7 +369,9 @@ mod tests { let entry = xml.parse::().expect("parse entry"); // Default is Markdown, but the explicit media type selects Org. - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.format, PostFormat::Org); assert_eq!(fields.body, "* Org body"); @@ -355,7 +389,8 @@ mod tests { let entry = xml.parse::().expect("parse entry"); // Default is Org, but the explicit media type selects Markdown. - let fields = entry_to_post_fields(&entry, PostFormat::Org).expect("valid body"); + let fields = entry_to_post_fields(&entry, PostFormat::Org, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.format, PostFormat::Markdown); assert_eq!(fields.body, "# Markdown body"); @@ -372,7 +407,8 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Org).expect("valid body"); + let fields = entry_to_post_fields(&entry, PostFormat::Org, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.format, PostFormat::Org); assert_eq!(fields.body, "some text"); @@ -391,7 +427,10 @@ mod tests { let entry = xml.parse::().expect("parse entry"); - assert!(entry_to_post_fields(&entry, PostFormat::Markdown).is_err()); + assert!( + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .is_err() + ); } #[test] @@ -406,7 +445,9 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!( fields.summary, @@ -425,7 +466,9 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.summary, None); } @@ -443,9 +486,17 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); - assert_eq!(fields.categories, vec!["rust", "programming"]); + assert_eq!( + fields.categories, + Presence::Present(vec![ + "rust".parse().unwrap(), + "programming".parse().unwrap() + ]) + ); } #[test] @@ -459,9 +510,11 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); - assert_eq!(fields.categories, Vec::::new()); + assert_eq!(fields.categories, Presence::Absent); } #[test] @@ -480,9 +533,14 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); - assert_eq!(fields.categories, vec!["rust".parse::().unwrap()]); + assert_eq!( + fields.categories, + Presence::Present(vec!["rust".parse::().unwrap()]) + ); } #[test] @@ -499,9 +557,41 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert!(fields.is_draft); + assert_eq!(fields.lifecycle, Presence::Present(PublicationState::Draft)); + } + + #[test] + fn entry_to_post_fields_explicit_non_draft_preserves_published_instant() { + let xml = r#"Testid2026-05-31T00:00:00Z2026-05-30T09:15:00Zbodyno"#; + let clock: UtcInstant = "2026-06-01T12:00:00Z".parse().expect("valid clock"); + let published: UtcInstant = "2026-05-30T09:15:00Z".parse().expect("valid timestamp"); + let entry = xml.parse::().expect("parse entry"); + + let fields = entry_to_post_fields(&entry, PostFormat::Markdown, clock).expect("valid body"); + + assert_eq!( + fields.lifecycle, + Presence::Present(PublicationState::Published(published)) + ); + } + + #[test] + fn entry_to_post_fields_explicit_non_draft_without_published_uses_request_clock() { + let xml = r#"Testid2026-05-31T00:00:00Zbodyno"#; + let clock: UtcInstant = "2026-06-01T12:00:00Z".parse().expect("valid clock"); + let entry = xml.parse::().expect("parse entry"); + + let fields = entry_to_post_fields(&entry, PostFormat::Markdown, clock).expect("valid body"); + + assert_eq!( + fields.lifecycle, + Presence::Present(PublicationState::Published(clock)) + ); } #[test] @@ -515,9 +605,71 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert!(!fields.is_draft); + assert_eq!(fields.lifecycle, Presence::Absent); + } + + #[test] + fn entry_to_post_fields_published_without_draft_marker_uses_its_instant() { + let xml = r#"Testid2026-05-31T00:00:00Z2026-05-30T09:15:00Zbody"#; + let clock: UtcInstant = "2026-06-01T12:00:00Z".parse().expect("valid clock"); + let published: UtcInstant = "2026-05-30T09:15:00Z".parse().expect("valid timestamp"); + let entry = xml.parse::().expect("parse entry"); + + let fields = entry_to_post_fields(&entry, PostFormat::Markdown, clock).expect("valid body"); + + assert_eq!( + fields.lifecycle, + Presence::Present(PublicationState::Published(published)) + ); + } + + #[test] + fn entry_to_post_fields_future_published_without_draft_marker_is_scheduled() { + let xml = r#"Testid2026-05-31T00:00:00Z2026-06-02T09:15:00Zbody"#; + let clock: UtcInstant = "2026-06-01T12:00:00Z".parse().expect("valid clock"); + let published: UtcInstant = "2026-06-02T09:15:00Z".parse().expect("valid timestamp"); + let entry = xml.parse::().expect("parse entry"); + + let fields = entry_to_post_fields(&entry, PostFormat::Markdown, clock).expect("valid body"); + + assert_eq!( + fields.lifecycle, + Presence::Present(PublicationState::Scheduled(published)) + ); + } + + #[test] + fn entry_to_post_fields_published_at_request_clock_is_published() { + let xml = r#"Testid2026-05-31T00:00:00Z2026-06-01T12:00:00Zbody"#; + let clock: UtcInstant = "2026-06-01T12:00:00Z".parse().expect("valid clock"); + let entry = xml.parse::().expect("parse entry"); + + let fields = entry_to_post_fields(&entry, PostFormat::Markdown, clock).expect("valid body"); + + assert_eq!( + fields.lifecycle, + Presence::Present(PublicationState::Published(clock)) + ); + } + + #[test] + fn entry_to_post_fields_explicit_non_draft_with_future_published_is_scheduled() { + let xml = r#"Testid2026-05-31T00:00:00Z2026-06-02T09:15:00Zbodyno"#; + let clock: UtcInstant = "2026-06-01T12:00:00Z".parse().expect("valid clock"); + let published: UtcInstant = "2026-06-02T09:15:00Z".parse().expect("valid timestamp"); + let entry = xml.parse::().expect("parse entry"); + + let fields = entry_to_post_fields(&entry, PostFormat::Markdown, clock).expect("valid body"); + + assert_eq!( + fields.lifecycle, + Presence::Present(PublicationState::Scheduled(published)) + ); } #[test] @@ -531,7 +683,9 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.title.as_deref(), Some("My Post Title")); } @@ -546,7 +700,9 @@ mod tests { "#; let entry = xml.parse::().expect("parse entry"); - let fields = entry_to_post_fields(&entry, PostFormat::Markdown).expect("valid body"); + let fields = + entry_to_post_fields(&entry, PostFormat::Markdown, UtcInstant::from(Utc::now())) + .expect("valid body"); assert_eq!(fields.title, None); } diff --git a/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index 0a9dd85e5..43ef80b55 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -8,21 +8,28 @@ use axum::extract::{FromRequestParts, Path, Query}; use axum::http::request::Parts; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use common::atompub::{CollectionFeedTitle, Entry, FeedMeta, entry_to_xml, render_feed}; -use common::etag::ETag; +use common::etag::{ETag, post_content_etag}; use common::ids::PostId; +use common::org::{OrgOperation, OrgStructuredMetadata, Presence, PublicationState, normalize_org}; use common::pagination::PageSize; +use common::post_body::PostBody; +use common::post_summary::PostSummary; +use common::post_title::PostTitle; use common::tag::TagLabel; use common::tagged_url::{BaseUrl, EditUriUrl, FeedUrl, PaginationUrl, compose}; use common::time::UtcInstant; use common::username::Username; -use common::visibility::ViewerIdentity; -use storage::{CollectionCursor, PostRecord, PostStorage, SiteConfigStorage, UserConfigStorage}; +use common::visibility::{AudienceTarget, ViewerIdentity}; +use storage::{ + AudienceStorage, CollectionCursor, InvalidAudienceTargets, PostRecord, PostStorage, + SiteConfigStorage, UserConfigStorage, validate_named_audience_targets, +}; use web::auth; -use super::mapping::{entry_to_post_fields, post_to_entry}; +use super::mapping::{PostFields, entry_to_post_fields, post_to_entry}; use super::{HandlerError, required_base_url}; const FEED_CONTENT_TYPE: &str = "application/atom+xml;type=feed;charset=utf-8"; @@ -37,6 +44,7 @@ const DEFAULT_PAGE_SIZE: PageSize = PageSize::clamped(25); /// Each field is pulled from the request `Extension`s the app router layers. pub struct PostServices { posts: Arc, + audiences: Arc, user_config: Arc, site_config: Arc, } @@ -49,6 +57,9 @@ impl FromRequestParts for PostServices { posts: Extension::>::from_request_parts(parts, state) .await? .0, + audiences: Extension::>::from_request_parts(parts, state) + .await? + .0, user_config: Extension::>::from_request_parts(parts, state) .await? .0, @@ -66,6 +77,12 @@ impl PostServices { self.posts.as_ref() } + /// Borrows the named-audience store for author-scoped target authorization. + #[must_use] + pub fn audiences(&self) -> &dyn AudienceStorage { + self.audiences.as_ref() + } + /// Borrows the per-user configuration store for one handler operation. #[must_use] pub fn user_config(&self) -> &dyn UserConfigStorage { @@ -79,39 +96,18 @@ impl PostServices { } } -/// A strong, content-hash `ETag` for a post: `"sha256-"` over the post's -/// content fields (title, stored body, format, summary, tag display names, and -/// the draft flag) — never a timestamp. So identical content yields an identical -/// `ETag` and an idempotent re-publish does not change it, removing the time-based -/// divergence false-positive (#78). +/// A strong, content-hash `ETag` for a post's mutable representation. The +/// transport-neutral projection is owned by `common`; this storage adapter only +/// projects ordered post-tag labels into it. pub(crate) fn etag_for(post: &PostRecord) -> ETag { - /// The content projection that the `ETag` hashes. Newtype fields stay typed - /// through this seam and serialize through their ADR-0063 string bridges; - /// `PostFormat` and `draft` are reduced to their stable wire values. `PostTag` - /// itself is never hashed because it carries DB-assigned ids that would differ - /// between identical-content posts. - #[derive(Serialize)] - struct EtagContent<'a> { - title: Option<&'a common::post_title::PostTitle>, - body: &'a common::post_body::PostBody, - format: String, - summary: Option<&'a common::post_summary::PostSummary>, - tags: Vec<&'a TagLabel>, - draft: bool, - } - let content = EtagContent { - title: post.title.as_ref(), - body: &post.body, - format: post.format.to_string(), - summary: post.summary.as_ref(), - // Tags are folded in iteration order, which `TAGS_SUBQUERY`'s `ORDER BY` - // makes deterministic across query plans and backends (#772). An ETag change - // costs a re-fetch, never staleness. - tags: post.tags.iter().map(|t| &t.tag_display).collect(), - draft: post.published_at.is_none(), - }; - let bytes = serde_json::to_vec(&content).unwrap_or_else(|_| Vec::new()); - ETag::sha256_of(&bytes) + post_content_etag( + post.title.as_ref(), + &post.body, + &post.format, + post.summary.as_ref(), + post.tags.iter().map(|tag| &tag.tag_display), + post.published_at.is_none(), + ) } /// Whether a request's `If-Match` precondition is satisfied for a post with ETAG. @@ -124,6 +120,137 @@ fn if_match_satisfied(headers: &HeaderMap, etag: &ETag) -> bool { None => true, } } +fn scalar_presence(value: Option<&T>) -> Presence { + value.cloned().map_or(Presence::Absent, Presence::Present) +} + +fn validated_categories( + categories: Presence>, +) -> Result>, HandlerError> { + match categories { + Presence::Absent => Ok(Presence::Absent), + Presence::Present(categories) => Ok(Presence::Present( + common::tag::parse_and_validate_tags(categories)?, + )), + } +} + +async fn authorize_audiences( + audiences: &dyn AudienceStorage, + author_user_id: common::ids::UserId, + targets: Presence>, +) -> Result>, HandlerError> { + let Presence::Present(targets) = targets else { + return Ok(Presence::Absent); + }; + validate_named_audience_targets(audiences, author_user_id, &targets) + .await + .map_err(|error| match error { + InvalidAudienceTargets::Invalid => HandlerError::BadRequest, + InvalidAudienceTargets::Storage(error) => HandlerError::from(error), + })?; + Ok(Presence::Present(targets)) +} + +/// Atom entry fields after format-specific normalization and shared validation. +struct NormalizedAtomInput { + body: PostBody, + title: Option, + summary: Option, + categories: Vec, + lifecycle: Presence, + audiences: Presence>, + expectations: storage::PostBookkeepingExpectation, +} + +/// Normalizes an incoming entry's format-dependent metadata and validates its +/// common storage fields before the create/update handler applies its fallback policy. +async fn normalize_atom_input( + fields: PostFields, + operation: OrgOperation, + request_clock: UtcInstant, + audiences: &dyn AudienceStorage, + author_user_id: common::ids::UserId, +) -> Result { + if fields.format != storage::PostFormat::Org { + let categories = match validated_categories(fields.categories)? { + Presence::Present(tags) => tags, + Presence::Absent => Vec::new(), + }; + return Ok(NormalizedAtomInput { + body: fields.body, + title: fields.title, + summary: fields.summary, + categories, + lifecycle: fields.lifecycle, + audiences: Presence::Absent, + expectations: storage::PostBookkeepingExpectation::default(), + }); + } + + let normalized = normalize_org( + fields.body.as_ref(), + OrgStructuredMetadata { + title: scalar_presence(fields.title.as_ref()), + summary: scalar_presence(fields.summary.as_ref()), + tags: validated_categories(fields.categories)?, + audiences: Presence::Absent, + lifecycle: fields.lifecycle, + }, + operation, + request_clock, + )?; + let audiences = + authorize_audiences(audiences, author_user_id, normalized.metadata.audiences).await?; + + Ok(NormalizedAtomInput { + body: normalized.body, + title: match normalized.metadata.title { + Presence::Present(title) => Some(title), + Presence::Absent => None, + }, + summary: match normalized.metadata.summary { + Presence::Present(summary) => Some(summary), + Presence::Absent => None, + }, + categories: match normalized.metadata.tags { + Presence::Present(tags) => tags, + Presence::Absent => Vec::new(), + }, + lifecycle: normalized.metadata.lifecycle, + audiences, + expectations: normalized.bookkeeping.into(), + }) +} + +fn create_published_at( + lifecycle: &Presence, + is_draft: bool, + request_clock: UtcInstant, +) -> Option { + match lifecycle { + Presence::Present(PublicationState::Draft) => None, + Presence::Present(PublicationState::Scheduled(at) | PublicationState::Published(at)) => { + Some(*at) + } + Presence::Absent if is_draft => None, + Presence::Absent => Some(request_clock), + } +} + +fn update_publish( + lifecycle: &Presence, + is_draft: bool, +) -> storage::PublishUpdate { + match lifecycle { + Presence::Present(PublicationState::Draft) => storage::PublishUpdate::Unpublish, + Presence::Present(PublicationState::Scheduled(at) | PublicationState::Published(at)) => { + storage::PublishUpdate::Publish { at: Some(*at) } + } + Presence::Absent if is_draft => storage::PublishUpdate::Unpublish, + Presence::Absent => storage::PublishUpdate::Publish { at: None }, + } +} /// Keyset-paging query parameters for the collection. #[derive(Debug, Deserialize)] @@ -320,26 +447,37 @@ pub async fn collection_post( body: String, ) -> Result { let posts = services.posts(); + let audiences = services.audiences(); let user_config = services.user_config(); let site_config = services.site_config(); super::require_user_match(&auth_user, &username)?; let entry: Entry = body.parse()?; + let request_clock = UtcInstant::now(); let default_format = storage::get_default_post_format(user_config, auth_user.user_id).await?; - let fields = entry_to_post_fields(&entry, default_format)?; - // Bound the tag set before anything is written: an over-cap entry must be - // rejected, not created-then-rejected (#771 D9/D12, ADR-0092). - let categories = common::tag::parse_and_validate_tags(fields.categories)?; - // Non-draft entries honor the wire ``: a future time schedules - // the post, a past time backdates it; absent falls back to "now". - let published_at = if fields.is_draft { - None - } else { - Some(fields.published.unwrap_or_else(UtcInstant::now)) + let fields = entry_to_post_fields(&entry, default_format, request_clock)?; + let format = fields.format; + let is_draft = fields.is_draft; + let NormalizedAtomInput { + body, + title, + summary, + categories, + lifecycle, + audiences: audience_input, + expectations, + } = normalize_atom_input( + fields, + OrgOperation::Create, + request_clock, + audiences, + auth_user.user_id, + ) + .await?; + let published_at = create_published_at(&lifecycle, is_draft, request_clock); + let audiences = match audience_input { + Presence::Present(audiences) => audiences, + Presence::Absent => vec![site_config.get_default_audience().await?.into()], }; - - // AtomPub has no audience picker; new posts adopt the instance default. - let default_audience = site_config.get_default_audience().await?; - // A client-supplied idempotency key dedups a retried create (duplicate-on-retry). let idem = headers .get("idempotency-key") @@ -351,15 +489,16 @@ pub async fn collection_post( posts, storage::PostCreation { user_id: auth_user.user_id, - body: fields.body, - title: fields.title.as_ref(), - format: fields.format, + body, + title: title.as_ref(), + format, slug_override: None, published_at, max_attempts: 100, - summary: fields.summary, - audiences: vec![default_audience.into()], + summary, + audiences, idempotency_key: idem, + expectations, }, ) .await; @@ -425,10 +564,11 @@ fn post_entry_response( /// /// # Errors /// -/// Returns `400` if the entry is malformed. +/// Returns `400` if the entry is malformed, invalid for replacement, or names +/// an audience the authenticated author does not own. /// Returns `403` if the authenticated user does not match the target username. -/// Returns `404` if the post is not found, is deleted, or belongs to another user. -/// Returns `412` if `If-Match` is present and stale. +/// Returns `404` if the post is not found, soft-deleted, or belongs to another user. +/// Returns `412` if an `If-Match` header is present and does not match the post's `ETag`. /// Returns `500` if storage fails. #[tracing::instrument(name = "atompub.posts.member_put", skip_all)] pub async fn member_put( @@ -439,6 +579,7 @@ pub async fn member_put( body: String, ) -> Result { let posts = services.posts(); + let audiences = services.audiences(); let user_config = services.user_config(); let site_config = services.site_config(); let current = owned_post(posts, &auth_user, &username, post_id).await?; @@ -448,35 +589,44 @@ pub async fn member_put( } let entry: Entry = body.parse()?; + let request_clock = UtcInstant::now(); let default_format = storage::get_default_post_format(user_config, auth_user.user_id).await?; - let fields = entry_to_post_fields(&entry, default_format)?; - // Bound the tag set before anything is written: an over-cap entry must be - // rejected, not updated-then-rejected (#771 D9/D12, ADR-0092). - let categories = common::tag::parse_and_validate_tags(fields.categories)?; - - // AtomPub has no audience picker; preserve the post's existing targeting - // across the edit rather than resetting it. - let audiences = posts.get_post_audiences(post_id).await?; + let fields = entry_to_post_fields(&entry, default_format, request_clock)?; + let format = fields.format; + let is_draft = fields.is_draft; + let NormalizedAtomInput { + body, + title, + summary, + categories, + lifecycle, + audiences: audience_input, + expectations, + } = normalize_atom_input( + fields, + OrgOperation::Update { post_id }, + request_clock, + audiences, + auth_user.user_id, + ) + .await?; + let audiences = match audience_input { + Presence::Present(audiences) => audiences, + Presence::Absent => posts.get_post_audiences(post_id).await?, + }; storage::perform_post_update( posts, storage::PostUpdate { post_id, editor_user_id: auth_user.user_id, - body: fields.body, - title: fields.title.as_ref(), - format: fields.format, + body, + title: title.as_ref(), + format, slug_override: None, - // A non-draft entry publishes at the wire `` timestamp - // (future = scheduled, past = backdated, absent = keep/now); a draft - // clears publication. - publish: if fields.is_draft { - storage::PublishUpdate::Unpublish - } else { - storage::PublishUpdate::Publish { - at: fields.published, - } - }, - summary: fields.summary, + publish: update_publish(&lifecycle, is_draft), + request_clock, + expectations, + summary, audiences, }, ) @@ -484,17 +634,13 @@ pub async fn member_put( posts.set_post_tags(post_id, &categories).await?; - // Load as the authenticated owner so a non-Public post is not hidden. let viewer = owner_viewer(&auth_user); let post = posts .get_post_by_id(post_id, &viewer) .await? .ok_or(HandlerError::Invariant)?; - let base = required_base_url(site_config).await?; - let entry_out = post_to_entry(&post, &base); - let xml = entry_to_xml(&entry_out)?; - + let xml = entry_to_xml(&post_to_entry(&post, &base))?; Ok(( StatusCode::OK, [ @@ -510,10 +656,12 @@ pub async fn member_put( mod etag_tests { use super::*; use chrono::{TimeZone, Utc}; - use common::ids::{PostId, TagId, UserId}; - use common::tag::Tag; - use common::test_support::{parse_post_body, parse_post_summary, parse_post_title}; - use storage::{PostFormat, PostTag, RenderedHtml}; + use common::ids::{TagId, UserId}; + use common::tag::{Tag, TagLabel}; + use common::test_support::{ + parse_post_body, parse_post_summary, parse_post_title, parse_utc_instant, + }; + use storage::{MockAudienceStorage, PostFormat, PostTag, PublishUpdate, RenderedHtml}; fn mk_tag(post_id: PostId, tag_id: TagId, slug: Tag, display: TagLabel) -> PostTag { PostTag { @@ -646,4 +794,64 @@ mod etag_tests { ); // tag display set assert_ne!(flip(&|p| p.published_at = None), e); // draft flip } + fn org_fields(body: &str) -> PostFields { + PostFields { + title: None, + body: parse_post_body(body), + format: PostFormat::Org, + summary: None, + categories: Presence::Absent, + lifecycle: Presence::Absent, + is_draft: false, + } + } + + #[tokio::test] + async fn org_normalization_keeps_an_absent_title_absent() { + let normalized = normalize_atom_input( + org_fields("Body"), + OrgOperation::Create, + parse_utc_instant("2026-08-26T12:00:00Z"), + &MockAudienceStorage::new(), + UserId::from(1), + ) + .await + .expect("normalization succeeds"); + + assert_eq!(normalized.title, None); + } + + #[tokio::test] + async fn org_audience_storage_failure_is_an_internal_handler_error() { + let mut audiences = MockAudienceStorage::new(); + audiences + .expect_list_audiences() + .returning(|_| Err(sqlx::Error::PoolClosed)); + + let result = normalize_atom_input( + org_fields("#+PROPERTY: JAUNDER_AUDIENCE named:42\nBody"), + OrgOperation::Create, + parse_utc_instant("2026-08-26T12:00:00Z"), + &audiences, + UserId::from(1), + ) + .await; + + assert!(matches!(result, Err(HandlerError::Internal(_)))); + } + + #[test] + fn legacy_draft_lifecycle_fallbacks_remain_unpublished() { + let clock = parse_utc_instant("2026-08-26T12:00:00Z"); + assert_eq!( + create_published_at(&Presence::Absent, true, clock), + None, + "a legacy Atom draft stays unpublished at create" + ); + assert_eq!( + update_publish(&Presence::Absent, true), + PublishUpdate::Unpublish, + "a legacy Atom draft stays unpublished at update" + ); + } } diff --git a/server/src/lib.rs b/server/src/lib.rs index 24aa55090..1a0b4c6f8 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -109,6 +109,7 @@ pub fn create_router_with_media_reference_ownership_resolver( // Leptos `#[server]` functions are wired separately via per-trait contexts // in `provide_app_state_contexts`. let posts_ext = state.posts.clone(); + let audiences_ext = state.audiences.clone(); // The projector's user-tag route resolves a username to a user id via the // user store (see `crate::projector`). let users_ext = state.users.clone(); @@ -198,6 +199,7 @@ pub fn create_router_with_media_reference_ownership_resolver( .layer(axum::Extension(instance_id)) .layer(axum::Extension(storage_path_ext)) .layer(axum::Extension(posts_ext)) + .layer(axum::Extension(audiences_ext)) .layer(axum::Extension(users_ext)) .layer(axum::Extension(user_config_ext)) .layer(axum::Extension(site_config_ext)) diff --git a/server/tests/atompub/atompub_posts.rs b/server/tests/atompub/atompub_posts.rs index 0c7f06c3e..baedbcf6f 100644 --- a/server/tests/atompub/atompub_posts.rs +++ b/server/tests/atompub/atompub_posts.rs @@ -400,6 +400,20 @@ fn entry_xml_with_published(title: &str, content: &str, published: Option<&str>) ) } +/// A non-draft text entry whose explicit Atom lifecycle marker preserves its +/// supplied `` instant. +fn entry_xml_with_draft_no_and_published(title: &str, content: &str, published: &str) -> String { + format!( + r#" + + {title} + {content} + {published} + no +"# + ) +} + /// Which cross-user request a `*_forbids_other_user` case issues. Each variant /// builds a request that `alice` (authenticated) directs at `bob`'s resource. enum ForbiddenRequest { @@ -896,6 +910,393 @@ async fn create_draft_entry_is_unpublished(#[case] backend: Backend) { ); } +/// Atom's explicit `app:draft` is a structured lifecycle scalar: `no` publishes +/// now and prevents an Org `JAUNDER_STATUS` header from supplying the lifecycle. +/// Other structured Atom fields still outrank their Org-header counterparts, and +/// accepted recognized headers are stripped from the native Org readback. +#[apply(backends)] +#[tokio::test] +async fn explicit_atom_draft_no_beats_org_metadata_and_canonicalizes_org(#[case] backend: Backend) { + let TestEnv { state, base } = setup_with_base_url(backend).await; + let session = create_user_and_session(&state).await; + let xml = r#" + + Atom title + Atom summary + #+TITLE: Header title +#+DESCRIPTION: Header summary +#+KEYWORDS: header-tag +#+PROPERTY: JAUNDER_STATUS draft +#+UNKNOWN: retained + +Org body + + no +"#; + + let response = make_app(&state, &base) + .oneshot(atompub_post_xml(&session, "posts", xml)) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + let location = atompub_location( + response + .headers() + .get(header::LOCATION) + .expect("create response has Location") + .to_str() + .expect("Location is text"), + ); + let response = make_app(&state, &base) + .oneshot( + atompub_at(&session, Method::GET, &location) + .body(Body::empty()) + .expect("build member GET"), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_string(response).await; + assert!(body.contains("Atom title"), "body: {body}"); + assert!( + body.contains("Atom summary"), + "body: {body}" + ); + assert!(body.contains("term=\"atom-tag\""), "body: {body}"); + assert!(body.contains("type=\"text/org\""), "body: {body}"); + assert!(body.contains(""), "body: {body}"); + assert!(!body.contains("app:draft"), "body: {body}"); + assert!(body.contains("#+UNKNOWN: retained"), "body: {body}"); + assert!(body.contains("Org body"), "body: {body}"); + assert!(!body.contains("JAUNDER_STATUS"), "body: {body}"); + assert!(!body.contains("#+TITLE:"), "body: {body}"); + assert!(!body.contains("#+DESCRIPTION:"), "body: {body}"); + assert!(!body.contains("#+KEYWORDS:"), "body: {body}"); +} + +/// Org metadata errors are `AtomPub` client errors and must not partially replace +/// the existing member before the full header has been accepted. +#[apply(backends)] +#[tokio::test] +async fn malformed_org_header_update_returns_400_without_mutation(#[case] backend: Backend) { + let TestEnv { state, base } = setup_with_base_url(backend).await; + let session = create_user_and_session(&state).await; + let post = session + .seed_post() + .body(parse_post_body("Original body")) + .seed(&state) + .await; + let invalid = entry_xml( + "Replacement", + "text/org", + "#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_STATUS published\n\nReplacement body", + ); + + let response = make_app(&state, &base) + .oneshot(atompub_put_xml( + &session, + &format!("posts/{}", post.post_id), + &invalid, + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = make_app(&state, &base) + .oneshot(atompub_get(&session, &format!("posts/{}", post.post_id))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_string(response).await; + assert!(body.contains("Original body"), "body: {body}"); + assert!(!body.contains("Replacement body"), "body: {body}"); +} + +/// A stale Org `JAUNDER_SYNCED` is an independent `AtomPub` precondition: even a +/// matching HTTP `If-Match` cannot make it apply, and the stored member remains +/// unchanged. +#[apply(backends)] +#[tokio::test] +async fn stale_org_synced_returns_412_despite_matching_if_match_without_mutation( + #[case] backend: Backend, +) { + let TestEnv { state, base } = setup_with_base_url(backend).await; + let session = create_user_and_session(&state).await; + let post = session + .seed_post() + .body(parse_post_body("Original body")) + .seed(&state) + .await; + let member = format!("posts/{}", post.post_id); + let initial = make_app(&state, &base) + .oneshot(atompub_get(&session, &member)) + .await + .unwrap(); + assert_eq!(initial.status(), StatusCode::OK); + let current_etag = etag_of(&initial); + let xml = entry_xml( + "Replacement", + "text/org", + &format!( + "#+PROPERTY: JAUNDER_ID {}\n#+PROPERTY: JAUNDER_SYNCED \"stale\"\n\nReplacement body", + post.post_id + ), + ); + + let response = make_app(&state, &base) + .oneshot( + atompub(&session, Method::PUT, &member) + .header(header::CONTENT_TYPE, "application/atom+xml") + .header(header::IF_MATCH, current_etag) + .body(Body::from(xml)) + .expect("build matching If-Match PUT"), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); + let response = make_app(&state, &base) + .oneshot(atompub_get(&session, &member)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_string(response).await; + assert!(body.contains("Original body"), "body: {body}"); + assert!(!body.contains("Replacement body"), "body: {body}"); +} + +/// Metadata without remaining Org content names no post and cannot replace an +/// existing member. +#[apply(backends)] +#[tokio::test] +async fn metadata_only_org_update_returns_400_without_mutation(#[case] backend: Backend) { + let TestEnv { state, base } = setup_with_base_url(backend).await; + let session = create_user_and_session(&state).await; + let post = session + .seed_post() + .body(parse_post_body("Original body")) + .seed(&state) + .await; + let metadata_only = entry_xml( + "Replacement", + "text/org", + "#+TITLE: Header title\n#+PROPERTY: JAUNDER_STATUS draft", + ); + + let response = make_app(&state, &base) + .oneshot(atompub_put_xml( + &session, + &format!("posts/{}", post.post_id), + &metadata_only, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = make_app(&state, &base) + .oneshot(atompub_get(&session, &format!("posts/{}", post.post_id))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_string(response).await; + assert!(body.contains("Original body"), "body: {body}"); +} + +/// Create bookkeeping is checked against the finalized Org record, including +/// collision-free slug, selected format, and the supplied publication instant. +#[apply(backends)] +#[tokio::test] +async fn create_org_bookkeeping_must_match_final_values(#[case] backend: Backend) { + let TestEnv { state, base } = setup_with_base_url(backend).await; + let session = create_user_and_session(&state).await; + let publication = "2024-01-02T03:04:05Z"; + let valid = format!( + r#" + + Final Title + #+PROPERTY: JAUNDER_SLUG final-title +#+PROPERTY: JAUNDER_FORMAT org +#+PROPERTY: JAUNDER_DATE_UTC 2024-01-02T03:04:05+00:00 + +Body + {publication} +"# + ); + let response = make_app(&state, &base) + .oneshot(atompub_post_xml(&session, "posts", &valid)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + for metadata in [ + "#+PROPERTY: JAUNDER_SLUG wrong-slug", + "#+PROPERTY: JAUNDER_FORMAT markdown", + "#+PROPERTY: JAUNDER_DATE_UTC 2024-01-02T03:04:06Z", + ] { + let xml = format!( + r#" + + Different Title + {metadata} + +Body + {publication} +"# + ); + let response = make_app(&state, &base) + .oneshot(atompub_post_xml(&session, "posts", &xml)) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "bookkeeping {metadata:?} must match final post" + ); + } + let response = make_app(&state, &base) + .oneshot(atompub_get(&session, "posts")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + body_string(response).await.matches("( publish, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: common::time::UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), } } @@ -635,6 +637,7 @@ async fn create_rendered_post_markdown_renders_and_stores(#[case] backend: Backe summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -676,6 +679,7 @@ async fn create_rendered_post_org_renders_and_stores(#[case] backend: Backend) { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -725,6 +729,7 @@ async fn create_rendered_post_slug_conflict_returns_storage_error(#[case] backen summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -854,6 +859,8 @@ async fn perform_post_update_markdown_renders_and_updates(#[case] backend: Backe publish: PublishUpdate::Unpublish, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: common::time::UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -894,6 +901,8 @@ async fn perform_post_update_org_renders_and_updates(#[case] backend: Backend) { publish: PublishUpdate::Unpublish, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: common::time::UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await diff --git a/server/tests/web/posts/create.rs b/server/tests/web/posts/create.rs index 39975f59f..0715c7c5e 100644 --- a/server/tests/web/posts/create.rs +++ b/server/tests/web/posts/create.rs @@ -427,6 +427,299 @@ async fn create_post_applies_tags_from_param(#[case] backend: Backend) { assert!(stored_tags.iter().any(|t| t.tag_display == "Rust")); } +#[apply(backends)] +#[tokio::test] +async fn create_org_header_merges_structured_metadata_and_stores_canonical_body( + #[case] backend: Backend, +) { + let TestEnv { state, base: _base } = backend.setup().await; + let session = create_user_and_session(&state).await; + let cookie = session.cookie(); + let payload = serde_json::json!({ + "post": { + "body": "#+TITLE: Header title\n#+DESCRIPTION: Header summary\n#+KEYWORDS: header, ignored\n#+PROPERTY: JAUNDER_AUDIENCE public\n#+PROPERTY: JAUNDER_STATUS published\n#+PROPERTY: JAUNDER_SLUG header-title\n#+PROPERTY: JAUNDER_FORMAT org\n#+UNKNOWN: preserved\n\nBody", + "format": "org", + "publish": false, + "tags": [], + "summary": "Structured summary", + "audience": { "base": "private", "named": [] }, + } + }); + let (status, body) = post_json( + &state, + ::PATH, + payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + let record = state + .posts + .get_post_by_id( + created.post_id, + &common::visibility::ViewerIdentity::Local { + user_id: session.user_id, + }, + ) + .await + .unwrap() + .expect("created post exists"); + assert_eq!(record.title.as_deref(), Some("Header title")); + assert_eq!(record.summary.as_deref(), Some("Structured summary")); + assert_eq!(record.body, "#+UNKNOWN: preserved\n\nBody\n"); + assert!(record.tags.is_empty()); + let (status, body) = post_form( + &state, + ::PATH, + format!("post_id={}", created.post_id), + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "audience body: {body}"); + let audience: common::visibility::AudienceSelection = serde_json::from_str(&body).unwrap(); + assert_eq!(audience.base, common::visibility::AudienceBase::Private); + assert!(audience.named.is_empty()); + assert!(record.published_at.is_none()); +} + +#[apply(backends)] +#[tokio::test] +async fn create_org_uses_header_lifecycle_when_publish_is_omitted(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let cookie = create_user_and_session(&state).await.cookie(); + let payload = serde_json::json!({ + "post": { + "body": "#+TITLE: Header lifecycle\n#+PROPERTY: JAUNDER_STATUS published\n\nBody", + "format": "org", + } + }); + let (status, body) = post_json( + &state, + ::PATH, + payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + assert!( + created.published_at.is_some(), + "an omitted transport lifecycle must leave the valid Org header effective" + ); +} + +#[apply(backends)] +#[tokio::test] +async fn create_non_org_requires_publish_presence(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let cookie = create_user_and_session(&state).await.cookie(); + let payload = serde_json::json!({ + "post": { + "body": "No lifecycle", + "format": "markdown", + } + }); + let (status, body) = post_json( + &state, + ::PATH, + payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "body: {body}"); +} + +#[apply(backends)] +#[tokio::test] +async fn create_org_header_named_audience_is_author_scoped_and_opaque(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let author = create_user_and_session(&state).await; + let foreign = create_user_and_session(&state).await; + let cookie = author.cookie(); + let owned = state + .audiences + .create_audience( + author.user_id, + &common::test_support::parse_audience_name("Owned"), + ) + .await + .unwrap(); + let foreign = state + .audiences + .create_audience( + foreign.user_id, + &common::test_support::parse_audience_name("Foreign"), + ) + .await + .unwrap(); + + let payload = |audience_id| { + serde_json::json!({ + "post": { + "body": format!("#+TITLE: Named\n#+PROPERTY: JAUNDER_AUDIENCE named:{audience_id}\n#+PROPERTY: JAUNDER_STATUS draft\n\nBody"), + "format": "org", + "publish": false, + } + }) + }; + let (status, body) = post_json( + &state, + ::PATH, + payload(owned), + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + let (status, body) = post_form( + &state, + ::PATH, + format!("post_id={}", created.post_id), + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "audience body: {body}"); + let selection: common::visibility::AudienceSelection = serde_json::from_str(&body).unwrap(); + assert_eq!(selection.named, vec![owned]); + + let (foreign_status, foreign_body) = post_json( + &state, + ::PATH, + payload(foreign), + Some(&cookie), + ) + .await; + let (unknown_status, unknown_body) = post_json( + &state, + ::PATH, + payload(common::ids::AudienceId::from(999_999)), + Some(&cookie), + ) + .await; + assert_eq!( + foreign_status, + StatusCode::INTERNAL_SERVER_ERROR, + "body: {foreign_body}" + ); + assert_eq!( + unknown_status, + StatusCode::INTERNAL_SERVER_ERROR, + "body: {unknown_body}" + ); + assert_eq!( + foreign_body, unknown_body, + "audience existence must remain opaque" + ); + let drafts = state + .posts + .list_drafts_by_user( + author.user_id, + None, + parse_row_limit("10"), + common::time::UtcInstant::now(), + ) + .await + .unwrap(); + assert_eq!( + drafts.len(), + 1, + "rejected audience writes must not create posts" + ); +} + +#[apply(backends)] +#[tokio::test] +async fn create_org_publish_now_overrides_header_draft(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let cookie = create_user_and_session(&state).await.cookie(); + let payload = serde_json::json!({ + "post": { + "body": "#+TITLE: Publish now\n#+PROPERTY: JAUNDER_STATUS draft\n\nBody", + "format": "org", + "publish": true, + } + }); + let (status, body) = post_json( + &state, + ::PATH, + payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + assert!( + created.published_at.is_some(), + "structured publish-now wins over header draft" + ); +} +#[apply(backends)] +#[tokio::test] +async fn create_org_metadata_failures_do_not_create_rows(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let session = create_user_and_session(&state).await; + let cookie = session.cookie(); + let cases = [ + ( + "#+PROPERTY: JAUNDER_STATUS draft", + serde_json::json!({ "publish": false }), + ), + ( + "#+PROPERTY: JAUNDER_STATUS scheduled\n#+DATE: [2026-02-30 Mon 12:00]\n#+PROPERTY: JAUNDER_DATE_TZ UTC\n\nBody", + serde_json::json!({ "publish": false }), + ), + ( + "#+TITLE: Expected slug\n#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_SLUG wrong\n\nBody", + serde_json::json!({ "publish": false }), + ), + ( + "#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_FORMAT markdown\n\nBody", + serde_json::json!({ "publish": false }), + ), + ( + "#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_DATE_UTC 2020-01-01T00:00:01Z\n\nBody", + serde_json::json!({ + "publish": true, + "publish_at": "2020-01-01T00:00:00Z", + }), + ), + ]; + for (org_body, lifecycle) in cases { + let mut post = serde_json::json!({ + "body": org_body, + "format": "org", + }); + post.as_object_mut() + .expect("test payload is an object") + .extend( + lifecycle + .as_object() + .expect("test lifecycle is an object") + .clone(), + ); + let (status, body) = post_json( + &state, + ::PATH, + serde_json::json!({ "post": post }), + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "body: {body}"); + } + let drafts = state + .posts + .list_drafts_by_user( + session.user_id, + None, + parse_row_limit("10"), + common::time::UtcInstant::now(), + ) + .await + .unwrap(); + assert!(drafts.is_empty(), "rejected creates must not leave rows"); +} + #[apply(backends)] #[tokio::test] async fn create_post_rejects_invalid_tag_token(#[case] backend: Backend) { diff --git a/server/tests/web/posts/update.rs b/server/tests/web/posts/update.rs index 1178eb402..17ecf71ce 100644 --- a/server/tests/web/posts/update.rs +++ b/server/tests/web/posts/update.rs @@ -828,3 +828,269 @@ async fn update_post_with_tags_unset_leaves_existing_tags_alone(#[case] backend: let slugs: Vec<&str> = stored.iter().map(|t| t.tag_slug.as_ref()).collect(); assert_eq!(slugs, vec!["keep"]); } + +#[apply(backends)] +#[tokio::test] +async fn update_org_header_applies_tags_and_rejects_mismatched_bookkeeping( + #[case] backend: Backend, +) { + let TestEnv { state, base: _base } = backend.setup().await; + let session = create_user_and_session(&state).await; + let cookie = session.cookie(); + let (status, body) = + create_post_json(&state, "original", "org", None, false, Some(&cookie)).await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + + let org_body = format!( + "#+TITLE: Canonical title\n#+KEYWORDS: org-tag, other\n#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_ID {}\n\nUpdated body", + created.post_id + ); + let update_payload = serde_json::json!({ + "post_id": created.post_id, + "post": { + "body": org_body, + "format": "org", + "publish": false, + } + }); + let (status, body) = post_json( + &state, + ::PATH, + update_payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "update body: {body}"); + let updated: SavedPost = serde_json::from_str(&body).unwrap(); + let record = state + .posts + .get_post_by_id( + updated.post_id, + &common::visibility::ViewerIdentity::Local { + user_id: session.user_id, + }, + ) + .await + .unwrap() + .expect("updated post exists"); + assert_eq!(record.title.as_deref(), Some("Canonical title")); + assert_eq!(record.body, "Updated body\n"); + assert_eq!( + record + .tags + .iter() + .map(|tag| tag.tag_slug.as_ref()) + .collect::>(), + vec!["org-tag", "other"] + ); + + let rejection_payload = serde_json::json!({ + "post_id": updated.post_id, + "post": { + "body": "#+TITLE: rejected\n#+PROPERTY: JAUNDER_ID 999\n\nRejected body", + "format": "org", + "publish": false, + } + }); + let (status, body) = post_json( + &state, + ::PATH, + rejection_payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "body: {body}"); + assert!( + body.contains("JAUNDER_ID does not match update target"), + "body: {body}" + ); + let unchanged = state + .posts + .get_post_by_id( + updated.post_id, + &common::visibility::ViewerIdentity::Local { + user_id: session.user_id, + }, + ) + .await + .unwrap() + .expect("post survives rejected write"); + assert_eq!(unchanged.body, record.body); + assert_eq!( + unchanged + .tags + .iter() + .map(|tag| (&tag.tag_slug, &tag.tag_display)) + .collect::>(), + record + .tags + .iter() + .map(|tag| (&tag.tag_slug, &tag.tag_display)) + .collect::>() + ); + assert_eq!(unchanged.title, record.title); +} + +#[apply(backends)] +#[tokio::test] +async fn update_org_uses_header_lifecycle_when_publish_is_omitted(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let cookie = create_user_and_session(&state).await.cookie(); + let (status, body) = + create_post_json(&state, "original", "org", None, false, Some(&cookie)).await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + let payload = serde_json::json!({ + "post_id": created.post_id, + "post": { + "body": "#+TITLE: Header lifecycle\n#+PROPERTY: JAUNDER_STATUS published\n\nUpdated body", + "format": "org", + } + }); + let (status, body) = post_json( + &state, + ::PATH, + payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "update body: {body}"); + let updated: SavedPost = serde_json::from_str(&body).unwrap(); + assert!( + updated.published_at.is_some(), + "an omitted transport lifecycle must leave the valid Org header effective" + ); +} + +#[apply(backends)] +#[tokio::test] +async fn update_non_org_requires_publish_presence(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let cookie = create_user_and_session(&state).await.cookie(); + let (status, body) = + create_post_json(&state, "original", "markdown", None, false, Some(&cookie)).await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + let payload = serde_json::json!({ + "post_id": created.post_id, + "post": { + "body": "No lifecycle", + "format": "markdown", + } + }); + let (status, body) = post_json( + &state, + ::PATH, + payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "body: {body}"); +} + +#[apply(backends)] +#[tokio::test] +async fn update_org_current_sync_succeeds_and_stale_sync_preserves_post(#[case] backend: Backend) { + let TestEnv { state, base: _base } = backend.setup().await; + let session = create_user_and_session(&state).await; + let cookie = session.cookie(); + let (status, body) = + create_post_json(&state, "original", "org", None, false, Some(&cookie)).await; + assert_eq!(status, StatusCode::OK, "create body: {body}"); + let created: SavedPost = serde_json::from_str(&body).unwrap(); + let before = state + .posts + .get_post_by_id( + created.post_id, + &common::visibility::ViewerIdentity::Local { + user_id: session.user_id, + }, + ) + .await + .unwrap() + .expect("created post exists"); + let current_etag = common::etag::post_content_etag( + before.title.as_ref(), + &before.body, + &before.format, + before.summary.as_ref(), + before.tags.iter().map(|tag| &tag.tag_display), + before.published_at.is_none(), + ); + let matching_payload = serde_json::json!({ + "post_id": created.post_id, + "post": { + "body": format!("#+TITLE: Changed\n#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_ID {}\n#+PROPERTY: JAUNDER_SYNCED {current_etag}\n\nChanged body", created.post_id), + "format": "org", + "publish": false, + } + }); + let (status, body) = post_json( + &state, + ::PATH, + matching_payload, + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK, "matching sync update: {body}"); + let changed: SavedPost = serde_json::from_str(&body).unwrap(); + let before_stale = state + .posts + .get_post_by_id( + changed.post_id, + &common::visibility::ViewerIdentity::Local { + user_id: session.user_id, + }, + ) + .await + .unwrap() + .expect("changed post exists"); + + let stale_payload = serde_json::json!({ + "post_id": changed.post_id, + "post": { + "body": format!("#+TITLE: Stale\n#+PROPERTY: JAUNDER_STATUS draft\n#+PROPERTY: JAUNDER_ID {}\n#+PROPERTY: JAUNDER_SYNCED {current_etag}\n\nStale body", changed.post_id), + "format": "org", + "publish": false, + } + }); + let (status, body) = post_json( + &state, + ::PATH, + stale_payload, + Some(&cookie), + ) + .await; + assert_eq!( + status, + StatusCode::INTERNAL_SERVER_ERROR, + "stale sync body: {body}" + ); + assert!(body.contains("\"conflict\""), "stale sync body: {body}"); + let unchanged = state + .posts + .get_post_by_id( + changed.post_id, + &common::visibility::ViewerIdentity::Local { + user_id: session.user_id, + }, + ) + .await + .unwrap() + .expect("stale update must not remove post"); + assert_eq!(unchanged.body, before_stale.body); + assert_eq!(unchanged.title, before_stale.title); + assert_eq!( + unchanged + .tags + .iter() + .map(|tag| (&tag.tag_slug, &tag.tag_display)) + .collect::>(), + before_stale + .tags + .iter() + .map(|tag| (&tag.tag_slug, &tag.tag_display)) + .collect::>() + ); +} diff --git a/storage/src/audiences.rs b/storage/src/audiences.rs index caae4ebc8..8e5a860d2 100644 --- a/storage/src/audiences.rs +++ b/storage/src/audiences.rs @@ -15,6 +15,7 @@ use common::audience::AudienceName; use common::ids::{AudienceId, SubscriptionId, UserId}; use common::time::UtcInstant; use sqlx::{Database, Pool}; +use std::collections::BTreeSet; use crate::backend::Backend; @@ -65,6 +66,70 @@ impl From for host::error::InternalError { } } } +/// Failure from validating named audience targets for a particular author. +/// +/// [`Invalid`](Self::Invalid) deliberately covers both foreign and nonexistent +/// audience identifiers. Callers must project it as one opaque validation error. +#[derive(Debug)] +pub enum InvalidAudienceTargets { + /// At least one named target is not owned by the author. + Invalid, + /// Listing the author's audiences failed. + Storage(sqlx::Error), +} + +impl From for host::error::InternalError { + fn from(error: InvalidAudienceTargets) -> Self { + match error { + InvalidAudienceTargets::Invalid => { + host::error::InternalError::validation("invalid audience") + } + InvalidAudienceTargets::Storage(error) => host::error::InternalError::storage(error), + } + } +} + +/// Validates that each named audience target belongs to `author_user_id`. +/// +/// The lookup is author-scoped, so a foreign identifier and an identifier that +/// does not exist produce the same [`InvalidAudienceTargets::Invalid`] error. +/// +/// # Errors +/// +/// Returns [`InvalidAudienceTargets::Invalid`] when any named target is not +/// owned by the author, or [`InvalidAudienceTargets::Storage`] when the +/// author-scoped lookup fails. +pub async fn validate_named_audience_targets( + storage: &dyn AudienceStorage, + author_user_id: UserId, + targets: &[common::visibility::AudienceTarget], +) -> Result<(), InvalidAudienceTargets> { + let named: BTreeSet<_> = targets + .iter() + .filter_map(|target| match target { + common::visibility::AudienceTarget::Named(id) => Some(*id), + common::visibility::AudienceTarget::Public + | common::visibility::AudienceTarget::Private + | common::visibility::AudienceTarget::Subscribers => None, + }) + .collect(); + if named.is_empty() { + return Ok(()); + } + + let allowed: BTreeSet<_> = storage + .list_audiences(author_user_id) + .await + .map_err(InvalidAudienceTargets::Storage)? + .into_iter() + .map(|audience| audience.audience_id) + .collect(); + if named.is_subset(&allowed) { + Ok(()) + } else { + Err(InvalidAudienceTargets::Invalid) + } +} /// Async operations on the `audiences` / `audience_members` tables. /// @@ -345,10 +410,11 @@ where #[cfg(test)] mod tests { - use super::AudienceError; + use super::{AudienceError, InvalidAudienceTargets, validate_named_audience_targets}; use crate::test_support::{Backend, SeedUser, backends}; use common::test_support::parse_audience_name; use common::time::UtcInstant; + use common::visibility::AudienceTarget; use host::error::{ErrorKind, InternalError}; use rstest::*; use rstest_reuse::*; @@ -427,6 +493,53 @@ mod tests { ); } + #[apply(backends)] + #[tokio::test] + async fn named_target_validation_is_author_scoped_and_opaque(#[case] backend: Backend) { + let env = backend.setup().await; + let author = SeedUser::new().seed(&env.state).await; + let other = SeedUser::new().seed(&env.state).await; + let owned = env + .state + .audiences + .create_audience(author.user_id, &parse_audience_name("Owned")) + .await + .unwrap(); + let foreign = env + .state + .audiences + .create_audience(other.user_id, &parse_audience_name("Foreign")) + .await + .unwrap(); + + validate_named_audience_targets( + env.state.audiences.as_ref(), + author.user_id, + &[AudienceTarget::Public, AudienceTarget::Named(owned)], + ) + .await + .unwrap(); + + let foreign = validate_named_audience_targets( + env.state.audiences.as_ref(), + author.user_id, + &[AudienceTarget::Named(foreign)], + ) + .await + .unwrap_err(); + let unknown = validate_named_audience_targets( + env.state.audiences.as_ref(), + author.user_id, + &[AudienceTarget::Named(common::ids::AudienceId::from( + 999_999, + ))], + ) + .await + .unwrap_err(); + assert!(matches!(foreign, InvalidAudienceTargets::Invalid)); + assert!(matches!(unknown, InvalidAudienceTargets::Invalid)); + } + // Each variant's `(kind, public_message)` is the wire projection; these pin it. #[test] fn from_audience_error_maps_variants() { @@ -445,4 +558,11 @@ mod tests { assert_eq!(storage.kind(), ErrorKind::Storage); assert_eq!(storage.public_message(), "storage operation failed"); } + + #[test] + fn invalid_audience_target_storage_failure_is_masked_as_storage() { + let error: InternalError = InvalidAudienceTargets::Storage(sqlx::Error::PoolClosed).into(); + assert_eq!(error.kind(), ErrorKind::Storage); + assert_eq!(error.public_message(), "storage operation failed"); + } } diff --git a/storage/src/post_service.rs b/storage/src/post_service.rs index 46ae455ba..38f554821 100644 --- a/storage/src/post_service.rs +++ b/storage/src/post_service.rs @@ -7,8 +7,8 @@ use thiserror::Error; use crate::{ - CreatePostError, CreatePostInput, PostFormat, PostRecord, PostStorage, PublishUpdate, - UpdatePostError, UpdatePostInput, + CreatePostError, CreatePostInput, PostBookkeepingExpectation, PostFormat, PostRecord, + PostStorage, PublishUpdate, UpdatePostError, UpdatePostInput, }; use common::ids::{PostId, UserId}; use common::post_body::PostBody; @@ -45,6 +45,8 @@ pub struct RenderedPostContent { pub audiences: Vec, /// Owned idempotency key to register with the post, or `None`. pub idempotency_key: Option, + /// Non-authoritative Org bookkeeping expected to match the final stored row. + pub expectations: PostBookkeepingExpectation, } /// Renders `body` according to `format` and creates the post via storage. @@ -75,6 +77,7 @@ pub fn render_post_input(content: RenderedPostContent) -> CreatePostInput { summary, audiences, idempotency_key, + expectations, } = content; let rendered = RenderOutput::render(&body, &format); CreatePostInput { @@ -87,6 +90,7 @@ pub fn render_post_input(content: RenderedPostContent) -> CreatePostInput { published_at, summary, audiences, + expectations, idempotency_key, } } @@ -119,6 +123,7 @@ pub fn seed_post_input( summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }) } @@ -138,6 +143,10 @@ pub enum PerformUpdateError { NotFound, #[error("not authorized")] Unauthorized, + #[error("post bookkeeping does not match the stored post")] + BookkeepingMismatch, + #[error("post content has changed")] + StaleContent, #[error("storage error: {0}")] Storage(#[source] sqlx::Error), } @@ -147,6 +156,8 @@ impl From for PerformUpdateError { match e { UpdatePostError::NotFound => Self::NotFound, UpdatePostError::Unauthorized => Self::Unauthorized, + UpdatePostError::BookkeepingMismatch => Self::BookkeepingMismatch, + UpdatePostError::StaleContent => Self::StaleContent, UpdatePostError::Internal(e) => Self::Storage(e), } } @@ -161,7 +172,9 @@ impl From for host::error::InternalError { fn from(error: PerformUpdateError) -> Self { use host::error::InternalError; match error { - PerformUpdateError::EmptyPost => { + PerformUpdateError::EmptyPost + | PerformUpdateError::BookkeepingMismatch + | PerformUpdateError::StaleContent => { InternalError::validation_source(error.to_string(), error) } PerformUpdateError::NotFound | PerformUpdateError::Unauthorized => { @@ -195,6 +208,10 @@ pub struct PostUpdate<'a> { /// Audience targeting for the post (replaces its existing rows). An empty /// vec (or `[Private]`) makes the post author-only. pub audiences: Vec, + /// The request clock reused if this update publishes a draft without a date. + pub request_clock: UtcInstant, + /// Non-authoritative Org bookkeeping expected to match the locked row. + pub expectations: PostBookkeepingExpectation, } /// Validates inputs, computes the slug, renders the body, and atomically @@ -220,6 +237,8 @@ pub async fn perform_post_update( publish, summary, audiences, + request_clock, + expectations, } = input; let (title, derived_slug) = derive_post_naming(title, &body, &format); @@ -247,6 +266,8 @@ pub async fn perform_post_update( publish, summary, audiences, + request_clock, + expectations, }; storage .update_post(post_id, editor_user_id, &input) @@ -276,6 +297,8 @@ pub enum PerformCreationError { /// create is a duplicate and no new post was written. #[error("idempotency key already used for this user")] IdempotencyConflict, + #[error("post bookkeeping does not match the stored post")] + BookkeepingMismatch, #[error("storage error: {0}")] Storage(#[source] sqlx::Error), } @@ -288,8 +311,9 @@ impl From for host::error::InternalError { use host::error::InternalError; match error { // Single-sourced from the variant's `#[error]` so the public message - // cannot drift from the cause the variant documents. - PerformCreationError::EmptyPost => InternalError::validation(error.to_string()), + PerformCreationError::EmptyPost | PerformCreationError::BookkeepingMismatch => { + InternalError::validation(error.to_string()) + } PerformCreationError::InvalidSlug(_) => { InternalError::validation_source(error.to_string(), error) } @@ -358,6 +382,8 @@ pub struct PostCreation<'a> { /// Client-supplied idempotency key (already trimmed / non-empty), or `None` /// to create without deduplication. pub idempotency_key: Option<&'a str>, + /// Non-authoritative Org bookkeeping expected to match the collision winner. + pub expectations: PostBookkeepingExpectation, } /// Validates inputs, computes the slug, renders the body, and atomically @@ -382,6 +408,7 @@ pub async fn perform_post_creation( summary, audiences, idempotency_key, + expectations, } = input; let (title, derived_slug) = derive_post_naming(title, &body, &format); @@ -402,6 +429,10 @@ pub async fn perform_post_creation( for attempt in 0..max_attempts { let slug = candidate_slug(&slug_seed, attempt).map_err(PerformCreationError::InvalidSlug)?; + let is_expected_slug = expectations + .slug + .as_ref() + .is_some_and(|expected| expected == &slug); match create_rendered_post( storage, @@ -415,6 +446,7 @@ pub async fn perform_post_creation( summary: summary.clone(), audiences: audiences.clone(), idempotency_key: idempotency_key.map(str::to_owned), + expectations: expectations.clone(), }, ) .await @@ -431,6 +463,9 @@ pub async fn perform_post_creation( .ok_or(PerformCreationError::CreatedNotFound)?; return Ok(record); } + Err(CreatePostError::SlugConflict) if is_expected_slug => { + return Err(PerformCreationError::BookkeepingMismatch); + } Err(CreatePostError::SlugConflict) => {} // A duplicate idempotency key is not a slug collision — do not retry; // the whole create (post included) rolled back. The caller looks up @@ -438,6 +473,9 @@ pub async fn perform_post_creation( Err(CreatePostError::IdempotencyConflict) => { return Err(PerformCreationError::IdempotencyConflict); } + Err(CreatePostError::BookkeepingMismatch) => { + return Err(PerformCreationError::BookkeepingMismatch); + } Err(CreatePostError::Internal(e)) => { return Err(PerformCreationError::Storage(e)); } @@ -480,6 +518,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -517,6 +556,7 @@ mod tests { summary: None, audiences: vec![], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -561,6 +601,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -593,6 +634,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -627,6 +669,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -656,6 +699,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -685,6 +729,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -738,6 +783,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -756,6 +802,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -774,6 +821,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -784,6 +832,372 @@ mod tests { assert_eq!(r3.slug, "hello-world-3"); } + #[apply(backends)] + #[tokio::test] + async fn bookkeeping_creation_expectations_follow_the_slug_retry_matrix( + #[case] backend: Backend, + ) { + let env = backend.setup().await; + let storage = &*env.state.posts; + let expected = parse_slug("expected"); + let expected_second = parse_slug("expected-2"); + let create = |user_id, expectations| PostCreation { + user_id, + body: parse_post_body("Expected"), + title: None, + format: PostFormat::Markdown, + slug_override: None, + published_at: None, + max_attempts: 10, + summary: None, + audiences: vec![AudienceTarget::Public], + idempotency_key: None, + expectations, + }; + + let first_free_user = SeedUser::new().seed(&env.state).await.user_id; + let first_free = perform_post_creation( + storage, + create( + first_free_user, + PostBookkeepingExpectation { + slug: Some(expected.clone()), + ..Default::default() + }, + ), + ) + .await + .unwrap(); + assert_eq!(first_free.slug, expected); + + let earlier_free_user = SeedUser::new().seed(&env.state).await.user_id; + assert!(matches!( + perform_post_creation( + storage, + create( + earlier_free_user, + PostBookkeepingExpectation { + slug: Some(expected_second.clone()), + ..Default::default() + }, + ), + ) + .await, + Err(PerformCreationError::BookkeepingMismatch) + )); + assert!( + storage + .list_collection_by_user(earlier_free_user, None, parse_row_limit("10")) + .await + .unwrap() + .is_empty() + ); + + let conflict_before_expected_user = SeedUser::new().seed(&env.state).await.user_id; + perform_post_creation( + storage, + create( + conflict_before_expected_user, + PostBookkeepingExpectation::default(), + ), + ) + .await + .unwrap(); + let collision_winner = perform_post_creation( + storage, + create( + conflict_before_expected_user, + PostBookkeepingExpectation { + slug: Some(expected_second.clone()), + ..Default::default() + }, + ), + ) + .await + .unwrap(); + assert_eq!(collision_winner.slug, expected_second); + + let occupied_expected_user = SeedUser::new().seed(&env.state).await.user_id; + perform_post_creation( + storage, + create( + occupied_expected_user, + PostBookkeepingExpectation::default(), + ), + ) + .await + .unwrap(); + assert!(matches!( + perform_post_creation( + storage, + create( + occupied_expected_user, + PostBookkeepingExpectation { + slug: Some(expected), + ..Default::default() + }, + ), + ) + .await, + Err(PerformCreationError::BookkeepingMismatch) + )); + assert_eq!( + storage + .list_collection_by_user(occupied_expected_user, None, parse_row_limit("10")) + .await + .unwrap() + .len(), + 1 + ); + } + + #[apply(backends)] + #[tokio::test] + async fn bookkeeping_creation_format_and_publication_mismatches_roll_back( + #[case] backend: Backend, + ) { + use common::time::UtcInstant; + + let env = backend.setup().await; + let storage = &*env.state.posts; + let create = |user_id, expectations| PostCreation { + user_id, + body: parse_post_body("Mismatch"), + title: None, + format: PostFormat::Markdown, + slug_override: None, + published_at: None, + max_attempts: 10, + summary: None, + audiences: vec![AudienceTarget::Public], + idempotency_key: None, + expectations, + }; + + let format_user = SeedUser::new().seed(&env.state).await.user_id; + assert!(matches!( + perform_post_creation( + storage, + create( + format_user, + PostBookkeepingExpectation { + format: Some(PostFormat::Org), + ..Default::default() + }, + ), + ) + .await, + Err(PerformCreationError::BookkeepingMismatch) + )); + assert!( + storage + .list_collection_by_user(format_user, None, parse_row_limit("10")) + .await + .unwrap() + .is_empty() + ); + + let publication_user = SeedUser::new().seed(&env.state).await.user_id; + assert!(matches!( + perform_post_creation( + storage, + create( + publication_user, + PostBookkeepingExpectation { + published_at: Some(Some(UtcInstant::now())), + ..Default::default() + }, + ), + ) + .await, + Err(PerformCreationError::BookkeepingMismatch) + )); + assert!( + storage + .list_collection_by_user(publication_user, None, parse_row_limit("10")) + .await + .unwrap() + .is_empty() + ); + } + + #[apply(backends)] + #[tokio::test] + async fn bookkeeping_update_uses_final_draft_or_published_slug(#[case] backend: Backend) { + let env = backend.setup().await; + let storage = &*env.state.posts; + let user_id = SeedUser::new().seed(&env.state).await.user_id; + let draft = crate::test_support::SeedRawPost::new(user_id) + .draft() + .seed(&env.state) + .await; + let published = crate::test_support::SeedRawPost::new(user_id) + .seed(&env.state) + .await; + let changed_slug = parse_slug("changed-slug"); + let update = |post_id, expected_slug| PostUpdate { + post_id, + editor_user_id: user_id, + body: parse_post_body("updated body"), + title: None, + format: PostFormat::Markdown, + slug_override: Some(&changed_slug), + publish: PublishUpdate::Publish { at: None }, + summary: None, + audiences: vec![AudienceTarget::Public], + request_clock: UtcInstant::now(), + expectations: PostBookkeepingExpectation { + slug: Some(expected_slug), + ..Default::default() + }, + }; + + let updated_draft = + perform_post_update(storage, update(draft.post_id, changed_slug.clone())) + .await + .unwrap(); + assert_eq!(updated_draft.slug, changed_slug); + + let updated_published = + perform_post_update(storage, update(published.post_id, published.slug.clone())) + .await + .unwrap(); + assert_eq!(updated_published.slug, published.slug); + } + + #[apply(backends)] + #[tokio::test] + async fn bookkeeping_update_publishes_now_at_the_supplied_request_clock( + #[case] backend: Backend, + ) { + let env = backend.setup().await; + let storage = &*env.state.posts; + let user_id = SeedUser::new().seed(&env.state).await.user_id; + let draft = crate::test_support::SeedRawPost::new(user_id) + .draft() + .seed(&env.state) + .await; + let clock: UtcInstant = "2042-07-01T12:00:00Z".parse().unwrap(); + let record = perform_post_update( + storage, + PostUpdate { + post_id: draft.post_id, + editor_user_id: user_id, + body: parse_post_body("updated body"), + title: None, + format: PostFormat::Markdown, + slug_override: None, + publish: PublishUpdate::Publish { at: None }, + summary: None, + audiences: vec![AudienceTarget::Public], + request_clock: clock, + expectations: PostBookkeepingExpectation::default(), + }, + ) + .await + .unwrap(); + assert_eq!(record.published_at, Some(clock)); + } + + #[apply(backends)] + #[tokio::test] + async fn bookkeeping_update_id_and_etag_mismatches_leave_the_post_unchanged( + #[case] backend: Backend, + ) { + use common::etag::ETag; + + let env = backend.setup().await; + let storage = &*env.state.posts; + let user_id = SeedUser::new().seed(&env.state).await.user_id; + let post = crate::test_support::SeedRawPost::new(user_id) + .draft() + .seed(&env.state) + .await; + let viewer = common::visibility::ViewerIdentity::local(user_id); + let original = storage + .get_post_by_id(post.post_id, &viewer) + .await + .unwrap() + .unwrap(); + let revision_count = env + .base + .pool() + .scalar_i64("SELECT COUNT(*) FROM post_revisions") + .await + .unwrap(); + let update = |expectations| PostUpdate { + post_id: post.post_id, + editor_user_id: user_id, + body: parse_post_body("changed body"), + title: None, + format: PostFormat::Markdown, + slug_override: None, + publish: PublishUpdate::Unpublish, + summary: None, + audiences: vec![AudienceTarget::Public], + request_clock: UtcInstant::now(), + expectations, + }; + + assert!(matches!( + perform_post_update( + storage, + update(PostBookkeepingExpectation { + post_id: Some(PostId::from(999_999)), + ..Default::default() + }), + ) + .await, + Err(PerformUpdateError::BookkeepingMismatch) + )); + assert!(matches!( + perform_post_update( + storage, + update(PostBookkeepingExpectation { + format: Some(PostFormat::Html), + ..Default::default() + }), + ) + .await, + Err(PerformUpdateError::BookkeepingMismatch) + )); + assert!(matches!( + perform_post_update( + storage, + update(PostBookkeepingExpectation { + published_at: Some(Some("2026-08-26T12:00:00Z".parse().unwrap())), + ..Default::default() + }), + ) + .await, + Err(PerformUpdateError::BookkeepingMismatch) + )); + assert!(matches!( + perform_post_update( + storage, + update(PostBookkeepingExpectation { + content_etag: Some(ETag::sha256_of(b"stale")), + ..Default::default() + }), + ) + .await, + Err(PerformUpdateError::StaleContent) + )); + let unchanged = storage + .get_post_by_id(post.post_id, &viewer) + .await + .unwrap() + .unwrap(); + assert_eq!(unchanged.body, original.body); + assert_eq!( + env.base + .pool() + .scalar_i64("SELECT COUNT(*) FROM post_revisions") + .await + .unwrap(), + revision_count + ); + } #[apply(backends)] #[tokio::test] async fn test_perform_post_creation_slug_exhaustion(#[case] backend: Backend) { @@ -804,6 +1218,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -822,6 +1237,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -843,6 +1259,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -872,6 +1289,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -908,6 +1326,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -925,6 +1344,8 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -964,6 +1385,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }; let err = perform_post_creation(storage, creation("* My Title\n", PostFormat::Org)) @@ -1000,6 +1422,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1017,6 +1440,8 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1046,6 +1471,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1076,6 +1502,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1110,6 +1537,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: key, + expectations: PostBookkeepingExpectation::default(), } } diff --git a/storage/src/postgres/posts.rs b/storage/src/postgres/posts.rs index c851918d1..68be8eaa7 100644 --- a/storage/src/postgres/posts.rs +++ b/storage/src/postgres/posts.rs @@ -2,11 +2,11 @@ use async_trait::async_trait; use sqlx::{Pool, Postgres, QueryBuilder}; use crate::posts::{ - DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostMediaReferenceBackfill, - PostOwnershipRow, PostTagRow, SELECT_POST_TAGS, UPSERT_TAG_RETURNING_ID, + DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostBookkeepingRow, + PostMediaReferenceBackfill, PostTagRow, SELECT_POST_TAGS, UPSERT_TAG_RETURNING_ID, media_advisory_lock_keys, media_lock_set, post_tag_diff, post_tags_from_rows, push_live_media_reference_predicate, push_media_reference_evidence_cte, - push_owner_media_reference_from_where, replace_legacy_post_media, + push_owner_media_reference_from_where, replace_legacy_post_media, update_expectation_error, }; use crate::{ InstanceId, PostDialect, PostRecord, PostStore, PublishUpdate, RenderedHtml, TaggingError, @@ -14,7 +14,12 @@ use crate::{ }; use common::ids::{PostId, TagId, UserId}; use common::tag::TagLabel; -use common::time::UtcInstant; + +type MediaRefRow = ( + common::media::MediaSource, + common::media::ContentHash, + common::media::Filename, +); pub(crate) fn finish_post_update_rejection( primary: Result, @@ -29,6 +34,23 @@ pub(crate) fn finish_post_update_rejection( ) } +async fn locked_update_expectation_error( + tx: &mut sqlx::Transaction<'_, Postgres>, + post_id: PostId, + existing: &PostBookkeepingRow, + input: &UpdatePostInput, +) -> Result, sqlx::Error> { + let tags = sqlx::query_scalar::<_, TagLabel>( + "SELECT pt.tag_display FROM post_tags pt \ + JOIN tags t ON t.tag_id = pt.tag_id \ + WHERE pt.post_id = $1 ORDER BY t.tag_slug COLLATE \"C\"", + ) + .bind(post_id) + .fetch_all(&mut **tx) + .await?; + Ok(update_expectation_error(post_id, existing, &tags, input)) +} + pub(crate) fn finish_post_tags_not_found( primary: Result<(), TaggingError>, rollback: Result<(), sqlx::Error>, @@ -85,14 +107,15 @@ impl PostDialect for Postgres { input: &UpdatePostInput, ) -> Result { let mut tx = pool.begin().await?; - let now = UtcInstant::now(); + let now = input.request_clock; // FOR UPDATE locks the row for the read-then-write: it stops a concurrent // edit from slipping between this ownership/liveness check and the UPDATE // below (ADR-0021 / #52). SQLite needs no equivalent — its transaction // already serializes writers. - let existing = sqlx::query_as::<_, PostOwnershipRow>( - "SELECT user_id, deleted_at FROM posts WHERE post_id = $1 FOR UPDATE", + let existing = sqlx::query_as::<_, PostBookkeepingRow>( + "SELECT user_id, deleted_at, title, slug, body, format, summary, published_at + FROM posts WHERE post_id = $1 FOR UPDATE", ) .bind(post_id) .fetch_optional(&mut *tx) @@ -105,23 +128,27 @@ impl PostDialect for Postgres { tx.rollback().await, ); } - Some((owner_id, deleted_at)) if owner_id != editor_user_id || deleted_at.is_some() => { + Some(existing) + if existing.user_id != editor_user_id || existing.deleted_at.is_some() => + { return finish_post_update_rejection( Err(UpdatePostError::Unauthorized), tx.rollback().await, ); } - - Some(_) => {} + Some(existing) => { + if let Some(error) = + locked_update_expectation_error(&mut tx, post_id, &existing, input).await? + { + return finish_post_update_rejection(Err(error), tx.rollback().await); + } + } } - let old_media: Vec<( - common::media::MediaSource, - common::media::ContentHash, - common::media::Filename, - )> = sqlx::query_as("SELECT source, sha256, filename FROM post_media WHERE post_id = $1") - .bind(post_id) - .fetch_all(&mut *tx) - .await?; + let old_media: Vec = + sqlx::query_as("SELECT source, sha256, filename FROM post_media WHERE post_id = $1") + .bind(post_id) + .fetch_all(&mut *tx) + .await?; let mut locked_media = media_lock_set(input.rendered.media()); locked_media.extend(old_media.into_iter().map(|(source, sha256, filename)| { common::media::MediaRef { @@ -354,10 +381,17 @@ mod tests { #[test] fn continuation_reporting_rollback_failures_preserve_post_domain_rejections_and_report_once() { - for primary in [UpdatePostError::NotFound, UpdatePostError::Unauthorized] { + for primary in [ + UpdatePostError::NotFound, + UpdatePostError::Unauthorized, + UpdatePostError::BookkeepingMismatch, + UpdatePostError::StaleContent, + ] { let expected = match primary { UpdatePostError::NotFound => "not-found", UpdatePostError::Unauthorized => "unauthorized", + UpdatePostError::BookkeepingMismatch => "bookkeeping-mismatch", + UpdatePostError::StaleContent => "stale-content", UpdatePostError::Internal(_) => unreachable!("test variants are domain errors"), }; let (result, trace) = crate::helpers::swallowed_test::capture(|| { @@ -368,6 +402,11 @@ mod tests { (&result, expected), (Err(UpdatePostError::NotFound), "not-found") | (Err(UpdatePostError::Unauthorized), "unauthorized") + | ( + Err(UpdatePostError::BookkeepingMismatch), + "bookkeeping-mismatch" + ) + | (Err(UpdatePostError::StaleContent), "stale-content") ), "primary variant changed: {result:?}" ); diff --git a/storage/src/posts.rs b/storage/src/posts.rs index 5058300d1..6992e269d 100644 --- a/storage/src/posts.rs +++ b/storage/src/posts.rs @@ -8,6 +8,7 @@ use sqlx::{Database, Pool, QueryBuilder, Row}; use thiserror::Error; use crate::InstanceId; +use common::etag::{ETag, post_content_etag}; use common::feed::FeedPath; use common::ids::{AudienceId, ChannelId, PostId, RevisionId, TagId, UserId}; use common::media::{MediaRef, MediaReference, MediaReferenceForm, MediaReferenceKind}; @@ -203,12 +204,48 @@ pub struct PostRevisionRecord { pub edited_at: UtcInstant, } +/// Non-authoritative metadata an Org ingress expects the stored post to match. +/// +/// Storage evaluates this inside the write transaction: create compares after its +/// successful unique-index insert, while update compares the locked pre-write row +/// and its tag projection before creating a revision. +#[derive(Clone, Debug, Default)] +pub struct PostBookkeepingExpectation { + /// Final collision-resolved slug. + pub slug: Option, + /// Final stored markup format. + pub format: Option, + /// Final stored publication instant; `Some(None)` expects a draft. + pub published_at: Option>, + /// Target identity for an update. + pub post_id: Option, + /// Current pre-write content validator for an update. + pub content_etag: Option, +} + +/// Converts normalized Org bookkeeping into the persistence checks that must run +/// inside the post write transaction. +impl From for PostBookkeepingExpectation { + fn from(bookkeeping: common::org::OrgBookkeeping) -> Self { + Self { + slug: bookkeeping.slug, + format: bookkeeping.format, + published_at: bookkeeping.date_utc.map(Some), + post_id: bookkeeping.post_id, + content_etag: bookkeeping.synced, + } + } +} + /// Errors that can occur when creating a post. #[derive(Debug, Error)] pub enum CreatePostError { /// A post with the same slug already exists for this user on this day. #[error("slug already taken for this user on this date")] SlugConflict, + /// A non-authoritative bookkeeping property disagreed with the final row. + #[error("post bookkeeping does not match the stored post")] + BookkeepingMismatch, /// The `(user_id, idempotency_key)` pair has already been used to create a /// post; the create is a duplicate of an earlier one. #[error("idempotency key already used for this user")] @@ -227,6 +264,12 @@ pub enum UpdatePostError { /// The user is not authorized to edit this post. #[error("not authorized")] Unauthorized, + /// A non-authoritative target/final-state property disagreed with the locked row. + #[error("post bookkeeping does not match the stored post")] + BookkeepingMismatch, + /// The non-authoritative current-content validator is stale. + #[error("post content has changed")] + StaleContent, /// An unexpected database error occurred. #[error(transparent)] Internal(#[from] sqlx::Error), @@ -242,6 +285,9 @@ impl From for host::error::InternalError { UpdatePostError::NotFound | UpdatePostError::Unauthorized => { InternalError::not_found("Post") } + UpdatePostError::BookkeepingMismatch | UpdatePostError::StaleContent => { + InternalError::validation_source(error.to_string(), error) + } UpdatePostError::Internal(e) => InternalError::storage(e), } } @@ -295,6 +341,8 @@ pub struct CreatePostInput { /// Audience targeting for the post. Each entry becomes a `post_audiences` /// row; `Private` and an empty vec produce no rows (the post is private). pub audiences: Vec, + /// Non-authoritative Org bookkeeping to compare after the successful row insert. + pub expectations: PostBookkeepingExpectation, /// If `Some`, register this idempotency key against the new post in the /// same transaction. A `(user_id, key)` collision maps to /// [`CreatePostError::IdempotencyConflict`] and rolls the whole create back. @@ -332,6 +380,10 @@ pub struct UpdatePostInput { /// `post_audiences` rows are replaced to match this vec; `Private` and an /// empty vec produce no rows (the post is private). pub audiences: Vec, + /// The single request clock used when publishing a previously-draft post now. + pub request_clock: UtcInstant, + /// Non-authoritative Org bookkeeping to compare under the owner lock. + pub expectations: PostBookkeepingExpectation, } /// A tag record returned by [`PostStorage`] tag queries. @@ -411,7 +463,18 @@ pub(crate) const INSERT_POST_TAG: &str = "INSERT INTO post_tags pub(crate) const DELETE_POST_TAG_BY_SLUG: &str = "DELETE FROM post_tags WHERE post_id = $1 AND tag_id = (SELECT tag_id FROM tags WHERE tag_slug = $2)"; -pub(crate) type PostOwnershipRow = (UserId, Option); +/// The locked pre-write columns needed for final-state and content expectations. +#[derive(sqlx::FromRow)] +pub(crate) struct PostBookkeepingRow { + pub user_id: UserId, + pub deleted_at: Option, + pub title: Option, + pub slug: Slug, + pub body: PostBody, + pub format: PostFormat, + pub summary: Option, + pub published_at: Option, +} pub(crate) type TagListRow = (TagId, Tag); pub(crate) type PostTagRow = (PostId, TagId, Tag, TagLabel); @@ -2754,6 +2817,18 @@ fn audience_target_from_row( } } +fn create_expectations_match(input: &CreatePostInput) -> bool { + let expected = &input.expectations; + expected + .slug + .as_ref() + .is_none_or(|slug| slug == &input.slug) + && expected.format.is_none_or(|format| format == input.format) + && expected + .published_at + .is_none_or(|published_at| published_at == input.published_at) +} + /// Maps an error from the idempotency-key `INSERT`. A `(user_id, key)` unique /// violation is a [`CreatePostError::IdempotencyConflict`] (a duplicate create), /// distinct from the post `INSERT`'s `SlugConflict` — attribution is by which @@ -2804,6 +2879,55 @@ pub(crate) fn media_lock_set(references: &[MediaReference]) -> BTreeSet Option { + let expected = &input.expectations; + if expected + .post_id + .is_some_and(|expected_id| expected_id != post_id) + { + return Some(UpdatePostError::BookkeepingMismatch); + } + let final_slug = if existing.published_at.is_some() { + &existing.slug + } else { + &input.slug + }; + let final_published_at = match input.publish { + PublishUpdate::Unpublish => None, + PublishUpdate::Publish { at: Some(at) } => Some(at), + PublishUpdate::Publish { at: None } => existing.published_at.or(Some(input.request_clock)), + }; + if expected + .slug + .as_ref() + .is_some_and(|slug| slug != final_slug) + || expected.format.is_some_and(|format| format != input.format) + || expected + .published_at + .is_some_and(|published_at| published_at != final_published_at) + { + return Some(UpdatePostError::BookkeepingMismatch); + } + + let current_etag = post_content_etag( + existing.title.as_ref(), + &existing.body, + &existing.format, + existing.summary.as_ref(), + tags.iter(), + existing.published_at.is_none(), + ); + expected + .content_etag + .as_ref() + .is_some_and(|etag| etag != ¤t_etag) + .then_some(UpdatePostError::StaleContent) +} /// Writes one post row and its audience rows onto a caller-supplied transaction /// connection, so it joins whatever transaction is open. /// @@ -2869,6 +2993,13 @@ where e => CreatePostError::Internal(e), })?; + // The unique index is the only slug-availability probe. A successful insert + // proves this candidate won; compare it before child writes so a mismatch + // rolls the entire transaction back without leaving related rows behind. + if !create_expectations_match(input) { + return Err(CreatePostError::BookkeepingMismatch); + } + replace_post_audiences::(conn, post_id, &input.audiences).await?; replace_post_media::(conn, post_id, input.rendered.media()).await?; @@ -3436,6 +3567,28 @@ mod tests { assert_eq!(draft.published_at, None); assert_eq!(draft.deleted_at, None); } + #[test] + fn org_bookkeeping_conversion_preserves_each_persistence_expectation() { + let published_at = "2026-08-26T12:00:00Z".parse().unwrap(); + let bookkeeping = common::org::OrgBookkeeping { + slug: Some(parse_slug("expected-slug")), + format: Some(PostFormat::Org), + post_id: Some(PostId::from(7)), + synced: Some(parse_etag("\"sha256-current\"")), + synced_at: None, + date_utc: Some(published_at), + }; + + let expectations: PostBookkeepingExpectation = bookkeeping.into(); + assert_eq!(expectations.slug, Some(parse_slug("expected-slug"))); + assert_eq!(expectations.format, Some(PostFormat::Org)); + assert_eq!(expectations.published_at, Some(Some(published_at))); + assert_eq!(expectations.post_id, Some(PostId::from(7))); + assert_eq!( + expectations.content_etag, + Some(parse_etag("\"sha256-current\"")) + ); + } #[test] fn media_advisory_lock_keys_are_sorted_and_deduplicated() { @@ -5138,6 +5291,17 @@ mod tests { let internal: InternalError = UpdatePostError::Internal(sqlx::Error::PoolClosed).into(); assert_eq!(internal.kind(), ErrorKind::Storage); assert_eq!(internal.public_message(), "storage operation failed"); + + for error in [ + UpdatePostError::BookkeepingMismatch, + UpdatePostError::StaleContent, + ] { + let expected_operator_message = error.to_string(); + let internal: InternalError = error.into(); + assert_eq!(internal.kind(), ErrorKind::Validation); + assert_eq!(internal.public_message(), expected_operator_message); + assert_eq!(internal.operator_message(), expected_operator_message); + } } // The `set_post_tags` lift masks as a server error @@ -5368,6 +5532,7 @@ mod tests { published_at: None, summary: None, audiences: vec![AudienceTarget::Public], + expectations: PostBookkeepingExpectation::default(), idempotency_key: None, }) .await diff --git a/storage/src/sqlite/posts.rs b/storage/src/sqlite/posts.rs index 8413bc6d5..ee7bcea6e 100644 --- a/storage/src/sqlite/posts.rs +++ b/storage/src/sqlite/posts.rs @@ -2,10 +2,11 @@ use async_trait::async_trait; use sqlx::{Pool, QueryBuilder, Sqlite, SqliteConnection}; use crate::posts::{ - DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostMediaReferenceBackfill, - PostOwnershipRow, PostTagRow, SELECT_POST_TAGS, UPSERT_TAG_RETURNING_ID, post_tag_diff, - post_tags_from_rows, push_live_media_reference_predicate, push_media_reference_evidence_cte, - push_owner_media_reference_from_where, replace_legacy_post_media, + DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostBookkeepingRow, + PostMediaReferenceBackfill, PostTagRow, SELECT_POST_TAGS, UPSERT_TAG_RETURNING_ID, + post_tag_diff, post_tags_from_rows, push_live_media_reference_predicate, + push_media_reference_evidence_cte, push_owner_media_reference_from_where, + replace_legacy_post_media, update_expectation_error, }; use crate::{ InstanceId, PostDialect, PostRecord, PostStore, PublishUpdate, RenderedHtml, TaggingError, @@ -13,7 +14,6 @@ use crate::{ }; use common::ids::{PostId, TagId, UserId}; use common::tag::TagLabel; -use common::time::UtcInstant; pub(crate) fn finish_post_update( primary: Result, @@ -106,22 +106,36 @@ impl PostDialect for Sqlite { // mirroring create_user_with_invite / sqlite/backup.rs. let mut conn = pool.acquire().await?; sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?; - let now = UtcInstant::now(); + let now = input.request_clock; let result: Result = async { - let existing = sqlx::query_as::<_, PostOwnershipRow>( - "SELECT user_id, deleted_at FROM posts WHERE post_id = $1", + let existing = sqlx::query_as::<_, PostBookkeepingRow>( + "SELECT user_id, deleted_at, title, slug, body, format, summary, published_at + FROM posts WHERE post_id = $1", ) .bind(post_id) .fetch_optional(&mut *conn) .await?; - match existing { + let existing = match existing { None => return Err(UpdatePostError::NotFound), - Some((owner_id, deleted_at)) if owner_id != editor_user_id || deleted_at.is_some() => { + Some(existing) + if existing.user_id != editor_user_id || existing.deleted_at.is_some() => + { return Err(UpdatePostError::Unauthorized); } - Some(_) => {} + Some(existing) => existing, + }; + let tags = sqlx::query_scalar::<_, TagLabel>( + "SELECT pt.tag_display FROM post_tags pt + JOIN tags t ON t.tag_id = pt.tag_id + WHERE pt.post_id = $1 ORDER BY t.tag_slug", + ) + .bind(post_id) + .fetch_all(&mut *conn) + .await?; + if let Some(error) = update_expectation_error(post_id, &existing, &tags, input) { + return Err(error); } lock_updated_media(&mut conn, post_id, input).await?; sqlx::query( diff --git a/storage/src/test_support.rs b/storage/src/test_support.rs index d95869677..0a6329722 100644 --- a/storage/src/test_support.rs +++ b/storage/src/test_support.rs @@ -16,8 +16,8 @@ use crate::media::MediaRecord; use crate::posts::{ - CreatePostError, CreatePostInput, INSERT_POST_TAG, PublishUpdate, UPSERT_TAG_RETURNING_ID, - UpdatePostInput, + CreatePostError, CreatePostInput, INSERT_POST_TAG, PostBookkeepingExpectation, PublishUpdate, + UPSERT_TAG_RETURNING_ID, UpdatePostInput, }; use crate::sql::quote_identifier; use crate::{AppState, DbConnectOptions, PostFormat, PostRecord, resolved_postgres_options}; @@ -1180,6 +1180,7 @@ impl SeedPost { summary: None, audiences: self.audiences, idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1348,6 +1349,7 @@ impl SeedRawPost { published_at: self.published_at, summary: self.summary, audiences: self.audiences, + expectations: PostBookkeepingExpectation::default(), idempotency_key: None, } } @@ -1501,6 +1503,8 @@ impl UpdateRawPost { publish: self.publish, summary: self.summary, audiences: self.audiences, + request_clock: UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), } } } @@ -1662,6 +1666,7 @@ async fn create_via_service( summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1694,6 +1699,8 @@ pub async fn update_post_body_via_service( slug_override: None, publish: crate::PublishUpdate::Publish { at: None }, summary: None, + request_clock: UtcInstant::now(), + expectations: PostBookkeepingExpectation::default(), audiences: vec![AudienceTarget::Public], }, ) diff --git a/web/src/posts/api.rs b/web/src/posts/api.rs index c3ffe7d73..5b4e0348d 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -46,17 +46,68 @@ use { crate::error::InternalError, crate::feed_events::enqueue_feed_events, crate::viewer::viewer_identity, - common::tag::Tag, + common::{ + org::{ + OrgNormalization, OrgOperation, OrgStructuredMetadata, Presence, PublicationState, + normalize_org, + }, + tag::Tag, + }, leptos::prelude::*, std::{collections::BTreeSet, sync::Arc}, storage::{ - FeedEventStorage, PostCreation, PostRecord, PostStorage, PostUpdate, PublishUpdate, - SiteConfigStorage, fetch_post_record, keyset_cursor, perform_post_creation, - perform_post_update, scheduled_keyset_cursor, to_post_cursor, to_scheduled_post_cursor, - wire_cursor, wire_scheduled_cursor, + AudienceStorage, FeedEventStorage, PerformUpdateError, PostBookkeepingExpectation, + PostCreation, PostRecord, PostStorage, PostUpdate, PublishUpdate, SiteConfigStorage, + fetch_post_record, keyset_cursor, perform_post_creation, perform_post_update, + scheduled_keyset_cursor, to_post_cursor, to_scheduled_post_cursor, wire_cursor, + wire_scheduled_cursor, }, }; +/// Builds structured lifecycle only when the transport explicitly supplied a +/// publication control. Omission lets an Org header lifecycle take effect. +#[cfg(feature = "server")] +fn structured_lifecycle( + publish: Option, + publish_at: Option, + request_clock: UtcInstant, +) -> Presence { + match publish { + None => Presence::Absent, + Some(false) => Presence::Present(PublicationState::Draft), + Some(true) => match publish_at { + Some(at) if at.value() > request_clock.value() => { + Presence::Present(PublicationState::Scheduled(at)) + } + Some(at) => Presence::Present(PublicationState::Published(at)), + None => Presence::Present(PublicationState::Published(request_clock)), + }, + } +} + +/// Normalizes an Org request after preserving the web wire's actual field +/// presence. Non-Org requests never reach this seam. +#[cfg(feature = "server")] +fn normalize_web_org( + body: &PostBody, + structured: OrgStructuredMetadata, + operation: OrgOperation, + request_clock: UtcInstant, +) -> Result { + normalize_org(body.as_ref(), structured, operation, request_clock) + .map_err(|error| InternalError::validation(error.to_string())) +} + +#[cfg(feature = "server")] +async fn validate_org_audiences( + targets: &[AudienceTarget], + author_user_id: common::ids::UserId, +) -> Result<(), InternalError> { + let audiences = expect_context::>(); + storage::validate_named_audience_targets(audiences.as_ref(), author_user_id, targets) + .await + .map_err(InternalError::from) +} #[cfg(feature = "server")] fn unpublished_post_from_record(post: PostRecord) -> UnpublishedPost { let summary_label = post.fallback_summary_label(); @@ -145,7 +196,7 @@ pub struct PostInputs { pub body: PostBody, pub format: PostFormat, pub slug_override: Option, - pub publish: bool, + pub publish: Option, pub publish_at: Option, pub tags: Option>, pub summary: Option, @@ -161,6 +212,7 @@ pub struct PostInputs { /// `datetime-local` value to UTC before sending. #[macros::server(input = Json, skip_all)] pub async fn create(post: PostInputs) -> WebResult { + let request_clock = UtcInstant::now(); let PostInputs { body, format, @@ -177,22 +229,84 @@ pub async fn create(post: PostInputs) -> WebResult { // The wire delivers `Vec` directly: each tag is validated at // arg-decode (ADR-0065) and a `TagLabel` is never empty, so the body only // dedups and enforces the per-post cap. - let validated_tags = common::tag::parse_and_validate_tags(tags.unwrap_or_default())?; + let structured_tags = tags.map(common::tag::parse_and_validate_tags).transpose()?; + let structured_audiences = audience + .as_ref() + .map(|selection| audience_targets_or_public(Some(selection))); - // Publish + a supplied time = scheduled (future) or backdated (past); - // publish + no time = live now; not publishing = draft (NULL). - let published_at = publish.then(|| publish_at.unwrap_or_else(UtcInstant::now)); - // `PostSummary`'s `FromStr` already trims and rejects empty at arg-decode - // (ADR-0065), so the value is passed through typed — no `non_empty_owned` - // normalization needed. - let audiences = audience_targets_or_public(audience.as_ref()); + let (body, title, summary, audiences, published_at, expectations, validated_tags) = if format + == PostFormat::Org + { + let normalized = normalize_web_org( + &body, + OrgStructuredMetadata { + title: Presence::Absent, + summary: summary.map_or(Presence::Absent, Presence::Present), + tags: structured_tags.map_or(Presence::Absent, Presence::Present), + audiences: structured_audiences.map_or(Presence::Absent, Presence::Present), + lifecycle: structured_lifecycle(publish, publish_at, request_clock), + }, + OrgOperation::Create, + request_clock, + )?; + let metadata = normalized.metadata; + let audiences = match metadata.audiences { + Presence::Present(audiences) => audiences, + Presence::Absent => audience_targets_or_public(None), + }; + validate_org_audiences(&audiences, auth.user_id).await?; + let published_at = match metadata.lifecycle { + Presence::Present(PublicationState::Draft) | Presence::Absent => None, + Presence::Present( + PublicationState::Scheduled(at) | PublicationState::Published(at), + ) => Some(at), + }; + let tags = match metadata.tags { + Presence::Present(tags) => tags, + Presence::Absent => Vec::new(), + }; + ( + normalized.body, + match metadata.title { + Presence::Present(title) => Some(title), + Presence::Absent => None, + }, + match metadata.summary { + Presence::Present(summary) => Some(summary), + Presence::Absent => None, + }, + audiences, + published_at, + normalized.bookkeeping.into(), + tags, + ) + } else { + // Non-Org writes require explicit lifecycle control; unlike Org, + // they have no header metadata from which to derive it. + let publish = publish + .ok_or_else(|| InternalError::validation("missing required structured lifecycle"))?; + let published_at = if publish { + Some(publish_at.unwrap_or(request_clock)) + } else { + None + }; + ( + body, + None, + summary, + structured_audiences.unwrap_or_else(|| audience_targets_or_public(None)), + published_at, + PostBookkeepingExpectation::default(), + structured_tags.unwrap_or_default(), + ) + }; let record = perform_post_creation( posts.as_ref(), PostCreation { user_id: auth.user_id, body, - title: None, + title: title.as_ref(), format, slug_override: slug_override.as_ref(), published_at, @@ -200,6 +314,7 @@ pub async fn create(post: PostInputs) -> WebResult { summary, audiences, idempotency_key: None, + expectations, }, ) .await?; @@ -299,6 +414,7 @@ pub async fn get_preview(post_id: PostId) -> WebResult { /// See `create` for why it crosses the boundary as a [`UtcInstant`]. #[macros::server(input = Json, skip_all)] pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { + let request_clock = UtcInstant::now(); let PostInputs { body, format, @@ -320,18 +436,77 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { .map(|p| p.tags.iter().map(|t| t.tag_slug.clone()).collect()) .unwrap_or_default(); - // Validate tags up-front so a malformed input rejects before any - // post mutation lands. The wire delivers `Vec` (validated at - // arg-decode per ADR-0065); the body only dedups and caps. `None` leaves - // the existing tags untouched. - let new_tags = tags.map(common::tag::parse_and_validate_tags).transpose()?; - - // See `create`: the typed `PostSummary` arg is already validated at - // decode, so no `non_empty_owned` normalization is applied here. - let audiences = audience_targets_or_public(audience.as_ref()); + // Validate tags up-front so a malformed input rejects before any post + // mutation lands. `None` preserves the current update surface behavior. + let structured_tags = tags.map(common::tag::parse_and_validate_tags).transpose()?; + let structured_audiences = audience + .as_ref() + .map(|selection| audience_targets_or_public(Some(selection))); - // A supplied time schedules/backdates; `None` lets storage keep an - // existing timestamp or stamp `now` for a not-yet-published post. + let (body, title, summary, audiences, publish, expectations, new_tags) = if format + == PostFormat::Org + { + let normalized = normalize_web_org( + &body, + OrgStructuredMetadata { + title: Presence::Absent, + summary: summary.map_or(Presence::Absent, Presence::Present), + tags: structured_tags.map_or(Presence::Absent, Presence::Present), + audiences: structured_audiences.map_or(Presence::Absent, Presence::Present), + lifecycle: structured_lifecycle(publish, publish_at, request_clock), + }, + OrgOperation::Update { post_id }, + request_clock, + )?; + let metadata = normalized.metadata; + let audiences = match metadata.audiences { + Presence::Present(audiences) => audiences, + Presence::Absent => audience_targets_or_public(None), + }; + validate_org_audiences(&audiences, auth.user_id).await?; + let publish = match metadata.lifecycle { + Presence::Present(PublicationState::Draft) | Presence::Absent => { + PublishUpdate::Unpublish + } + Presence::Present( + PublicationState::Scheduled(at) | PublicationState::Published(at), + ) => PublishUpdate::Publish { at: Some(at) }, + }; + ( + normalized.body, + match metadata.title { + Presence::Present(title) => Some(title), + Presence::Absent => None, + }, + match metadata.summary { + Presence::Present(summary) => Some(summary), + Presence::Absent => None, + }, + audiences, + publish, + normalized.bookkeeping.into(), + match metadata.tags { + Presence::Present(tags) => Some(tags), + Presence::Absent => None, + }, + ) + } else { + let publish = publish + .ok_or_else(|| InternalError::validation("missing required structured lifecycle"))?; + ( + body, + None, + summary, + structured_audiences.unwrap_or_else(|| audience_targets_or_public(None)), + if publish { + PublishUpdate::Publish { at: publish_at } + } else { + PublishUpdate::Unpublish + }, + PostBookkeepingExpectation::default(), + structured_tags, + ) + }; let record = perform_post_update( posts.as_ref(), @@ -339,21 +514,22 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { post_id, editor_user_id: auth.user_id, body, - title: None, + title: title.as_ref(), format, slug_override: slug_override.as_ref(), - publish: if publish { - PublishUpdate::Publish { at: publish_at } - } else { - PublishUpdate::Unpublish - }, + publish, summary, audiences, + request_clock, + expectations, }, ) - .await?; - - let mut all_tag_slugs: BTreeSet = old_tag_slugs; + .await + .map_err(|error| match error { + PerformUpdateError::StaleContent => InternalError::conflict(error.to_string()), + error => error.into(), + })?; + let mut all_tag_slugs = old_tag_slugs; if let Some(new_tags) = new_tags { posts.set_post_tags(post_id, &new_tags).await?; // Union old with new so both the vacated and the newly-occupied tag @@ -724,7 +900,7 @@ mod tests { body: parse_post_body("hi"), format: PostFormat::Markdown, slug_override: None, - publish: false, + publish: Some(false), publish_at: None, tags: None, summary: None, @@ -850,7 +1026,6 @@ mod server_tests { use super::{PostInputs, create, list_drafts, publish, update}; use crate::error::WebError; use crate::test_support::auth_parts; - use chrono::Utc; use common::ids::{PostId, UserId}; use common::pagination::PageSize; use common::slug::Slug; @@ -861,12 +1036,12 @@ mod server_tests { use leptos::reactive::owner::Owner; use std::sync::Arc; use storage::{ - FeedEventStorage, MockFeedEventStorage, MockPostStorage, PostFormat, PostRecord, - PostStorage, RenderedHtml, UpdatePostError, + AudienceStorage, FeedEventStorage, MockAudienceStorage, MockFeedEventStorage, + MockPostStorage, PostFormat, PostRecord, PostStorage, RenderedHtml, UpdatePostError, }; fn owned_post(user_id: UserId) -> PostRecord { - let now = Utc::now(); + let now = UtcInstant::now(); PostRecord { post_id: PostId::from(1), user_id, @@ -876,8 +1051,8 @@ mod server_tests { body: parse_post_body("body"), format: PostFormat::Markdown, rendered_html: RenderedHtml::from_trusted("

body

"), - created_at: UtcInstant::from(now), - updated_at: UtcInstant::from(now), + created_at: now, + updated_at: now, published_at: None, deleted_at: None, summary: None, @@ -907,6 +1082,30 @@ mod server_tests { } } + #[test] + fn structured_lifecycle_preserves_transport_presence() { + use common::org::{Presence, PublicationState}; + + let clock: UtcInstant = "2026-08-26T12:00:00Z".parse().unwrap(); + assert!(matches!( + super::structured_lifecycle(None, None, clock), + Presence::Absent + )); + assert!(matches!( + super::structured_lifecycle(Some(false), None, clock), + Presence::Present(PublicationState::Draft) + )); + assert!(matches!( + super::structured_lifecycle(Some(true), None, clock), + Presence::Present(PublicationState::Published(at)) if at == clock + )); + let future = common::test_support::parse_utc_instant("2026-08-26T12:01:00Z"); + assert!(matches!( + super::structured_lifecycle(Some(true), Some(future), clock), + Presence::Present(PublicationState::Scheduled(at)) if at == future + )); + } + /// The probing-row twin of `listing.rs`'s /// `every_paginated_fetcher_asks_storage_for_the_probing_row`, which cannot reach /// this path: `list_drafts` is a `#[server]` fn needing an owner and an @@ -990,6 +1189,7 @@ mod server_tests { owner.set(); provide_context(auth_parts(UserId::from(1), "alice")); provide_context(Arc::new(posts) as Arc); + provide_context(Arc::new(MockAudienceStorage::new()) as Arc); let mut events = MockFeedEventStorage::new(); events.expect_enqueue_many().returning(|_| Ok(())); provide_context(Arc::new(events) as Arc); @@ -1003,7 +1203,7 @@ mod server_tests { body: parse_post_body("body"), format: PostFormat::Markdown, slug_override: None, - publish: false, + publish: Some(false), publish_at: None, tags, summary: None, @@ -1077,9 +1277,79 @@ mod server_tests { .expect_update_post() .returning(|_id, _user, _input| Ok(owned_post(UserId::from(1)))); posts.expect_set_post_tags().times(0); + let owner = mutation_owner(posts); let result = update(PostId::from(1), post_inputs(None)).await; drop(owner); result.expect("update succeeds"); } + // guard:no-backend — mock store + #[tokio::test] + async fn update_org_keeps_structured_audience_and_summary() { + use common::test_support::parse_post_summary; + use common::visibility::{AudienceBase, AudienceSelection, AudienceTarget}; + + let mut posts = MockPostStorage::new(); + posts + .expect_get_post_by_id() + .returning(|_id, _viewer| Ok(Some(owned_post(UserId::from(1))))); + posts + .expect_update_post() + .withf(|_id, _user, input| { + input.summary.as_deref() == Some("structured summary") + && input.audiences == [AudienceTarget::Subscribers] + }) + .returning(|_id, _user, _input| Ok(owned_post(UserId::from(1)))); + let owner = mutation_owner(posts); + let result = update( + PostId::from(1), + PostInputs { + body: parse_post_body("Body"), + format: PostFormat::Org, + slug_override: None, + publish: Some(false), + publish_at: None, + tags: None, + summary: Some(parse_post_summary("structured summary")), + audience: Some(AudienceSelection { + base: AudienceBase::Subscribers, + named: vec![], + }), + }, + ) + .await; + drop(owner); + result.expect("update succeeds"); + } + + // guard:no-backend — mock store + #[tokio::test] + async fn update_projects_stale_org_sync_to_conflict() { + let mut posts = MockPostStorage::new(); + posts + .expect_get_post_by_id() + .returning(|_id, _viewer| Ok(Some(owned_post(UserId::from(1))))); + posts + .expect_update_post() + .returning(|_id, _user, _input| Err(UpdatePostError::StaleContent)); + let owner = mutation_owner(posts); + let result = update( + PostId::from(1), + PostInputs { + body: parse_post_body( + "#+PROPERTY: JAUNDER_ID 1\n#+PROPERTY: JAUNDER_SYNCED \"sha256-stale\"\n\nbody", + ), + format: PostFormat::Org, + slug_override: None, + publish: Some(false), + publish_at: None, + tags: None, + summary: None, + audience: None, + }, + ) + .await; + drop(owner); + assert!(matches!(result, Err(WebError::Conflict { .. }))); + } } diff --git a/web/src/posts/component.rs b/web/src/posts/component.rs index 4b20aa5bf..8b504ab1a 100644 --- a/web/src/posts/component.rs +++ b/web/src/posts/component.rs @@ -693,7 +693,7 @@ fn CompactComposer( placeholder="Optional summary or excerpt" /> - +
@@ -1438,7 +1438,7 @@ fn ComposeOptions( />
- +
diff --git a/web/src/posts/compose_state.rs b/web/src/posts/compose_state.rs index d1744969f..7f0809d26 100644 --- a/web/src/posts/compose_state.rs +++ b/web/src/posts/compose_state.rs @@ -62,6 +62,10 @@ pub struct ComposeState { /// renders the control; the compact composer leaves it empty (publish-now). pub publish_at: RwSignal, pub tags: RwSignal>, + /// Whether the author explicitly supplied the current tag collection. New + /// and freshly-seeded editors leave this false so Org header metadata can + /// fill the absence; changing tags makes even an empty collection explicit. + tags_supplied: RwSignal, pub audience: RwSignal, } @@ -79,6 +83,7 @@ impl ComposeState { summary_field: Field::::optional(), publish_at: RwSignal::new(String::new()), tags: RwSignal::new(Vec::new()), + tags_supplied: RwSignal::new(false), audience: RwSignal::new(AudienceSelection { base: AudienceBase::Public, named: Vec::new(), @@ -105,9 +110,9 @@ impl ComposeState { slug_override: Option, ) -> PostInputs { let (publish, publish_at) = match publication { - PublicationIntent::Draft => (false, None), - PublicationIntent::PublishNow => (true, None), - PublicationIntent::PublishAt(at) => (true, Some(at)), + PublicationIntent::Draft => (Some(false), None), + PublicationIntent::PublishNow => (Some(true), None), + PublicationIntent::PublishAt(at) => (Some(true), Some(at)), }; PostInputs { body, @@ -115,12 +120,25 @@ impl ComposeState { slug_override, publish, publish_at, - tags: Some(self.tags.get().into_iter().map(|t| t.display).collect()), + tags: self + .tags_supplied + .get() + .then(|| self.tags.get().into_iter().map(|t| t.display).collect()), summary: self.summary_field.parsed(), audience: Some(self.audience.get()), } } + /// Mark the tag collection as explicitly supplied after a tag-input mutation. + /// + /// The tag widget owns its interaction paths, so callers hand this callback to + /// it rather than duplicating presence bookkeeping in each event handler. + #[must_use] + pub fn tag_input_changed(&self) -> Callback<()> { + let tags_supplied = self.tags_supplied; + Callback::new(move |()| tags_supplied.set(true)) + } + /// Load an existing post's contents into the fields this bundle owns. /// /// The editor reuses the bundle because it edits the same things and dispatches @@ -139,6 +157,7 @@ impl ComposeState { self.summary_field .set_input(fetched.post.summary.as_deref().unwrap_or_default()); self.tags.set(fetched.post.tags.clone()); + self.tags_supplied.set(false); } /// Empty the composer for the next post, after a successful create. @@ -151,6 +170,7 @@ impl ComposeState { self.summary_field.reset(); self.publish_at.set(String::new()); self.tags.set(Vec::new()); + self.tags_supplied.set(false); } } @@ -231,17 +251,21 @@ mod tests { let draft = state.inputs(body.clone(), PublicationIntent::Draft, None); assert_eq!(draft.body.as_ref(), "hello"); - assert!(!draft.publish); + assert_eq!(draft.publish, Some(false)); assert_eq!(draft.publish_at, None); assert_eq!(draft.format, PostFormat::Markdown); assert!(draft.slug_override.is_none()); + assert_eq!( + draft.tags, None, + "an untouched new composer leaves Org header tags absent" + ); let now = state.inputs(body.clone(), PublicationIntent::PublishNow, None); - assert!(now.publish); + assert_eq!(now.publish, Some(true)); assert_eq!(now.publish_at, None); let scheduled = state.inputs(body, PublicationIntent::PublishAt(scheduled_at), None); - assert!(scheduled.publish); + assert_eq!(scheduled.publish, Some(true)); assert_eq!(scheduled.publish_at, Some(scheduled_at)); }); } @@ -414,6 +438,15 @@ mod tests { assert_eq!(state.body.value.get(), "raw"); assert_eq!(state.format.get(), PostFormat::Markdown); assert_eq!(state.tags.get().len(), 1); + let inputs = state.inputs( + "edited body".parse().expect("a non-blank body parses"), + PublicationIntent::PublishNow, + None, + ); + assert_eq!( + inputs.tags, None, + "loaded tags remain implicit until the author changes them" + ); assert_eq!( state.summary_field.value.get(), "", @@ -422,6 +455,38 @@ mod tests { }); } + #[test] + fn tag_interactions_make_even_an_empty_collection_explicit() { + with_owner(|| { + let state = ComposeState::new(); + let input = + crate::tags::InputState::new(state.tags).with_on_change(state.tag_input_changed()); + let tag = crate::posts::render::test_fixtures::sample_post() + .post + .tags + .into_iter() + .next() + .expect("the sample post has a tag"); + let body: PostBody = "body".parse().expect("a non-blank body parses"); + + input.commit(tag.clone()); + assert_eq!( + state + .inputs(body.clone(), PublicationIntent::PublishNow, None) + .tags, + Some(vec![tag.display.clone()]), + "adding a tag supplies the structured collection" + ); + + input.remove(&tag); + assert_eq!( + state.inputs(body, PublicationIntent::PublishNow, None).tags, + Some(Vec::new()), + "clearing tags after interaction remains an explicit empty collection" + ); + }); + } + #[test] fn reset_clears_the_post_body_but_keeps_format_and_audience() { with_owner(|| { diff --git a/web/src/tags/component.rs b/web/src/tags/component.rs index 864451ea9..800c67e84 100644 --- a/web/src/tags/component.rs +++ b/web/src/tags/component.rs @@ -17,17 +17,18 @@ use super::input_state::InputState; /// /// Renders each tag in `tags` as a removable chip and emits one /// `` per chip so an enclosing -/// form receives a `Vec`. Its behavior lives on [`InputState`]. +/// form receives a `Vec`; calls `on_change` after an actual tag mutation. #[component] pub fn TagInput( tags: RwSignal>, + on_change: Callback<()>, #[prop(default = "tags")] name: &'static str, ) -> impl IntoView { - let state = InputState::new(tags); + let state = InputState::new(tags).with_on_change(on_change); view! {
- + ` so an enclosing form receives the tags. #[component] -fn TagChips(tags: RwSignal>, name: &'static str) -> impl IntoView { +fn TagChips( + tags: RwSignal>, + name: &'static str, + on_remove: Callback, +) -> impl IntoView { move || { tags.get() .into_iter() .map(|tag| { - let slug = tag.slug.clone(); + let tag_for_remove = tag.clone(); let display = tag.display.to_string(); view! { @@ -80,9 +85,7 @@ fn TagChips(tags: RwSignal>, name: &'static str) -> impl IntoVie type="button" class="j-tag-chip-remove" aria-label="Remove tag" - on:click=move |_| { - tags.update(|t| t.retain(|x| x.slug != slug)); - } + on:click=move |_| on_remove.run(tag_for_remove.clone()) > "\u{00d7}" diff --git a/web/src/tags/input_state.rs b/web/src/tags/input_state.rs index 22cf61eb0..ff731b3b9 100644 --- a/web/src/tags/input_state.rs +++ b/web/src/tags/input_state.rs @@ -15,14 +15,15 @@ use super::input_logic::{next_suggestion, parse_committed_tag, prev_suggestion, /// The committed `tags` plus the transient text-field / autocomplete signals. /// -/// Every field is an `RwSignal` (a `Copy` handle into the reactive runtime), so the -/// whole struct is `Copy` and can be handed to each event closure and child callback -/// without per-signal capture. `pub` (and re-exported from `tags`) only so the +/// Its signals and change callback are `Copy` handles into the reactive runtime, so +/// the whole struct can be handed to each event closure and child callback without +/// per-signal capture. `pub` (and re-exported from `tags`) only so the /// wasm-only `component` — which never host-compiles — doesn't leave these host-lib /// items looking like dead code. #[derive(Clone, Copy)] pub struct InputState { pub tags: RwSignal>, + on_change: Callback<()>, pub input_text: RwSignal, pub error: RwSignal>, pub suggestions: RwSignal>, @@ -37,6 +38,7 @@ impl InputState { pub fn new(tags: RwSignal>) -> Self { Self { tags, + on_change: Callback::new(|()| {}), input_text: RwSignal::new(String::new()), error: RwSignal::new(None), suggestions: RwSignal::new(Vec::new()), @@ -46,6 +48,21 @@ impl InputState { } } + /// Notify the owning form when a committed tag collection actually changes. + #[must_use] + pub fn with_on_change(mut self, on_change: Callback<()>) -> Self { + self.on_change = on_change; + self + } + + fn mutate_tags(self, mutate: impl FnOnce(&mut Vec) -> bool) { + let mut changed = false; + self.tags.update(|tags| changed = mutate(tags)); + if changed { + self.on_change.run(()); + } + } + /// Dismiss the autocomplete dropdown and clear its selection. pub fn close_suggestions(self) { self.suggestions.set(Vec::new()); @@ -56,12 +73,26 @@ impl InputState { /// The one commit path — shared by the keyboard handler and the dropdown click: /// dedup-append `tag`, then clear the field and close the suggestions. pub fn commit(self, tag: TagSummary) { - self.tags.update(|t| push_unique(t, tag)); + self.mutate_tags(|tags| { + let length = tags.len(); + push_unique(tags, tag); + tags.len() != length + }); self.input_text.set(String::new()); self.error.set(None); self.close_suggestions(); } + /// Remove the chip represented by `tag`, notifying the owning form only when + /// that collection changed. + pub fn remove(self, tag: &TagSummary) { + self.mutate_tags(|tags| { + let length = tags.len(); + tags.retain(|current| current.slug != tag.slug); + tags.len() != length + }); + } + /// Apply a text-field change: mirror the value and reset the error/selection. /// Returns `Some((prefix, tick))` when a debounced suggestion fetch should be /// scheduled for a non-empty prefix, or `None` when the field is now empty. @@ -112,9 +143,7 @@ impl InputState { true } "Backspace" if self.input_text.get().is_empty() => { - self.tags.update(|t| { - t.pop(); - }); + self.mutate_tags(|tags| tags.pop().is_some()); false } "ArrowDown" => {