Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 57 additions & 31 deletions crates/buzz-cli/src/commands/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
//! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`.
//! 2. Mutate the tag set (strip `auth`, apply change).
//! 3. Re-validate the full envelope through Layer A before submitting.
//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid
//! overwriting a concurrently advancing head.
//! 4. Set `created_at = max(client_now, head.created_at + 1)` so the
//! replacement dominates the observed head and uses wall clock for
//! ordinary stale heads. Unusually future heads may still hit the relay's
//! timestamp-drift guard until time advances.
//!
//! Limitations recorded in this phase:
//! - Relay hints are read-preserved but not authored (`--repo` carries
Expand Down Expand Up @@ -120,13 +122,17 @@ async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<()

// ── Build helpers ─────────────────────────────────────────────────────────────

/// Advance the `created_at` counter off an observed head.
fn next_timestamp(head: &Event) -> Result<Timestamp, CliError> {
head.created_at
/// Choose the later of client wall clock and the instant after the observed head.
///
/// The relay remains authoritative for timestamp drift: a sufficiently future
/// head can require a timestamp that the relay will temporarily reject.
fn next_timestamp(head: &Event, now: Timestamp) -> Result<Timestamp, CliError> {
let after_head = head

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

When this commit is submitted in a PR, the required DCO check will fail because its message contains no Signed-off-by trailer. Recreate the commit with git commit -s or otherwise add the sign-off before merging.

AGENTS.md reference: AGENTS.md:L112-L112

Useful? React with 👍 / 👎.

.created_at
.as_secs()
.checked_add(1)
.map(Timestamp::from)
.ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))
.ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))?;
Ok(Timestamp::from(after_head.max(now.as_secs())))
}

/// Strip `auth` from a tag list and pass the resulting envelope through
Expand Down Expand Up @@ -288,7 +294,7 @@ pub async fn cmd_add_repo(
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let next_ts = next_timestamp(&head)?;
let next_ts = next_timestamp(&head, Timestamp::now())?;

// Build the new tag set: keep existing tags (including hinted members),
// append new members only if not already present (by coordinate).
Expand Down Expand Up @@ -342,7 +348,7 @@ pub async fn cmd_remove_repo(
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let next_ts = next_timestamp(&head)?;
let next_ts = next_timestamp(&head, Timestamp::now())?;

// Verify all requested repos exist in the project.
let existing_coords: std::collections::HashSet<String> = head
Expand Down Expand Up @@ -434,7 +440,7 @@ pub async fn cmd_update(
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let next_ts = next_timestamp(&head)?;
let next_ts = next_timestamp(&head, Timestamp::now())?;

// Build the new tag set. For each singleton metadata field:
// - setter present: replace value (strip old, append new)
Expand Down Expand Up @@ -491,7 +497,7 @@ pub async fn cmd_update(
///
/// Head-based and verified:
/// 1. Fetch own live head — `NotFound` if absent.
/// 2. Build tombstone at `head.created_at + 1`.
/// 2. Build tombstone at `max(client_now, head.created_at + 1)`.
/// 3. Submit.
/// 4. Re-query the coordinate; if a newer head survived → `Conflict`.
pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> {
Expand All @@ -500,7 +506,7 @@ pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError>
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let next_ts = next_timestamp(&head)?;
let next_ts = next_timestamp(&head, Timestamp::now())?;

let pubkey_hex = client.keys().public_key().to_hex();
let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug)
Expand Down Expand Up @@ -979,31 +985,51 @@ mod tests {

// ── next_timestamp ordering ───────────────────────────────────────────────

/// `next_timestamp` must return `head.created_at + 1` regardless of the wall
/// clock. NIP-MP Deletion rule: a tombstone older than the live head does
/// NOT remove it, so we must advance strictly off the observed head — never
/// use wall-clock time, which could be behind a head that was bumped
/// multiple times in the same second.
#[test]
fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() {
// Build a minimal signed event with a created_at far in the future.
fn project_head_at(created_at: u64) -> Event {
let keys = nostr::Keys::generate();
let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286
let tags = vec![
make_test_tag(&["d", "platform"]),
make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]),
];
let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope");
let head = builder.sign_with_keys(&keys).expect("sign");
// Verify the event actually has our future timestamp.
assert_eq!(head.created_at, far_future_ts);
rebuild_project("", tags, Timestamp::from(created_at))
.expect("valid head envelope")
.sign_with_keys(&keys)
.expect("sign")
}

// next_timestamp must return far_future + 1, not now().
let next = next_timestamp(&head).expect("no overflow");
assert_eq!(
next.as_secs(),
far_future_ts.as_secs() + 1,
"tombstone must be strictly after head, even when head is far in the future"
#[test]
fn next_timestamp_uses_later_of_wall_clock_and_after_head() {
let cases = [
("stale head", 100, 1_000, 1_000),
("head equal to now", 1_000, 1_000, 1_001),
("future head", 1_500, 1_000, 1_501),
("last timestamp inside future boundary", 1_899, 1_000, 1_900),
(
"future boundary cannot be dominated inside the window",
1_900,
1_000,
1_901,
),
];

for (name, head_ts, now, expected) in cases {
let head = project_head_at(head_ts);
let next = next_timestamp(&head, Timestamp::from(now)).expect("no overflow");

assert_eq!(next.as_secs(), expected, "case: {name}");
}
}

#[test]
fn next_timestamp_rejects_overflowing_head() {
let head = project_head_at(u64::MAX);

let err = next_timestamp(&head, Timestamp::from(1_000u64))
.expect_err("maximum timestamp cannot be advanced");

assert!(
matches!(err, CliError::Other(ref message) if message == "project timestamp cannot be advanced"),
"unexpected error: {err}"
);
}

Expand Down
Loading