diff --git a/Cargo.lock b/Cargo.lock index aa884ba..32b616e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" [[package]] name = "nscript-cli" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-host-crypto", "nscript-ir", @@ -400,7 +400,7 @@ dependencies = [ [[package]] name = "nscript-host-crypto" -version = "0.3.4" +version = "0.3.5" dependencies = [ "base64", "chacha20", @@ -420,7 +420,7 @@ dependencies = [ [[package]] name = "nscript-ir" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-modules", "nscript-semantics", @@ -431,7 +431,7 @@ dependencies = [ [[package]] name = "nscript-lang" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-modules", "nscript-semantics", @@ -443,14 +443,14 @@ dependencies = [ [[package]] name = "nscript-mcp" -version = "0.3.4" +version = "0.3.5" dependencies = [ "serde_json", ] [[package]] name = "nscript-modules" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-syntax", "semver", @@ -459,7 +459,7 @@ dependencies = [ [[package]] name = "nscript-runtime" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-modules", "nscript-semantics", @@ -474,7 +474,7 @@ dependencies = [ [[package]] name = "nscript-semantics" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-modules", "nscript-syntax", @@ -484,14 +484,14 @@ dependencies = [ [[package]] name = "nscript-syntax" -version = "0.3.4" +version = "0.3.5" dependencies = [ "unicode-ident", ] [[package]] name = "nscript-wasm" -version = "0.3.4" +version = "0.3.5" dependencies = [ "nscript-ir", "nscript-lang", diff --git a/Cargo.toml b/Cargo.toml index 8999c8d..fe2b010 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.3.4" +version = "0.3.5" edition = "2024" license = "MIT" repository = "https://github.com/imattau/nscript" diff --git a/PLAN.md b/PLAN.md index 2f41a59..cd34fb6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -775,6 +775,18 @@ and exposes the security-relevant analysis it will render. ESM and are exercised in node (`studio/tests/core.test.mjs`) without a browser; `npm run build` produces the deployable static site. +## Clawstr release scope (0.3.5) + +- P0: reject unsafe handler predicates, resolve `me` at subscription time, + lower module-defined tag records, reject computed startup tags, expose lowered + tags in IR 0.2, and provide `starts_with` / `strip_prefix` for event tag values. +- P1: predicate/interpolation documentation and custom tag conformance coverage + are included. Handler-visible inputs and the broader NIP-22/73/25/32 vectors + remain follow-up work; existing runtime coverage already exercises NIP-22, + NIP-25, and NIP-32 operation paths. +- P2: full NIP-57 zap flow, Windows/Linux arm64 bundles, and public HTTP OAuth + remain deferred. + ## Deferred Module operation dispatch, full handler/stream evaluation (a core evaluator now diff --git a/conformance/MATRIX.md b/conformance/MATRIX.md index d325d5c..5b74d50 100644 --- a/conformance/MATRIX.md +++ b/conformance/MATRIX.md @@ -14,6 +14,9 @@ against deterministic host fakes. | Declarative NIP modules | `modules/valid/nip10.nsm` | `modules/invalid/dynamic-wire-name.nsm` | `vectors/module-descriptors.json` | | Secret-key isolation | `secret-with-capability.ns` | `secret-without-capability.ns` | Secret redaction | | Query lowering and bounds | `mentions.ns` | `unbounded-local-filter.ns` | Inspect filter lowering trace | +| Handler predicate safety | `mentions.ns` | semantics/runtime unsupported predicate tests | Principal-resolved filter trace | +| Module-defined wire tags | `scenarios/clawstr-tagged-post.ns` | — | `I` tag in publication IR | +| Read-side tag values | `read-tag-value.ns` | — | `starts_with` / `strip_prefix` handler run | | Pattern exhaustiveness | `exhaustive-match.ns` | `non-exhaustive-match.ns`, `unreachable-pattern.ns` | — | | At-least-once handlers | `idempotent-handler.ns` | `dispatch_event` runtime test | Duplicate delivery | | Store transactions | `idempotent-handler.ns` | `dispatch_event_transactional` runtime test | Transaction rollback | diff --git a/conformance/modules/clawstrroot/clawstr/0.1.0.nsm b/conformance/modules/clawstrroot/clawstr/0.1.0.nsm new file mode 100644 index 0000000..eab5988 --- /dev/null +++ b/conformance/modules/clawstrroot/clawstr/0.1.0.nsm @@ -0,0 +1,14 @@ +module clawstr @ 0.1.0 +language ">=0.1.0 <0.2.0" + +tag WebRoot { + wire: "I" + field url: Text at 1 +} + +event ClawstrPost { + kind: 1111 + mode: regular + content: Text as text + field root: WebRoot +} diff --git a/conformance/scenarios/clawstr-tagged-post.ns b/conformance/scenarios/clawstr-tagged-post.ns new file mode 100644 index 0000000..b3402ea --- /dev/null +++ b/conformance/scenarios/clawstr-tagged-post.ns @@ -0,0 +1,16 @@ +use nip46 +use clawstr + +signer account = nip46() +relayset public = configured + +permissions { + publish ClawstrPost to public + sign ClawstrPost with account + relay public +} + +publish ClawstrPost { + content: "hello", + root: WebRoot { url: "https://clawstr.com/c/nostr" } +} to public with account diff --git a/conformance/valid/mentions.ns b/conformance/valid/mentions.ns index 3c360e0..284d66a 100644 --- a/conformance/valid/mentions.ns +++ b/conformance/valid/mentions.ns @@ -9,9 +9,8 @@ permissions { } stream mentions = select Note - where tags.p contains me from public -on mentions { +on mentions where tags.p contains me { print(event.content) } diff --git a/conformance/valid/read-tag-value.ns b/conformance/valid/read-tag-value.ns new file mode 100644 index 0000000..c671ced --- /dev/null +++ b/conformance/valid/read-tag-value.ns @@ -0,0 +1,19 @@ +use nip01 + +relayset public = configured + +permissions { + read Note from public + relay public + log +} + +stream notes = select Note from public + +on notes { + for tag in event.tags { + if starts_with(tag, "I=") { + print(strip_prefix(tag, "I=")) + } + } +} diff --git a/conformance/vectors/default-publish.ir.json b/conformance/vectors/default-publish.ir.json index 07bb6a4..e624d1f 100644 --- a/conformance/vectors/default-publish.ir.json +++ b/conformance/vectors/default-publish.ir.json @@ -1,5 +1,5 @@ { - "schema": "nscript-ir/0.1", + "schema": "nscript-ir/0.2", "profile": "hardened-agent", "modules": [ { @@ -35,6 +35,7 @@ "event": "Note", "kind": 1, "content": "Hello from a constrained agent", + "tags": [], "created_at": "host" }, { diff --git a/conformance/vectors/hello-note.ir.json b/conformance/vectors/hello-note.ir.json index 8a15de4..ffc7cf0 100644 --- a/conformance/vectors/hello-note.ir.json +++ b/conformance/vectors/hello-note.ir.json @@ -1,5 +1,5 @@ { - "schema": "nscript-ir/0.1", + "schema": "nscript-ir/0.2", "profile": "standard", "modules": [ { @@ -35,6 +35,7 @@ "event": "Note", "kind": 1, "content": "Hello, Nostr", + "tags": [], "created_at": "host" }, { diff --git a/crates/nscript-cli/src/main.rs b/crates/nscript-cli/src/main.rs index bb9077b..160e7a7 100644 --- a/crates/nscript-cli/src/main.rs +++ b/crates/nscript-cli/src/main.rs @@ -423,6 +423,7 @@ fn check_program(arguments: &[String]) -> ExitCode { finish(&path, diagnostics) } +#[allow(clippy::too_many_lines)] fn inspect_program(arguments: &[String], json: bool) -> ExitCode { let Ok((path, program, graph, mut diagnostics)) = load_program(arguments) else { return ExitCode::from(2); @@ -476,11 +477,15 @@ fn inspect_program(arguments: &[String], json: bool) -> ExitCode { "handlers": checked.handlers.iter().map(|handler| serde_json::json!({ "event": handler.event_type, "author": handler.author, + "author_is_principal": handler.author_is_principal, "tags": handler.tag_equals, + "tag_equals_principal": handler.tag_equals_principal, "filter_trace": { "event": handler.event_type, "author": handler.author, - "tags": handler.tag_equals + "author_is_principal": handler.author_is_principal, + "tags": handler.tag_equals, + "tag_equals_principal": handler.tag_equals_principal } })).collect::>(), "footprint": nscript_semantics::footprint(&program, &checked, &graph, &[]), @@ -1075,12 +1080,17 @@ fn run_program(arguments: &[String]) -> ExitCode { for timer in &timers.schedules { println!("timer {} at {}", timer.name, timer.next_at); } - let subscriptions = nscript_runtime::Runtime::< - nscript_runtime::FakeRelayHost, - nscript_runtime::FakeSignerHost, - nscript_runtime::FakeClock, - nscript_runtime::RecordingAudit, - >::handler_subscriptions(&checked, Some("public")); + let subscriptions = + nscript_runtime::Runtime::< + nscript_runtime::FakeRelayHost, + nscript_runtime::FakeSignerHost, + nscript_runtime::FakeClock, + nscript_runtime::RecordingAudit, + >::handler_subscriptions(&checked, Some("public"), options.principal.as_deref()) + .unwrap_or_else(|error| { + eprintln!("error[R1004]: {error:?}"); + std::process::exit(1) + }); for subscription in &subscriptions { println!("subscription {}", subscription.event_type); } @@ -1467,7 +1477,11 @@ fn deploy_program(arguments: &[String]) -> ExitCode { nscript_runtime::Nip46SignerHost, nscript_runtime::RealClock, nscript_runtime::RecordingAudit, - >::handler_subscriptions(&checked, None); + >::handler_subscriptions(&checked, None, options.principal.as_deref()) + .unwrap_or_else(|error| { + eprintln!("error[R1004]: {error:?}"); + std::process::exit(1) + }); for (handler, request) in checked.handlers.iter().zip(&mut requests) { let declared = checked .events diff --git a/crates/nscript-cli/tests/cli.rs b/crates/nscript-cli/tests/cli.rs index 576b226..a0d0d36 100644 --- a/crates/nscript-cli/tests/cli.rs +++ b/crates/nscript-cli/tests/cli.rs @@ -472,6 +472,22 @@ fn run_reports_checked_handler_subscriptions() { assert!(String::from_utf8_lossy(&output.stdout).contains("subscription Note")); } +#[test] +fn run_resolves_me_predicates_from_as() { + let output = Command::new(env!("CARGO_BIN_EXE_nscript")) + .arg("run") + .arg(repository_path("conformance/valid/mentions.ns")) + .args(["--as", "deadbeef"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("subscription Note")); +} + #[test] fn the_idempotent_handler_fixture_actually_dedupes_when_run() { // Every other test touching this fixture only checks or inspects it, or @@ -508,6 +524,50 @@ fn the_idempotent_handler_fixture_actually_dedupes_when_run() { ); } +#[test] +fn module_typed_clawstr_tag_is_emitted_in_publication_ir() { + let output = Command::new(env!("CARGO_BIN_EXE_nscript")) + .args(["compile", "--emit", "ir"]) + .arg(repository_path( + "conformance/scenarios/clawstr-tagged-post.ns", + )) + .arg("-M") + .arg(repository_path("conformance/modules/clawstrroot")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let ir: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + ir["operations"][0]["tags"], + serde_json::json!([["I", "https://clawstr.com/c/nostr"]]) + ); +} + +#[test] +fn handler_can_extract_a_tag_value_with_string_helpers() { + let output = Command::new(env!("CARGO_BIN_EXE_nscript")) + .arg("run") + .arg(repository_path("conformance/valid/read-tag-value.ns")) + .args([ + "--event", + r#"{"event_type":"Note","tags":[["I","https://clawstr.com/c/nostr"],["t","other"]]}"#, + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("log info: https://clawstr.com/c/nostr") + ); +} + #[test] fn generates_npack_compatible_manifest() { let output = Command::new(env!("CARGO_BIN_EXE_nscript")) @@ -1135,6 +1195,27 @@ fn test_event_runs_a_handler_that_calls_a_module_operation() { assert!(!stdout.contains("operation "), "{stdout}"); } +#[test] +fn test_event_resolves_me_predicates_before_counting_matches() { + let matching = Command::new(env!("CARGO_BIN_EXE_nscript")) + .arg("test-event") + .arg(repository_path("conformance/valid/mentions.ns")) + .args([ + "--as", + "deadbeef", + "--event", + r#"{"event_type":"Note","tags":[["p","deadbeef"]]}"#, + ]) + .output() + .unwrap(); + assert!( + matching.status.success(), + "{}", + String::from_utf8_lossy(&matching.stderr) + ); + assert!(String::from_utf8_lossy(&matching.stdout).contains("matched: 1/1 handlers")); +} + #[test] fn test_event_reports_a_failing_handler_and_runs_every_matching_handler() { let source = "permissions {\n log\n}\n\non Note {\n print(me)\n}\n\non Note {\n print(\"second\")\n}\n"; diff --git a/crates/nscript-ir/src/lib.rs b/crates/nscript-ir/src/lib.rs index b8e301f..fe9f405 100644 --- a/crates/nscript-ir/src/lib.rs +++ b/crates/nscript-ir/src/lib.rs @@ -207,6 +207,7 @@ pub enum IrOperation { event: String, kind: u16, content: Option, + tags: Vec<(String, String)>, created_at: &'static str, }, SignEvent { @@ -257,11 +258,17 @@ pub fn lower(program: &Program, checked: &CheckedProgram, graph: &ResolvedModule .map(|event| event.kind) .or_else(|| event_kind(graph, &publication.event)) .unwrap_or(1); + let tags = checked + .events + .get(&publication.event) + .and_then(|event| event.lower_tags(&publication.tags).ok()) + .unwrap_or_else(|| publication.tags.clone()); operations.push(IrOperation::CreateEvent { result: unsigned.clone(), event: publication.event.clone(), kind, content: publication.content.clone(), + tags, created_at: "host", }); operations.push(IrOperation::SignEvent { @@ -276,7 +283,7 @@ pub fn lower(program: &Program, checked: &CheckedProgram, graph: &ResolvedModule }); } NostrIr { - schema: "nscript-ir/0.1", + schema: "nscript-ir/0.2", profile: match program.profile { RuntimeProfile::Standard => "standard", RuntimeProfile::HardenedAgent => "hardened-agent", @@ -384,7 +391,7 @@ mod tests { #[test] fn wasm_artifact_is_valid_container_with_capability_metadata() { let ir = NostrIr { - schema: "nscript-ir/0.1", + schema: "nscript-ir/0.2", profile: "standard", modules: Vec::new(), capabilities: vec![super::IrCapability { diff --git a/crates/nscript-mcp/src/main.rs b/crates/nscript-mcp/src/main.rs index ba728c7..225ba54 100644 --- a/crates/nscript-mcp/src/main.rs +++ b/crates/nscript-mcp/src/main.rs @@ -308,9 +308,15 @@ fn call_tool(params: &Value, cancelled: &Arc) -> Result = handler.get("tags").and_then(Value::as_array).into_iter().flatten().map(|predicate| { + let mut predicates = handler.get("tags").and_then(Value::as_array).cloned().unwrap_or_default(); + if let Some(principal_tags) = handler.get("tag_equals_principal").and_then(Value::as_array) { + predicates.extend(principal_tags.iter().map(|name| json!([name, principal]))); + } + let tag_checks: Vec = predicates.iter().map(|predicate| { let name = predicate.get(0).and_then(Value::as_str).unwrap_or(""); let expected = predicate.get(1).and_then(Value::as_str).unwrap_or(""); let found = event.get("tags").and_then(Value::as_array).into_iter().flatten().any(|tag| tag.get(0).and_then(Value::as_str) == Some(name) && tag.get(1).and_then(Value::as_str) == Some(expected)); diff --git a/crates/nscript-runtime/src/eval.rs b/crates/nscript-runtime/src/eval.rs index 72f76b0..7003a6b 100644 --- a/crates/nscript-runtime/src/eval.rs +++ b/crates/nscript-runtime/src/eval.rs @@ -109,6 +109,17 @@ pub trait EvalHost { }) } + /// Lower a nested nominal tag record using the resolved event schema. + fn lower_typed_tag( + &self, + _event_type: &str, + _field: &str, + _tag_type: &str, + _values: &BTreeMap, + ) -> Option<(String, String)> { + None + } + /// Whether `module.function` is a pure fold query (RFC 0002 §3), so the /// evaluator runs it via [`Self::call_pure_function`] instead of the /// gated [`Self::call_operation`]: no permission, no effect, no @@ -853,6 +864,37 @@ impl Interpreter<'_, H> { tags.push(publish_tag_pair(&field, item)?); } } + Value::Record { + name: tag_type, + fields, + } => { + let values = fields + .into_iter() + .map(|(name, value)| { + let rendered = match value { + Value::Text(value) | Value::PubKey(value) => value, + Value::Int(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + Value::Unit => String::new(), + other => { + return Err(fail(format!( + "tag slot `{name}` must be scalar, found a {}", + other.kind() + ))); + } + }; + Ok((name, rendered)) + }) + .collect::, Stop>>()?; + let Some((wire, value)) = + self.host.lower_typed_tag(&name, &field, &tag_type, &values) + else { + return Err(fail(format!( + "`{field}` has no tag schema for `{tag_type}` in event `{name}`" + ))); + }; + tags.push((wire, value)); + } other => tags.push(publish_tag_pair(&field, other)?), }, } @@ -960,6 +1002,21 @@ impl Interpreter<'_, H> { }; Ok(Value::Text(value.display())) } + ExprKind::Identifier(name) if name == "starts_with" => match arguments.as_slice() { + [Value::Text(value), Value::Text(prefix)] => { + Ok(Value::Bool(value.starts_with(prefix))) + } + _ => Err(fail("`starts_with` takes two text values")), + }, + ExprKind::Identifier(name) if name == "strip_prefix" => match arguments.as_slice() { + [Value::Text(value), Value::Text(prefix)] => value + .strip_prefix(prefix) + .map(|stripped| Value::Text(stripped.to_owned())) + .ok_or_else(|| { + fail("`strip_prefix` input does not start with the given prefix") + }), + _ => Err(fail("`strip_prefix` takes two text values")), + }, ExprKind::Identifier(name) if name == "len" => match arguments.as_slice() { [Value::List(items)] => { Ok(Value::Int(i64::try_from(items.len()).unwrap_or(i64::MAX))) @@ -1334,6 +1391,23 @@ where self.principal.clone() } + fn lower_typed_tag( + &self, + event_type: &str, + field: &str, + tag_type: &str, + values: &BTreeMap, + ) -> Option<(String, String)> { + let (expected, definition) = self.events.get(event_type)?.tag_fields.get(field)?; + if expected != tag_type { + return None; + } + Some(( + definition.wire_name.clone(), + nscript_semantics::lower_typed_tag_values(definition, values)?, + )) + } + fn call_operation( &mut self, module: &str, @@ -1815,7 +1889,15 @@ where limits: EvalLimits, mut idempotency_host: Option<&mut dyn IdempotencyHost>, ) -> Vec { - let requests = Self::handler_subscriptions(checked, None); + let requests = match Self::handler_subscriptions(checked, None, principal) { + Ok(requests) => requests, + Err(error) => { + return vec![HandlerOutcome { + handler: "subscription".to_owned(), + result: Err(error), + }]; + } + }; let mut outcomes = Vec::new(); for (handler, request) in checked.handlers.iter().zip(&requests) { if !Self::matches_subscription(request, event) { @@ -1996,7 +2078,7 @@ where O: OperationHost, { let policy = policy_for(checked); - let requests = Self::handler_subscriptions(checked, relayset); + let requests = Self::handler_subscriptions(checked, relayset, principal)?; let mut report = CycleReport::default(); for (index, (handler, request)) in checked.handlers.iter().zip(&requests).enumerate() { let handle = self.subscribe(subscription_host, request)?; @@ -2806,6 +2888,69 @@ mod tests { assert_eq!(runtime.relay.published[0].unsigned.content, "reply: hi"); } + #[test] + fn a_handler_publishes_module_typed_tag_records_as_wire_tags() { + let source = "use nip01\nuse nip46\nsigner account = nip46()\nrelayset public = configured\npermissions {\n read Note from public\n publish Note to public\n sign Note with account\n relay public\n log\n}\non Note where tags.t contains \"trigger\" { publish Note { content: \"reply\", root: WebRoot { url: event.content } } to public with account }"; + let (program, mut checked) = checked(source); + checked.events.insert( + "Note".to_owned(), + nscript_semantics::CheckedEvent { + kind: 1, + mode: nscript_semantics::CheckedEventMode::Regular, + parameter: None, + tag_fields: BTreeMap::from([( + "root".to_owned(), + ( + "WebRoot".to_owned(), + nscript_semantics::CheckedTagDefinition { + wire_name: "I".to_owned(), + slots: vec![nscript_semantics::CheckedTagSlot::Field { + name: "url".to_owned(), + position: 1, + }], + }, + ), + )]), + }, + ); + let policy = policy_for(&checked); + let mut ops = SimulatedOperations::new(BTreeMap::new()); + let mut log = FakeLogHost::default(); + let mut runtime = Runtime::new( + FakeRelayHost { + relays: [("fake://public".to_owned(), true)].into_iter().collect(), + ..FakeRelayHost::default() + }, + FakeSignerHost::default(), + FakeClock::default(), + RecordingAudit::default(), + ); + let hit = event_from_json( + &serde_json::json!({"content":"https://clawstr.com/c/nostr","tags":[["t","trigger"]]}), + ) + .unwrap(); + let outcomes = runtime.run_handlers_for_event( + &program, + &checked, + &hit, + &policy, + &mut ops, + &mut log, + None, + EvalLimits::default(), + None, + ); + assert_eq!(outcomes.len(), 1); + assert!(outcomes[0].result.is_ok(), "{:?}", outcomes[0].result); + assert_eq!(runtime.relay.published.len(), 1); + assert!( + runtime.relay.published[0] + .unsigned + .tags + .contains(&("I".to_owned(), "https://clawstr.com/c/nostr".to_owned())) + ); + } + #[test] fn a_handlers_publish_lowers_computed_fields_declared_kinds_and_the_d_tag() { let source = "use nip01\nuse nip46\n\nsigner account = nip46()\nrelayset public = configured\n\nevent Reply {\n kind: 30717\n parameterised by thread\n thread: Text\n content: Text\n}\n\npermissions {\n publish Reply to public\n sign Reply with account\n relay public\n log\n}\n\non Note where tags.t contains \"trigger\" {\n let result = publish Reply {\n thread: event.content,\n content: \"hi\",\n p: [\"npub1alice\", \"npub1bob\"],\n count: 2 + 3,\n } to public with account\n print(result.accepted)\n}\n"; diff --git a/crates/nscript-runtime/src/lib.rs b/crates/nscript-runtime/src/lib.rs index 3fffafb..0b41b62 100644 --- a/crates/nscript-runtime/src/lib.rs +++ b/crates/nscript-runtime/src/lib.rs @@ -502,6 +502,7 @@ impl PublishReport { #[derive(Clone, Debug, Eq, PartialEq)] pub enum RuntimeError { + MissingPrincipal, CapabilityDenied { capability: String, }, @@ -2424,6 +2425,7 @@ where event: event_type.to_owned(), content, tags, + tag_values: Vec::new(), signer: signer.to_owned(), relayset: relayset.to_owned(), span: nscript_syntax::Span::default(), @@ -2711,23 +2713,48 @@ where } /// Lower checked `on` handlers into typed subscription requests. - #[must_use] + /// + /// # Errors + /// + /// Returns [`RuntimeError::MissingPrincipal`] when a handler filter uses `me` + /// but no principal is configured. pub fn handler_subscriptions( checked: &CheckedProgram, relayset: Option<&str>, - ) -> Vec { + principal: Option<&str>, + ) -> Result, RuntimeError> { checked .handlers .iter() - .map(|handler| SubscriptionRequest { - event_type: handler.event_type.clone(), - relayset: relayset.map(str::to_owned), - kinds: Vec::new(), - tag_equals: handler.tag_equals.clone(), - cursor: None, - author: handler.author.clone(), - since: None, - limit: None, + .map(|handler| { + if (handler.author_is_principal || !handler.tag_equals_principal.is_empty()) + && principal.is_none() + { + return Err(RuntimeError::MissingPrincipal); + } + let mut tag_equals = handler.tag_equals.clone(); + if let Some(principal) = principal { + tag_equals.extend( + handler + .tag_equals_principal + .iter() + .map(|name| (name.clone(), principal.to_owned())), + ); + } + Ok(SubscriptionRequest { + event_type: handler.event_type.clone(), + relayset: relayset.map(str::to_owned), + kinds: Vec::new(), + tag_equals, + cursor: None, + author: if handler.author_is_principal { + principal.map(str::to_owned) + } else { + handler.author.clone() + }, + since: None, + limit: None, + }) }) .collect() } @@ -2927,7 +2954,7 @@ where &mut StorageTransaction, ) -> Result<(), RuntimeError>, { - let subscriptions = Self::handler_subscriptions(checked, relayset); + let subscriptions = Self::handler_subscriptions(checked, relayset, None)?; let mut dispatched = 0; for (handler, request) in checked.handlers.iter().zip(subscriptions.iter()) { let handle = self.subscribe(subscription_host, request)?; @@ -2983,7 +3010,7 @@ where T: TransactionalStorageHost, L: LogHost, { - let subscriptions = Self::handler_subscriptions(checked, relayset); + let subscriptions = Self::handler_subscriptions(checked, relayset, None)?; let mut dispatched = 0; for (handler, request) in checked.handlers.iter().zip(subscriptions.iter()) { let handle = self.subscribe(subscription_host, request)?; @@ -10530,7 +10557,7 @@ impl Runtime { FakeClock { now: 1_700_000_000 }, RecordingAudit::default(), ); - let subscriptions = Self::handler_subscriptions(checked, Some("public")); + let subscriptions = Self::handler_subscriptions(checked, Some("public"), principal)?; for handle in 1..=subscriptions.len() { relay .queued_events @@ -14248,6 +14275,7 @@ mod tests { event: "Note".to_owned(), content: Some("hello".to_owned()), tags: Vec::new(), + tag_values: Vec::new(), signer: "account".to_owned(), relayset: "public".to_owned(), span: Span::default(), @@ -14283,6 +14311,7 @@ mod tests { ("thread".to_owned(), "abc".to_owned()), ("t".to_owned(), "op".to_owned()), ], + tag_values: Vec::new(), signer: "account".to_owned(), relayset: "public".to_owned(), span: Span::default(), @@ -14293,6 +14322,7 @@ mod tests { kind: 30_717, mode: CheckedEventMode::Addressable, parameter: Some("thread".to_owned()), + tag_fields: BTreeMap::new(), }, )]), ..CheckedProgram::default() @@ -14420,6 +14450,7 @@ mod tests { event: "Note".to_owned(), content: Some("hello".to_owned()), tags: Vec::new(), + tag_values: Vec::new(), signer: "account".to_owned(), relayset: "public".to_owned(), span: Span::default(), @@ -14782,7 +14813,8 @@ mod tests { let subscriptions = Runtime::::handler_subscriptions( &checked, Some("public"), - ); + None, + ).expect("no principal-dependent filters"); assert_eq!(subscriptions.len(), 1); assert_eq!(subscriptions[0].event_type, "Note"); assert_eq!(subscriptions[0].relayset.as_deref(), Some("public")); @@ -14792,6 +14824,22 @@ mod tests { ); } + #[test] + fn principal_predicates_resolve_at_subscription_time() { + let source = "permissions {\n read Note from public\n relay public\n log\n}\nstream notes = select Note from public\non notes where author == me { print(\"author\") }\non notes where tags.p contains me { print(\"mention\") }"; + let program = nscript_syntax::parse_program(source).0; + let (checked, diagnostics) = nscript_semantics::check(&program); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let checked = checked.expect("program checks"); + assert!(Runtime::::handler_subscriptions(&checked, Some("public"), None).is_err()); + let subscriptions = Runtime::::handler_subscriptions(&checked, Some("public"), Some("deadbeef")).expect("principal is resolved"); + assert_eq!(subscriptions[0].author.as_deref(), Some("deadbeef")); + assert_eq!( + subscriptions[1].tag_equals, + [("p".to_owned(), "deadbeef".to_owned())] + ); + } + #[test] fn handler_body_executes_print_through_log_host() { let source = "permissions {\n read Note from public\n relay public\n log\n}\non Note { print(\"hello\") }"; diff --git a/crates/nscript-semantics/src/lib.rs b/crates/nscript-semantics/src/lib.rs index 784a12e..0f25b56 100644 --- a/crates/nscript-semantics/src/lib.rs +++ b/crates/nscript-semantics/src/lib.rs @@ -32,11 +32,38 @@ pub struct CheckedPublication { /// is lowered live from its evaluated record instead (see /// `nscript-runtime`'s interpreter). pub tags: Vec<(String, String)>, + /// Literal author fields preserved until module event schemas are bound. + pub tag_values: Vec, pub signer: String, pub relayset: String, pub span: Span, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CheckedPublicationTag { + Scalar { + field: String, + value: String, + }, + Typed { + field: String, + tag_type: String, + values: BTreeMap, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckedTagDefinition { + pub wire_name: String, + pub slots: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CheckedTagSlot { + Field { name: String, position: u16 }, + Literal { value: String, position: u16 }, +} + /// The storage mode an event's kind range implies for replacement. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum CheckedEventMode { @@ -59,6 +86,8 @@ pub struct CheckedEvent { /// that lowers to the event's single `d` tag. Module events name their /// own `d` field like any other author field. pub parameter: Option, + /// Event author fields whose nominal type is a module-declared Nostr tag. + pub tag_fields: BTreeMap, } impl CheckedEvent { @@ -140,7 +169,9 @@ pub struct CheckedHandler { pub event_type: String, pub has_predicate: bool, pub author: Option, + pub author_is_principal: bool, pub tag_equals: Vec<(String, String)>, + pub tag_equals_principal: Vec, pub body_items: usize, pub body: Vec, pub span: Span, @@ -959,26 +990,66 @@ fn schedule_value(expression: &Expr, every: bool) -> Option { } } -fn handler_predicate_filters(expression: Option<&Expr>) -> (Option, Vec<(String, String)>) { - let Some(expression) = expression else { - return (None, Vec::new()); - }; +fn handler_predicate_filters( + expression: &Expr, + author: &mut Option, + author_is_principal: &mut bool, + tag_equals: &mut Vec<(String, String)>, + tag_equals_principal: &mut Vec, +) -> bool { let ExprKind::Binary { operator, left, right, } = &expression.value else { - return (None, Vec::new()); + return false; }; + if operator == "and" { + return handler_predicate_filters( + left, + author, + author_is_principal, + tag_equals, + tag_equals_principal, + ) && handler_predicate_filters( + right, + author, + author_is_principal, + tag_equals, + tag_equals_principal, + ); + } match (operator.as_str(), &left.value, &right.value) { - ("==", ExprKind::Identifier(name), ExprKind::Identifier(value)) if name == "author" => { - (Some(value.clone()), Vec::new()) + ("==", ExprKind::Identifier(name), ExprKind::Text(value)) if name == "author" => { + if author.as_ref().is_some_and(|previous| previous != value) { + return false; + } + *author = Some(value.clone()); + true } - ("contains", ExprKind::Member { value, name }, ExprKind::Text(text)) if matches!(&value.value, ExprKind::Identifier(base) if base == "tags") => { - (None, vec![(name.value.clone(), text.clone())]) + ("==", ExprKind::Identifier(name), ExprKind::Identifier(value)) + if name == "author" && value == "me" => + { + if author.is_some() || *author_is_principal { + return false; + } + *author_is_principal = true; + true + } + ("contains", ExprKind::Member { value, name }, ExprKind::Text(text)) if matches!(&value.value, ExprKind::Identifier(base) if base == "tags") => + { + tag_equals.push((name.value.clone(), text.clone())); + true } - _ => (None, Vec::new()), + ("contains", ExprKind::Member { value, name }, ExprKind::Identifier(principal)) + if principal == "me" + && matches!(&value.value, ExprKind::Identifier(base) if base == "tags") => + { + tag_equals_principal.push(name.value.clone()); + true + } + _ => false, } } @@ -1048,6 +1119,7 @@ fn program_events(program: &Program) -> BTreeMap { } }, parameter, + tag_fields: BTreeMap::new(), }, )) } @@ -1068,8 +1140,52 @@ fn program_events(program: &Program) -> BTreeMap { pub fn bind_module_events(graph: &ResolvedModuleGraph, checked: &mut CheckedProgram) { for module in graph.modules.values() { for event in &module.descriptor.events { - checked.events.entry(event.name.clone()).or_insert_with(|| { - CheckedEvent { + let mut tag_fields = BTreeMap::new(); + for field in &event.fields { + if let Some(tag) = module + .descriptor + .tags + .iter() + .find(|tag| tag.name == field.type_name) + { + let mut slots = tag + .slots + .iter() + .map(|slot| match slot { + nscript_modules::TagSlot::Field { name, position, .. } => { + CheckedTagSlot::Field { + name: name.clone(), + position: *position, + } + } + nscript_modules::TagSlot::Literal { value, position } => { + CheckedTagSlot::Literal { + value: value.clone(), + position: *position, + } + } + }) + .collect::>(); + slots.sort_by_key(|slot| match slot { + CheckedTagSlot::Field { position, .. } + | CheckedTagSlot::Literal { position, .. } => *position, + }); + tag_fields.insert( + field.name.clone(), + ( + field.type_name.clone(), + CheckedTagDefinition { + wire_name: tag.wire_name.clone(), + slots, + }, + ), + ); + } + } + checked + .events + .entry(event.name.clone()) + .or_insert_with(|| CheckedEvent { kind: event.kind, mode: match event.mode { nscript_modules::EventMode::Regular => CheckedEventMode::Regular, @@ -1077,16 +1193,69 @@ pub fn bind_module_events(graph: &ResolvedModuleGraph, checked: &mut CheckedProg nscript_modules::EventMode::Addressable => CheckedEventMode::Addressable, nscript_modules::EventMode::Ephemeral => CheckedEventMode::Ephemeral, }, - // Module events carry no identifier parameter: an - // addressable module event authors its `d` tag as an - // ordinary field, and `lower_tags` leaves it alone. parameter: None, + tag_fields, + }); + } + } + for publication in &mut checked.publications { + let Some(event) = checked.events.get(&publication.event) else { + continue; + }; + for tag in &publication.tag_values { + match tag { + CheckedPublicationTag::Scalar { .. } => {} + CheckedPublicationTag::Typed { + field, + tag_type, + values, + } => { + let Some((expected_type, definition)) = event.tag_fields.get(field) else { + continue; + }; + if expected_type != tag_type { + continue; + } + if let Some(value) = lower_typed_tag_values(definition, values) { + publication.tags.push((definition.wire_name.clone(), value)); + } } - }); + } } } } +#[must_use] +pub fn lower_typed_tag_values( + definition: &CheckedTagDefinition, + values: &BTreeMap, +) -> Option { + let max_position = definition + .slots + .iter() + .map(|slot| match slot { + CheckedTagSlot::Field { position, .. } | CheckedTagSlot::Literal { position, .. } => { + *position as usize + } + }) + .max() + .unwrap_or(0); + let mut columns = vec![String::new(); max_position]; + for slot in &definition.slots { + match slot { + CheckedTagSlot::Field { name, position } => { + if let Some(value) = values.get(name) { + columns[*position as usize - 1].clone_from(value); + } + } + CheckedTagSlot::Literal { value, position } => { + columns[*position as usize - 1].clone_from(value); + } + } + } + Some(columns.join("\u{1f}")) +} + /// The inferred capability footprint of a program, grouped into the categories /// a Studio permission panel presents. Computed from what the checker inferred /// (`effects`, operation calls, publications, handlers, schedules) joined @@ -1398,6 +1567,7 @@ struct Checker<'a> { handlers: Vec, diagnostics: Vec, event_bindings: BTreeSet, + handler_depth: usize, } impl<'a> Checker<'a> { @@ -1414,6 +1584,7 @@ impl<'a> Checker<'a> { handlers: Vec::new(), diagnostics: Vec::new(), event_bindings: BTreeSet::new(), + handler_depth: 0, }; checker.collect_permissions(); checker @@ -1574,12 +1745,32 @@ impl<'a> Checker<'a> { self.check_expr(predicate, locals); } if let Some(event_type) = events::handler_event_type(source, self.program) { - let (author, tag_equals) = handler_predicate_filters(predicate.as_ref()); + let mut author = None; + let mut author_is_principal = false; + let mut tag_equals = Vec::new(); + let mut tag_equals_principal = Vec::new(); + if let Some(predicate) = predicate + && !handler_predicate_filters( + predicate, + &mut author, + &mut author_is_principal, + &mut tag_equals, + &mut tag_equals_principal, + ) + { + self.diagnostics.push(Diagnostic { + code: "E2402", + message: "handler predicate cannot be safely lowered; use `author == \"\"`, `author == me`, `tags. contains \"\"`, or `tags. contains me`, joined with `and`".to_owned(), + span: predicate.span, + }); + } self.handlers.push(CheckedHandler { event_type, has_predicate: predicate.is_some(), author, + author_is_principal, tag_equals, + tag_equals_principal, body_items: body.len(), body: body.clone(), span, @@ -1604,7 +1795,9 @@ impl<'a> Checker<'a> { span, }, ); + self.handler_depth += 1; self.check_items(body, &mut handler); + self.handler_depth -= 1; } StatementKind::Once { key, body } => { self.effects.insert(Effect::Storage); @@ -1642,7 +1835,9 @@ impl<'a> Checker<'a> { span: duration.span, }); } + self.handler_depth += 1; self.check_items(body, &mut locals.clone()); + self.handler_depth -= 1; } StatementKind::Send { value, signer } => { self.check_expr(value, locals); @@ -1839,22 +2034,64 @@ impl<'a> Checker<'a> { }), _ => None, }; + self.check_top_level_tag_values(value); // Author fields as literal wire pairs. Only literals can be carried // this way: a top-level `publish` runs from this checked snapshot, // and top-level statements (so any computed field) do not run at // startup. A handler's own `publish` lowers its evaluated record // live instead, so it is not limited to literals. - let tags = literal_publication_tags(value); + let tag_values = literal_publication_tag_values(value); + let tags = tag_values + .iter() + .filter_map(|tag| match tag { + CheckedPublicationTag::Scalar { field, value } => { + Some((field.clone(), value.clone())) + } + CheckedPublicationTag::Typed { .. } => None, + }) + .collect(); self.publications.push(CheckedPublication { event, content, tags, + tag_values, signer: signer_name, relayset, span, }); } + fn check_top_level_tag_values(&mut self, value: &Expr) { + if self.handler_depth != 0 { + return; + } + let ExprKind::Construct { fields, .. } = &value.value else { + return; + }; + for (name, field) in fields { + if name.value == "content" + || name.value == "tags" + || EVENT_BOOKKEEPING_FIELDS.contains(&name.value.as_str()) + { + continue; + } + let computable = match &field.value { + ExprKind::List(items) => items.iter().all(|item| literal_wire_text(item).is_some()), + ExprKind::Construct { fields, .. } => fields + .iter() + .all(|(_, item)| literal_wire_text(item).is_some()), + _ => literal_wire_text(field).is_some(), + }; + if !computable { + self.diagnostics.push(Diagnostic { + code: "E2403", + message: format!("top-level tag field `{}` must be a literal; computed tag values are not executed at startup", name.value), + span: field.span, + }); + } + } + } + fn check_match(&mut self, span: Span, value: &Expr, arms: &[nscript_syntax::ast::MatchArm]) { let mut wildcard = false; let mut variants = BTreeSet::new(); @@ -1997,7 +2234,7 @@ fn literal_wire_text(expression: &Expr) -> Option { /// `List` field contributes one pair per element but only when every /// element is literal (a computed element would publish a silently /// partial tag set). A non-record expression has no author fields. -fn literal_publication_tags(value: &Expr) -> Vec<(String, String)> { +fn literal_publication_tag_values(value: &Expr) -> Vec { let mut tags = Vec::new(); if let ExprKind::Construct { fields, .. } = &value.value { for (name, field) in fields { @@ -2014,16 +2251,38 @@ fn literal_publication_tags(value: &Expr) -> Vec<(String, String)> { .map(literal_wire_text) .collect::>>(); if let Some(rendered) = rendered { - tags.extend( - rendered - .into_iter() - .map(|value| (name.value.clone(), value)), - ); + tags.extend(rendered.into_iter().map(|value| { + CheckedPublicationTag::Scalar { + field: name.value.clone(), + value, + } + })); } } _ => { if let Some(text) = literal_wire_text(field) { - tags.push((name.value.clone(), text)); + tags.push(CheckedPublicationTag::Scalar { + field: name.value.clone(), + value: text, + }); + } else if let ExprKind::Construct { + name: type_name, + fields, + } = &field.value + { + let values = fields + .iter() + .filter_map(|(slot, value)| { + literal_wire_text(value).map(|text| (slot.value.clone(), text)) + }) + .collect::>(); + if values.len() == fields.len() { + tags.push(CheckedPublicationTag::Typed { + field: name.value.clone(), + tag_type: type_name.value.clone(), + values, + }); + } } } } @@ -2037,6 +2296,7 @@ mod tests { use nscript_modules::{ModuleDependency, ModuleOrigin, ModuleRegistry, parse_module}; use nscript_syntax::parse_program; use semver::VersionReq; + use std::collections::BTreeMap; use super::{ CheckedEvent, CheckedEventMode, analyze, analyze_with_modules, bind_module_events, check, @@ -2071,7 +2331,7 @@ mod tests { #[test] fn a_literal_publish_carries_its_literal_fields_as_wire_pairs() { - let source = "use nip01\nuse nip46\n\nsigner account = nip46()\nrelayset public = configured\n\npermissions {\n publish Note to public\n sign Note with account\n relay public\n}\n\npublish Note {\n content: \"hello\",\n t: [\"a\", \"b\"],\n k: 7,\n id: \"not-a-tag\",\n kind: 1,\n computed: 1 + 1\n} to public with account\n"; + let source = "use nip01\nuse nip46\n\nsigner account = nip46()\nrelayset public = configured\n\npermissions {\n publish Note to public\n sign Note with account\n relay public\n}\n\npublish Note {\n content: \"hello\",\n t: [\"a\", \"b\"],\n k: 7,\n id: \"not-a-tag\",\n kind: 1,\n} to public with account\n"; let (program, diagnostics) = parse_program(source); assert!(diagnostics.is_empty(), "{diagnostics:?}"); let (checked, diagnostics) = check(&program); @@ -2092,6 +2352,30 @@ mod tests { ); } + #[test] + fn computed_top_level_tags_are_rejected() { + let source = r#"use nip01 +use nip46 +signer account = nip46() +relayset public = configured +permissions { + publish Note to public + sign Note with account + relay public +} +publish Note { content: "x", t: $"tag-{1 + 1}" } to public with account +"#; + let (program, parse_diagnostics) = parse_program(source); + assert!(parse_diagnostics.is_empty(), "{parse_diagnostics:?}"); + let (_, diagnostics) = check(&program); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "E2403"), + "{diagnostics:?}" + ); + } + #[test] fn input_declarations_populate_checked_inputs() { let source = "permissions {\n log\n clock\n}\n\ninput publisher: Text\ninput retries: Int\n\nevery 1d {\n print(publisher)\n}\n"; @@ -2110,6 +2394,44 @@ mod tests { ); } + #[test] + fn unsafe_handler_predicates_are_rejected_instead_of_dropped() { + let source = "use nip01\nrelayset public = configured\npermissions { read Note from public +relay public +log\n}\nstream mentions = select Note from public +on mentions where author == alice { print(event.content) }\n"; + let (program, parse_diagnostics) = parse_program(source); + assert!(parse_diagnostics.is_empty(), "{parse_diagnostics:?}"); + let (_, diagnostics) = check(&program); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "E2402"), + "{diagnostics:?}" + ); + } + + #[test] + fn safe_handler_predicates_lower_literal_tags_and_authors() { + let source = "use nip01\nrelayset public = configured\npermissions { read Note from public +relay public +log\n}\nstream posts = select Note from public\non posts where author == \"some-pubkey\" { print(event.content) }\non posts where tags.p contains me { print(event.content) }\non posts where tags.I contains \"https://clawstr.com/c/nostr\" { print(event.content) }\n"; + let (program, parse_diagnostics) = parse_program(source); + assert!(parse_diagnostics.is_empty(), "{parse_diagnostics:?}"); + let (checked, diagnostics) = check(&program); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let handler = &checked.as_ref().expect("checks").handlers[0]; + assert_eq!(handler.author.as_deref(), Some("some-pubkey")); + assert_eq!(handler.tag_equals, [] as [(String, String); 0]); + let mention = &checked.as_ref().expect("checks").handlers[1]; + assert_eq!(mention.tag_equals_principal, ["p".to_owned()]); + let scoped = &checked.as_ref().expect("checks").handlers[2]; + assert_eq!( + scoped.tag_equals, + [("I".to_owned(), "https://clawstr.com/c/nostr".to_owned())] + ); + } + #[test] fn an_input_of_an_unknown_type_is_a_diagnostic() { let source = "permissions {\n log\n}\n\ninput publisher: NotARealType\n"; @@ -2132,6 +2454,7 @@ mod tests { kind: 30_717, mode: CheckedEventMode::Addressable, parameter: Some("thread".to_owned()), + tag_fields: BTreeMap::new(), }; assert_eq!( reply @@ -2170,6 +2493,7 @@ mod tests { kind: 1, mode: CheckedEventMode::Regular, parameter: None, + tag_fields: BTreeMap::new(), }; assert_eq!( plain @@ -2239,6 +2563,61 @@ mod tests { ); } + #[test] + fn module_tag_typed_event_fields_lower_to_wire_tags() { + let module_source = r#"module clawstr @ 0.1.0 +language ">=0.1.0 <0.2.0" +tag WebRoot { + wire: "I" + field url: Text at 1 +} +event ClawstrPost { + kind: 1111 + mode: regular + content: Text as text + field root: WebRoot +} +"#; + let (descriptor, diagnostics) = parse_module(module_source); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let mut registry = ModuleRegistry::with_builtins(); + registry + .register( + descriptor.expect("module descriptor"), + ModuleOrigin::File("clawstr.nsm".into()), + ) + .expect("register"); + let graph = registry + .resolve(&[ModuleDependency { + name: "clawstr".to_owned(), + requirement: VersionReq::STAR, + }]) + .expect("resolve module"); + let (program, parse_diagnostics) = parse_program(""); + assert!(parse_diagnostics.is_empty(), "{parse_diagnostics:?}"); + let (checked, diagnostics) = check(&program); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let mut checked = checked.expect("checked"); + checked.publications.push(super::CheckedPublication { + event: "ClawstrPost".to_owned(), + content: Some("hello".to_owned()), + tags: Vec::new(), + tag_values: vec![super::CheckedPublicationTag::Typed { + field: "root".to_owned(), + tag_type: "WebRoot".to_owned(), + values: [("url".to_owned(), "https://clawstr.com/c/nostr".to_owned())].into(), + }], + signer: "account".to_owned(), + relayset: "public".to_owned(), + span: nscript_syntax::Span::default(), + }); + bind_module_events(&graph, &mut checked); + assert_eq!( + checked.publications[0].tags, + [("I".to_owned(), "https://clawstr.com/c/nostr".to_owned())] + ); + } + #[test] fn a_scoped_concord_grant_needs_a_known_verb_and_a_declared_scope() { assert_eq!( diff --git a/crates/nscript-wasm/src/lib.rs b/crates/nscript-wasm/src/lib.rs index 20b0eb4..d7bf878 100644 --- a/crates/nscript-wasm/src/lib.rs +++ b/crates/nscript-wasm/src/lib.rs @@ -163,7 +163,11 @@ fn process_request(request: &str) -> String { "inspect" => handle_inspect(source, &modules), "ir" => handle_ir(source, &modules), "compile" => handle_compile(source, &modules), - "run" => handle_run(source, &modules), + "run" => handle_run( + source, + &modules, + value.get("principal").and_then(Value::as_str), + ), "symbols" => handle_symbols(source, &modules), "completions" => handle_completions(source, &modules, &value), "hover" => handle_hover(source, &modules, &value), @@ -293,11 +297,15 @@ fn handle_inspect(source: &str, modules: &[ModuleSource]) -> String { "handlers": inferred.handlers.iter().map(|handler| json!({ "event": handler.event_type, "author": handler.author, + "author_is_principal": handler.author_is_principal, "tags": handler.tag_equals, + "tag_equals_principal": handler.tag_equals_principal, "filter_trace": { "event": handler.event_type, "author": handler.author, - "tags": handler.tag_equals + "author_is_principal": handler.author_is_principal, + "tags": handler.tag_equals, + "tag_equals_principal": handler.tag_equals_principal } })).collect::>(), }) @@ -347,7 +355,7 @@ fn handle_compile(source: &str, modules: &[ModuleSource]) -> String { #[must_use] #[allow(clippy::too_many_lines)] -fn handle_run(source: &str, modules: &[ModuleSource]) -> String { +fn handle_run(source: &str, modules: &[ModuleSource], principal: Option<&str>) -> String { let loaded = load(source, modules); if !loaded.diagnostics.is_empty() { return ok(json!({ @@ -382,11 +390,14 @@ fn handle_run(source: &str, modules: &[ModuleSource]) -> String { Ok(_) => None, Err(error) => Some(format!("{error:?}")), }; - let subscriptions = - Runtime::::handler_subscriptions( - &checked, - Some("public"), - ); + let subscriptions = match Runtime::::handler_subscriptions( + &checked, + Some("public"), + principal, + ) { + Ok(subscriptions) => subscriptions, + Err(error) => return ok(json!({"diagnostics":[{"code":"R1004","severity":"error","message":format!("{error:?}"),"line":0,"column":0,"start":0,"end":0}],"executed":false,"trace":null})), + }; let operation_policy = checked .operation_calls .iter() diff --git a/docs/IR.md b/docs/IR.md index 850419a..cc75330 100644 --- a/docs/IR.md +++ b/docs/IR.md @@ -1,14 +1,14 @@ # NScript IR interchange The checked compiler lowers programs into `NostrIr`, serialized with the -schema marker `nscript-ir/0.1`. The same JSON appears on the CLI `compile +schema marker `nscript-ir/0.2`. The same JSON appears on the CLI `compile --emit ir` output and in the `nscript.ir` custom section of emitted WASM. The interchange is non-executable. It contains resolved module identities and hashes, declared capabilities, permissions, and typed publication operations. Consumers MUST reject unknown schema markers rather than guessing at field -semantics. Producers may add a new schema marker when changing field meaning; -within `0.1`, operation ordering and serialized field names remain stable. +semantics. Version `0.2` adds lowered wire tags to `create_event`; operation +ordering and existing serialized field names remain stable. The `nscript.dispatch` WASM custom section contains only the operation array from this IR. Hosts must validate its bounds and operation-to-import mapping diff --git a/docs/MCP.md b/docs/MCP.md index 3855cea..89e9952 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -23,14 +23,14 @@ The default transport is stdio. The server finds `nscript` beside its own execut } ``` -Install the published npm bundle on Linux x86_64 or macOS Apple Silicon with `npx -y nscript-mcp`. It bundles both the MCP server and CLI. The `nscript-mcp` command uses stdio by default. +Install the published npm bundle on Linux x86_64 or macOS Apple Silicon with `npx -y nscript-mcp@0.3.5`. It bundles both the MCP server and CLI. The `nscript-mcp` command uses stdio by default. ## Tools - **`nscript_check`** parses and statically checks source, returning compiler diagnostics and a success status. - **`nscript_inspect`** returns a structured manifest with effects, resolved modules, operation calls, and publication trace. - **`nscript_simulate_event`** sends a JSON event to the CLI simulator. It accepts optional `principal` (`--as`), declared scalar `inputs`, and `module_dirs`. -- **`nscript_test_event`** simulates an event and reports the dispatched handler count and per-handler event type, author, and tag filter trace. Use it to diagnose events that match zero handlers. It accepts `principal` and `module_dirs`. +- **`nscript_test_event`** simulates an event and reports the dispatched handler count and per-handler event type, author, and tag filter trace. Principal-based predicates are resolved against its `principal` argument. Use it to diagnose events that match zero handlers. Unsupported handler predicate shapes fail checking with E2402, so they cannot silently widen a subscription. It accepts `principal` and `module_dirs`. Simulation is dry: it does not connect to relays, publish events, or access signer keys. Operations without a simulator implementation are recorded and answered with declared placeholder values. diff --git a/examples/service-operator-bot.ns b/examples/service-operator-bot.ns index 8db9765..d30891e 100644 --- a/examples/service-operator-bot.ns +++ b/examples/service-operator-bot.ns @@ -283,8 +283,8 @@ on Locator where tags.d contains "peer-relay" { // Reader: an NCC-09 grant received from someone else naming this // operator as the delegate — the other direction from the principal // section above. -on AuthorityGrant where tags.p contains service_pubkey() { - if ncc09.grant_is_valid(event.tags, event.created_at) { +on AuthorityGrant { + if event.tags contains "p=" + service_pubkey() and ncc09.grant_is_valid(event.tags, event.created_at) { print("received a grant for " + ncc09.service(event.tags) + ": scopes " + text(ncc09.scopes(event.tags))) } else { print("received grant rejected: revoked, expired, not yet valid, or incomplete") diff --git a/npm/nscript-mcp/README.md b/npm/nscript-mcp/README.md index e87f354..4f74673 100644 --- a/npm/nscript-mcp/README.md +++ b/npm/nscript-mcp/README.md @@ -1,6 +1,6 @@ # nscript-mcp -Install the NScript MCP server with npx -y nscript-mcp or add it as a project +Install the NScript MCP server with npx -y nscript-mcp@0.3.5 or add it as a project dependency. The package includes the NScript CLI and MCP server for Linux x86_64 and macOS Apple Silicon. diff --git a/npm/nscript-mcp/package.json b/npm/nscript-mcp/package.json index 0f9ba7f..43bda27 100644 --- a/npm/nscript-mcp/package.json +++ b/npm/nscript-mcp/package.json @@ -1,6 +1,6 @@ { "name": "nscript-mcp", - "version": "0.3.4", + "version": "0.3.5", "description": "MCP server for checking, inspecting, and simulating NScript programs", "license": "MIT", "repository": { diff --git a/spec/diagnostics.md b/spec/diagnostics.md index 6af568d..e233e22 100644 --- a/spec/diagnostics.md +++ b/spec/diagnostics.md @@ -23,6 +23,8 @@ language major version. | E2203 | missing-default-signer | Bare unsigned publication has no declared signer default | | E2204 | missing-default-relays | Publication without `to` has no declared relay-set default | | E2401 | unbounded-local-filter | A non-lowerable query predicate lacks a finite remote bound | +| E2402 | unsafe-handler-predicate | A handler predicate cannot be safely lowered to its subscription filter | +| E2403 | computed-startup-tag | A top-level publish contains a computed tag value that cannot be lowered at startup | | E3001 | missing-permission | A reachable inferred effect is not covered by the manifest | | E3002 | unavailable-capability | The host cannot provision a declared mandatory capability | | E3101 | high-risk-consent-required | Secret, filesystem, or payment authority lacks distinct consent | diff --git a/spec/language.md b/spec/language.md index 32d957d..c86fb45 100644 --- a/spec/language.md +++ b/spec/language.md @@ -52,6 +52,12 @@ and return types are mandatory at public module boundaries; local types MAY be inferred. There are no truthy conversions and no null value. Exhaustive matching is required for enums, `Option`, and `Result`. +Interpolated text uses the form `$"hello {name}"`. Each expression inside +braces is converted with `text(value)` and concatenated with the surrounding +literal text; `{{` and `}}` produce literal braces. Interpolation is evaluated +at run time in handlers. A top-level publish cannot execute computed tag fields +during startup and reports E2403; use literal tag fields there. + ## 3. Events and tags An event declaration describes author-controlled fields and its Nostr lowering: @@ -163,7 +169,7 @@ MAY run locally only when its remote superset is bounded by an explicit `limit`, ```nostr let notes = select Note - where author == alice and tags.t contains "nostr" + where author == "" and tags.t contains "nostr" since 24h limit 100 from public @@ -178,12 +184,20 @@ bounded relay queries followed by local intersection; they MUST NOT be placed as multiple filters in one `REQ`, because filters within a request are alternatives. A `stream` is an unbounded subscription. `on` registers a handler and does not -block top-level registration of later handlers. +block top-level registration of later handlers. Handler `where` predicates are +pushed into the remote subscription only for these shapes: `author == ""`, +`author == me`, `tags. contains ""`, and +`tags. contains me`, joined with `and`. Identifier names such as +`author == alice`, `or`, and other expressions are rejected with E2402; they +never become an unrestricted subscription. `me` is resolved from the configured +principal when the subscription is created. A body check can express other +filters when appropriate, for example `event.pubkey == me` or +`event.tags contains "p=" + text(me)`. ```nostr -stream mentions = select Note where tags.p contains me from public +stream notes = select Note from public -on mentions { +on notes where tags.p contains me { print(event.content) } ```