Skip to content

seller node: buzz persona — kind-0 rate card + ephemeral presence - #158

Closed
orveth wants to merge 1 commit into
mainfrom
seller-node-buzz-persona
Closed

seller node: buzz persona — kind-0 rate card + ephemeral presence#158
orveth wants to merge 1 commit into
mainfrom
seller-node-buzz-persona

Conversation

@orveth

@orveth orveth commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Rebased on main (includes #135 seller-node, plus #147/#149).

Slice 1 of mobee×buzz: the seller node enrolls as a buzz inhabitant under its existing protocol identity — publishing a kind-0 persona with a human-readable rate card and maintaining a live presence heartbeat while it is up. Config-off ⇒ fully inert.

What this adds

  • [buzz] config (home::BuzzConfig, optional): relay URL + persona (name, about, rate, capabilities, mint, heartbeat cadence). Absent ⇒ the node opens no connection and publishes nothing.
  • seller_node::buzz — the persona subsystem:
    • kind-0 rate card published on boot, signed by the seller's existing key. The rate card (rate · capabilities · mint · blurb) is assembled into the human-readable about (rate_card_about, pure + unit-tested).
    • Clobber guard — kind-0 is one-per-key replaceable. Before the first publish the node fetches the key's current kind-0; a foreign one (no mobee marker tag) ⇒ refuse rather than clobber. Fail-closed if the current kind-0 can't be read. (clobber_decision, pure + unit-tested.)
    • Presence — a live authed WS connection publishing an ephemeral kind-20001 "online" status every 30s (the deployed relay keys presence on the authed pubkey and reads the content status; tags ignored). Clean disconnect on shutdown clears presence immediately; crash ⇒ relay TTL expiry (~60s).
  • One identity, one signer. The persona is signed by the existing seller_node::signer actor — including the NIP-42 auth the relay asks of the presence socket, via NodeNostrSigner (a nostr-sdk signer that delegates every sign to the actor). No new keys; no key material leaves the actor.
  • Signing allowlist (doctrine, default-deny at the actor). The raw-sign capability signs only {kind 0 persona, kind 20001 presence, kind 22242 NIP-42 auth}, enforced in two layers:
    • Inside the signer actor (sign_unsigned_event, source of truth UNSIGNED_SIGN_ALLOWLIST): default-deny for every caller of SignerHandle::sign_unsigned, not just the buzz wrapper — any other kind is refused + logged. So even a future holder of the handle cannot turn this path into a sign-anything oracle for the protocol key.
    • At the buzz wrapper (NodeNostrSigner::sign_event): belt-and-suspenders, sourced from the same actor constant.
    • Call-graph argument: the actor has two signing entry points — sign(draft) (the trade path, kinds set by the gateway draft constructors; the outbox's designed protocol-signing path, unchanged) and sign_unsigned (the buzz/auth path). The allowlist gates sign_unsigned only. sign_unsigned's sole production caller is the buzz wrapper; the actor also refuses a foreign-authored event. SellerNode::signer() stays public but is safe — the actor default-denies, so a holder of the handle cannot sign a trade kind through it.
    • Teeth: sign_unsigned_refuses_non_allowlisted_kind_directly (the gate's exact probe — a direct sign_unsigned(kind-3402) with no adapter is refused at the actor, and the full trade block 3400/3401/3402/3403/3404/3405/30340; red-on-revert on the actor-side check), adapter_signs_only_allowlisted_kinds (wrapper layer), sign_unsigned_signs_own_and_refuses_foreign_author.
  • SellerNode::start_buzz — the boot hook (returns Ok(None) when [buzz] is absent). The mobee sell → node cutover (separate slice) will call it.

Scope discipline

Zero changes to any money-path file (payment.rs, authorize_pay.rs, wallet*.rs, seller.rs, seller_daemon/), to buyer/, or to mcp.rs. No buzz-side/server code. PROTOCOL_VERSION "0" untouched. No machine-readable rate card, no mention-watching/chat-bridge/paste-pay/notary (later slices).

Acceptance evidence

  • Items 4 + 5 (all shipped feature combos compile; money-path suite green — the first command IS the money suite, zero changes under wallet/collect/journal): all 5 green on the final tree (rebased on main). cargo test --no-default-features --features gateway,git-delivery,wallet --release → 568 passed · cargo test --features acp,gateway,git-delivery,wallet --release → 575 passed · cargo build --features acp --release · cargo test --features acp --release · cargo build -p mobee --release — all rc0.
  • Items 1-3 logic: in-process nostr-relay-builder fixture (buzz_relay_it.rs) — boot publishes a kind-0 fetchable by pubkey with the rate card; a pre-seeded foreign kind-0 makes start_buzz refuse; our own marked kind-0 is replaced; presence beats flow while up and stop on clean shutdown. All green.
  • Items 1-3 LIVE (deployed relay, buzzrelay.orveth.dev, admitted demo keys) — both #[ignore] harnesses PASS:
    • live_buzz_kind0_round_trip_against_deployed_relay: NIP-42 auth via the signer actor + kind-0 publish + fetch-back. kind-0 id aa164db1…, about = Rust reviewer · rate 50 sat/job · does: code, test · pays via testnut · hire me on mobee.
    • live_buzz_presence_against_deployed_relay: a live authed WS SUB {kinds:[20001], authors:[persona]} receives the "online" heartbeat while up (2 beats, content "online"), and receives no further beats after a clean shutdown (count stable). The canonical operator snapshot is the relay's NIP-98 POST /query (authors filter, relay-signed response); a WS REQ for an ephemeral hits the DB and returns empty by design.

🤖 Generated with Claude Code

Slice 1 of mobee×buzz. The seller node enrolls as a buzz inhabitant under
its existing protocol identity: it publishes a NIP-01 kind-0 persona carrying
a human-readable rate card and maintains a live presence heartbeat while up.
Config-off (no `[buzz]` section) is fully inert.

- home::BuzzConfig — optional `[buzz]` section (relay URL + persona: name,
  about, rate, capabilities, mint, heartbeat cadence).
- seller_node::buzz — the persona subsystem:
  * kind-0 rate card published on boot, rate/caps/mint assembled into a
    human-readable `about` (rate_card_about, pure + unit-tested);
  * clobber guard — kind-0 is one-per-key replaceable, so before the first
    publish the node fetches the key's current kind-0 and REFUSES a foreign
    one (no mobee marker), fail-closed if it cannot read it (clobber_decision,
    pure + unit-tested);
  * presence — a live WS connection publishing an ephemeral kind-20001
    PRESENCE_UPDATE every 30s; clean disconnect on shutdown clears presence,
    crash lets the relay TTL expire it.
- One identity, one signer. The persona (and any NIP-42 auth the relay asks of
  the presence socket) is signed by the existing seller_node::signer actor via
  NodeNostrSigner, so the seller key never leaves the actor. The only signer
  change is a new sign_unsigned command that refuses a foreign-authored event.
- SellerNode::start_buzz — the boot hook (Ok(None) when `[buzz]` absent). The
  `mobee sell` -> node cutover (separate slice) will call it.

Zero changes to any money-path file, to buyer/, or to mcp.rs. PROTOCOL_VERSION
"0" untouched. Live kind-0 + presence logic proven against an in-process
nostr-relay-builder fixture (seller_node::buzz_relay_it); a deployed-relay
round-trip is wired behind #[ignore] for the live check once demo keys are
admitted.

Stacks on #135 (seller-node-durable-store-roster).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@orveth
orveth force-pushed the seller-node-buzz-persona branch from c8bceee to 9708313 Compare July 25, 2026 16:35
orveth added a commit that referenced this pull request Jul 25, 2026
SellerNodeRunner::boot opens the durable node, reconciles durable state, then
connects ONE authenticated relay client and builds the shared publisher over it:

- Key custody (team-lead approved option a): the seller key lives in exactly two
  places — the signer actor and this one authenticated relay client, constructed
  at a single site with the secret read once and dropped. No accessor exposes it,
  it is never logged/serialized. The client holds it because mobee-relay
  authenticates the seller via NIP-42 before delivering p-gated kind-1059 wraps.
  Follow-up (post-#158): converge onto the NostrSigner-over-actor adapter to
  collapse custody back to actor-only.
- NIP-42 handshake via the neutral crate::relay_auth (Authenticated / NoChallenge
  degrade / fatal), notifications subscribed before connect.
- RelayPublisher now shares the runner's authenticated client (Arc clone) instead
  of opening its own — one connection, one NIP-42 session.

run() subscribes the targeted offer/award/gift-wrap filters and runs the select
loop: a periodic tick drains the outbox; each event dispatches by kind. The
offer->claim, award->execute, gift-wrap->pay arms + the #150 watchdog + #162
recovery are ported on top of this next (marked PORT); `mobee sell` is NOT yet
pointed here, so this is inert scaffold.

Money-suite 558 passed / 0 failed; runner compiles clean (no new warnings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
orveth added a commit that referenced this pull request Jul 26, 2026
Union-resolved with the #169 cutover in seller_node/{mod,signer}.rs:
both sides' actor commands kept; signer() stays pub(crate) (cutover's
narrowing) with the persona's default-deny rationale folded into the doc.
@orveth

orveth commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Closing per gudnuf's V1 process ruling (2026-07-27): dev is the single integration line — no parallel open-for-main PRs. This PR's content is fully integrated into dev (verified: branch tip is an ancestor of dev @ 7463cbe), gate-passed and field-validated. Main receives merged integration at gudnuf-paced release cuts, not per-PR. History preserved on the branch and in the gate record; reopen is a git ref away if a release cut needs it isolated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants