commit 266e494dbb330585a6006063931adb47e86f111e
parent 3d6d81fb122c4060ba008904bac19fa228a3e31a
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 12:10:22 +0000
cli: add deterministic discovery bundle export
- add unsigned deterministic bundle artifacts for nostr.json handler metadata and a stable manifest
- add discovery export-bundle to write deployment-ready files without treating signed events as artifacts
- document the checked example config and the bundle export path in the discovery guide
- validate with cargo metadata --format-version 1 --no-deps cargo check --locked cargo fmt --check and cargo test --locked
Diffstat:
3 files changed, 163 insertions(+), 1 deletion(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -119,6 +119,10 @@ pub enum MycDiscoveryCommand {
},
RenderNip89,
PublishNip89,
+ ExportBundle {
+ #[arg(long)]
+ out: PathBuf,
+ },
}
#[derive(Debug, Args)]
@@ -305,6 +309,10 @@ pub async fn run_from_env() -> Result<(), MycError> {
let output = publish_nip89_event(&runtime).await?;
print_json(&output)
}
+ MycDiscoveryCommand::ExportBundle { out } => {
+ let output = MycDiscoveryContext::from_runtime(&runtime)?.write_bundle(out)?;
+ print_json(&output)
+ }
}
}
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -17,6 +17,10 @@ use crate::error::MycError;
use crate::transport::MycNostrTransport;
const NIP46_RPC_KIND: u32 = 24_133;
+const DISCOVERY_BUNDLE_VERSION: u32 = 1;
+const DISCOVERY_BUNDLE_MANIFEST_FILE_NAME: &str = "bundle.json";
+const DISCOVERY_BUNDLE_NIP89_FILE_NAME: &str = "nip89-handler.json";
+const DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH: &str = ".well-known/nostr.json";
#[derive(Clone)]
pub struct MycDiscoveryContext {
@@ -72,6 +76,42 @@ pub struct MycPublishedNip89Output {
pub event: RadrootsNostrEvent,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycNip89HandlerDocument {
+ pub kinds: Vec<u32>,
+ pub identifier: String,
+ pub relays: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub nostrconnect_url: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub metadata: Option<RadrootsNostrMetadata>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycDiscoveryBundleManifest {
+ pub version: u32,
+ pub domain: String,
+ pub author_public_key_hex: String,
+ pub signer_public_key_hex: String,
+ pub public_relays: Vec<String>,
+ pub publish_relays: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub nostrconnect_url: Option<String>,
+ pub nip05_relative_path: String,
+ pub nip89_relative_path: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycDiscoveryBundleOutput {
+ pub output_dir: PathBuf,
+ pub manifest_path: PathBuf,
+ pub nip05_path: PathBuf,
+ pub nip89_handler_path: PathBuf,
+ pub manifest: MycDiscoveryBundleManifest,
+ pub nip05_document: MycNip05Document,
+ pub nip89_handler: MycNip89HandlerDocument,
+}
+
impl MycDiscoveryContext {
pub fn from_runtime(runtime: &MycRuntime) -> Result<Self, MycError> {
let discovery = &runtime.config().discovery;
@@ -197,6 +237,34 @@ impl MycDiscoveryContext {
})
}
+ pub fn render_nip89_handler_document(&self) -> MycNip89HandlerDocument {
+ MycNip89HandlerDocument {
+ kinds: vec![NIP46_RPC_KIND],
+ identifier: self.handler_identifier.clone(),
+ relays: self.public_relays.iter().map(ToString::to_string).collect(),
+ nostrconnect_url: self.nostrconnect_url.clone(),
+ metadata: self.metadata.clone(),
+ }
+ }
+
+ pub fn render_bundle_manifest(&self) -> MycDiscoveryBundleManifest {
+ MycDiscoveryBundleManifest {
+ version: DISCOVERY_BUNDLE_VERSION,
+ domain: self.domain.clone(),
+ author_public_key_hex: self.app_identity.public_key_hex(),
+ signer_public_key_hex: self.signer_identity.public_key_hex(),
+ public_relays: self.public_relays.iter().map(ToString::to_string).collect(),
+ publish_relays: self
+ .publish_relays
+ .iter()
+ .map(ToString::to_string)
+ .collect(),
+ nostrconnect_url: self.nostrconnect_url.clone(),
+ nip05_relative_path: DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH.to_owned(),
+ nip89_relative_path: DISCOVERY_BUNDLE_NIP89_FILE_NAME.to_owned(),
+ }
+ }
+
pub fn build_signed_handler_event(&self) -> Result<RadrootsNostrEvent, MycError> {
let builder = radroots_nostr_build_application_handler_event(&self.build_handler_spec())?;
builder
@@ -208,6 +276,38 @@ impl MycDiscoveryContext {
})
}
+ pub fn write_bundle(
+ &self,
+ output_dir: impl AsRef<Path>,
+ ) -> Result<MycDiscoveryBundleOutput, MycError> {
+ let output_dir = output_dir.as_ref().to_path_buf();
+ fs::create_dir_all(&output_dir).map_err(|source| MycError::DiscoveryIo {
+ path: output_dir.clone(),
+ source,
+ })?;
+
+ let manifest = self.render_bundle_manifest();
+ let nip05_document = self.render_nip05_document();
+ let nip89_handler = self.render_nip89_handler_document();
+ let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME);
+ let nip05_path = output_dir.join(DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH);
+ let nip89_handler_path = output_dir.join(DISCOVERY_BUNDLE_NIP89_FILE_NAME);
+
+ write_pretty_json(&manifest_path, &manifest)?;
+ write_pretty_json(&nip05_path, &nip05_document)?;
+ write_pretty_json(&nip89_handler_path, &nip89_handler)?;
+
+ Ok(MycDiscoveryBundleOutput {
+ output_dir,
+ manifest_path,
+ nip05_path,
+ nip89_handler_path,
+ manifest,
+ nip05_document,
+ nip89_handler,
+ })
+ }
+
fn build_handler_spec(&self) -> RadrootsNostrApplicationHandlerSpec {
let mut spec = RadrootsNostrApplicationHandlerSpec::new(vec![NIP46_RPC_KIND]);
spec.identifier = Some(self.handler_identifier.clone());
@@ -319,6 +419,26 @@ fn sanitize_optional_string(value: Option<&str>) -> Option<String> {
}
}
+fn write_pretty_json<T>(path: &Path, value: &T) -> Result<(), MycError>
+where
+ T: Serialize,
+{
+ if let Some(parent) = path.parent() {
+ if !parent.as_os_str().is_empty() {
+ fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ }
+ let encoded = serde_json::to_string_pretty(value)?;
+ fs::write(path, encoded).map_err(|source| MycError::DiscoveryIo {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ Ok(())
+}
+
fn render_nostrconnect_url(
template: &str,
signer_identity: &RadrootsIdentity,
@@ -477,4 +597,37 @@ mod tests {
assert!(written.contains("\"nip46\""));
assert!(written.contains(&context.app_identity().public_key_hex()));
}
+
+ #[test]
+ fn write_bundle_writes_deterministic_artifacts() {
+ let runtime = runtime();
+ let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
+ let bundle_dir = runtime.paths().state_dir.join("bundle");
+
+ let first = context
+ .write_bundle(&bundle_dir)
+ .expect("first bundle write");
+ let manifest_first = fs::read_to_string(&first.manifest_path).expect("manifest");
+ let nip05_first = fs::read_to_string(&first.nip05_path).expect("nip05");
+ let nip89_first = fs::read_to_string(&first.nip89_handler_path).expect("nip89");
+
+ let second = context
+ .write_bundle(&bundle_dir)
+ .expect("second bundle write");
+ let manifest_second = fs::read_to_string(&second.manifest_path).expect("manifest");
+ let nip05_second = fs::read_to_string(&second.nip05_path).expect("nip05");
+ let nip89_second = fs::read_to_string(&second.nip89_handler_path).expect("nip89");
+
+ assert_eq!(first.manifest.version, 1);
+ assert_eq!(first.manifest.nip05_relative_path, ".well-known/nostr.json");
+ assert_eq!(first.manifest.nip89_relative_path, "nip89-handler.json");
+ assert_eq!(first.nip05_path, bundle_dir.join(".well-known/nostr.json"));
+ assert_eq!(
+ first.nip89_handler_path,
+ bundle_dir.join("nip89-handler.json")
+ );
+ assert_eq!(manifest_first, manifest_second);
+ assert_eq!(nip05_first, nip05_second);
+ assert_eq!(nip89_first, nip89_second);
+ }
}
diff --git a/src/lib.rs b/src/lib.rs
@@ -22,7 +22,8 @@ pub use config::{
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use discovery::{
- MycDiscoveryContext, MycNip05Document, MycNip05DocumentSection, MycPublishedNip89Output,
+ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycNip05Document,
+ MycNip05DocumentSection, MycNip89HandlerDocument, MycPublishedNip89Output,
MycRenderedNip05Output, MycRenderedNip89Output, publish_nip89_event, render_nip05_output,
};
pub use error::MycError;