Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions conformance/MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 14 additions & 0 deletions conformance/modules/clawstrroot/clawstr/0.1.0.nsm
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions conformance/scenarios/clawstr-tagged-post.ns
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions conformance/valid/mentions.ns
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
19 changes: 19 additions & 0 deletions conformance/valid/read-tag-value.ns
Original file line number Diff line number Diff line change
@@ -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="))
}
}
}
3 changes: 2 additions & 1 deletion conformance/vectors/default-publish.ir.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema": "nscript-ir/0.1",
"schema": "nscript-ir/0.2",
"profile": "hardened-agent",
"modules": [
{
Expand Down Expand Up @@ -35,6 +35,7 @@
"event": "Note",
"kind": 1,
"content": "Hello from a constrained agent",
"tags": [],
"created_at": "host"
},
{
Expand Down
3 changes: 2 additions & 1 deletion conformance/vectors/hello-note.ir.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema": "nscript-ir/0.1",
"schema": "nscript-ir/0.2",
"profile": "standard",
"modules": [
{
Expand Down Expand Up @@ -35,6 +35,7 @@
"event": "Note",
"kind": 1,
"content": "Hello, Nostr",
"tags": [],
"created_at": "host"
},
{
Expand Down
30 changes: 22 additions & 8 deletions crates/nscript-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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::<Vec<_>>(),
"footprint": nscript_semantics::footprint(&program, &checked, &graph, &[]),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1467,7 +1477,11 @@ fn deploy_program(arguments: &[String]) -> ExitCode {
nscript_runtime::Nip46SignerHost<nscript_host_crypto::nip46::RealNip46Transport>,
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
Expand Down
81 changes: 81 additions & 0 deletions crates/nscript-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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";
Expand Down
Loading
Loading