add encrypted Nostr session sharing (#8922)

Signed-off-by: callebtc <93376500+callebtc@users.noreply.github.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
callebtc 2026-05-12 22:06:44 -05:00 committed by GitHub
parent 8c36ba86c6
commit dbbee1cdbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1469 additions and 27 deletions

364
Cargo.lock generated
View file

@ -17,6 +17,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@ -379,6 +389,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "async-utility"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151"
dependencies = [
"futures-util",
"gloo-timers",
"tokio",
"wasm-bindgen-futures",
]
[[package]]
name = "async-wsocket"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069"
dependencies = [
"async-utility",
"futures",
"futures-util",
"js-sys",
"tokio",
"tokio-rustls",
"tokio-socks",
"tokio-tungstenite 0.26.2",
"url",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "atoi"
version = "2.0.0"
@ -388,6 +429,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "atomic-destructor"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -1014,6 +1061,12 @@ dependencies = [
"walkdir",
]
[[package]]
name = "bech32"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]]
name = "better_scoped_tls"
version = "1.0.1"
@ -1315,6 +1368,17 @@ dependencies = [
"serde",
]
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"serde",
"unicode-normalization",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@ -1336,6 +1400,23 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitcoin-io"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
[[package]]
name = "bitcoin_hashes"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [
"bitcoin-io",
"hex-conservative",
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -1793,6 +1874,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.0"
@ -1804,6 +1896,19 @@ dependencies = [
"rand_core 0.10.0",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.44"
@ -1826,6 +1931,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@ -2409,6 +2515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -4362,6 +4469,18 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "goose"
version = "1.34.0"
@ -4417,6 +4536,8 @@ dependencies = [
"minijinja",
"mockall",
"nanoid",
"nostr",
"nostr-sdk",
"oauth2",
"once_cell",
"opentelemetry",
@ -4437,6 +4558,7 @@ dependencies = [
"reqwest 0.13.2",
"rmcp",
"rubato",
"rustls",
"schemars 1.2.1",
"sec1",
"serde",
@ -4803,6 +4925,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
"arrayvec",
]
[[package]]
name = "hipstr"
version = "0.6.0"
@ -5405,6 +5536,18 @@ dependencies = [
"tempfile",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -6208,6 +6351,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "negentropy"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -6296,6 +6445,83 @@ dependencies = [
"nom 8.0.0",
]
[[package]]
name = "nostr"
version = "0.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
dependencies = [
"base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
"cbc",
"chacha20 0.9.1",
"chacha20poly1305",
"getrandom 0.2.17",
"hex",
"instant",
"scrypt",
"secp256k1",
"serde",
"serde_json",
"unicode-normalization",
"url",
]
[[package]]
name = "nostr-database"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
dependencies = [
"lru",
"nostr",
"tokio",
]
[[package]]
name = "nostr-gossip"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6"
dependencies = [
"nostr",
]
[[package]]
name = "nostr-relay-pool"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
dependencies = [
"async-utility",
"async-wsocket",
"atomic-destructor",
"hex",
"lru",
"negentropy",
"nostr",
"nostr-database",
"tokio",
"tracing",
]
[[package]]
name = "nostr-sdk"
version = "0.44.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393"
dependencies = [
"async-utility",
"nostr",
"nostr-database",
"nostr-gossip",
"nostr-relay-pool",
"tokio",
"tracing",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -6616,6 +6842,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.4"
@ -6857,6 +7089,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -6890,6 +7133,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pctx_code_execution_runtime"
version = "0.2.0"
@ -7257,6 +7510,17 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -7693,7 +7957,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20",
"chacha20 0.10.0",
"getrandom 0.4.2",
"rand_core 0.10.0",
]
@ -8398,6 +8662,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@ -8500,6 +8773,18 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"password-hash",
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "sdd"
version = "3.0.10"
@ -8520,6 +8805,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"rand 0.8.5",
"secp256k1-sys",
"serde",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@ -10723,6 +11028,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-socks"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@ -10734,6 +11051,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.26.2",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@ -11204,6 +11537,25 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
@ -11443,6 +11795,16 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"

View file

@ -543,6 +543,28 @@ enum SessionCommand {
default_value = "markdown"
)]
format: String,
#[arg(
long = "nostr",
help = "Publish the JSON session export as an encrypted Nostr event and print a Goose share link"
)]
nostr: bool,
#[arg(
long = "relay",
value_name = "RELAY",
help = "Nostr relay URL to publish to (can be specified multiple times)",
action = clap::ArgAction::Append
)]
relays: Vec<String>,
},
#[command(about = "Import a session from JSON or an encrypted Nostr share link")]
Import {
#[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")]
input: String,
#[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")]
nostr: bool,
},
#[command(name = "diagnostics")]
Diagnostics {
@ -1227,6 +1249,8 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
identifier,
output,
format,
nostr,
relays,
} => {
let session_manager = SessionManager::instance();
let session_identifier = if let Some(id) = identifier {
@ -1244,8 +1268,17 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
}
}
};
crate::commands::session::handle_session_export(session_identifier, output, format)
.await?;
crate::commands::session::handle_session_export(
session_identifier,
output,
format,
nostr,
relays,
)
.await?;
}
SessionCommand::Import { input, nostr } => {
crate::commands::session::handle_session_import(input, nostr).await?;
}
SessionCommand::Diagnostics { identifier, output } => {
let session_manager = SessionManager::instance();

View file

@ -3,7 +3,8 @@ use anyhow::{Context, Result};
use cliclack::{confirm, multiselect, select};
use etcetera::home_dir;
use goose::session::{generate_diagnostics, Session, SessionManager};
use goose::config::Config;
use goose::session::{generate_diagnostics, nostr_share, Session, SessionManager, SessionType};
use goose::utils::safe_truncate;
use regex::Regex;
use std::fs;
@ -216,6 +217,8 @@ pub async fn handle_session_export(
session_id: String,
output_path: Option<PathBuf>,
format: String,
nostr: bool,
relays: Vec<String>,
) -> Result<()> {
let session_manager = SessionManager::instance();
let session = match session_manager.get_session(&session_id, true).await {
@ -241,6 +244,29 @@ pub async fn handle_session_export(
_ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
};
if nostr {
if format != "json" {
return Err(anyhow::anyhow!(
"Nostr session sharing only supports --format json"
));
}
if output_path.is_some() {
return Err(anyhow::anyhow!(
"Nostr session sharing cannot be combined with --output"
));
}
let relays = nostr_share::resolve_relays(relays, Config::global());
let share = nostr_share::publish_session_json(&output, relays).await?;
println!("Session published to Nostr relays:");
for relay in &share.relays {
println!("- {}", relay);
}
println!("\nShare link:");
println!("{}", share.deeplink);
return Ok(());
}
if let Some(output_path) = output_path {
fs::write(&output_path, output).with_context(|| {
format!("Failed to write to output file: {}", output_path.display())
@ -253,6 +279,25 @@ pub async fn handle_session_export(
Ok(())
}
pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> {
let json = if nostr || input.starts_with("goose://sessions/nostr") {
nostr_share::import_session_json_from_deeplink(&input).await?
} else {
fs::read_to_string(&input)
.with_context(|| format!("Failed to read session import file: {input}"))?
};
let session_manager = SessionManager::instance();
let session = session_manager
.import_session(&json, Some(SessionType::User))
.await?;
println!("Session imported:");
println!("{} - {}", session.id, session.name);
Ok(())
}
pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
println!(
"Generating diagnostics bundle for session '{}'...",

View file

@ -443,6 +443,8 @@ derive_utoipa!(IconTheme as IconThemeSchema);
super::routes::session::delete_session,
super::routes::session::export_session,
super::routes::session::import_session,
super::routes::session::share_session_nostr,
super::routes::session::import_session_nostr,
super::routes::session::update_session_user_recipe_values,
super::routes::session::fork_session,
super::routes::session::get_session_extensions,
@ -512,6 +514,9 @@ derive_utoipa!(IconTheme as IconThemeSchema);
super::routes::session_events::SessionReplyResponse,
super::routes::session_events::CancelRequest,
super::routes::session::ImportSessionRequest,
super::routes::session::ShareSessionNostrRequest,
super::routes::session::ShareSessionNostrResponse,
super::routes::session::ImportSessionNostrRequest,
super::routes::session::SessionListResponse,
super::routes::session::UpdateSessionNameRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest,

View file

@ -11,6 +11,7 @@ use axum::{
};
use goose::agents::ExtensionConfig;
use goose::recipe::Recipe;
use goose::session::nostr_share;
use goose::session::session_manager::{SessionInsights, SessionType};
use goose::session::{EnabledExtensionsState, Session};
use serde::{Deserialize, Serialize};
@ -50,6 +51,28 @@ pub struct ImportSessionRequest {
json: String,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShareSessionNostrRequest {
#[serde(default)]
relays: Vec<String>,
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShareSessionNostrResponse {
deeplink: String,
nevent: String,
event_id: String,
relays: Vec<String>,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportSessionNostrRequest {
deeplink: String,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ForkRequest {
@ -364,6 +387,79 @@ async fn import_session(
Ok(Json(session))
}
#[utoipa::path(
post,
path = "/sessions/{session_id}/share/nostr",
request_body = ShareSessionNostrRequest,
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session shared to Nostr successfully", body = ShareSessionNostrResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn share_session_nostr(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(request): Json<ShareSessionNostrRequest>,
) -> Result<Json<ShareSessionNostrResponse>, StatusCode> {
let exported = state
.session_manager()
.export_session(&session_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let relays = nostr_share::resolve_relays(request.relays, goose::config::Config::global());
let share = nostr_share::publish_session_json(&exported, relays)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ShareSessionNostrResponse {
deeplink: share.deeplink,
nevent: share.nevent,
event_id: share.event_id,
relays: share.relays,
}))
}
#[utoipa::path(
post,
path = "/sessions/import/nostr",
request_body = ImportSessionNostrRequest,
responses(
(status = 200, description = "Nostr shared session imported successfully", body = Session),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 400, description = "Bad request - Invalid Nostr share link"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn import_session_nostr(
State(state): State<Arc<AppState>>,
Json(request): Json<ImportSessionNostrRequest>,
) -> Result<Json<Session>, StatusCode> {
let json = nostr_share::import_session_json_from_deeplink(&request.deeplink)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let session = state
.session_manager()
.import_session(&json, Some(SessionType::User))
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
Ok(Json(session))
}
#[utoipa::path(
post,
path = "/sessions/{session_id}/fork",
@ -505,10 +601,18 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/sessions/{session_id}", get(get_session))
.route("/sessions/{session_id}", delete(delete_session))
.route("/sessions/{session_id}/export", get(export_session))
.route(
"/sessions/{session_id}/share/nostr",
post(share_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route(
"/sessions/import",
post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route(
"/sessions/import/nostr",
post(import_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route("/sessions/insights", get(get_session_insights))
.route("/sessions/{session_id}/name", put(update_session_name))
.route(

View file

@ -39,6 +39,7 @@ aws-providers = [
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
vulkan = ["local-inference", "llama-cpp-2/vulkan"]
rustls-tls = [
"dep:rustls",
"reqwest/rustls",
"rmcp/reqwest",
"sqlx/runtime-tokio-rustls",
@ -59,6 +60,7 @@ native-tls = [
"oauth2/native-tls",
]
[lints]
workspace = true
@ -194,6 +196,9 @@ goose-acp-macros = { path = "../goose-acp-macros" }
tower-http = { workspace = true, features = ["cors"] }
http-body-util = "0.1.3"
process-wrap = { version = "9.1.0", features = ["std"] }
nostr = { version = "0.44.2", features = ["nip44"] }
nostr-sdk = { version = "0.44.1", features = ["nip44"] }
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]

View file

@ -2,6 +2,7 @@ mod chat_history_search;
mod diagnostics;
pub mod extension_data;
mod legacy;
pub mod nostr_share;
pub mod session_manager;
pub use diagnostics::{

View file

@ -0,0 +1,370 @@
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use nostr::nips::nip19::{FromBech32, Nip19Event, ToBech32};
use nostr::nips::nip44;
use nostr::prelude::*;
use nostr_sdk::Client;
use crate::config::{Config, ConfigError};
pub const EVENT_KIND: u16 = 30278;
pub const CONFIG_RELAYS_KEY: &str = "GOOSE_NOSTR_RELAYS";
const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
"wss://relay.nostr.band",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NostrShare {
pub deeplink: String,
pub nevent: String,
pub event_id: String,
pub relays: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedShareLink {
pub nevent: String,
pub decryption_key: String,
}
#[async_trait]
pub trait NostrPublisher {
async fn publish(&self, event: Event, relays: &[String]) -> Result<()>;
}
#[async_trait]
pub trait NostrFetcher {
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event>;
}
pub struct LiveNostrClient;
#[async_trait]
impl NostrPublisher for LiveNostrClient {
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
install_rustls_crypto_provider();
let client = Client::default();
for relay in relays {
client
.add_relay(relay)
.await
.with_context(|| format!("Failed to add relay {relay}"))?;
}
client.try_connect(Duration::from_secs(8)).await;
let output = client
.send_event_to(relays.iter().map(String::as_str), &event)
.await
.context("Failed to publish session to Nostr relays")?;
client.shutdown().await;
if output.success.is_empty() {
return Err(anyhow!(
"Failed to publish session to any Nostr relay: {:?}",
output.failed
));
}
Ok(())
}
}
#[async_trait]
impl NostrFetcher for LiveNostrClient {
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event> {
install_rustls_crypto_provider();
let client = Client::default();
for relay in relays {
client
.add_relay(relay)
.await
.with_context(|| format!("Failed to add relay {relay}"))?;
}
client.try_connect(Duration::from_secs(8)).await;
let filter = Filter::new()
.id(event_id)
.kind(Kind::Custom(EVENT_KIND))
.limit(1);
let events = client
.fetch_events_from(
relays.iter().map(String::as_str),
filter,
Duration::from_secs(10),
)
.await
.context("Failed to fetch shared session from Nostr relays")?;
client.shutdown().await;
events
.into_iter()
.next()
.ok_or_else(|| anyhow!("Shared session event not found"))
}
}
#[cfg(feature = "rustls-tls")]
fn install_rustls_crypto_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
#[cfg(not(feature = "rustls-tls"))]
fn install_rustls_crypto_provider() {}
pub fn default_relays() -> Vec<String> {
DEFAULT_RELAYS
.iter()
.map(|relay| relay.to_string())
.collect()
}
pub fn relays_from_config(config: &Config) -> Vec<String> {
match config.get_param::<Vec<String>>(CONFIG_RELAYS_KEY) {
Ok(relays) if !relays.is_empty() => normalize_relays(relays),
Err(ConfigError::NotFound(_)) => default_relays(),
_ => default_relays(),
}
}
pub fn resolve_relays(cli_relays: Vec<String>, config: &Config) -> Vec<String> {
if cli_relays.is_empty() {
relays_from_config(config)
} else {
normalize_relays(cli_relays)
}
}
pub async fn publish_session_json(session_json: &str, relays: Vec<String>) -> Result<NostrShare> {
publish_session_json_with(session_json, relays, &LiveNostrClient).await
}
pub async fn publish_session_json_with<P>(
session_json: &str,
relays: Vec<String>,
publisher: &P,
) -> Result<NostrShare>
where
P: NostrPublisher + Sync,
{
let relays = normalize_relays(relays);
if relays.is_empty() {
return Err(anyhow!("At least one Nostr relay is required"));
}
let relay_urls = relays
.iter()
.map(|relay| RelayUrl::parse(relay))
.collect::<Result<Vec<_>, _>>()?;
let publish_keys = Keys::generate();
let encryption_key = SecretKey::generate();
let encryption_keys = Keys::new(encryption_key.clone());
let encrypted = nip44::encrypt(
&encryption_key,
&encryption_keys.public_key(),
session_json,
nip44::Version::V2,
)?;
let event = EventBuilder::new(Kind::Custom(EVENT_KIND), encrypted)
.tag(Tag::identifier(format!(
"goose-session-{}",
uuid::Uuid::now_v7()
)))
.tag(Tag::parse(["client", "goose"])?)
.sign_with_keys(&publish_keys)?;
publisher.publish(event.clone(), &relays).await?;
let nevent = Nip19Event::new(event.id)
.author(event.pubkey)
.kind(Kind::Custom(EVENT_KIND))
.relays(relay_urls)
.to_bech32()?;
let decryption_key = encryption_key.to_secret_hex();
let deeplink = build_deeplink(&nevent, &decryption_key);
Ok(NostrShare {
deeplink,
nevent,
event_id: event.id.to_hex(),
relays,
})
}
pub async fn import_session_json_from_deeplink(deeplink: &str) -> Result<String> {
import_session_json_from_deeplink_with(deeplink, &LiveNostrClient).await
}
pub async fn import_session_json_from_deeplink_with<F>(
deeplink: &str,
fetcher: &F,
) -> Result<String>
where
F: NostrFetcher + Sync,
{
let ParsedShareLink {
nevent,
decryption_key,
} = parse_deeplink(deeplink)?;
let event_ref = Nip19Event::from_bech32(&nevent)?;
let relays = event_ref
.relays
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
if relays.is_empty() {
return Err(anyhow!("Shared session link does not include any relays"));
}
let event = fetcher.fetch(event_ref.event_id, &relays).await?;
if event.kind != Kind::Custom(EVENT_KIND) {
return Err(anyhow!(
"Unexpected Nostr event kind: {}",
u16::from(event.kind)
));
}
let secret_key = SecretKey::parse(&decryption_key)?;
let encryption_keys = Keys::new(secret_key.clone());
nip44::decrypt(&secret_key, &encryption_keys.public_key(), event.content).map_err(Into::into)
}
pub fn build_deeplink(nevent: &str, decryption_key: &str) -> String {
format!(
"goose://sessions/nostr?nevent={}&key={}",
urlencoding::encode(nevent),
urlencoding::encode(decryption_key)
)
}
pub fn parse_deeplink(deeplink: &str) -> Result<ParsedShareLink> {
let parsed = url::Url::parse(deeplink).context("Invalid Goose session share link")?;
if parsed.scheme() != "goose"
|| parsed.host_str() != Some("sessions")
|| parsed.path() != "/nostr"
{
return Err(anyhow!("Invalid Goose Nostr session share link"));
}
let nevent = parsed
.query_pairs()
.find_map(|(key, value)| (key == "nevent").then(|| value.into_owned()))
.ok_or_else(|| anyhow!("Missing nevent parameter"))?;
let decryption_key = parsed
.query_pairs()
.find_map(|(key, value)| (key == "key").then(|| value.into_owned()))
.ok_or_else(|| anyhow!("Missing decryption key parameter"))?;
Ok(ParsedShareLink {
nevent,
decryption_key,
})
}
fn normalize_relays(relays: Vec<String>) -> Vec<String> {
let mut normalized = Vec::new();
for relay in relays {
let relay = relay.trim();
if relay.is_empty() || normalized.iter().any(|existing| existing == relay) {
continue;
}
normalized.push(relay.to_string());
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
struct RecordingPublisher {
event: Arc<Mutex<Option<Event>>>,
relays: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl NostrPublisher for RecordingPublisher {
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
*self.event.lock().unwrap() = Some(event);
*self.relays.lock().unwrap() = relays.to_vec();
Ok(())
}
}
struct StaticFetcher(Event);
#[async_trait]
impl NostrFetcher for StaticFetcher {
async fn fetch(&self, _event_id: EventId, _relays: &[String]) -> Result<Event> {
Ok(self.0.clone())
}
}
#[tokio::test]
async fn publish_builds_deeplink_and_encrypted_kind_30278_event() {
let event = Arc::new(Mutex::new(None));
let relays = Arc::new(Mutex::new(Vec::new()));
let publisher = RecordingPublisher {
event: event.clone(),
relays: relays.clone(),
};
let share = publish_session_json_with(
r#"{"id":"session-id","conversation":{"messages":[]}}"#,
vec!["wss://relay.example".to_string()],
&publisher,
)
.await
.unwrap();
assert!(share.deeplink.starts_with("goose://sessions/nostr?"));
assert!(share.nevent.starts_with("nevent1"));
assert_eq!(share.relays, vec!["wss://relay.example"]);
assert_eq!(*relays.lock().unwrap(), vec!["wss://relay.example"]);
let event = event.lock().unwrap().clone().unwrap();
assert_eq!(event.kind, Kind::Custom(EVENT_KIND));
assert_ne!(
event.content,
r#"{"id":"session-id","conversation":{"messages":[]}}"#
);
}
#[tokio::test]
async fn publish_and_import_round_trips_session_json() {
let event = Arc::new(Mutex::new(None));
let publisher = RecordingPublisher {
event: event.clone(),
relays: Arc::new(Mutex::new(Vec::new())),
};
let json = r#"{"id":"session-id","name":"shared"}"#;
let share =
publish_session_json_with(json, vec!["wss://relay.example".to_string()], &publisher)
.await
.unwrap();
let fetched_event = event.lock().unwrap().clone().unwrap();
let imported =
import_session_json_from_deeplink_with(&share.deeplink, &StaticFetcher(fetched_event))
.await
.unwrap();
assert_eq!(imported, json);
}
#[test]
fn parses_deeplink() {
let parsed = parse_deeplink("goose://sessions/nostr?nevent=abc&key=def").unwrap();
assert_eq!(parsed.nevent, "abc");
assert_eq!(parsed.decryption_key, "def");
}
}

View file

@ -3145,6 +3145,50 @@
]
}
},
"/sessions/import/nostr": {
"post": {
"tags": [
"Session Management"
],
"operationId": "import_session_nostr",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImportSessionNostrRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Nostr shared session imported successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Session"
}
}
}
},
"400": {
"description": "Bad request - Invalid Nostr share link"
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/sessions/insights": {
"get": {
"tags": [
@ -3656,6 +3700,61 @@
]
}
},
"/sessions/{session_id}/share/nostr": {
"post": {
"tags": [
"Session Management"
],
"operationId": "share_session_nostr",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Unique identifier for the session",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShareSessionNostrRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session shared to Nostr successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShareSessionNostrResponse"
}
}
}
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"404": {
"description": "Session not found"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/sessions/{session_id}/user_recipe_values": {
"put": {
"tags": [
@ -5625,6 +5724,17 @@
}
}
},
"ImportSessionNostrRequest": {
"type": "object",
"required": [
"deeplink"
],
"properties": {
"deeplink": {
"type": "string"
}
}
},
"ImportSessionRequest": {
"type": "object",
"required": [
@ -8105,6 +8215,43 @@
}
}
},
"ShareSessionNostrRequest": {
"type": "object",
"properties": {
"relays": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"ShareSessionNostrResponse": {
"type": "object",
"required": [
"deeplink",
"nevent",
"eventId",
"relays"
],
"properties": {
"deeplink": {
"type": "string"
},
"eventId": {
"type": "string"
},
"nevent": {
"type": "string"
},
"relays": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"SlashCommand": {
"type": "object",
"required": [

View file

@ -8,7 +8,7 @@ import {
useLocation,
useSearchParams,
} from 'react-router-dom';
import { openSharedSessionFromDeepLink } from './sessionLinks';
import { openSharedSessionFromDeepLink, importNostrSessionFromDeepLink } from './sessionLinks';
import { type SharedSessionDetails } from './sharedSessions';
import { ErrorUI } from './components/ErrorBoundary';
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
@ -428,6 +428,11 @@ export function AppInner() {
setIsLoadingSharedSession(true);
setSharedSessionError(null);
try {
if (link.startsWith('goose://sessions/nostr')) {
await importNostrSessionFromDeepLink(link);
navigate('/sessions');
return;
}
await openSharedSessionFromDeepLink(link, (_view: View, options?: ViewOptions) => {
navigate('/shared-session', { state: options });
});
@ -438,14 +443,18 @@ export function AppInner() {
action: 'open_shared_session',
recoverable: true,
});
// Navigate to shared session view with error
const shareToken = link.replace('goose://sessions/', '');
const options = {
sessionDetails: null,
error: errorMessage(error, 'Unknown error'),
shareToken,
};
navigate('/shared-session', { state: options });
if (link.startsWith('goose://sessions/nostr')) {
toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`);
navigate('/sessions');
} else {
const shareToken = link.replace('goose://sessions/', '');
const options = {
sessionDetails: null,
error: errorMessage(error, 'Unknown error'),
shareToken,
};
navigate('/shared-session', { state: options });
}
} finally {
setIsLoadingSharedSession(false);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -593,6 +593,10 @@ export type ImportAppResponse = {
name: string;
};
export type ImportSessionNostrRequest = {
deeplink: string;
};
export type ImportSessionRequest = {
json: string;
};
@ -1368,6 +1372,17 @@ export type SetupResponse = {
success: boolean;
};
export type ShareSessionNostrRequest = {
relays?: Array<string>;
};
export type ShareSessionNostrResponse = {
deeplink: string;
eventId: string;
nevent: string;
relays: Array<string>;
};
export type SlashCommand = {
command: string;
command_type: CommandType;
@ -4091,6 +4106,37 @@ export type ImportSessionResponses = {
export type ImportSessionResponse = ImportSessionResponses[keyof ImportSessionResponses];
export type ImportSessionNostrData = {
body: ImportSessionNostrRequest;
path?: never;
query?: never;
url: '/sessions/import/nostr';
};
export type ImportSessionNostrErrors = {
/**
* Bad request - Invalid Nostr share link
*/
400: unknown;
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ImportSessionNostrResponses = {
/**
* Nostr shared session imported successfully
*/
200: Session;
};
export type ImportSessionNostrResponse = ImportSessionNostrResponses[keyof ImportSessionNostrResponses];
export type GetSessionInsightsData = {
body?: never;
path?: never;
@ -4473,6 +4519,42 @@ export type UpdateSessionNameResponses = {
200: unknown;
};
export type ShareSessionNostrData = {
body: ShareSessionNostrRequest;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}/share/nostr';
};
export type ShareSessionNostrErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ShareSessionNostrResponses = {
/**
* Session shared to Nostr successfully
*/
200: ShareSessionNostrResponse;
};
export type ShareSessionNostrResponse2 = ShareSessionNostrResponses[keyof ShareSessionNostrResponses];
export type UpdateSessionUserRecipeValuesData = {
body: UpdateSessionUserRecipeValuesRequest;
path: {

View file

@ -11,6 +11,8 @@ import {
Trash2,
Download,
Upload,
Share2,
LoaderCircle,
ExternalLink,
Copy,
Puzzle,
@ -28,18 +30,29 @@ import { Skeleton } from '../ui/skeleton';
import { toast } from 'react-toastify';
import { ConfirmationModal } from '../ui/ConfirmationModal';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import {
deleteSession,
exportSession,
forkSession,
importSession,
importSessionNostr,
listSessions,
searchSessions,
shareSessionNostr,
Session,
updateSessionName,
ExtensionConfig,
ExtensionData,
} from '../../api';
import { getTunnelStatus } from '../../api/sdk.gen';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
import { shouldShowNewChatTitle } from '../../sessions';
@ -55,6 +68,11 @@ const i18n = defineMessages({
sessionUpdateFailed: { id: 'sessions.toast.updateFailed', defaultMessage: 'Failed to update session description: {error}' },
chatHistory: { id: 'sessions.chatHistory', defaultMessage: 'Chat history' },
importSession: { id: 'sessions.import', defaultMessage: 'Import Session' },
importNostrSession: { id: 'sessions.importNostr', defaultMessage: 'Import Link' },
importNostrTitle: { id: 'sessions.importNostr.title', defaultMessage: 'Import Nostr Session' },
importNostrDesc: { id: 'sessions.importNostr.description', defaultMessage: 'Paste a Goose Nostr share link to fetch, decrypt, and import the session.' },
importNostrPlaceholder: { id: 'sessions.importNostr.placeholder', defaultMessage: 'goose://sessions/nostr?nevent=...&key=...' },
importing: { id: 'sessions.importing', defaultMessage: 'Importing...' },
chatHistoryDesc: { id: 'sessions.chatHistoryDesc', defaultMessage: 'View and search your past conversations with Goose. {shortcut} to search.' },
searchPlaceholder: { id: 'sessions.searchPlaceholder', defaultMessage: 'Search history...' },
errorLoading: { id: 'sessions.error.loading', defaultMessage: 'Error Loading Sessions' },
@ -73,12 +91,19 @@ const i18n = defineMessages({
importSuccess: { id: 'sessions.toast.imported', defaultMessage: 'Session imported successfully' },
importFailed: { id: 'sessions.toast.importFailed', defaultMessage: 'Failed to import session: {error}' },
exportSuccess: { id: 'sessions.toast.exported', defaultMessage: 'Session exported successfully' },
shareNostrSuccess: { id: 'sessions.toast.shareNostr', defaultMessage: 'Encrypted Nostr share link created' },
shareNostrFailed: { id: 'sessions.toast.shareNostrFailed', defaultMessage: 'Failed to create Nostr share link: {error}' },
copied: { id: 'sessions.toast.copied', defaultMessage: 'Copied to clipboard' },
openInNewWindow: { id: 'sessions.action.openNewWindow', defaultMessage: 'Open in new window' },
editSessionName: { id: 'sessions.action.editName', defaultMessage: 'Edit session name' },
duplicateSession: { id: 'sessions.action.duplicate', defaultMessage: 'Duplicate session' },
deleteSession: { id: 'sessions.action.delete', defaultMessage: 'Delete session' },
exportSession: { id: 'sessions.action.export', defaultMessage: 'Export session' },
shareNostrSession: { id: 'sessions.action.shareNostr', defaultMessage: 'Share encrypted Nostr link' },
extensions: { id: 'sessions.extensions', defaultMessage: 'Extensions:' },
shareNostrTitle: { id: 'sessions.shareNostr.title', defaultMessage: 'Encrypted Nostr Share Link' },
shareNostrDesc: { id: 'sessions.shareNostr.description', defaultMessage: 'Anyone with this link can fetch and decrypt the session. Treat it like a secret.' },
close: { id: 'sessions.close', defaultMessage: 'Close' },
});
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
@ -265,6 +290,14 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<Session | null>(null);
const [showImportLinkModal, setShowImportLinkModal] = useState(false);
const [nostrImportLink, setNostrImportLink] = useState('');
const [isImportingNostr, setIsImportingNostr] = useState(false);
const [shareLink, setShareLink] = useState('');
const [showShareLinkModal, setShowShareLinkModal] = useState(false);
const [sharingSessionId, setSharingSessionId] = useState<string | null>(null);
const [nostrEnabled, setNostrEnabled] = useState(true);
// Search state for debouncing
const [searchTerm, setSearchTerm] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
@ -338,6 +371,17 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
loadSessions();
}, [loadSessions]);
// Hide Nostr sharing when tunnel is disabled (restricted/enterprise bundles)
useEffect(() => {
getTunnelStatus()
.then(({ data }) => {
if (data?.state === 'disabled') {
setNostrEnabled(false);
}
})
.catch(() => {});
}, []);
// Timing logic to prevent flicker between skeleton and content on initial load
useEffect(() => {
if (!isLoading && showSkeleton) {
@ -542,10 +586,62 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
toast.success(intl.formatMessage(i18n.exportSuccess));
}, [intl]);
const handleShareSessionNostr = useCallback(
async (session: Session, e: React.MouseEvent) => {
e.stopPropagation();
setSharingSessionId(session.id);
try {
const response = await shareSessionNostr({
path: { session_id: session.id },
body: {},
throwOnError: true,
});
setShareLink(response.data.deeplink);
setShowShareLinkModal(true);
toast.success(intl.formatMessage(i18n.shareNostrSuccess));
} catch (error) {
toast.error(intl.formatMessage(i18n.shareNostrFailed, { error: errorMessage(error, 'Unknown error') }));
} finally {
setSharingSessionId(null);
}
},
[intl]
);
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleImportNostrLink = useCallback(async () => {
const deeplink = nostrImportLink.trim();
if (!deeplink) return;
setIsImportingNostr(true);
try {
await importSessionNostr({
body: { deeplink },
throwOnError: true,
});
setNostrImportLink('');
setShowImportLinkModal(false);
toast.success(intl.formatMessage(i18n.importSuccess));
await loadSessions();
} catch (error) {
toast.error(intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') }));
} finally {
setIsImportingNostr(false);
}
}, [intl, loadSessions, nostrImportLink]);
const handleCopyShareLink = useCallback(async () => {
try {
await navigator.clipboard.writeText(shareLink);
toast.success(intl.formatMessage(i18n.copied));
} catch (error) {
toast.error(`Failed to copy: ${errorMessage(error, 'Unknown error')}`);
}
}, [intl, shareLink]);
const handleImportSession = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@ -586,14 +682,18 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
onDuplicateClick,
onDeleteClick,
onExportClick,
onShareClick,
onOpenInNewWindow,
isSharing,
}: {
session: Session;
onEditClick: (session: Session) => void;
onDuplicateClick: (session: Session) => void;
onDeleteClick: (session: Session) => void;
onExportClick: (session: Session, e: React.MouseEvent) => void;
onShareClick: (session: Session, e: React.MouseEvent) => void;
onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void;
isSharing: boolean;
}) {
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
@ -630,6 +730,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
[onExportClick, session]
);
const handleShareClick = useCallback(
(e: React.MouseEvent) => {
onShareClick(session, e);
},
[onShareClick, session]
);
const handleOpenInNewWindowClick = useCallback(
(e: React.MouseEvent) => {
onOpenInNewWindow(session, e);
@ -736,6 +843,20 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
>
<Download className="w-3 h-3 text-text-secondary hover:text-text-primary" />
</button>
{nostrEnabled && (
<button
onClick={handleShareClick}
disabled={isSharing}
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer disabled:cursor-wait disabled:opacity-60"
title={intl.formatMessage(i18n.shareNostrSession)}
>
{isSharing ? (
<LoaderCircle className="w-3 h-3 text-text-secondary animate-spin" />
) : (
<Share2 className="w-3 h-3 text-text-secondary hover:text-text-primary" />
)}
</button>
)}
</div>
</Card>
);
@ -828,7 +949,9 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
onDuplicateClick={handleDuplicateSession}
onDeleteClick={handleDeleteSession}
onExportClick={handleExportSession}
onShareClick={handleShareSessionNostr}
onOpenInNewWindow={handleOpenInNewWindow}
isSharing={sharingSessionId === session.id}
/>
))}
</div>
@ -855,15 +978,28 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">{intl.formatMessage(i18n.chatHistory)}</h1>
<Button
onClick={handleImportClick}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{intl.formatMessage(i18n.importSession)}
</Button>
<div className="flex items-center gap-2">
{nostrEnabled && (
<Button
onClick={() => setShowImportLinkModal(true)}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Share2 className="w-4 h-4" />
{intl.formatMessage(i18n.importNostrSession)}
</Button>
)}
<Button
onClick={handleImportClick}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{intl.formatMessage(i18n.importSession)}
</Button>
</div>
</div>
<p className="text-sm text-text-secondary mb-4">
{intl.formatMessage(i18n.chatHistoryDesc, { shortcut: getSearchShortcutText() })}
@ -957,6 +1093,83 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
onSave={handleModalSave}
/>
<Dialog open={showImportLinkModal} onOpenChange={setShowImportLinkModal}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="w-5 h-5" />
{intl.formatMessage(i18n.importNostrTitle)}
</DialogTitle>
<DialogDescription>{intl.formatMessage(i18n.importNostrDesc)}</DialogDescription>
</DialogHeader>
<textarea
value={nostrImportLink}
onChange={(event) => setNostrImportLink(event.target.value)}
placeholder={intl.formatMessage(i18n.importNostrPlaceholder)}
className="min-h-28 w-full resize-none rounded-lg border border-border-primary bg-background-primary p-3 text-sm text-text-primary outline-none focus:ring-2 focus:ring-border-active"
disabled={isImportingNostr}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowImportLinkModal(false)}
disabled={isImportingNostr}
>
{intl.formatMessage(i18n.cancel)}
</Button>
<Button
onClick={handleImportNostrLink}
disabled={isImportingNostr || !nostrImportLink.trim()}
>
{isImportingNostr ? (
<>
<LoaderCircle className="w-4 h-4 animate-spin" />
{intl.formatMessage(i18n.importing)}
</>
) : (
intl.formatMessage(i18n.importSession)
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showShareLinkModal} onOpenChange={setShowShareLinkModal}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="w-5 h-5" />
{intl.formatMessage(i18n.shareNostrTitle)}
</DialogTitle>
<DialogDescription>{intl.formatMessage(i18n.shareNostrDesc)}</DialogDescription>
</DialogHeader>
<div className="relative rounded-lg border border-border-primary bg-background-secondary p-3 pr-12">
<code className="block max-h-36 overflow-y-auto break-all text-sm text-text-primary">
{shareLink}
</code>
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-2"
onClick={handleCopyShareLink}
disabled={!shareLink}
>
<Copy className="h-4 w-4" />
<span className="sr-only">{intl.formatMessage(i18n.copied)}</span>
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowShareLinkModal(false)}>
{intl.formatMessage(i18n.close)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmationModal
isOpen={showDeleteConfirmation}
title={intl.formatMessage(i18n.deleteTitle)}

View file

@ -3908,6 +3908,9 @@
"sessions.action.openNewWindow": {
"defaultMessage": "Open in new window"
},
"sessions.action.shareNostr": {
"defaultMessage": "Share encrypted Nostr link"
},
"sessions.cancel": {
"defaultMessage": "Cancel"
},
@ -3917,6 +3920,9 @@
"sessions.chatHistoryDesc": {
"defaultMessage": "View and search your past conversations with Goose. {shortcut} to search."
},
"sessions.close": {
"defaultMessage": "Close"
},
"sessions.delete.message": {
"defaultMessage": "Are you sure you want to delete the session \"{name}\"? This action cannot be undone."
},
@ -3947,6 +3953,21 @@
"sessions.import": {
"defaultMessage": "Import Session"
},
"sessions.importNostr": {
"defaultMessage": "Import Link"
},
"sessions.importNostr.description": {
"defaultMessage": "Paste a Goose Nostr share link to fetch, decrypt, and import the session."
},
"sessions.importNostr.placeholder": {
"defaultMessage": "goose://sessions/nostr?nevent=...&key=..."
},
"sessions.importNostr.title": {
"defaultMessage": "Import Nostr Session"
},
"sessions.importing": {
"defaultMessage": "Importing..."
},
"sessions.loadingMore": {
"defaultMessage": "Loading more sessions..."
},
@ -3965,6 +3986,15 @@
"sessions.searchPlaceholder": {
"defaultMessage": "Search history..."
},
"sessions.shareNostr.description": {
"defaultMessage": "Anyone with this link can fetch and decrypt the session. Treat it like a secret."
},
"sessions.shareNostr.title": {
"defaultMessage": "Encrypted Nostr Share Link"
},
"sessions.toast.copied": {
"defaultMessage": "Copied to clipboard"
},
"sessions.toast.deleteFailed": {
"defaultMessage": "Failed to delete session \"{name}\": {error}"
},
@ -3986,6 +4016,12 @@
"sessions.toast.imported": {
"defaultMessage": "Session imported successfully"
},
"sessions.toast.shareNostr": {
"defaultMessage": "Encrypted Nostr share link created"
},
"sessions.toast.shareNostrFailed": {
"defaultMessage": "Failed to create Nostr share link: {error}"
},
"sessions.toast.updateFailed": {
"defaultMessage": "Failed to update session description: {error}"
},

View file

@ -511,7 +511,7 @@ app.on('open-url', async (_event, url) => {
if (process.platform !== 'win32') {
const parsedUrl = new URL(url);
log.info('[Main] Received open-url event:', url);
log.info('[Main] Received open-url event:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
await app.whenReady();
@ -540,7 +540,7 @@ app.on('open-url', async (_event, url) => {
// For extension/session URLs, store the deep link for processing after React is ready
pendingDeepLink = url;
log.info('[Main] Stored pending deep link for processing after React ready:', url);
log.info('[Main] Stored pending deep link for processing after React ready:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
const existingWindows = BrowserWindow.getAllWindows();
if (existingWindows.length > 0) {

View file

@ -1,6 +1,18 @@
import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions';
import { View, ViewOptions } from './utils/navigationUtils';
import { errorMessage } from './utils/conversionUtils';
import { importSessionNostr } from './api';
/**
* Imports a session from an encrypted Nostr deep link.
* Separated from shared-session handling so callers can route independently.
*/
export async function importNostrSessionFromDeepLink(url: string): Promise<void> {
await importSessionNostr({
body: { deeplink: url },
throwOnError: true,
});
}
/**
* Handles opening a shared session from a deep link