feat: sprout notes NIP-23 long-form CLI + relay a-tag deletion (#719)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max (sprout agent) <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn (sprout agent) <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-21 22:30:21 -04:00
committed by GitHub
co-authored by Dawn Max Quinn
parent 441b6c4b1a
commit 504cfea20d
8 changed files with 1780 additions and 7 deletions
+42
View File
@@ -401,6 +401,44 @@ sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
sprout messages vote --event "$FORUM_EVENT_ID" --direction down | jq .
```
### 6.12 Notes (NIP-23 long-form, kind:30023)
Editable team-knowledge notes keyed by `(kind:30023, you, d=slug)`. `set` is an
idempotent upsert; `rm` is a NIP-09 a-tag deletion. Output is plain text (refs),
not JSON — except `get`/`ls`, which emit JSON.
```bash
# set (first publish — --title required, body from stdin)
cat <<'EOF' | sprout notes set --name dco-check --title "DCO Check" \
--summary "How we verify DCO" --tag dco --tag ci --content -
Run `git log --format='%(trailers:key=Signed-off-by)'` ...
EOF
# → prints event_id / naddr / coordinate / slug / title
# set (edit — omit --title to carry it forward; published_at preserved)
echo "Updated body." | sprout notes set --name dco-check --content -
# get by name (own author resolves directly; cross-author #d query otherwise)
sprout notes get --name dco-check | jq .
sprout notes get --name dco-check --content-only
# get by naddr (exact coordinate; paste the naddr from a set/get above)
sprout notes get --naddr "$NADDR" | jq .
# ls (own by default; --author all across the team; --tag filters)
sprout notes ls | jq .
sprout notes ls --tag dco | jq .
sprout notes ls --author all --limit 10 | jq .
# rm (NIP-09 a-tag deletion; subsequent get must 404)
sprout notes rm --name dco-check
# → prints deleted <coordinate> / deletion <event-id>
sprout notes get --name dco-check # exits non-zero: not found
# rm of a slug you never published → NotFound, no kind:5 emitted
sprout notes rm --name does-not-exist # exits non-zero
```
---
## 7. Error Path Testing
@@ -532,3 +570,7 @@ sprout channels delete --channel "$FORUM_ID" | jq .
| 52 | `upload file` | ☐ | |
| 53 | `pack validate` | ☐ | Local, no relay |
| 54 | `pack inspect` | ☐ | Local, no relay |
| 55 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard |
| 56 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 |
| 57 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
| 58 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |
+1
View File
@@ -3,6 +3,7 @@ pub mod dms;
pub mod feed;
pub mod mem;
pub mod messages;
pub mod notes;
pub mod pack;
pub mod reactions;
pub mod repos;
File diff suppressed because it is too large Load Diff
+84
View File
@@ -178,6 +178,9 @@ enum Cmd {
/// Publish notes and manage the social graph (NIP-01/02)
#[command(subcommand)]
Social(SocialCmd),
/// Publish and edit long-form NIP-23 notes — team knowledge base
#[command(subcommand)]
Notes(NotesCmd),
/// Announce and discover git repositories (NIP-34)
#[command(subcommand)]
Repos(ReposCmd),
@@ -774,6 +777,85 @@ pub enum SocialCmd {
},
}
// ---------------------------------------------------------------------------
// Notes subcommands (NIP-23 long-form)
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
pub enum NotesCmd {
/// Create or update a note. Idempotent upsert keyed by `(me, --name)`.
///
/// `published_at` is preserved on edits (only set on first create).
/// `--title` is required on first create; on subsequent edits the existing
/// title is carried forward when `--title` is omitted, and `--title ""`
/// explicitly clears it.
#[command(
after_help = "Examples:\n echo '# Hello' | sprout notes set --name hello --title 'Hello' --content -\n sprout notes set --name hello --tag onboarding --content - < draft.md"
)]
Set {
/// Slug — becomes the `d` tag. `[a-z0-9._-]{1,80}`.
#[arg(long)]
name: String,
/// Note title (NIP-23 `title` tag). Required on first create; omit to carry; `""` to clear.
#[arg(long)]
title: Option<String>,
/// Short summary (NIP-23 `summary` tag). Omit to carry; `""` to clear.
#[arg(long)]
summary: Option<String>,
/// Topic tag (NIP-23 `t` tag). May be repeated. Replaces (not merges) existing tags on edit; omit to carry forward.
#[arg(long = "tag")]
tags: Vec<String>,
/// Clear all `t` tags on update. Mutually exclusive with `--tag`.
/// Without this and without `--tag`, existing tags are carried forward.
#[arg(long, default_value_t = false)]
clear_tags: bool,
/// Markdown body. Use `-` to read from stdin.
#[arg(long)]
content: String,
/// Allow committing an empty body (refused by default to catch upstream pipeline failures).
#[arg(long, default_value_t = false)]
allow_empty: bool,
},
/// Read a note by `--naddr` (exact) or `--name <slug>` (cross-author lookup).
Get {
/// NIP-19 `naddr1…` or `30023:<pubkey>:<slug>` coordinate. Mutually exclusive with `--name`.
#[arg(long)]
naddr: Option<String>,
/// Slug to look up across authors. Mutually exclusive with `--naddr`.
#[arg(long)]
name: Option<String>,
/// Disambiguate `--name` to a specific author (hex pubkey, display name, or `me`).
#[arg(long)]
author: Option<String>,
/// Print only the markdown body, not the full event JSON.
#[arg(long, default_value_t = false)]
content_only: bool,
},
/// List notes. Defaults to your own.
Ls {
/// Hex pubkey, display name, `me`, or `all`.
#[arg(long, default_value = "me")]
author: Option<String>,
/// Filter by NIP-23 `t` tag.
#[arg(long)]
tag: Option<String>,
/// Max results (default 50, hard cap 200).
#[arg(long)]
limit: Option<u32>,
},
/// Delete one of your own notes via NIP-09 (kind:5).
///
/// Emits an a-tag-only deletion targeting the addressable coordinate
/// `30023:<pubkey>:<slug>` (no `e` tag — an `e` tag would route around the
/// relay's coordinate soft-delete and leave the note alive). Read-before-
/// write gives a clean NotFound when there's nothing to delete.
Rm {
/// Slug of the note to delete. Only your own notes can be removed.
#[arg(long)]
name: String,
},
}
// ---------------------------------------------------------------------------
// Repos subcommands
// ---------------------------------------------------------------------------
@@ -983,6 +1065,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await,
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
@@ -1014,6 +1097,7 @@ mod tests {
"feed",
"mem",
"messages",
"notes",
"pack",
"reactions",
"repos",
+30
View File
@@ -550,6 +550,36 @@ pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result<bool> {
Ok(result.rows_affected() > 0)
}
/// Soft-delete the live row for an addressable coordinate
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key.
///
/// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any
/// parameterized-replaceable kind. The WHERE clause mirrors
/// `replace_parameterized_event` so the coordinate semantics stay consistent:
/// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global
/// per the spec — `channel_id` is stored for query scoping, not identity).
///
/// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched
/// (already deleted, or never existed).
pub async fn soft_delete_by_coordinate(
pool: &PgPool,
kind: i32,
pubkey: &[u8],
d_tag: &str,
) -> Result<bool> {
let result = sqlx::query(
"UPDATE events SET deleted_at = NOW() \
WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL",
)
.bind(kind)
.bind(pubkey)
.bind(d_tag)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Atomically soft-delete an event and decrement thread reply counters.
///
/// Wraps the delete + counter update in a single transaction so a crash between
+11
View File
@@ -267,6 +267,17 @@ impl Db {
event::soft_delete_event(&self.pool, event_id).await
}
/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`.
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds.
pub async fn soft_delete_by_coordinate(
&self,
kind: i32,
pubkey: &[u8],
d_tag: &str,
) -> Result<bool> {
event::soft_delete_by_coordinate(&self.pool, kind, pubkey, d_tag).await
}
/// Atomically soft-delete an event and decrement thread reply counters.
pub async fn soft_delete_event_and_update_thread(
&self,
@@ -7,9 +7,9 @@ use tracing::{info, warn};
use uuid::Uuid;
use sprout_core::kind::{
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
event_kind_u32, is_parameterized_replaceable, KIND_GIT_REPO_ANNOUNCEMENT,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS,
KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
};
use sprout_db::channel::MemberRole;
@@ -104,7 +104,7 @@ pub async fn validate_standard_deletion_event(
let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key());
let target_ids = extract_target_event_ids(event);
if target_ids.is_empty() {
if !has_e_tag(event) {
// a-tag deletion: verify author owns the addressable event
let a_tag = event
.tags
@@ -1380,11 +1380,53 @@ async fn handle_a_tag_deletion(event: &Event, state: &Arc<AppState>) -> anyhow::
}
}
}
// Generic NIP-33 (parameterized-replaceable) soft-delete by coordinate.
//
// Listed after the workflow branch so workflow's bespoke deletion
// (which doesn't soft-delete the `events` row by design — that's a
// separate concern) takes precedence. For every other addressable
// kind, including kind:30023 (NIP-23 long-form), we soft-delete the
// live row matching `(kind, pubkey, d_tag)` so REQs stop returning it.
// See https://github.com/block/sprout/issues/714.
k if is_parameterized_replaceable(k) => {
let pubkey_bytes = match hex::decode(pubkey_hex) {
Ok(b) => b,
Err(e) => {
return Err(anyhow::anyhow!(
"invalid pubkey hex in a-tag {pubkey_hex}: {e}"
));
}
};
// Safe cast: NIP-33 kinds are 3000039999, well within i32.
let kind_i32 = k as i32;
let deleted = state
.db
.soft_delete_by_coordinate(kind_i32, &pubkey_bytes, d_tag)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to soft-delete by coordinate {kind_i32}:{pubkey_hex}:{d_tag}: {e}"
)
})?;
if deleted {
tracing::info!(
kind = k,
d_tag = d_tag,
"NIP-09 a-tag deletion: soft-deleted addressable event by coordinate"
);
} else {
tracing::debug!(
kind = k,
d_tag = d_tag,
"NIP-09 a-tag deletion: no live row matched coordinate"
);
}
}
_ => {
tracing::debug!(
kind = kind_num,
d_tag = d_tag,
"NIP-09 a-tag deletion for unhandled kind — no side effect"
"NIP-09 a-tag deletion for non-NIP-33 kind — no side effect"
);
}
}
@@ -1397,8 +1439,10 @@ async fn handle_standard_deletion_event(
state: &Arc<AppState>,
) -> anyhow::Result<()> {
let target_ids = extract_target_event_ids(event);
if target_ids.is_empty() {
// NIP-09 a-tag deletion path for addressable events
if !has_e_tag(event) {
// NIP-09 a-tag deletion path for addressable events. Keyed on the
// absence of *any* e tag (not just valid e-ids): a malformed e + a must
// not route here and silently soft-delete the coordinate.
return handle_a_tag_deletion(event, state).await;
}
@@ -1553,6 +1597,15 @@ fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> V
event.pubkey.serialize().to_vec()
}
/// True if the event carries any `e` tag at all, regardless of whether its
/// value decodes to a valid 32-byte id. NIP-09 treats `e`/`a` as target
/// classes: a malformed `e` makes the deletion ambiguous, not addressable-only.
/// Routing keys on this rather than on decoded-target count so a malformed `e`
/// alongside an `a` never silently soft-deletes a coordinate.
fn has_e_tag(event: &Event) -> bool {
event.tags.iter().any(|t| t.kind().to_string() == "e")
}
fn extract_target_event_ids(event: &Event) -> Vec<Vec<u8>> {
event
.tags
@@ -317,3 +317,246 @@ async fn test_long_form_stale_write_rejected() {
client.disconnect().await.expect("disconnect");
}
/// NIP-09 a-tag deletion: a kind:5 deletion targeting the addressable
/// coordinate `30023:<pubkey>:<d-tag>` causes the live event row for that
/// coordinate to be soft-deleted, so subsequent REQs no longer return it.
///
/// Regression test for issue #714 — before the fix,
/// `handle_a_tag_deletion` only handled the workflow kind and silently
/// no-op'd for kind:30023.
#[tokio::test]
#[ignore]
async fn test_long_form_a_tag_deletion() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
// Publish a note.
let d_tag = format!("a-del-{}", uuid::Uuid::new_v4().simple());
let note = build_long_form_event(&keys, &d_tag, "Doomed Article", "Body.", vec![]);
let note_id = note.id;
let ok = client.send_event(note).await.expect("send note");
assert!(ok.accepted, "note should be accepted: {}", ok.message);
// Sanity check it's queryable before deletion.
let sid_pre = sub_id("a-del-pre");
let filter_pre = Filter::new()
.kind(Kind::Custom(KIND_LONG_FORM))
.author(keys.public_key())
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid_pre, vec![filter_pre])
.await
.expect("subscribe pre");
let pre = client
.collect_until_eose(&sid_pre, Duration::from_secs(5))
.await
.expect("collect pre");
assert!(
pre.iter().any(|e| e.id == note_id),
"note should be queryable before deletion"
);
// Build the addressable coordinate and emit a kind:5 deletion targeting it.
let a_coord = format!(
"{}:{}:{}",
KIND_LONG_FORM,
keys.public_key().to_hex(),
d_tag
);
let del = EventBuilder::new(
Kind::EventDeletion,
"",
vec![Tag::parse(&["a", &a_coord]).unwrap()],
)
.sign_with_keys(&keys)
.unwrap();
let ok_del = client.send_event(del).await.expect("send deletion");
assert!(
ok_del.accepted,
"a-tag deletion should be accepted: {}",
ok_del.message
);
// Query — should now be empty.
let sid_post = sub_id("a-del-post");
let filter_post = Filter::new()
.kind(Kind::Custom(KIND_LONG_FORM))
.author(keys.public_key())
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid_post, vec![filter_post])
.await
.expect("subscribe post");
let post = client
.collect_until_eose(&sid_post, Duration::from_secs(5))
.await
.expect("collect post");
assert!(
post.is_empty(),
"a-tag deletion should remove the note from REQ results (got {} events)",
post.len()
);
client.disconnect().await.expect("disconnect");
}
/// A kind:5 carrying a malformed `e` tag alongside a valid `a` coordinate must
/// NOT be routed as an addressable deletion — a malformed `e` makes the
/// deletion ambiguous, not addressable-only. Regression guard for relay
/// routing keyed on "no e tags present" rather than "no valid e-ids decoded":
/// the note must survive.
#[tokio::test]
#[ignore]
async fn test_long_form_malformed_e_plus_a_does_not_delete() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let d_tag = format!("mixed-del-{}", uuid::Uuid::new_v4().simple());
let note = build_long_form_event(&keys, &d_tag, "Survivor", "Body.", vec![]);
let note_id = note.id;
let ok = client.send_event(note).await.expect("send note");
assert!(ok.accepted, "note should be accepted: {}", ok.message);
// kind:5 with a *malformed* e tag (not 64 hex chars) plus a valid a coord.
let a_coord = format!(
"{}:{}:{}",
KIND_LONG_FORM,
keys.public_key().to_hex(),
d_tag
);
let del = EventBuilder::new(
Kind::EventDeletion,
"",
vec![
Tag::parse(&["e", "not-a-valid-event-id"]).unwrap(),
Tag::parse(&["a", &a_coord]).unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
// Relay may accept-and-noop or reject; either is fine. The contract under
// test is that the coordinate is NOT soft-deleted.
let _ = client.send_event(del).await.expect("send mixed deletion");
let sid = sub_id("mixed-del-post");
let filter = Filter::new()
.kind(Kind::Custom(KIND_LONG_FORM))
.author(keys.public_key())
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let post = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
post.iter().any(|e| e.id == note_id),
"malformed-e + a must NOT soft-delete the coordinate; note should survive"
);
client.disconnect().await.expect("disconnect");
}
/// `notes set` re-publish preserves the original `published_at` while letting
/// `created_at` advance. This is the contract that NIP-23 readers rely on to
/// tell "when the author first wrote this" from "when they last updated it",
/// and the carry-forward logic in `sprout-cli`'s `build_set_event` (unit-tested
/// there) only works if the relay round-trips the tag faithfully.
///
/// The carry rule is duplicated inline here (rather than reaching into
/// `sprout-cli`) so this e2e crate stays free of CLI deps; the rule's
/// correctness is unit-tested in `commands::notes::tests`.
#[tokio::test]
#[ignore]
async fn test_long_form_set_twice_preserves_published_at() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let d_tag = format!("preserve-pat-{}", uuid::Uuid::new_v4().simple());
let original_published_at: u64 = 1_700_000_000;
// First publish: stamp `published_at` = original_published_at.
let v1 = build_long_form_event(
&keys,
&d_tag,
"First",
"v1 body",
vec![Tag::parse(&["published_at", &original_published_at.to_string()]).unwrap()],
);
let ok1 = client.send_event(v1).await.expect("send v1");
assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message);
// Ensure created_at advances between writes.
tokio::time::sleep(Duration::from_secs(1)).await;
// Re-publish carrying the original `published_at` forward — what
// `notes set` does on update when `--title` (or nothing) changes.
let v2 = EventBuilder::new(
Kind::Custom(KIND_LONG_FORM),
"v2 body",
vec![
Tag::parse(&["d", &d_tag]).unwrap(),
Tag::parse(&["title", "First"]).unwrap(),
Tag::parse(&["published_at", &original_published_at.to_string()]).unwrap(),
],
)
.custom_created_at(Timestamp::now())
.sign_with_keys(&keys)
.unwrap();
let v2_id = v2.id;
let v2_created_at = v2.created_at.as_u64();
let ok2 = client.send_event(v2).await.expect("send v2");
assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message);
// Re-fetch: there should be exactly one live event for (kind, author, d-tag),
// and its `published_at` should still be the original — even though
// `created_at` advanced.
let sid = sub_id("preserve-pat");
let filter = Filter::new()
.kind(Kind::Custom(KIND_LONG_FORM))
.author(keys.public_key())
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "exactly one live event after re-publish");
let live = &events[0];
assert_eq!(live.id, v2_id, "surviving event is v2");
assert_eq!(
live.created_at.as_u64(),
v2_created_at,
"created_at advanced to v2's timestamp"
);
let pa = live
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("published_at"))
.and_then(|t| t.as_slice().get(1).cloned())
.and_then(|v| v.parse::<u64>().ok());
assert_eq!(
pa,
Some(original_published_at),
"published_at must be preserved across re-publish"
);
client.disconnect().await.expect("disconnect");
}