1
0
forked from GRIN/grim

goblin: Authorize Sessions transport + runtime (encrypted relay channel, session store)

- session.rs: channel-event builders (session-open / session-end / wrapped
  sign_result), decrypt, SessionSummary + PendingMoney, and the serve/
  complete_money runtime orchestration (with unit tests).
- client.rs: in-memory session store on NostrService (add/end/resume,
  summaries, money-prompt queue + answers, high-volume notice); channel
  subscription over the sessions' wallet channel keys, handle_channel event
  serving, session-open announce, drain-on-resume via the frame heartbeat,
  and money-answer draining wired into the service loop.
- lib.rs: PENDING_TRUST crate-root stash (set/take).
- dispatch: goblin:trust routed after authorize, before pay, in both the
  deep-link and camera-scan dispatchers.
This commit is contained in:
2ro
2026-07-06 17:14:32 -04:00
parent 939d1b18d1
commit fbc6599ab0
5 changed files with 628 additions and 3 deletions
+11
View File
@@ -277,6 +277,17 @@ impl SendFlow {
}
return;
}
// A "Trust with Goblin" (Authorize Sessions) request is grabbed BEFORE any
// pay parsing too: it establishes a signing session, never a payment, and a
// trust-shaped payload that fails validation is dropped entirely. A valid
// one is stashed for the Goblin surface, whose per-frame router closes this
// flow and opens the trust-grant modal.
if crate::nostr::trusturi::is_trust_shaped(text) {
if let Some(t) = crate::nostr::trusturi::parse(text) {
crate::set_pending_trust(t);
}
return;
}
// Proof-on-request context (frozen contract 4.1) rides alongside the
// recipient/amount/memo routing and is independent of whether we land on
// Review or Search, so pull it from the same pure parse. `order` is a
+6
View File
@@ -520,6 +520,12 @@ impl WalletsContent {
}
None
}
Some(d) if crate::nostr::trusturi::is_trust_shaped(&d) => {
if let Some(t) = crate::nostr::trusturi::parse(&d) {
crate::set_pending_trust(t);
}
None
}
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
crate::set_pending_pay_uri(d);
None
+22
View File
@@ -574,6 +574,28 @@ lazy_static! {
/// are dropped at the dispatch site and never reach the UI.
static ref PENDING_AUTHORIZE: Arc<RwLock<Option<nostr::authuri::AuthorizeUri>>> =
Arc::new(RwLock::new(None));
/// A pending, already-VALIDATED "Trust with Goblin" (Authorize Sessions)
/// request (`goblin:trust?...` deep link or QR), waiting for the Goblin
/// surface to open its trust-grant modal. Only a fully validated
/// [`nostr::trusturi::TrustUri`] (nonce, domain binding, channel key, relay
/// hint, and a kind set that survives ceiling stripping) is ever stashed
/// here; rejected trust URIs are dropped at the dispatch site.
static ref PENDING_TRUST: Arc<RwLock<Option<nostr::trusturi::TrustUri>>> =
Arc::new(RwLock::new(None));
}
/// Stash a VALIDATED trust request for the Goblin surface to open its trust-grant
/// modal (see [`take_pending_trust`]). The most recent request wins; the surface
/// ignores new requests while one approval (login, authorize, or trust) is
/// already pending.
pub fn set_pending_trust(trust: nostr::trusturi::TrustUri) {
*PENDING_TRUST.write() = Some(trust);
}
/// Take (and clear) a pending trust request, if any. The Goblin wallet view
/// polls this each frame and opens the "Trust <domain>?" grant modal.
pub fn take_pending_trust() -> Option<nostr::trusturi::TrustUri> {
PENDING_TRUST.write().take()
}
/// Stash a payment deep link for the Goblin surface to open (see
+352 -3
View File
@@ -52,6 +52,11 @@ pub struct NostrProfile {
/// duplicate REQs on the relays.
const GIFTWRAP_SUB: &str = "goblin-giftwrap";
/// Stable subscription id for the Authorize Sessions encrypted channel (kind
/// 24140), re-subscribed (replace-not-duplicate) whenever the session set
/// changes so newly granted sessions start receiving immediately.
const CHANNEL_SUB: &str = "goblin-session-channel";
/// The Goblin news publisher (kind 30023 long-form). The Home news panel shows
/// this key's latest post, fetched from our own relay set.
const NEWS_NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
@@ -156,6 +161,22 @@ pub struct NostrService {
/// Serializes a manual payment-cancel against a concurrent S2 finalize+post
/// so the two can't both succeed (cancel the outputs AND post on-chain).
cancel_finalize_lock: Mutex<()>,
/// Active Authorize Sessions (v2). In memory only: quitting the wallet ends
/// every session. The service loop subscribes each session's encrypted
/// channel, serves silent low-tier signs, and routes money-tier requests to
/// the GUI money prompt.
sessions: RwLock<Vec<crate::nostr::session::Session>>,
/// Set whenever the session set changes so the loop re-subscribes the channel
/// filter and publishes `session-open` for any new session.
sessions_dirty: AtomicBool,
/// Money-tier requests awaiting the GUI's per-action password prompt (FIFO).
money_pending: Mutex<Vec<crate::nostr::session::PendingMoney>>,
/// The GUI's answers to money prompts (the answered request plus approve/decline),
/// drained by the loop which then signs (or declines) and publishes on the channel.
money_answers: Mutex<Vec<(crate::nostr::session::PendingMoney, bool)>>,
/// A single non-blocking "signing a lot" notice for the GUI toast, set when a
/// session trips the soft rate cap.
session_notice: RwLock<Option<String>>,
}
/// Phase of the most recent outgoing send, polled by the send UI.
@@ -206,6 +227,11 @@ impl NostrService {
last_send_error: RwLock::new(None),
cancel_notice: RwLock::new(None),
cancel_finalize_lock: Mutex::new(()),
sessions: RwLock::new(Vec::new()),
sessions_dirty: AtomicBool::new(false),
money_pending: Mutex::new(Vec::new()),
money_answers: Mutex::new(Vec::new()),
session_notice: RwLock::new(None),
})
}
@@ -231,6 +257,107 @@ impl NostrService {
self.recv.read().iter().any(|h| &h.keys.public_key() == pk)
}
// --- Authorize Sessions (v2) -------------------------------------------
/// Register a freshly granted session and wake the loop to subscribe its
/// channel and publish `session-open`.
pub fn add_session(&self, session: crate::nostr::session::Session) {
self.sessions.write().push(session);
self.sessions_dirty.store(true, Ordering::SeqCst);
}
/// True when at least one session is live (for the Trusted Sites badge/list).
pub fn has_sessions(&self) -> bool {
!self.sessions.read().is_empty()
}
/// Read-only snapshots for the Trusted Sites list, newest last.
pub fn session_summaries(&self) -> Vec<crate::nostr::session::SessionSummary> {
let now = unix_time() as u64;
self.sessions
.read()
.iter()
.map(|s| s.summary(now))
.collect()
}
/// End (revoke) the session for `domain`: mark it ended, send the courtesy
/// `session-end`, and drop it. Immediate and unilateral.
pub fn end_session(&self, domain: &str) {
let now = unix_time() as u64;
let mut end_event = None;
{
let mut sessions = self.sessions.write();
if let Some(s) = sessions.iter_mut().find(|s| s.domain == domain) {
s.end();
end_event = s.session_end_event(now).ok();
}
sessions.retain(|s| s.domain != domain);
}
self.sessions_dirty.store(true, Ordering::SeqCst);
// Best-effort courtesy notice to the site; teardown already happened.
if let Some(ev) = end_event {
self.publish_event_best_effort(ev);
}
}
/// Resume a paused session (the user tapped "resume" in Trusted Sites).
pub fn resume_session(&self, domain: &str) {
let now = unix_time() as u64;
if let Some(s) = self
.sessions
.write()
.iter_mut()
.find(|s| s.domain == domain)
{
s.resume(now);
}
}
/// The front money-tier request awaiting the user, if any (GUI polls this to
/// raise its per-action password prompt).
pub fn peek_money_prompt(&self) -> Option<crate::nostr::session::PendingMoney> {
self.money_pending.lock().first().cloned()
}
/// Record the user's answer to a money prompt: remove it from the display
/// queue and hand the full request to the loop, which signs (or declines) and
/// publishes the result on the channel.
pub fn answer_money_prompt(&self, req_id: &str, approved: bool) {
let answered = {
let mut pending = self.money_pending.lock();
let idx = pending.iter().position(|p| p.req.id == req_id);
idx.map(|i| pending.remove(i))
};
if let Some(p) = answered {
self.money_answers.lock().push((p, approved));
}
}
/// Take (and clear) the "signing a lot" notice, if any.
pub fn take_session_notice(&self) -> Option<String> {
self.session_notice.write().take()
}
/// Publish an event on the service runtime without blocking the caller
/// (used for the best-effort `session-end` courtesy). No-op if the loop is
/// not running.
fn publish_event_best_effort(&self, event: nostr_sdk::Event) {
let (Some(client), Some(handle)) =
(self.client.read().clone(), self.rt_handle.read().clone())
else {
return;
};
let urls: Vec<String> = self.relays();
handle.spawn(async move {
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.send_event_to(&urls, &event),
)
.await;
});
}
/// Update a held identity's PRIVATE tag in the in-memory set (and the active
/// copy when it is the same identity), so the switcher re-renders immediately
/// after a rename without a service rebuild. The caller persists the file.
@@ -1326,6 +1453,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// names right away (so you see refreshed info from app open), unless one ran
// within the last interval.
let mut last_name_sweep = svc.store.last_name_sweep_at().unwrap_or(0);
// Tracks the app foreground state so a background→foreground transition drains
// any session-channel requests queued on the relay while the wallet slept.
let mut was_foreground = crate::app_foreground();
loop {
if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() {
break;
@@ -1334,9 +1464,11 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
notification = notifications.recv() => {
match notification {
Ok(RelayPoolNotification::Event { event, .. }) => {
// News long-form posts and gift wraps ride the same feed;
// route by kind (handle_wrap ignores non-1059 anyway).
if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote {
// News long-form posts, session-channel envelopes, and gift
// wraps ride the same feed; route by kind.
if event.kind.as_u16() == crate::nostr::session::CHANNEL_EVENT_KIND {
handle_channel(&svc, &client, &event).await;
} else if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote {
handle_news(&svc, pk, *event).await;
} else {
handle_wrap(&svc, &wallet, *event).await;
@@ -1408,6 +1540,21 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
svc.resolve_contact_identity(&c.npub);
}
}
// Authorize Sessions (v2): when the session set changed, re-subscribe
// the encrypted channel and publish `session-open` for new sessions;
// then sign/decline any money-tier prompts the user answered.
if svc.sessions_dirty.swap(false, Ordering::SeqCst) {
resubscribe_channel(&client, &relays, &svc).await;
announce_new_sessions(&svc, &client, &relays).await;
}
serve_money_answers(&svc, &client).await;
// Drain requests queued while backgrounded on a resume (the Build-95
// frame-heartbeat pattern), gated on the app being foregrounded.
let fg = crate::app_foreground();
if fg && !was_foreground && svc.has_sessions() {
drain_channel(&svc, &client, &relays).await;
}
was_foreground = fg;
}
}
}
@@ -1906,6 +2053,208 @@ fn first_tag_value(event: &Event, name: &str) -> Option<String> {
})
}
/// Serve one Authorize Sessions channel event (kind 24140). Matches it to a
/// live session by the site's channel key, decrypts under the session key,
/// enforces every rule (via the pure `session` core), and either publishes a
/// signed `sign_result` back to the site, enqueues a money-tier prompt for the
/// GUI, or tears the session down on a `session-end` signal. Fails closed and
/// silent on anything it cannot match, decrypt, or parse.
async fn handle_channel(svc: &Arc<NostrService>, client: &Client, event: &Event) {
use crate::nostr::session::{self, PendingMoney, SignRequest, SignResult};
if event.kind.as_u16() != session::CHANNEL_EVENT_KIND || event.verify().is_err() {
return;
}
let now = unix_time() as u64;
let mut publish: Option<Event> = None;
let mut money: Option<PendingMoney> = None;
let mut notice = false;
let mut ended = false;
{
let mut sessions = svc.sessions.write();
// Origin binding: the only key allowed to request is the site channel key
// bound at grant time. Nothing else can even open an envelope.
let Some(s) = sessions
.iter_mut()
.find(|s| s.site_session_pubkey == event.pubkey && !s.ended)
else {
return;
};
let Ok(plaintext) = s.decrypt(&event.pubkey, &event.content) else {
return;
};
if !session::envelope_within_cap(&plaintext) {
return;
}
let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) else {
return;
};
match val.get("type").and_then(|t| t.as_str()) {
Some("session-end") => {
s.end();
ended = true;
}
Some("sign") => {
let Ok(req) = serde_json::from_value::<SignRequest>(val) else {
return;
};
// The signing identity's unlocked keys from the in-memory snapshot.
let keys = svc
.recv_snapshot()
.into_iter()
.find(|h| h.keys.public_key() == s.identity_pubkey)
.map(|h| h.keys);
match keys {
Some(keys) => {
let served = session::serve(s, &req, &keys, now);
notice = served.notify_high_volume;
if served.money_pending {
money = Some(PendingMoney {
domain: s.domain.clone(),
identity_pubkey: s.identity_pubkey,
req,
});
} else if let Some(json) = served.response {
publish = s.wrap_channel_event(&json, now).ok();
}
}
None => {
// Identity no longer held: honest refusal, not a hang.
let json = serde_json::to_string(&SignResult::refused(
&req.id,
session::SignError::IdentityMismatch,
))
.unwrap_or_default();
publish = s.wrap_channel_event(&json, now).ok();
}
}
}
_ => {}
}
}
if ended {
svc.sessions.write().retain(|s| !s.ended);
svc.sessions_dirty.store(true, Ordering::SeqCst);
}
if notice {
*svc.session_notice.write() = Some("signing".to_string());
}
if let Some(p) = money {
svc.money_pending.lock().push(p);
}
if let Some(ev) = publish {
let urls = svc.relays();
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await;
}
}
/// Drain the GUI's answers to money-tier prompts: sign (or decline) each and
/// publish the `sign_result` on its session channel. Called from the loop tick.
async fn serve_money_answers(svc: &Arc<NostrService>, client: &Client) {
use crate::nostr::session;
let answers: Vec<(session::PendingMoney, bool)> =
std::mem::take(&mut *svc.money_answers.lock());
if answers.is_empty() {
return;
}
let now = unix_time() as u64;
let snapshot = svc.recv_snapshot();
for (pending, approved) in answers {
let mut publish: Option<Event> = None;
{
let mut sessions = svc.sessions.write();
if let Some(s) = sessions
.iter_mut()
.find(|s| s.domain == pending.domain && !s.ended)
{
let keys = snapshot
.iter()
.find(|h| h.keys.public_key() == s.identity_pubkey)
.map(|h| h.keys.clone());
if let Some(keys) = keys {
let json = session::complete_money(s, &pending.req, &keys, approved, now);
publish = s.wrap_channel_event(&json, now).ok();
}
}
}
if let Some(ev) = publish {
let urls = svc.relays();
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await;
}
}
}
/// The channel subscription/fetch filter over the live sessions' wallet channel
/// keys, or `None` when there are no sessions. Bounded `since` to the request
/// expiration: anything older has lapsed its NIP-40 expiration anyway.
fn channel_filter(svc: &Arc<NostrService>) -> Option<Filter> {
let pks: Vec<PublicKey> = svc
.sessions
.read()
.iter()
.filter(|s| !s.ended)
.map(|s| s.wallet_channel_pk)
.collect();
if pks.is_empty() {
return None;
}
let now = unix_time() as u64;
let since = now.saturating_sub(crate::nostr::session::REQUEST_EXPIRATION_SECS);
Some(
Filter::new()
.kind(Kind::from(crate::nostr::session::CHANNEL_EVENT_KIND))
.pubkeys(pks)
.since(Timestamp::from_secs(since)),
)
}
/// (Re)subscribe the encrypted session channel over the current session set.
async fn resubscribe_channel(client: &Client, relays: &[String], svc: &Arc<NostrService>) {
if let Some(filter) = channel_filter(svc) {
if let Err(e) = client
.subscribe_with_id_to(relays, SubscriptionId::new(CHANNEL_SUB), filter, None)
.await
{
warn!("nostr: session-channel subscribe failed: {e}");
}
}
}
/// Publish the one-time `session-open` for every session not yet announced, and
/// mark them announced. Called when the session set changes.
async fn announce_new_sessions(svc: &Arc<NostrService>, client: &Client, relays: &[String]) {
let now = unix_time() as u64;
let mut events = Vec::new();
{
let mut sessions = svc.sessions.write();
for s in sessions.iter_mut().filter(|s| !s.announced && !s.ended) {
if let Ok(ev) = s.session_open_event(now) {
events.push(ev);
}
s.announced = true;
}
}
for ev in events {
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(relays, &ev)).await;
}
}
/// Drain any channel requests queued on the relay while the wallet was asleep,
/// serving each. Called on a background→foreground transition (the Build-95
/// frame-heartbeat resume pattern) and once at loop start.
async fn drain_channel(svc: &Arc<NostrService>, client: &Client, relays: &[String]) {
let Some(filter) = channel_filter(svc) else {
return;
};
if let Ok(events) = client
.fetch_events_from(relays, filter, FETCH_TIMEOUT)
.await
{
for ev in events.into_iter() {
handle_channel(svc, client, &ev).await;
}
}
}
/// Ingest one kind-30023 news post from the Goblin news key and cache it (the
/// store dedupes newest-per-`d`). Guards kind + author so a stray event on the
/// news subscription can't spoof the panel.
+237
View File
@@ -575,6 +575,9 @@ pub struct Session {
pub paused: bool,
/// Set when the session has ended (logout, wallet end, TTL, idle).
pub ended: bool,
/// True once the wallet has published its `session-open` envelope and
/// subscribed the channel for this session (a one-time runtime step).
pub announced: bool,
/// Replay-dedup ring: request id -> cached response JSON. A duplicate id
/// returns the cached result, never a second signature.
seen: HashMap<String, String>,
@@ -629,6 +632,7 @@ impl Session {
last_used_at: now,
paused: false,
ended: false,
announced: false,
seen: HashMap::new(),
seen_order: VecDeque::new(),
silent_times: VecDeque::new(),
@@ -745,6 +749,71 @@ impl Session {
self.ended = true;
}
/// The wallet's ephemeral channel keypair (reconstructed from the stored
/// secret) for signing and decrypting channel envelopes.
fn channel_keys(&self) -> Keys {
Keys::new(self.wallet_channel_sk.clone())
}
/// Decrypt a channel envelope from the site (its outer event `pubkey` must be
/// `site_session_pubkey`, checked by the caller) into its plaintext payload.
pub fn decrypt(&self, sender: &PublicKey, payload: &str) -> Result<String, String> {
open_envelope(&self.wallet_channel_sk, sender, payload)
}
/// Wrap a plaintext payload as a signed, NIP-44-encrypted, addressed channel
/// event (kind [`CHANNEL_EVENT_KIND`]) carrying a NIP-40 `expiration` tag,
/// ready to publish back to the site.
pub fn wrap_channel_event(&self, plaintext: &str, now: u64) -> Result<Event, String> {
let content = seal_envelope(
&self.wallet_channel_sk,
&self.site_session_pubkey,
plaintext,
)?;
let exp = Timestamp::from(now + REQUEST_EXPIRATION_SECS);
EventBuilder::new(Kind::from(CHANNEL_EVENT_KIND), content)
.tags(vec![
Tag::public_key(self.site_session_pubkey),
Tag::expiration(exp),
])
.sign_with_keys(&self.channel_keys())
.map_err(|e| e.to_string())
}
/// The `session-open` channel event: hands the site the wallet channel key
/// (the event's own `pubkey`) and confirms the signing identity.
pub fn session_open_event(&self, now: u64) -> Result<Event, String> {
let open = SessionOpen {
msg_type: "session-open".to_string(),
wallet_pubkey: self.wallet_channel_pk.to_hex(),
identity: self.identity_pubkey.to_hex(),
};
let json = serde_json::to_string(&open).map_err(|e| e.to_string())?;
self.wrap_channel_event(&json, now)
}
/// The `session-end` channel event: tells the site the wallet ended the
/// session (a courtesy on wallet-side revocation; the teardown is unilateral
/// regardless of delivery).
pub fn session_end_event(&self, now: u64) -> Result<Event, String> {
let end = SessionEnd {
msg_type: "session-end".to_string(),
};
let json = serde_json::to_string(&end).map_err(|e| e.to_string())?;
self.wrap_channel_event(&json, now)
}
/// A read-only snapshot for the Trusted Sites list.
pub fn summary(&self, now: u64) -> SessionSummary {
SessionSummary {
domain: self.domain.clone(),
label: self.label.clone(),
categories: render_grant(&self.silent_kind_set).categories,
ttl_remaining_secs: self.ttl_remaining(now),
paused: self.paused,
}
}
/// Resume a paused session (the user tapped "resume"). Clears the pause and
/// the rate window so counting starts fresh.
pub fn resume(&mut self, now: u64) {
@@ -795,6 +864,121 @@ pub fn envelope_within_cap(plaintext: &str) -> bool {
plaintext.len() <= MAX_ENVELOPE_BYTES
}
/// A read-only snapshot of a session for the Trusted Sites list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionSummary {
pub domain: String,
pub label: String,
/// The low-tier categories this session can sign silently.
pub categories: Vec<TrustCategory>,
/// Seconds until the hard TTL (the session-detail "time remaining").
pub ttl_remaining_secs: u64,
/// True when the hard rate cap paused the silent path.
pub paused: bool,
}
/// A money-tier request awaiting the user's per-action password prompt. The
/// runtime hands one of these to the GUI, which raises the v1-style authorize
/// modal; the GUI's answer routes back through [`complete_money`].
#[derive(Debug, Clone)]
pub struct PendingMoney {
/// The trusted domain the request arrived on.
pub domain: String,
/// The signing identity for this session (looked up to sign on approval).
pub identity_pubkey: PublicKey,
/// The full request, replayed verbatim to sign on approval.
pub req: SignRequest,
}
// ---------------------------------------------------------------------------
// Runtime serving orchestration (thin, so the async relay loop stays dumb).
// ---------------------------------------------------------------------------
/// The upshot of serving one decoded request against a session. The async loop
/// acts on it and never touches classification, signing, or bookkeeping itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Served {
/// A `sign_result` JSON to publish back to the site on the channel, or `None`
/// when the request is a money-tier prompt still pending the user.
pub response: Option<String>,
/// True when the soft rate cap tripped: surface a single non-blocking notice.
pub notify_high_volume: bool,
/// True when this request needs the money-tier password prompt (the loop
/// enqueues it for the GUI and publishes nothing yet).
pub money_pending: bool,
}
/// Serve a decoded sign request against a live session. Silent low-tier requests
/// are signed here and turned into a `sign_result` JSON; refusals and cached
/// duplicates likewise return a JSON to publish; a money-tier request returns
/// `money_pending` with no response (the GUI raises the prompt, then the loop
/// calls [`complete_money`]). `sign_keys` are the session identity's unlocked
/// keys, looked up by the loop from the wallet's in-memory snapshot.
pub fn serve(session: &mut Session, req: &SignRequest, sign_keys: &Keys, now: u64) -> Served {
match session.decide(req, now) {
Decision::Duplicate(json) => Served {
response: Some(json),
notify_high_volume: false,
money_pending: false,
},
Decision::Refuse(err) => {
let json =
serde_json::to_string(&SignResult::refused(&req.id, err)).unwrap_or_default();
session.remember(&req.id, &json, false, now);
Served {
response: Some(json),
notify_high_volume: false,
money_pending: false,
}
}
Decision::MoneyPrompt => Served {
response: None,
notify_high_volume: false,
money_pending: true,
},
Decision::Silent { notify_high_volume } => {
let result = match sign_session_event(sign_keys, &req.event, now) {
Ok(ev) => SignResult::ok(&req.id, &ev),
Err(err) => SignResult::refused(&req.id, err),
};
let json = serde_json::to_string(&result).unwrap_or_default();
// A produced silent signature counts toward the rate window; a refusal
// on the silent path does not.
session.remember(&req.id, &json, result.ok, now);
Served {
response: Some(json),
notify_high_volume,
money_pending: false,
}
}
}
}
/// Complete a money-tier request after the user answered the password prompt.
/// `approved` true signs the event and returns the `sign_result`; false returns
/// the `user_declined` refusal. Either way the result is remembered so a replay
/// of the same id returns it verbatim. Money signs are individually gated and so
/// never count toward the silent rate window.
pub fn complete_money(
session: &mut Session,
req: &SignRequest,
sign_keys: &Keys,
approved: bool,
now: u64,
) -> String {
let result = if approved {
match sign_session_event(sign_keys, &req.event, now) {
Ok(ev) => SignResult::ok(&req.id, &ev),
Err(err) => SignResult::refused(&req.id, err),
}
} else {
SignResult::refused(&req.id, SignError::UserDeclined)
};
let json = serde_json::to_string(&result).unwrap_or_default();
session.remember(&req.id, &json, false, now);
json
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1217,4 +1401,57 @@ mod tests {
assert_eq!(req.event.kind, 7);
assert_eq!(req.event.tags, vec![vec!["e".to_string(), "x".to_string()]]);
}
#[test]
fn serve_silent_signs_and_publishes_result() {
let now = 1_751_800_000u64;
let (mut s, id) = mk_session(&[7], now);
let req = mk_req(&id, 7, "+", now, "sv1");
let out = serve(&mut s, &req, &id, now);
assert!(!out.money_pending);
let json = out.response.expect("silent sign yields a response");
assert!(json.contains("\"ok\":true"));
assert!(json.contains("\"id\":\"sv1\""));
// The signed event rides back in the result and verifies.
let parsed: SignResult = serde_json::from_str(&json).unwrap();
let ev: Event = serde_json::from_value(parsed.event.unwrap()).unwrap();
assert!(ev.verify().is_ok());
assert_eq!(ev.pubkey, id.public_key());
}
#[test]
fn serve_money_is_pending_then_completes_or_declines() {
let now = 1_751_800_000u64;
let (mut s, id) = mk_session(&[1], now);
let req = mk_req(&id, 17, "{}", now, "mv1");
let out = serve(&mut s, &req, &id, now);
assert!(out.money_pending);
assert!(out.response.is_none(), "money pends: nothing published yet");
// User approves -> signed result.
let json = complete_money(&mut s, &req, &id, true, now);
assert!(json.contains("\"ok\":true"));
// A replay of the same id now returns the cached signed result.
match s.decide(&req, now) {
Decision::Duplicate(cached) => assert!(cached.contains("\"ok\":true")),
other => panic!("expected Duplicate, got {other:?}"),
}
// A fresh money request the user declines -> user_declined.
let req2 = mk_req(&id, 17, "{}", now, "mv2");
let declined = complete_money(&mut s, &req2, &id, false, now);
assert!(declined.contains("\"error\":\"user_declined\""));
}
#[test]
fn serve_refuses_kind_not_in_set() {
let now = 1_751_800_000u64;
let (mut s, id) = mk_session(&[7], now);
let req = mk_req(&id, 1, "hi", now, "rf1");
let out = serve(&mut s, &req, &id, now);
assert!(!out.money_pending);
assert!(
out.response
.unwrap()
.contains("\"error\":\"kind_not_in_session\"")
);
}
}