From cfdbd9b025a6a8068de57a0d90b736a7fac05480 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 09:49:57 -0400 Subject: [PATCH 01/15] feat: normalize Org metadata headers --- Cargo.lock | 41 +- Cargo.toml | 1 + common/Cargo.toml | 15 +- common/src/etag.rs | 43 +- common/src/lib.rs | 1 + common/src/org.rs | 810 ++++++++++++++++++ docs/ARCHITECTURE.md | 39 +- .../drafts/server-side-org-metadata-block.md | 92 ++ .../2026-08-26-issue-77-org-header-block.md | 134 +++ .../2026-08-26-issue-77-org-header-block.md | 98 +++ server/src/atompub/posts.rs | 48 +- 11 files changed, 1263 insertions(+), 59 deletions(-) create mode 100644 common/src/org.rs create mode 100644 docs/adr/drafts/server-side-org-metadata-block.md create mode 100644 docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md create mode 100644 docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md 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/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..84df6e03b --- /dev/null +++ b/common/src/org.rs @@ -0,0 +1,810 @@ +//! 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 { + 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_lifecycle(structured.lifecycle, parsed.lifecycle, request_clock)?; + + 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, +} +fn parse_leading_block( + source: &str, + document: &Org, + request_clock: UtcInstant, +) -> Result { + let mut parsed = ParsedBlock::default(); + let header_end = leading_keyword_end(document); + let mut body = Vec::new(); + let mut offset = 0; + let mut titles = Vec::new(); + let mut summaries = Vec::new(); + let mut tags = Vec::new(); + let mut audiences = Vec::new(); + let mut date = None; + let mut timezone = None; + let mut status = None; + + for source_line in source.split_inclusive('\n') { + let line = source_line.strip_suffix('\n').unwrap_or(source_line); + if offset < header_end && line.trim_start().starts_with("#+") { + match keyword(line) { + Some((name, value)) if name == "property" && !recognized_property(value) => { + body.push(line); + } + Some((name, value)) if recognized(&name) => match name.as_str() { + "title" => titles.push(nonblank(value, "TITLE")?.to_owned()), + "description" => summaries.push(nonblank(value, "DESCRIPTION")?.to_owned()), + "keywords" => { + 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"); + } + for term in terms { + tags.push(term.parse().map_err(|_| { + OrgMetadataError::Invalid("invalid KEYWORDS tag".into()) + })?); + } + } + "date" => set_once(&mut date, nonblank(value, "DATE")?.to_owned(), "DATE")?, + "property" => parse_property( + value, + &mut timezone, + &mut status, + &mut audiences, + &mut parsed.bookkeeping, + )?, + _ => unreachable!(), + }, + _ => body.push(line), + } + } else { + body.push(line); + } + offset += source_line.len(); + } + + if !titles.is_empty() { + parsed.title = Presence::Present( + titles + .join("\n") + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid TITLE".into()))?, + ); + } + if !summaries.is_empty() { + parsed.summary = Presence::Present( + summaries + .join("\n") + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid DESCRIPTION".into()))?, + ); + } + if !tags.is_empty() { + parsed.tags = Presence::Present( + parse_and_validate_tags(tags) + .map_err(|_| OrgMetadataError::Invalid("invalid KEYWORDS".into()))?, + ); + } + if !audiences.is_empty() { + parsed.audiences = Presence::Present(audiences); + } + parsed.lifecycle = parse_lifecycle(status, date, timezone, request_clock)?; + parsed.body = body.join("\n"); + Ok(parsed) +} + +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 recognized(name: &str) -> bool { + matches!( + name, + "title" | "description" | "keywords" | "date" | "property" + ) +} + +fn recognized_property(value: &str) -> bool { + value.split_whitespace().next().is_some_and(|name| { + matches!( + name.to_ascii_lowercase().as_str(), + "jaunder_date_tz" + | "jaunder_status" + | "jaunder_audience" + | "jaunder_slug" + | "jaunder_format" + | "jaunder_id" + | "jaunder_synced" + | "jaunder_synced_at" + | "jaunder_date_utc" + ) + }) +} +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( + value: &str, + timezone: &mut Option, + status: &mut Option, + audiences: &mut Vec, + bookkeeping: &mut OrgBookkeeping, +) -> Result<(), OrgMetadataError> { + let Some((name, value)) = value.split_once(char::is_whitespace) else { + return invalid("PROPERTY must name a value"); + }; + let name = name.to_ascii_lowercase(); + let value = nonblank(value.trim(), "PROPERTY")?; + match name.as_str() { + "jaunder_date_tz" => set_once(timezone, value.to_owned(), "JAUNDER_DATE_TZ"), + "jaunder_status" => set_once(status, value.to_owned(), "JAUNDER_STATUS"), + "jaunder_audience" => { + audiences.push(parse_audience(value)?); + Ok(()) + } + "jaunder_slug" => set_bookkeeping( + &mut bookkeeping.slug, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder slug".into()))?, + "JAUNDER_SLUG", + ), + "jaunder_format" => set_bookkeeping( + &mut bookkeeping.format, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder format".into()))?, + "JAUNDER_FORMAT", + ), + "jaunder_id" => set_bookkeeping( + &mut bookkeeping.post_id, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder ID".into()))?, + "JAUNDER_ID", + ), + "jaunder_synced" => set_bookkeeping( + &mut bookkeeping.synced, + ETag::from_str(value) + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder sync ETag".into()))?, + "JAUNDER_SYNCED", + ), + "jaunder_synced_at" => set_bookkeeping( + &mut bookkeeping.synced_at, + value + .parse() + .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder sync time".into()))?, + "JAUNDER_SYNCED_AT", + ), + "jaunder_date_utc" => set_bookkeeping( + &mut bookkeeping.date_utc, + value.parse().map_err(|_| { + OrgMetadataError::Invalid("invalid Jaunder publication time".into()) + })?, + "JAUNDER_DATE_UTC", + ), + _ => Ok(()), + } +} +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"), + }; + Ok(Presence::Present(state)) +} + +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 choose_lifecycle( + structured: Presence, + header: Presence, + clock: UtcInstant, +) -> Result, OrgMetadataError> { + let chosen = choose(structured, header); + match chosen { + Presence::Present(PublicationState::Published(at)) if at.value() > clock.value() => { + invalid("published instant must not be future") + } + value => Ok(value), + } +} +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 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"); + } + + #[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"); + } + + #[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"); + } + + #[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"); + 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..26) + .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..261beb614 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -342,16 +342,28 @@ 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 their presentation header block on output. `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 before canonicalizing because canonicalization removes recognized +metadata. **`RenderedHtml` guarantees "contains no active markup", through two named doors** ([ADR-0079](adr/0079-rendered-html-sanitization.md)). @@ -663,6 +675,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..35db88947 --- /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 +the keywords comma parser drops empty terms and then requires at least one tag. +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/superpowers/plans/2026-08-26-issue-77-org-header-block.md b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md new file mode 100644 index 000000000..06b0045fa --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.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. + +- [ ] 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. + +- [ ] 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. + +- [ ] 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. + +- [ ] 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/superpowers/specs/2026-08-26-issue-77-org-header-block.md b/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md new file mode 100644 index 000000000..a3a5bffba --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md @@ -0,0 +1,98 @@ +# 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. + `KEYWORDS` flattens comma-separated occurrences, drops empty comma terms, and + uses existing `TagLabel` validation, slug deduplication, order, and tag cap; + no remaining value is invalid. 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/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index 0a9dd85e5..cc0d69a42 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -8,12 +8,13 @@ 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::pagination::PageSize; +#[cfg(test)] use common::tag::TagLabel; use common::tagged_url::{BaseUrl, EditUriUrl, FeedUrl, PaginationUrl, compose}; use common::time::UtcInstant; @@ -79,39 +80,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. From 052642052d406d03eec9918a0cd449e10636ce42 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 10:19:50 -0400 Subject: [PATCH 02/15] feat: validate Org bookkeeping atomically --- .../2026-08-26-issue-77-org-header-block.md | 2 +- server/src/atompub/error.rs | 5 +- server/src/atompub/posts.rs | 9 +- server/tests/storage/listing.rs | 5 +- server/tests/storage/posts.rs | 13 +- storage/src/post_service.rs | 440 +++++++++++++++++- storage/src/postgres/posts.rs | 52 ++- storage/src/posts.rs | 119 +++++ storage/src/sqlite/posts.rs | 35 +- storage/src/test_support.rs | 11 +- web/src/posts/api.rs | 12 +- 11 files changed, 661 insertions(+), 42 deletions(-) diff --git a/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md index 06b0045fa..812912d21 100644 --- a/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md +++ b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md @@ -47,7 +47,7 @@ Out: tag rules, the full lifecycle matrix, weekday/DST edges, one-clock equality, and metadata-only rejection. -- [ ] Task 2: Make persistence-dependent finalization atomic on both backends +- [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; diff --git a/server/src/atompub/error.rs b/server/src/atompub/error.rs index 051f2583b..0677ee1fa 100644 --- a/server/src/atompub/error.rs +++ b/server/src/atompub/error.rs @@ -134,7 +134,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 +145,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), } diff --git a/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index cc0d69a42..645bc4ebd 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -311,10 +311,12 @@ pub async fn collection_post( 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 request_clock = chrono::Utc::now(); + let published_at = if fields.is_draft { None } else { - Some(fields.published.unwrap_or_else(UtcInstant::now)) + Some(fields.published.unwrap_or(request_clock)) }; // AtomPub has no audience picker; new posts adopt the instance default. @@ -340,6 +342,7 @@ pub async fn collection_post( summary: fields.summary, audiences: vec![default_audience.into()], idempotency_key: idem, + expectations: storage::PostBookkeepingExpectation::default(), }, ) .await; @@ -434,6 +437,8 @@ pub async fn member_put( // rejected, not updated-then-rejected (#771 D9/D12, ADR-0092). let categories = common::tag::parse_and_validate_tags(fields.categories)?; + let request_clock = chrono::Utc::now(); + // 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?; @@ -456,6 +461,8 @@ pub async fn member_put( at: fields.published, } }, + request_clock, + expectations: storage::PostBookkeepingExpectation::default(), summary: fields.summary, audiences, }, diff --git a/server/tests/storage/listing.rs b/server/tests/storage/listing.rs index cae833186..8397ca13d 100644 --- a/server/tests/storage/listing.rs +++ b/server/tests/storage/listing.rs @@ -12,8 +12,8 @@ use common::{ use std::sync::Arc; use storage::test_support::{Backend, SeedRawPost, SeedUser, backends, fp}; use storage::{ - AppState, FeedCacheRow, GoLivePost, ListByTagError, PostCursor, PostFormat, PostRecord, - RenderedPostContent, create_rendered_post, + AppState, FeedCacheRow, GoLivePost, ListByTagError, PostBookkeepingExpectation, PostCursor, + PostFormat, PostRecord, RenderedPostContent, create_rendered_post, }; use rstest::*; @@ -94,6 +94,7 @@ async fn seed_post_published_at( summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await diff --git a/server/tests/storage/posts.rs b/server/tests/storage/posts.rs index a49d5660d..6bcb8c102 100644 --- a/server/tests/storage/posts.rs +++ b/server/tests/storage/posts.rs @@ -12,8 +12,8 @@ use rstest::*; use rstest_reuse::*; use storage::test_support::{Backend, SeedRawPost, SeedUser, TestEnv, UpdateRawPost, backends}; use storage::{ - CreatePostError, PostFormat, PostUpdate, PublishUpdate, RenderedPostContent, UpdatePostError, - create_rendered_post, perform_post_update, + CreatePostError, PostBookkeepingExpectation, PostFormat, PostUpdate, PublishUpdate, + RenderedPostContent, UpdatePostError, create_rendered_post, perform_post_update, }; use super::fixtures::{anon_by_tag, open_pool}; @@ -155,6 +155,8 @@ fn update_input<'a>( publish, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: Utc::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: Utc::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: Utc::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await diff --git a/storage/src/post_service.rs b/storage/src/post_service.rs index 46ae455ba..0f4897362 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: DateTime, + /// 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,374 @@ 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::from(Utc::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: Utc::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, + ) { + use chrono::TimeZone; + + 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 = Utc.with_ymd_and_hms(2042, 7, 1, 12, 0, 0).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: Utc::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 +1220,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -822,6 +1239,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -843,6 +1261,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -872,6 +1291,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -908,6 +1328,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -925,6 +1346,8 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: Utc::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -964,6 +1387,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 +1424,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1017,6 +1442,8 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], + request_clock: Utc::now(), + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1046,6 +1473,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1076,6 +1504,7 @@ mod tests { summary: None, audiences: vec![AudienceTarget::Public], idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await @@ -1110,6 +1539,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..9bf4c92c9 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, - 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, + DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostBookkeepingRow, + PostMediaReferenceBackfill, PostOwnershipRow, 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, update_expectation_error, }; use crate::{ InstanceId, PostDialect, PostRecord, PostStore, PublishUpdate, RenderedHtml, TaggingError, @@ -85,34 +85,48 @@ 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) .await?; - match existing { + let existing = match existing { None => { return finish_post_update_rejection( Err(UpdatePostError::NotFound), 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) => { + 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?; + if let Some(error) = update_expectation_error(post_id, &existing, &tags, input) { + return finish_post_update_rejection(Err(error), tx.rollback().await); + } + } } let old_media: Vec<( common::media::MediaSource, @@ -354,10 +368,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 +389,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..23c5b65a2 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,34 @@ 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, +} + /// 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 +250,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 +271,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 +327,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 +366,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: DateTime, + /// Non-authoritative Org bookkeeping to compare under the owner lock. + pub expectations: PostBookkeepingExpectation, } /// A tag record returned by [`PostStorage`] tag queries. @@ -412,6 +450,18 @@ 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 +2804,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.map(UtcInstant::value) == 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 +2866,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.map(UtcInstant::value) != 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 +2980,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?; @@ -5368,6 +5486,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..d8d09b489 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, 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, update_expectation_error, }; use crate::{ InstanceId, PostDialect, PostRecord, PostStore, PublishUpdate, RenderedHtml, TaggingError, @@ -106,22 +107,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..0402503eb 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: Utc::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: Utc::now(), + expectations: PostBookkeepingExpectation::default(), audiences: vec![AudienceTarget::Public], }, ) diff --git a/web/src/posts/api.rs b/web/src/posts/api.rs index c3ffe7d73..5f323ad7f 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -50,10 +50,10 @@ use { 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, + FeedEventStorage, 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, }, }; @@ -200,6 +200,7 @@ pub async fn create(post: PostInputs) -> WebResult { summary, audiences, idempotency_key: None, + expectations: PostBookkeepingExpectation::default(), }, ) .await?; @@ -329,6 +330,7 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { // 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()); + let request_clock = Utc::now(); // A supplied time schedules/backdates; `None` lets storage keep an // existing timestamp or stamp `now` for a not-yet-published post. @@ -349,6 +351,8 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { }, summary, audiences, + request_clock, + expectations: PostBookkeepingExpectation::default(), }, ) .await?; From 7781fb850ffa1206962602cf18e3908e8b51f09c Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 10:56:07 -0400 Subject: [PATCH 03/15] feat: ingest Org metadata across authoring surfaces --- common/src/atompub/entry.rs | 70 +++- common/src/atompub/mod.rs | 4 +- .../2026-08-26-issue-77-org-header-block.md | 4 +- server/src/atompub/error.rs | 20 + server/src/atompub/mapping.rs | 145 +++++-- server/src/atompub/posts.rs | 285 ++++++++++--- server/src/lib.rs | 2 + server/tests/atompub/atompub_posts.rs | 387 ++++++++++++++++++ server/tests/web/posts/create.rs | 246 +++++++++++ server/tests/web/posts/update.rs | 209 ++++++++++ storage/src/posts.rs | 36 ++ web/src/posts/api.rs | 323 +++++++++++++-- 12 files changed, 1580 insertions(+), 151 deletions(-) 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/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md index 812912d21..407e413f5 100644 --- a/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md +++ b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md @@ -71,7 +71,7 @@ Out: behavior, explicit publication-clock persistence, and identical SQLite/PostgreSQL results. -- [ ] Task 3: Adapt web create/update without duplicating Org policy +- [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 @@ -84,7 +84,7 @@ Out: suites for precedence, explicit empty collections, current omission/default behavior, audiences, bookkeeping, atomic rejection, and error kinds. -- [ ] Task 4: Adapt AtomPub POST/PUT and preserve protocol behavior +- [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 diff --git a/server/src/atompub/error.rs b/server/src/atompub/error.rs index 0677ee1fa..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 @@ -255,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 @@ -271,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..b4a501c9d 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,17 @@ 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 @@ -86,6 +92,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 +118,38 @@ 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); + // 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| d.with_timezone(&Utc)); + let lifecycle = match draft_marker(entry) { + Some(true) => Presence::Present(PublicationState::Draft), + Some(false) => Presence::Present(PublicationState::Published(request_clock)), + None => published + .map(UtcInstant::from) + .map(PublicationState::Published) + .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. - // Inverse of `post_to_entry`'s `published: post.published_at.map(fixed_offset)`: - // read the entry's `` (a fixed-offset datetime) back to UTC. - let published = entry - .published() - .map(|d| UtcInstant::from(d.with_timezone(&Utc))); 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 +298,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 +318,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 +336,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 +356,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 +376,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 +394,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 +414,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 +432,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 +453,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 +473,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 +497,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 +520,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 +544,26 @@ 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_is_published_at_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 +577,12 @@ 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] @@ -531,7 +596,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 +613,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 645bc4ebd..45bb57623 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -13,17 +13,23 @@ use serde::Deserialize; use common::atompub::{CollectionFeedTitle, Entry, FeedMeta, entry_to_xml, render_feed}; use common::etag::{ETag, post_content_etag}; use common::ids::PostId; +use common::org::{OrgOperation, OrgStructuredMetadata, Presence, PublicationState, normalize_org}; use common::pagination::PageSize; -#[cfg(test)] +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, PostRecord, PostStorage, SiteConfigStorage, + UserConfigStorage, +}; 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"; @@ -38,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, } @@ -50,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, @@ -67,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 { @@ -104,6 +120,145 @@ 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); + }; + if !targets + .iter() + .any(|target| matches!(target, AudienceTarget::Named(_))) + { + return Ok(Presence::Present(targets)); + } + let owned = audiences.list_audiences(author_user_id).await?; + if targets.iter().any(|target| { + matches!(target, AudienceTarget::Named(id) if !owned.iter().any(|audience| audience.audience_id == *id)) + }) { + return Err(HandlerError::BadRequest); + } + 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: chrono::DateTime, + 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.into(), + )?; + 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: chrono::DateTime, +) -> Option> { + match lifecycle { + Presence::Present(PublicationState::Draft) => None, + Presence::Present(PublicationState::Scheduled(at) | PublicationState::Published(at)) => { + Some((*at).into()) + } + 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).into()), + } + } + 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)] @@ -300,28 +455,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 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 request_clock = chrono::Utc::now(); - - let published_at = if fields.is_draft { - None - } else { - Some(fields.published.unwrap_or(request_clock)) + let default_format = storage::get_default_post_format(user_config, auth_user.user_id).await?; + let fields = entry_to_post_fields(&entry, default_format, request_clock.into())?; + 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") @@ -333,16 +497,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: storage::PostBookkeepingExpectation::default(), + expectations, }, ) .await; @@ -408,10 +572,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( @@ -422,6 +587,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?; @@ -431,39 +597,44 @@ pub async fn member_put( } let entry: Entry = body.parse()?; - 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)?; - let request_clock = chrono::Utc::now(); - - // 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 default_format = storage::get_default_post_format(user_config, auth_user.user_id).await?; + let fields = entry_to_post_fields(&entry, default_format, request_clock.into())?; + 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, - } - }, + publish: update_publish(&lifecycle, is_draft), request_clock, - expectations: storage::PostBookkeepingExpectation::default(), - summary: fields.summary, + expectations, + summary, audiences, }, ) @@ -471,17 +642,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, [ 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..881d67c84 100644 --- a/server/tests/atompub/atompub_posts.rs +++ b/server/tests/atompub/atompub_posts.rs @@ -896,6 +896,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("::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_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"), + chrono::Utc::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"), + chrono::Utc::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..08a6d6221 100644 --- a/server/tests/web/posts/update.rs +++ b/server/tests/web/posts/update.rs @@ -828,3 +828,212 @@ 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_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/posts.rs b/storage/src/posts.rs index 23c5b65a2..3c0904864 100644 --- a/storage/src/posts.rs +++ b/storage/src/posts.rs @@ -223,6 +223,20 @@ pub struct PostBookkeepingExpectation { 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 { @@ -3554,6 +3568,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() { diff --git a/web/src/posts/api.rs b/web/src/posts/api.rs index 5f323ad7f..8dec9b352 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -46,17 +46,96 @@ use { crate::error::InternalError, crate::feed_events::enqueue_feed_events, crate::viewer::viewer_identity, - common::tag::Tag, + chrono::Utc, + common::{ + org::{ + OrgNormalization, OrgOperation, OrgStructuredMetadata, Presence, PublicationState, + normalize_org, + }, + tag::Tag, + }, leptos::prelude::*, std::{collections::BTreeSet, sync::Arc}, storage::{ - FeedEventStorage, 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, + 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 from an explicit publication instant. The +/// boolean's no-date value is the web transport's existing default behavior, +/// not metadata that may suppress an Org header lifecycle. +#[cfg(feature = "server")] +fn structured_lifecycle( + publish: bool, + publish_at: Option, + request_clock: chrono::DateTime, +) -> Presence { + if !publish { + return Presence::Present(PublicationState::Draft); + } + match publish_at { + Some(at) if at.value() > request_clock => { + Presence::Present(PublicationState::Scheduled(at)) + } + Some(at) => Presence::Present(PublicationState::Published(at)), + None => Presence::Present(PublicationState::Published(request_clock.into())), + } +} + +/// 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: chrono::DateTime, +) -> Result { + normalize_org( + body.as_ref(), + structured, + operation, + UtcInstant::from(request_clock), + ) + .map_err(|error| InternalError::validation(error.to_string())) +} + +/// Refuses unknown and foreign named audiences with the same opaque validation +/// response. The storage query is author-scoped, so this reveals neither fact. +#[cfg(feature = "server")] +async fn authorize_org_audiences( + targets: &[AudienceTarget], + author_user_id: common::ids::UserId, +) -> Result<(), InternalError> { + let named: BTreeSet<_> = targets + .iter() + .filter_map(|target| match target { + AudienceTarget::Named(id) => Some(*id), + AudienceTarget::Public | AudienceTarget::Private | AudienceTarget::Subscribers => None, + }) + .collect(); + if named.is_empty() { + return Ok(()); + } + + let audiences = expect_context::>(); + let allowed: BTreeSet<_> = audiences + .list_audiences(author_user_id) + .await + .map_err(InternalError::storage)? + .into_iter() + .map(|audience| audience.audience_id) + .collect(); + if named.is_subset(&allowed) { + Ok(()) + } else { + Err(InternalError::validation("invalid audience")) + } +} #[cfg(feature = "server")] fn unpublished_post_from_record(post: PostRecord) -> UnpublishedPost { let summary_label = post.fallback_summary_label(); @@ -161,6 +240,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 = Utc::now(); let PostInputs { body, format, @@ -177,22 +257,81 @@ 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())?; - - // 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 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))); + + 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), + }; + authorize_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.value()), + }; + 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 { + // Publish + a supplied time = scheduled (future) or backdated (past); + // publish + no time = live now; not publishing = draft (NULL). + let published_at = if publish { + Some(publish_at.map_or(request_clock, UtcInstant::value)) + } 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,7 +339,7 @@ pub async fn create(post: PostInputs) -> WebResult { summary, audiences, idempotency_key: None, - expectations: PostBookkeepingExpectation::default(), + expectations, }, ) .await?; @@ -300,6 +439,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 = Utc::now(); let PostInputs { body, format, @@ -321,19 +461,78 @@ 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()); - let request_clock = Utc::now(); - - // A supplied time schedules/backdates; `None` lets storage keep an - // existing timestamp or stamp `now` for a not-yet-published post. + // 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))); + + 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), + }; + authorize_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.value()), + }, + }; + ( + 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 { + ( + body, + None, + summary, + structured_audiences.unwrap_or_else(|| audience_targets_or_public(None)), + if publish { + PublishUpdate::Publish { + at: publish_at.map(UtcInstant::value), + } + } else { + PublishUpdate::Unpublish + }, + PostBookkeepingExpectation::default(), + structured_tags, + ) + }; let record = perform_post_update( posts.as_ref(), @@ -341,23 +540,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: PostBookkeepingExpectation::default(), + 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 @@ -911,6 +1109,22 @@ mod server_tests { } } + #[test] + fn structured_lifecycle_always_supplies_web_draft_and_publish_now() { + use chrono::TimeZone; + use common::org::{Presence, PublicationState}; + + let clock = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap(); + assert!(matches!( + super::structured_lifecycle(false, None, clock), + Presence::Present(PublicationState::Draft) + )); + assert!(matches!( + super::structured_lifecycle(true, None, clock), + Presence::Present(PublicationState::Published(at)) if at.value() == clock + )); + } + /// 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 @@ -1086,4 +1300,35 @@ mod server_tests { 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: false, + publish_at: None, + tags: None, + summary: None, + audience: None, + }, + ) + .await; + drop(owner); + assert!(matches!(result, Err(WebError::Conflict { .. }))); + } } From 12ca3810af24b8f6b38eb6ea3448baeea37f0528 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 11:13:52 -0400 Subject: [PATCH 04/15] fix: preserve Org metadata presence semantics --- .../drafts/server-side-org-metadata-block.md | 4 +- .../2026-08-26-issue-77-org-header-block.md | 9 +- server/src/atompub/mapping.rs | 36 +- server/src/atompub/posts.rs | 22 +- server/tests/atompub/atompub_posts.rs | 75 ++++ server/tests/web/posts/create.rs | 47 +++ server/tests/web/posts/update.rs | 57 +++ storage/src/audiences.rs | 122 ++++++- web/src/posts/api.rs | 328 +++++++++--------- web/src/posts/compose_state.rs | 12 +- 10 files changed, 509 insertions(+), 203 deletions(-) diff --git a/docs/adr/drafts/server-side-org-metadata-block.md b/docs/adr/drafts/server-side-org-metadata-block.md index 35db88947..16cd137ad 100644 --- a/docs/adr/drafts/server-side-org-metadata-block.md +++ b/docs/adr/drafts/server-side-org-metadata-block.md @@ -36,8 +36,8 @@ Recognized mutable metadata is `#+TITLE`, repeated or comma-separated 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 -the keywords comma parser drops empty terms and then requires at least one tag. -Audience values are exactly `public`, `subscribers`, `private`, or +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. diff --git a/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md b/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md index a3a5bffba..b9199a2f1 100644 --- a/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md +++ b/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md @@ -24,10 +24,11 @@ content. `#+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. - `KEYWORDS` flattens comma-separated occurrences, drops empty comma terms, and - uses existing `TagLabel` validation, slug deduplication, order, and tag cap; - no remaining value is invalid. Other blank recognized values are invalid. +- `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. diff --git a/server/src/atompub/mapping.rs b/server/src/atompub/mapping.rs index b4a501c9d..7c97fe38d 100644 --- a/server/src/atompub/mapping.rs +++ b/server/src/atompub/mapping.rs @@ -129,7 +129,9 @@ pub fn entry_to_post_fields( let published = entry.published().map(|d| d.with_timezone(&Utc)); let lifecycle = match draft_marker(entry) { Some(true) => Presence::Present(PublicationState::Draft), - Some(false) => Presence::Present(PublicationState::Published(request_clock)), + Some(false) => Presence::Present(PublicationState::Published( + published.map_or(request_clock, UtcInstant::from), + )), None => published .map(UtcInstant::from) .map(PublicationState::Published) @@ -553,7 +555,22 @@ mod tests { } #[test] - fn entry_to_post_fields_explicit_non_draft_is_published_at_request_clock() { + 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"); @@ -585,6 +602,21 @@ mod tests { 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_extracts_title() { let xml = r#" diff --git a/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index 45bb57623..548eb74cb 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -24,8 +24,8 @@ use common::time::UtcInstant; use common::username::Username; use common::visibility::{AudienceTarget, ViewerIdentity}; use storage::{ - AudienceStorage, CollectionCursor, PostRecord, PostStorage, SiteConfigStorage, - UserConfigStorage, + AudienceStorage, CollectionCursor, InvalidAudienceTargets, PostRecord, PostStorage, + SiteConfigStorage, UserConfigStorage, validate_named_audience_targets, }; use web::auth; @@ -143,18 +143,12 @@ async fn authorize_audiences( let Presence::Present(targets) = targets else { return Ok(Presence::Absent); }; - if !targets - .iter() - .any(|target| matches!(target, AudienceTarget::Named(_))) - { - return Ok(Presence::Present(targets)); - } - let owned = audiences.list_audiences(author_user_id).await?; - if targets.iter().any(|target| { - matches!(target, AudienceTarget::Named(id) if !owned.iter().any(|audience| audience.audience_id == *id)) - }) { - return Err(HandlerError::BadRequest); - } + 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)) } diff --git a/server/tests/atompub/atompub_posts.rs b/server/tests/atompub/atompub_posts.rs index 881d67c84..88b441f68 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 { @@ -2125,6 +2139,34 @@ async fn create_with_past_published_is_live_backdated(#[case] backend: Backend) ); } +#[apply(backends)] +#[tokio::test] +async fn create_with_explicit_draft_no_preserves_published_instant(#[case] backend: Backend) { + let TestEnv { state, base } = setup_with_base_url(backend).await; + let session = create_user_and_session(&state).await; + let app = make_app(&state, &base); + let xml = entry_xml_with_draft_no_and_published("Old post", "body", "2000-01-01T00:00:00Z"); + + let response = app + .oneshot(atompub_post_xml(&session, "posts", &xml)) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + let post_id = location_post_id(&response); + let owner = common::visibility::ViewerIdentity::local(session.user_id); + let rec = state + .posts + .get_post_by_id(PostId::from(post_id), &owner) + .await + .unwrap() + .unwrap(); + assert_eq!( + rec.published_at.unwrap().to_rfc3339(), + "2000-01-01T00:00:00+00:00" + ); +} + #[apply(backends)] #[tokio::test] async fn update_with_future_published_schedules_post(#[case] backend: Backend) { @@ -2163,6 +2205,39 @@ async fn update_with_future_published_schedules_post(#[case] backend: Backend) { ); } +#[apply(backends)] +#[tokio::test] +async fn update_with_explicit_draft_no_preserves_published_instant(#[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().seed(&state).await; + let app = make_app(&state, &base); + let xml = + entry_xml_with_draft_no_and_published("Backdated", "new body", "2000-01-01T00:00:00Z"); + + let response = app + .oneshot(atompub_put_xml( + &session, + &format!("posts/{}", post.post_id), + &xml, + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let viewer = common::visibility::ViewerIdentity::Anonymous; + let rec = state + .posts + .get_post_by_id(post.post_id, &viewer) + .await + .unwrap() + .unwrap(); + assert_eq!( + rec.published_at.unwrap().to_rfc3339(), + "2000-01-01T00:00:00+00:00" + ); +} + /// POST a create as alice, optionally with an `Idempotency-Key`. async fn create_post_keyed( app: axum::Router, diff --git a/server/tests/web/posts/create.rs b/server/tests/web/posts/create.rs index 709f428c1..67c38491f 100644 --- a/server/tests/web/posts/create.rs +++ b/server/tests/web/posts/create.rs @@ -483,6 +483,53 @@ async fn create_org_header_merges_structured_metadata_and_stores_canonical_body( 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) { diff --git a/server/tests/web/posts/update.rs b/server/tests/web/posts/update.rs index 08a6d6221..17ecf71ce 100644 --- a/server/tests/web/posts/update.rs +++ b/server/tests/web/posts/update.rs @@ -932,6 +932,63 @@ async fn update_org_header_applies_tags_and_rejects_mismatched_bookkeeping( 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) { diff --git a/storage/src/audiences.rs b/storage/src/audiences.rs index caae4ebc8..a2e3be813 100644 --- a/storage/src/audiences.rs +++ b/storage/src/audiences.rs @@ -14,6 +14,7 @@ use async_trait::async_trait; use common::audience::AudienceName; use common::ids::{AudienceId, SubscriptionId, UserId}; use common::time::UtcInstant; +use std::collections::BTreeSet; use sqlx::{Database, Pool}; 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. /// @@ -286,13 +351,12 @@ where subscription_id: SubscriptionId, ) -> Result<(), AudienceError> { sqlx::query( - "INSERT INTO audience_members (audience_id, subscription_id, author_user_id) \ - VALUES ($1, $2, $3) \ - ON CONFLICT (audience_id, subscription_id) DO NOTHING", + "INSERT INTO audience_members (author_user_id, audience_id, subscription_id) \ + VALUES ($1, $2, $3)", ) + .bind(author_user_id) .bind(audience_id) .bind(subscription_id) - .bind(author_user_id) .execute(&self.pool) .await?; Ok(()) @@ -345,10 +409,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 +492,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() { diff --git a/web/src/posts/api.rs b/web/src/posts/api.rs index 8dec9b352..841ab1912 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -65,24 +65,24 @@ use { }, }; -/// Builds structured lifecycle only from an explicit publication instant. The -/// boolean's no-date value is the web transport's existing default behavior, -/// not metadata that may suppress an Org header lifecycle. +/// 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: bool, + publish: Option, publish_at: Option, request_clock: chrono::DateTime, ) -> Presence { - if !publish { - return Presence::Present(PublicationState::Draft); - } - match publish_at { - Some(at) if at.value() > request_clock => { - Presence::Present(PublicationState::Scheduled(at)) - } - Some(at) => Presence::Present(PublicationState::Published(at)), - None => Presence::Present(PublicationState::Published(request_clock.into())), + match publish { + None => Presence::Absent, + Some(false) => Presence::Present(PublicationState::Draft), + Some(true) => match publish_at { + Some(at) if at.value() > request_clock => { + Presence::Present(PublicationState::Scheduled(at)) + } + Some(at) => Presence::Present(PublicationState::Published(at)), + None => Presence::Present(PublicationState::Published(request_clock.into())), + }, } } @@ -104,37 +104,15 @@ fn normalize_web_org( .map_err(|error| InternalError::validation(error.to_string())) } -/// Refuses unknown and foreign named audiences with the same opaque validation -/// response. The storage query is author-scoped, so this reveals neither fact. #[cfg(feature = "server")] -async fn authorize_org_audiences( +async fn validate_org_audiences( targets: &[AudienceTarget], author_user_id: common::ids::UserId, ) -> Result<(), InternalError> { - let named: BTreeSet<_> = targets - .iter() - .filter_map(|target| match target { - AudienceTarget::Named(id) => Some(*id), - AudienceTarget::Public | AudienceTarget::Private | AudienceTarget::Subscribers => None, - }) - .collect(); - if named.is_empty() { - return Ok(()); - } - let audiences = expect_context::>(); - let allowed: BTreeSet<_> = audiences - .list_audiences(author_user_id) + storage::validate_named_audience_targets(audiences.as_ref(), author_user_id, targets) .await - .map_err(InternalError::storage)? - .into_iter() - .map(|audience| audience.audience_id) - .collect(); - if named.is_subset(&allowed) { - Ok(()) - } else { - Err(InternalError::validation("invalid audience")) - } + .map_err(InternalError::from) } #[cfg(feature = "server")] fn unpublished_post_from_record(post: PostRecord) -> UnpublishedPost { @@ -224,7 +202,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, @@ -262,69 +240,72 @@ pub async fn create(post: PostInputs) -> WebResult { .as_ref() .map(|selection| audience_targets_or_public(Some(selection))); - 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), - }; - authorize_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.value()), - }; - 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, - ) + 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.value()), + }; + 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.map_or(request_clock, UtcInstant::value)) } else { - // Publish + a supplied time = scheduled (future) or backdated (past); - // publish + no time = live now; not publishing = draft (NULL). - let published_at = if publish { - Some(publish_at.map_or(request_clock, UtcInstant::value)) - } else { - None - }; - ( - body, - None, - summary, - structured_audiences.unwrap_or_else(|| audience_targets_or_public(None)), - published_at, - PostBookkeepingExpectation::default(), - structured_tags.unwrap_or_default(), - ) + 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(), @@ -468,71 +449,74 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { .as_ref() .map(|selection| audience_targets_or_public(Some(selection))); - 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), - }; - authorize_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.value()), - }, - }; - ( - 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 { - ( - body, - None, - summary, - structured_audiences.unwrap_or_else(|| audience_targets_or_public(None)), - if publish { - PublishUpdate::Publish { - at: publish_at.map(UtcInstant::value), - } - } else { - PublishUpdate::Unpublish - }, - PostBookkeepingExpectation::default(), - structured_tags, - ) + 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.value()), + }, + }; + ( + 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.map(UtcInstant::value), + } + } else { + PublishUpdate::Unpublish + }, + PostBookkeepingExpectation::default(), + structured_tags, + ) + }; let record = perform_post_update( posts.as_ref(), @@ -926,7 +910,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, @@ -1110,17 +1094,21 @@ mod server_tests { } #[test] - fn structured_lifecycle_always_supplies_web_draft_and_publish_now() { + fn structured_lifecycle_preserves_transport_presence() { use chrono::TimeZone; use common::org::{Presence, PublicationState}; let clock = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap(); assert!(matches!( - super::structured_lifecycle(false, None, clock), + 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(true, None, clock), + super::structured_lifecycle(Some(true), None, clock), Presence::Present(PublicationState::Published(at)) if at.value() == clock )); } @@ -1221,7 +1209,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, @@ -1320,7 +1308,7 @@ mod server_tests { ), format: PostFormat::Org, slug_override: None, - publish: false, + publish: Some(false), publish_at: None, tags: None, summary: None, diff --git a/web/src/posts/compose_state.rs b/web/src/posts/compose_state.rs index d1744969f..3dd24f53e 100644 --- a/web/src/posts/compose_state.rs +++ b/web/src/posts/compose_state.rs @@ -105,9 +105,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, @@ -231,17 +231,17 @@ 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()); 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)); }); } From 587b5b39c8dd86a12ae402aa872b89cbf4966541 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 11:49:37 -0400 Subject: [PATCH 05/15] test: prove Org metadata client compatibility --- docs/ARCHITECTURE.md | 15 ++- .../2026-08-26-issue-77-org-header-block.md | 2 +- elisp/test/jaunder-pull-integration.el | 22 +++++ elisp/test/jaunder-pull-test.el | 14 ++- end2end/tests/atompub.spec.ts | 98 ++++++++++++++++--- end2end/tests/posts.spec.ts | 72 ++++++++++++++ web/src/posts/component.rs | 4 +- web/src/posts/compose_state.rs | 75 +++++++++++++- web/src/tags/component.rs | 19 ++-- web/src/tags/input_state.rs | 43 ++++++-- 10 files changed, 324 insertions(+), 40 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 261beb614..7cc76b55b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -359,11 +359,16 @@ 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 their presentation header block on output. `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 before canonicalizing because canonicalization removes recognized -metadata. +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)). diff --git a/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md index 407e413f5..e03cb142a 100644 --- a/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md +++ b/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md @@ -97,7 +97,7 @@ Out: full Org metadata, precedence, audience masking, collision-resolved slug, successful canonical native-source reads, 400/412 failures, and no mutation. -- [ ] Task 5: Prove client and browser compatibility; finish projections +- [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 diff --git a/elisp/test/jaunder-pull-integration.el b/elisp/test/jaunder-pull-integration.el index 9574334eb..9d5398c84 100644 --- a/elisp/test/jaunder-pull-integration.el +++ b/elisp/test/jaunder-pull-integration.el @@ -84,6 +84,28 @@ 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) + (should (equal + (jaunder--buffer-property "JAUNDER_ID") + id)) + (should (jaunder--buffer-property + "JAUNDER_SYNCED"))) + ;; `jaunder-publish' refreshes local + ;; sync bookkeeping; the blocked + ;; re-pull below must preserve those + ;; post-republish bytes. + (setq bytes (buffer-string)) + (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..25fb64082 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,77 @@ 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"); +}); + +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/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 3dd24f53e..ba0541c8b 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. A new + /// composer leaves this false so Org header metadata can fill the absence; + /// loading a post or 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(), @@ -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(true); } /// 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); } } @@ -235,6 +255,10 @@ mod tests { 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_eq!(now.publish, Some(true)); @@ -414,6 +438,23 @@ 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, + Some( + fetched + .post + .tags + .iter() + .map(|tag| tag.display.clone()) + .collect() + ), + "an existing post always supplies its loaded tag replacement" + ); assert_eq!( state.summary_field.value.get(), "", @@ -422,6 +463,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" => { From 80a8ce6dc0733a78c0affbd780b88930b6e80e29 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 12:15:44 -0400 Subject: [PATCH 06/15] fix: validate displaced Org lifecycle metadata --- common/src/org.rs | 57 ++++++++++++++++++---- end2end/tests/posts.spec.ts | 87 ++++++++++++++++++++++++++++++++++ web/src/posts/compose_state.rs | 20 +++----- 3 files changed, 141 insertions(+), 23 deletions(-) diff --git a/common/src/org.rs b/common/src/org.rs index 84df6e03b..c2d27e864 100644 --- a/common/src/org.rs +++ b/common/src/org.rs @@ -120,6 +120,7 @@ pub fn normalize_org( 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)?; @@ -134,7 +135,7 @@ pub fn normalize_org( let tags = choose(structured.tags, parsed.tags); let audiences = choose(structured.audiences, parsed.audiences); validate_audiences(&audiences)?; - let lifecycle = choose_lifecycle(structured.lifecycle, parsed.lifecycle, request_clock)?; + let lifecycle = choose(structured.lifecycle, parsed.lifecycle); Ok(OrgNormalization { body, @@ -422,7 +423,9 @@ fn parse_lifecycle( ("published", at) => PublicationState::Published(at.unwrap_or(clock)), _ => return invalid("invalid JAUNDER_STATUS lifecycle"), }; - Ok(Presence::Present(state)) + let lifecycle = Presence::Present(state); + validate_lifecycle(&lifecycle, clock)?; + Ok(lifecycle) } fn parse_org_date(value: &str, timezone: &str) -> Result { @@ -457,17 +460,15 @@ fn choose(structured: Presence, header: Presence) -> Presence { Presence::Absent => header, } } -fn choose_lifecycle( - structured: Presence, - header: Presence, +fn validate_lifecycle( + lifecycle: &Presence, clock: UtcInstant, -) -> Result, OrgMetadataError> { - let chosen = choose(structured, header); - match chosen { +) -> Result<(), OrgMetadataError> { + match lifecycle { Presence::Present(PublicationState::Published(at)) if at.value() > clock.value() => { invalid("published instant must not be future") } - value => Ok(value), + _ => Ok(()), } } fn validate_audiences(audiences: &Presence>) -> Result<(), OrgMetadataError> { @@ -678,6 +679,44 @@ mod tests { invalid("#+DATE: [2026-08-26 Wed 12:00]\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!( diff --git a/end2end/tests/posts.spec.ts b/end2end/tests/posts.spec.ts index 25fb64082..326daf97b 100644 --- a/end2end/tests/posts.spec.ts +++ b/end2end/tests/posts.spec.ts @@ -166,6 +166,93 @@ Canonical Org body`, 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, }) => { diff --git a/web/src/posts/compose_state.rs b/web/src/posts/compose_state.rs index ba0541c8b..7f0809d26 100644 --- a/web/src/posts/compose_state.rs +++ b/web/src/posts/compose_state.rs @@ -62,9 +62,9 @@ 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. A new - /// composer leaves this false so Org header metadata can fill the absence; - /// loading a post or changing tags makes even an empty collection explicit. + /// 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, } @@ -157,7 +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(true); + self.tags_supplied.set(false); } /// Empty the composer for the next post, after a successful create. @@ -444,16 +444,8 @@ mod tests { None, ); assert_eq!( - inputs.tags, - Some( - fetched - .post - .tags - .iter() - .map(|tag| tag.display.clone()) - .collect() - ), - "an existing post always supplies its loaded tag replacement" + inputs.tags, None, + "loaded tags remain implicit until the author changes them" ); assert_eq!( state.summary_field.value.get(), From 71c915f28f8612963996c6047d60146ee70f59fa Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 12:17:37 -0400 Subject: [PATCH 07/15] docs: archive issue 77 planning --- .../2026-08-26-issue-77-org-header-block-plan.md} | 0 .../2026-08-26-issue-77-org-header-block-spec.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/{superpowers/plans/2026-08-26-issue-77-org-header-block.md => archive/2026-08-26-issue-77-org-header-block-plan.md} (100%) rename docs/{superpowers/specs/2026-08-26-issue-77-org-header-block.md => archive/2026-08-26-issue-77-org-header-block-spec.md} (100%) diff --git a/docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md b/docs/archive/2026-08-26-issue-77-org-header-block-plan.md similarity index 100% rename from docs/superpowers/plans/2026-08-26-issue-77-org-header-block.md rename to docs/archive/2026-08-26-issue-77-org-header-block-plan.md diff --git a/docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md b/docs/archive/2026-08-26-issue-77-org-header-block-spec.md similarity index 100% rename from docs/superpowers/specs/2026-08-26-issue-77-org-header-block.md rename to docs/archive/2026-08-26-issue-77-org-header-block-spec.md From 78ca70384c1c23fe973f35a0a965966c28f2bf0d Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 12:34:27 -0400 Subject: [PATCH 08/15] fix: preserve audience membership idempotency --- storage/src/audiences.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/storage/src/audiences.rs b/storage/src/audiences.rs index a2e3be813..88a105206 100644 --- a/storage/src/audiences.rs +++ b/storage/src/audiences.rs @@ -351,12 +351,13 @@ where subscription_id: SubscriptionId, ) -> Result<(), AudienceError> { sqlx::query( - "INSERT INTO audience_members (author_user_id, audience_id, subscription_id) \ - VALUES ($1, $2, $3)", + "INSERT INTO audience_members (audience_id, subscription_id, author_user_id) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (audience_id, subscription_id) DO NOTHING", ) - .bind(author_user_id) .bind(audience_id) .bind(subscription_id) + .bind(author_user_id) .execute(&self.pool) .await?; Ok(()) From 5dffd276e950b6ac16c75161cb64851c9a3f70fd Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 12:40:44 -0400 Subject: [PATCH 09/15] test: provide audience context for Org updates --- web/src/posts/api.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/posts/api.rs b/web/src/posts/api.rs index 841ab1912..3b41f1d8b 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -1047,8 +1047,8 @@ 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 { @@ -1196,6 +1196,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); From e21444d9b88e053408c778c6a54e9eb256d808ab Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 13:14:10 -0400 Subject: [PATCH 10/15] fix: classify future Atom publication metadata --- elisp/test/jaunder-pull-integration.el | 6 +-- server/src/atompub/mapping.rs | 66 ++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/elisp/test/jaunder-pull-integration.el b/elisp/test/jaunder-pull-integration.el index 9d5398c84..a1cdeabde 100644 --- a/elisp/test/jaunder-pull-integration.el +++ b/elisp/test/jaunder-pull-integration.el @@ -91,16 +91,12 @@ (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"))) - ;; `jaunder-publish' refreshes local - ;; sync bookkeeping; the blocked - ;; re-pull below must preserve those - ;; post-republish bytes. - (setq bytes (buffer-string)) (when (buffer-live-p pulled-buffer) (with-current-buffer pulled-buffer (set-buffer-modified-p nil))) diff --git a/server/src/atompub/mapping.rs b/server/src/atompub/mapping.rs index 7c97fe38d..b25a76d06 100644 --- a/server/src/atompub/mapping.rs +++ b/server/src/atompub/mapping.rs @@ -73,6 +73,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 @@ -126,15 +136,17 @@ pub fn entry_to_post_fields( let is_draft = is_draft(entry); // 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| d.with_timezone(&Utc)); + let published = entry + .published() + .map(|published| UtcInstant::from(published.with_timezone(&Utc))); let lifecycle = match draft_marker(entry) { Some(true) => Presence::Present(PublicationState::Draft), - Some(false) => Presence::Present(PublicationState::Published( - published.map_or(request_clock, UtcInstant::from), + Some(false) => Presence::Present(classify_published( + published.unwrap_or(request_clock), + request_clock, )), None => published - .map(UtcInstant::from) - .map(PublicationState::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 @@ -617,6 +629,50 @@ mod tests { ); } + #[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] fn entry_to_post_fields_extracts_title() { let xml = r#" From 80f61f7628cce8ff4c81078ad8b430194983a29e Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 13:57:06 -0400 Subject: [PATCH 11/15] test: cover Org metadata error branches --- common/src/org.rs | 288 +++++++++++++++++++++++------------- server/src/atompub/posts.rs | 73 ++++++++- storage/src/audiences.rs | 7 + storage/src/posts.rs | 11 ++ web/src/posts/api.rs | 44 ++++++ 5 files changed, 317 insertions(+), 106 deletions(-) diff --git a/common/src/org.rs b/common/src/org.rs index c2d27e864..7ea683d60 100644 --- a/common/src/org.rs +++ b/common/src/org.rs @@ -160,96 +160,154 @@ struct ParsedBlock { 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 mut parsed = ParsedBlock::default(); let header_end = leading_keyword_end(document); let mut body = Vec::new(); let mut offset = 0; - let mut titles = Vec::new(); - let mut summaries = Vec::new(); - let mut tags = Vec::new(); - let mut audiences = Vec::new(); - let mut date = None; - let mut timezone = None; - let mut status = None; + let mut fields = HeaderFields::default(); for source_line in source.split_inclusive('\n') { let line = source_line.strip_suffix('\n').unwrap_or(source_line); - if offset < header_end && line.trim_start().starts_with("#+") { - match keyword(line) { - Some((name, value)) if name == "property" && !recognized_property(value) => { - body.push(line); - } - Some((name, value)) if recognized(&name) => match name.as_str() { - "title" => titles.push(nonblank(value, "TITLE")?.to_owned()), - "description" => summaries.push(nonblank(value, "DESCRIPTION")?.to_owned()), - "keywords" => { - 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"); - } - for term in terms { - tags.push(term.parse().map_err(|_| { - OrgMetadataError::Invalid("invalid KEYWORDS tag".into()) - })?); - } - } - "date" => set_once(&mut date, nonblank(value, "DATE")?.to_owned(), "DATE")?, - "property" => parse_property( - value, - &mut timezone, - &mut status, - &mut audiences, - &mut parsed.bookkeeping, - )?, - _ => unreachable!(), - }, - _ => body.push(line), - } - } else { + let handled = offset < header_end && parse_header_keyword(line, &mut fields)?; + if !handled { body.push(line); } offset += source_line.len(); } - if !titles.is_empty() { + let mut parsed = ParsedBlock { + body: body.join("\n"), + bookkeeping: fields.bookkeeping, + ..ParsedBlock::default() + }; + if !fields.titles.is_empty() { parsed.title = Presence::Present( - titles + fields + .titles .join("\n") .parse() .map_err(|_| OrgMetadataError::Invalid("invalid TITLE".into()))?, ); } - if !summaries.is_empty() { + if !fields.summaries.is_empty() { parsed.summary = Presence::Present( - summaries + fields + .summaries .join("\n") .parse() .map_err(|_| OrgMetadataError::Invalid("invalid DESCRIPTION".into()))?, ); } - if !tags.is_empty() { + if !fields.tags.is_empty() { parsed.tags = Presence::Present( - parse_and_validate_tags(tags) + parse_and_validate_tags(fields.tags) .map_err(|_| OrgMetadataError::Invalid("invalid KEYWORDS".into()))?, ); } - if !audiences.is_empty() { - parsed.audiences = Presence::Present(audiences); + if !fields.audiences.is_empty() { + parsed.audiences = Presence::Present(fields.audiences); } - parsed.lifecycle = parse_lifecycle(status, date, timezone, request_clock)?; - parsed.body = body.join("\n"); + 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::() @@ -275,29 +333,38 @@ fn keyword(line: &str) -> Option<(String, &str)> { Some((key.to_ascii_lowercase(), value.trim())) } -fn recognized(name: &str) -> bool { - matches!( +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, - "title" | "description" | "keywords" | "date" | "property" - ) -} - -fn recognized_property(value: &str) -> bool { - value.split_whitespace().next().is_some_and(|name| { - matches!( - name.to_ascii_lowercase().as_str(), - "jaunder_date_tz" - | "jaunder_status" - | "jaunder_audience" - | "jaunder_slug" - | "jaunder_format" - | "jaunder_id" - | "jaunder_synced" - | "jaunder_synced_at" - | "jaunder_date_utc" - ) - }) + 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")) @@ -317,66 +384,60 @@ fn set_once(slot: &mut Option, value: String, name: &str) -> Result<(), } fn parse_property( + name: PropertyName, value: &str, - timezone: &mut Option, - status: &mut Option, - audiences: &mut Vec, - bookkeeping: &mut OrgBookkeeping, + fields: &mut HeaderFields, ) -> Result<(), OrgMetadataError> { - let Some((name, value)) = value.split_once(char::is_whitespace) else { - return invalid("PROPERTY must name a value"); - }; - let name = name.to_ascii_lowercase(); - let value = nonblank(value.trim(), "PROPERTY")?; - match name.as_str() { - "jaunder_date_tz" => set_once(timezone, value.to_owned(), "JAUNDER_DATE_TZ"), - "jaunder_status" => set_once(status, value.to_owned(), "JAUNDER_STATUS"), - "jaunder_audience" => { - audiences.push(parse_audience(value)?); + 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(()) } - "jaunder_slug" => set_bookkeeping( - &mut bookkeeping.slug, + PropertyName::Slug => set_bookkeeping( + &mut fields.bookkeeping.slug, value .parse() .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder slug".into()))?, "JAUNDER_SLUG", ), - "jaunder_format" => set_bookkeeping( - &mut bookkeeping.format, + PropertyName::Format => set_bookkeeping( + &mut fields.bookkeeping.format, value .parse() .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder format".into()))?, "JAUNDER_FORMAT", ), - "jaunder_id" => set_bookkeeping( - &mut bookkeeping.post_id, + PropertyName::Id => set_bookkeeping( + &mut fields.bookkeeping.post_id, value .parse() .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder ID".into()))?, "JAUNDER_ID", ), - "jaunder_synced" => set_bookkeeping( - &mut bookkeeping.synced, + PropertyName::Synced => set_bookkeeping( + &mut fields.bookkeeping.synced, ETag::from_str(value) .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder sync ETag".into()))?, "JAUNDER_SYNCED", ), - "jaunder_synced_at" => set_bookkeeping( - &mut bookkeeping.synced_at, + PropertyName::SyncedAt => set_bookkeeping( + &mut fields.bookkeeping.synced_at, value .parse() .map_err(|_| OrgMetadataError::Invalid("invalid Jaunder sync time".into()))?, "JAUNDER_SYNCED_AT", ), - "jaunder_date_utc" => set_bookkeeping( - &mut bookkeeping.date_utc, + PropertyName::DateUtc => set_bookkeeping( + &mut fields.bookkeeping.date_utc, value.parse().map_err(|_| { OrgMetadataError::Invalid("invalid Jaunder publication time".into()) })?, "JAUNDER_DATE_UTC", ), - _ => Ok(()), } } fn set_bookkeeping(slot: &mut Option, value: T, name: &str) -> Result<(), OrgMetadataError> { @@ -570,6 +631,18 @@ mod tests { 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( @@ -593,6 +666,7 @@ mod tests { ]) ); invalid("#+KEYWORDS: , ,\nBody"); + invalid("#+KEYWORDS: rust/lang\nBody"); } #[test] @@ -677,6 +751,14 @@ mod tests { "#+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] @@ -734,6 +816,7 @@ mod tests { 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] @@ -749,6 +832,7 @@ mod tests { 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", diff --git a/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index 548eb74cb..9cc388cef 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -658,10 +658,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 { @@ -794,4 +796,67 @@ 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").value(), + &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 Err(error) = normalize_atom_input( + org_fields("#+PROPERTY: JAUNDER_AUDIENCE named:42\nBody"), + OrgOperation::Create, + parse_utc_instant("2026-08-26T12:00:00Z").value(), + &audiences, + UserId::from(1), + ) + .await + else { + panic!("storage failures must not become validation errors"); + }; + + assert!(matches!(error, HandlerError::Internal(_))); + } + + #[test] + fn legacy_draft_lifecycle_fallbacks_remain_unpublished() { + let clock = parse_utc_instant("2026-08-26T12:00:00Z").value(); + 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/storage/src/audiences.rs b/storage/src/audiences.rs index 88a105206..d513dfe56 100644 --- a/storage/src/audiences.rs +++ b/storage/src/audiences.rs @@ -558,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/posts.rs b/storage/src/posts.rs index 3c0904864..6d0c78988 100644 --- a/storage/src/posts.rs +++ b/storage/src/posts.rs @@ -5292,6 +5292,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 diff --git a/web/src/posts/api.rs b/web/src/posts/api.rs index 3b41f1d8b..6676cf303 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -1111,6 +1111,11 @@ mod server_tests { super::structured_lifecycle(Some(true), None, clock), Presence::Present(PublicationState::Published(at)) if at.value() == 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 @@ -1284,11 +1289,50 @@ 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] From c08492675448a1455564e3857982c60b6643b010 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 14:56:38 -0400 Subject: [PATCH 12/15] fix: align Org metadata with typed UTC instants --- server/src/atompub/mapping.rs | 1 - server/src/atompub/posts.rs | 28 ++++++++-------- server/tests/atompub/atompub_posts.rs | 4 +-- server/tests/storage/posts.rs | 6 ++-- server/tests/web/posts/create.rs | 4 +-- storage/src/audiences.rs | 2 +- storage/src/post_service.rs | 16 ++++------ storage/src/postgres/posts.rs | 1 - storage/src/posts.rs | 11 +++---- storage/src/sqlite/posts.rs | 1 - storage/src/test_support.rs | 4 +-- web/src/posts/api.rs | 46 ++++++++++----------------- 12 files changed, 52 insertions(+), 72 deletions(-) diff --git a/server/src/atompub/mapping.rs b/server/src/atompub/mapping.rs index b25a76d06..b236644e1 100644 --- a/server/src/atompub/mapping.rs +++ b/server/src/atompub/mapping.rs @@ -40,7 +40,6 @@ pub struct PostFields { /// Legacy Atom lifecycle fallback used after Org normalization when neither /// wire nor header metadata supplied a lifecycle. pub is_draft: bool, - } /// The wire `atom:content` `type` for a post format (ADR-0023). `Html` uses the diff --git a/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index 9cc388cef..9e8782cb7 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -168,7 +168,7 @@ struct NormalizedAtomInput { async fn normalize_atom_input( fields: PostFields, operation: OrgOperation, - request_clock: chrono::DateTime, + request_clock: UtcInstant, audiences: &dyn AudienceStorage, author_user_id: common::ids::UserId, ) -> Result { @@ -198,7 +198,7 @@ async fn normalize_atom_input( lifecycle: fields.lifecycle, }, operation, - request_clock.into(), + request_clock, )?; let audiences = authorize_audiences(audiences, author_user_id, normalized.metadata.audiences).await?; @@ -226,12 +226,12 @@ async fn normalize_atom_input( fn create_published_at( lifecycle: &Presence, is_draft: bool, - request_clock: chrono::DateTime, -) -> Option> { + request_clock: UtcInstant, +) -> Option { match lifecycle { Presence::Present(PublicationState::Draft) => None, Presence::Present(PublicationState::Scheduled(at) | PublicationState::Published(at)) => { - Some((*at).into()) + Some(*at) } Presence::Absent if is_draft => None, Presence::Absent => Some(request_clock), @@ -245,9 +245,7 @@ fn update_publish( match lifecycle { Presence::Present(PublicationState::Draft) => storage::PublishUpdate::Unpublish, Presence::Present(PublicationState::Scheduled(at) | PublicationState::Published(at)) => { - storage::PublishUpdate::Publish { - at: Some((*at).into()), - } + storage::PublishUpdate::Publish { at: Some(*at) } } Presence::Absent if is_draft => storage::PublishUpdate::Unpublish, Presence::Absent => storage::PublishUpdate::Publish { at: None }, @@ -454,9 +452,9 @@ pub async fn collection_post( let site_config = services.site_config(); super::require_user_match(&auth_user, &username)?; let entry: Entry = body.parse()?; - let request_clock = chrono::Utc::now(); + 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, request_clock.into())?; + let fields = entry_to_post_fields(&entry, default_format, request_clock)?; let format = fields.format; let is_draft = fields.is_draft; let NormalizedAtomInput { @@ -591,9 +589,9 @@ pub async fn member_put( } let entry: Entry = body.parse()?; - let request_clock = chrono::Utc::now(); + 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, request_clock.into())?; + let fields = entry_to_post_fields(&entry, default_format, request_clock)?; let format = fields.format; let is_draft = fields.is_draft; let NormalizedAtomInput { @@ -813,7 +811,7 @@ mod etag_tests { let normalized = normalize_atom_input( org_fields("Body"), OrgOperation::Create, - parse_utc_instant("2026-08-26T12:00:00Z").value(), + parse_utc_instant("2026-08-26T12:00:00Z"), &MockAudienceStorage::new(), UserId::from(1), ) @@ -833,7 +831,7 @@ mod etag_tests { let Err(error) = normalize_atom_input( org_fields("#+PROPERTY: JAUNDER_AUDIENCE named:42\nBody"), OrgOperation::Create, - parse_utc_instant("2026-08-26T12:00:00Z").value(), + parse_utc_instant("2026-08-26T12:00:00Z"), &audiences, UserId::from(1), ) @@ -847,7 +845,7 @@ mod etag_tests { #[test] fn legacy_draft_lifecycle_fallbacks_remain_unpublished() { - let clock = parse_utc_instant("2026-08-26T12:00:00Z").value(); + let clock = parse_utc_instant("2026-08-26T12:00:00Z"); assert_eq!( create_published_at(&Presence::Absent, true, clock), None, diff --git a/server/tests/atompub/atompub_posts.rs b/server/tests/atompub/atompub_posts.rs index 88b441f68..baedbcf6f 100644 --- a/server/tests/atompub/atompub_posts.rs +++ b/server/tests/atompub/atompub_posts.rs @@ -2162,7 +2162,7 @@ async fn create_with_explicit_draft_no_preserves_published_instant(#[case] backe .unwrap() .unwrap(); assert_eq!( - rec.published_at.unwrap().to_rfc3339(), + rec.published_at.unwrap().value().to_rfc3339(), "2000-01-01T00:00:00+00:00" ); } @@ -2233,7 +2233,7 @@ async fn update_with_explicit_draft_no_preserves_published_instant(#[case] backe .unwrap() .unwrap(); assert_eq!( - rec.published_at.unwrap().to_rfc3339(), + rec.published_at.unwrap().value().to_rfc3339(), "2000-01-01T00:00:00+00:00" ); } diff --git a/server/tests/storage/posts.rs b/server/tests/storage/posts.rs index 6bcb8c102..0914dab8f 100644 --- a/server/tests/storage/posts.rs +++ b/server/tests/storage/posts.rs @@ -155,7 +155,7 @@ fn update_input<'a>( publish, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: common::time::UtcInstant::now(), expectations: PostBookkeepingExpectation::default(), } } @@ -859,7 +859,7 @@ async fn perform_post_update_markdown_renders_and_updates(#[case] backend: Backe publish: PublishUpdate::Unpublish, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: common::time::UtcInstant::now(), expectations: PostBookkeepingExpectation::default(), }, ) @@ -901,7 +901,7 @@ async fn perform_post_update_org_renders_and_updates(#[case] backend: Backend) { publish: PublishUpdate::Unpublish, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: common::time::UtcInstant::now(), expectations: PostBookkeepingExpectation::default(), }, ) diff --git a/server/tests/web/posts/create.rs b/server/tests/web/posts/create.rs index 67c38491f..0715c7c5e 100644 --- a/server/tests/web/posts/create.rs +++ b/server/tests/web/posts/create.rs @@ -617,7 +617,7 @@ async fn create_org_header_named_audience_is_author_scoped_and_opaque(#[case] ba author.user_id, None, parse_row_limit("10"), - chrono::Utc::now(), + common::time::UtcInstant::now(), ) .await .unwrap(); @@ -713,7 +713,7 @@ async fn create_org_metadata_failures_do_not_create_rows(#[case] backend: Backen session.user_id, None, parse_row_limit("10"), - chrono::Utc::now(), + common::time::UtcInstant::now(), ) .await .unwrap(); diff --git a/storage/src/audiences.rs b/storage/src/audiences.rs index d513dfe56..8e5a860d2 100644 --- a/storage/src/audiences.rs +++ b/storage/src/audiences.rs @@ -14,8 +14,8 @@ use async_trait::async_trait; use common::audience::AudienceName; use common::ids::{AudienceId, SubscriptionId, UserId}; use common::time::UtcInstant; -use std::collections::BTreeSet; use sqlx::{Database, Pool}; +use std::collections::BTreeSet; use crate::backend::Backend; diff --git a/storage/src/post_service.rs b/storage/src/post_service.rs index 0f4897362..38f554821 100644 --- a/storage/src/post_service.rs +++ b/storage/src/post_service.rs @@ -209,7 +209,7 @@ pub struct PostUpdate<'a> { /// 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: DateTime, + pub request_clock: UtcInstant, /// Non-authoritative Org bookkeeping expected to match the locked row. pub expectations: PostBookkeepingExpectation, } @@ -1004,7 +1004,7 @@ mod tests { create( publication_user, PostBookkeepingExpectation { - published_at: Some(Some(UtcInstant::from(Utc::now()))), + published_at: Some(Some(UtcInstant::now())), ..Default::default() }, ), @@ -1045,7 +1045,7 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: UtcInstant::now(), expectations: PostBookkeepingExpectation { slug: Some(expected_slug), ..Default::default() @@ -1070,8 +1070,6 @@ mod tests { async fn bookkeeping_update_publishes_now_at_the_supplied_request_clock( #[case] backend: Backend, ) { - use chrono::TimeZone; - let env = backend.setup().await; let storage = &*env.state.posts; let user_id = SeedUser::new().seed(&env.state).await.user_id; @@ -1079,7 +1077,7 @@ mod tests { .draft() .seed(&env.state) .await; - let clock = Utc.with_ymd_and_hms(2042, 7, 1, 12, 0, 0).unwrap(); + let clock: UtcInstant = "2042-07-01T12:00:00Z".parse().unwrap(); let record = perform_post_update( storage, PostUpdate { @@ -1137,7 +1135,7 @@ mod tests { publish: PublishUpdate::Unpublish, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: UtcInstant::now(), expectations, }; @@ -1346,7 +1344,7 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: UtcInstant::now(), expectations: PostBookkeepingExpectation::default(), }, ) @@ -1442,7 +1440,7 @@ mod tests { publish: PublishUpdate::Publish { at: None }, summary: None, audiences: vec![AudienceTarget::Public], - request_clock: Utc::now(), + request_clock: UtcInstant::now(), expectations: PostBookkeepingExpectation::default(), }, ) diff --git a/storage/src/postgres/posts.rs b/storage/src/postgres/posts.rs index 9bf4c92c9..85618cc8a 100644 --- a/storage/src/postgres/posts.rs +++ b/storage/src/postgres/posts.rs @@ -14,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_rejection( primary: Result, diff --git a/storage/src/posts.rs b/storage/src/posts.rs index 6d0c78988..6992e269d 100644 --- a/storage/src/posts.rs +++ b/storage/src/posts.rs @@ -381,7 +381,7 @@ pub struct UpdatePostInput { /// 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: DateTime, + pub request_clock: UtcInstant, /// Non-authoritative Org bookkeeping to compare under the owner lock. pub expectations: PostBookkeepingExpectation, } @@ -463,18 +463,17 @@ 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 deleted_at: Option, pub title: Option, pub slug: Slug, pub body: PostBody, pub format: PostFormat, pub summary: Option, - pub published_at: Option>, + pub published_at: Option, } pub(crate) type TagListRow = (TagId, Tag); pub(crate) type PostTagRow = (PostId, TagId, Tag, TagLabel); @@ -2827,7 +2826,7 @@ fn create_expectations_match(input: &CreatePostInput) -> bool { && expected.format.is_none_or(|format| format == input.format) && expected .published_at - .is_none_or(|published_at| published_at.map(UtcInstant::value) == input.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 @@ -2910,7 +2909,7 @@ pub(crate) fn update_expectation_error( || expected.format.is_some_and(|format| format != input.format) || expected .published_at - .is_some_and(|published_at| published_at.map(UtcInstant::value) != final_published_at) + .is_some_and(|published_at| published_at != final_published_at) { return Some(UpdatePostError::BookkeepingMismatch); } diff --git a/storage/src/sqlite/posts.rs b/storage/src/sqlite/posts.rs index d8d09b489..07d79a0b9 100644 --- a/storage/src/sqlite/posts.rs +++ b/storage/src/sqlite/posts.rs @@ -14,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, diff --git a/storage/src/test_support.rs b/storage/src/test_support.rs index 0402503eb..0a6329722 100644 --- a/storage/src/test_support.rs +++ b/storage/src/test_support.rs @@ -1503,7 +1503,7 @@ impl UpdateRawPost { publish: self.publish, summary: self.summary, audiences: self.audiences, - request_clock: Utc::now(), + request_clock: UtcInstant::now(), expectations: PostBookkeepingExpectation::default(), } } @@ -1699,7 +1699,7 @@ pub async fn update_post_body_via_service( slug_override: None, publish: crate::PublishUpdate::Publish { at: None }, summary: None, - request_clock: Utc::now(), + 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 6676cf303..5b4e0348d 100644 --- a/web/src/posts/api.rs +++ b/web/src/posts/api.rs @@ -46,7 +46,6 @@ use { crate::error::InternalError, crate::feed_events::enqueue_feed_events, crate::viewer::viewer_identity, - chrono::Utc, common::{ org::{ OrgNormalization, OrgOperation, OrgStructuredMetadata, Presence, PublicationState, @@ -71,17 +70,17 @@ use { fn structured_lifecycle( publish: Option, publish_at: Option, - request_clock: chrono::DateTime, + 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 => { + 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.into())), + None => Presence::Present(PublicationState::Published(request_clock)), }, } } @@ -93,15 +92,10 @@ fn normalize_web_org( body: &PostBody, structured: OrgStructuredMetadata, operation: OrgOperation, - request_clock: chrono::DateTime, + request_clock: UtcInstant, ) -> Result { - normalize_org( - body.as_ref(), - structured, - operation, - UtcInstant::from(request_clock), - ) - .map_err(|error| InternalError::validation(error.to_string())) + normalize_org(body.as_ref(), structured, operation, request_clock) + .map_err(|error| InternalError::validation(error.to_string())) } #[cfg(feature = "server")] @@ -218,7 +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 = Utc::now(); + let request_clock = UtcInstant::now(); let PostInputs { body, format, @@ -265,7 +259,7 @@ pub async fn create(post: PostInputs) -> WebResult { Presence::Present(PublicationState::Draft) | Presence::Absent => None, Presence::Present( PublicationState::Scheduled(at) | PublicationState::Published(at), - ) => Some(at.value()), + ) => Some(at), }; let tags = match metadata.tags { Presence::Present(tags) => tags, @@ -292,7 +286,7 @@ pub async fn create(post: PostInputs) -> WebResult { let publish = publish .ok_or_else(|| InternalError::validation("missing required structured lifecycle"))?; let published_at = if publish { - Some(publish_at.map_or(request_clock, UtcInstant::value)) + Some(publish_at.unwrap_or(request_clock)) } else { None }; @@ -420,7 +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 = Utc::now(); + let request_clock = UtcInstant::now(); let PostInputs { body, format, @@ -476,9 +470,7 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { } Presence::Present( PublicationState::Scheduled(at) | PublicationState::Published(at), - ) => PublishUpdate::Publish { - at: Some(at.value()), - }, + ) => PublishUpdate::Publish { at: Some(at) }, }; ( normalized.body, @@ -507,9 +499,7 @@ pub async fn update(post_id: PostId, post: PostInputs) -> WebResult { summary, structured_audiences.unwrap_or_else(|| audience_targets_or_public(None)), if publish { - PublishUpdate::Publish { - at: publish_at.map(UtcInstant::value), - } + PublishUpdate::Publish { at: publish_at } } else { PublishUpdate::Unpublish }, @@ -1036,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; @@ -1052,7 +1041,7 @@ mod server_tests { }; fn owned_post(user_id: UserId) -> PostRecord { - let now = Utc::now(); + let now = UtcInstant::now(); PostRecord { post_id: PostId::from(1), user_id, @@ -1062,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, @@ -1095,10 +1084,9 @@ mod server_tests { #[test] fn structured_lifecycle_preserves_transport_presence() { - use chrono::TimeZone; use common::org::{Presence, PublicationState}; - let clock = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap(); + let clock: UtcInstant = "2026-08-26T12:00:00Z".parse().unwrap(); assert!(matches!( super::structured_lifecycle(None, None, clock), Presence::Absent @@ -1109,7 +1097,7 @@ mod server_tests { )); assert!(matches!( super::structured_lifecycle(Some(true), None, clock), - Presence::Present(PublicationState::Published(at)) if at.value() == clock + Presence::Present(PublicationState::Published(at)) if at == clock )); let future = common::test_support::parse_utc_instant("2026-08-26T12:01:00Z"); assert!(matches!( From 22995591abf8b5f828ee724a17614076beedb61e Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 15:30:53 -0400 Subject: [PATCH 13/15] test: cover Atom audience storage failure --- server/src/atompub/posts.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/server/src/atompub/posts.rs b/server/src/atompub/posts.rs index 9e8782cb7..43ef80b55 100644 --- a/server/src/atompub/posts.rs +++ b/server/src/atompub/posts.rs @@ -828,19 +828,16 @@ mod etag_tests { .expect_list_audiences() .returning(|_| Err(sqlx::Error::PoolClosed)); - let Err(error) = normalize_atom_input( + 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 - else { - panic!("storage failures must not become validation errors"); - }; + .await; - assert!(matches!(error, HandlerError::Internal(_))); + assert!(matches!(result, Err(HandlerError::Internal(_)))); } #[test] From da79309788f68b0951be681430d441a8a39fd6cf Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 16:27:54 -0400 Subject: [PATCH 14/15] fix: integrate Org bookkeeping with media locks --- storage/src/postgres/posts.rs | 56 ++++++++++++++++++++++------------- storage/src/sqlite/posts.rs | 8 ++--- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/storage/src/postgres/posts.rs b/storage/src/postgres/posts.rs index 85618cc8a..68be8eaa7 100644 --- a/storage/src/postgres/posts.rs +++ b/storage/src/postgres/posts.rs @@ -3,9 +3,9 @@ use sqlx::{Pool, Postgres, QueryBuilder}; use crate::posts::{ DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostBookkeepingRow, - PostMediaReferenceBackfill, PostOwnershipRow, 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, + 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, update_expectation_error, }; use crate::{ @@ -15,6 +15,12 @@ use crate::{ use common::ids::{PostId, TagId, UserId}; use common::tag::TagLabel; +type MediaRefRow = ( + common::media::MediaSource, + common::media::ContentHash, + common::media::Filename, +); + pub(crate) fn finish_post_update_rejection( primary: Result, rollback: Result<(), sqlx::Error>, @@ -28,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>, @@ -98,7 +121,7 @@ impl PostDialect for Postgres { .fetch_optional(&mut *tx) .await?; - let existing = match existing { + match existing { None => { return finish_post_update_rejection( Err(UpdatePostError::NotFound), @@ -114,27 +137,18 @@ impl PostDialect for Postgres { ); } Some(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 COLLATE \"C\"", - ) - .bind(post_id) - .fetch_all(&mut *tx) - .await?; - if let Some(error) = update_expectation_error(post_id, &existing, &tags, input) { + 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 { diff --git a/storage/src/sqlite/posts.rs b/storage/src/sqlite/posts.rs index 07d79a0b9..ee7bcea6e 100644 --- a/storage/src/sqlite/posts.rs +++ b/storage/src/sqlite/posts.rs @@ -3,10 +3,10 @@ use sqlx::{Pool, QueryBuilder, Sqlite, SqliteConnection}; use crate::posts::{ DELETE_POST_TAG_BY_SLUG, INSERT_POST_TAG, MediaReferenceEvidence, PostBookkeepingRow, - 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, update_expectation_error, + 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, From e54896f9ffb77a22b8b0478c49d5884a4d59f1c1 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Wed, 26 Aug 2026 16:33:11 -0400 Subject: [PATCH 15/15] test: follow configured post tag limit --- common/src/org.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/org.rs b/common/src/org.rs index 7ea683d60..d1c7775e3 100644 --- a/common/src/org.rs +++ b/common/src/org.rs @@ -851,7 +851,7 @@ mod tests { invalid("#+TITLE: \nBody"); invalid("#+DESCRIPTION: \nBody"); - let tags = (0..26) + let tags = (0..=crate::tag::MAX_TAGS_PER_POST) .map(|index| format!("tag{index}")) .collect::>() .join(", ");