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
9 changes: 7 additions & 2 deletions NCC-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,8 +709,13 @@ builds and compares; NCC-13 does not define their identity either (§3).
`operator_for` shape) and `requirement_address`/`requirement_constraint`
split it back, while `requires`/`optional_dependencies`/`conflicts` read
every repeated entry, in order, for the caller to AND-fold (§13);
`operator_for_tag`/`operator_project`/`operator_application` build and
read the `ncc:13:publish` operator claim (§33); `semver_is_valid` and
`release_operator_for`/`release_operator_project`/
`release_operator_application` build and read the `ncc:13:publish`
operator claim (§33) — named distinctly from NCC-10's
`operator_for_tag`, since importing both modules in one program
requires every declared name across them to be unique (a real
collision found composing the two in `examples/service-operator-bot.ns`);
`semver_is_valid` and
`semver_compare` implement SemVer §11 precedence (major/minor/patch,
build metadata parsed and discarded, prerelease ranked below the release
it precedes, prerelease identifiers compared per §11.4); and
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,15 @@ the encrypted-stream handler shape. [`docs/CONCORD-BOT.md`](docs/CONCORD-BOT.md)
explains what runs in simulation, what is covered by the real-host integration
tests, and what the live CLI runner still needs.

Every other convention-specific example exercises one NCC in isolation.
[`examples/service-operator-bot.ns`](examples/service-operator-bot.ns) instead
composes the whole service cluster this codebase has landed — NCC-02, 05, 06,
07, 09, 10, 11 and 13 — the way a real operator would actually use them
together: advertise capabilities and endpoints, delegate scoped operator
authority instead of a signing key, cut a signed software release, judge
operator-claimed status against the grant that is supposed to authorise it,
and decide whether a peer's endpoints are worth depending on.

## Design principles

1. Nostr is the environment, not a library.
Expand Down
173 changes: 173 additions & 0 deletions crates/nscript-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2181,6 +2181,179 @@ fn the_release_monitor_publishes_judges_upgrades_and_selects_artefacts() {
assert!(stdout.contains("selected artefact: deb"), "{stdout}");
}

#[test]
#[allow(clippy::too_many_lines)]
fn the_service_operator_bot_composes_the_full_ncc_service_cluster() {
let path = repository_path("examples/service-operator-bot.ns");
let run = |extra: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_nscript"))
.arg("run")
.arg(&path)
.args(extra)
.output()
.unwrap()
};

// No event: the schedule's literal-field publishes (manifest, record,
// state) run at startup and the 24h timer registers.
let output = run(&[]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("timer schedule-0"), "{stdout}");
assert!(stdout.contains("9 handler(s) registered"), "{stdout}");

// The encrypted locator refresh needs a configured principal (`me`).
let refresh = r#"{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "refresh", "tags": [["t", "refresh-locator"]]}"#;
let output = run(&["--as", &"a".repeat(64), "--event", refresh]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("locator published: true/1 relays"),
"{stdout}"
);

// NCC-09 as principal: delegate two scopes to an automation key.
let authorise = r#"{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "bring up CI", "tags": [["t", "authorise-operator"]]}"#;
let output = run(&["--event", authorise]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("operator grant published: true/1 relays"),
"{stdout}"
);

// NCC-13 as publisher: a signed release with a dependency and a conflict.
let cut_release = r#"{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "ship it", "tags": [["t", "cut-release"]]}"#;
let output = run(&["--event", cut_release]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("release published: true/1 relays"),
"{stdout}"
);

// An operator-claimed state is accepted only while the NCC-09 grant
// this operator issued to itself as "known" is active for that scope.
let operator = "b".repeat(64);
let operator_claim = format!(
r#"{{"event_type": "ServiceState", "kind": 30065, "created_at": 1790380000, "content": "", "tags": [["d", "{operator}:relay-ops"], ["service", "relay-ops"], ["state", "maintenance"], ["operator_for", "{operator}\u001frelay-ops"]]}}"#
);
let output = run(&["--event", &operator_claim]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("accepted, ncc:10:publish is active"),
"{stdout}"
);

// A direct state is reported as the service's own claim.
let direct = r#"{"event_type": "ServiceState", "kind": 30065, "created_at": 1790380000, "content": "", "tags": [["d", "relay-ops"], ["service", "relay-ops"], ["state", "operational"]]}"#;
let output = run(&["--event", direct]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("relay-ops reports operational directly"),
"{stdout}"
);

// A peer's ServiceRecord is validated under NCC-02 before its Locator
// (below) is judged worth depending on.
let peer_record = r#"{"event_type": "ServiceRecord", "kind": 30059, "created_at": 1790380000, "content": "", "tags": [["d", "peer-relay"], ["u", "wss://peer.example.com"], ["k", "6666666666666666666666666666666666666666666666666666666666666666"], ["exp", "1799999999"]]}"#;
let output = run(&["--event", peer_record]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("peer-relay record valid"), "{stdout}");

// The peer's Locator: ranked by NCC-06 over the NCC-05 payload.
let peer_locator = r#"{"event_type": "Locator", "kind": 30058, "created_at": 1790380000, "content": "{\"v\":1,\"ttl\":600,\"updated_at\":1790380000,\"endpoints\":[{\"url\":\"wss://peer.example.com\",\"priority\":10},{\"url\":\"ws://peer-onion.onion\"}]}", "tags": [["d", "peer-relay"], ["expiration", "1790470000"]]}"#;
let output = run(&["--event", peer_locator]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(
"peer-relay endpoints, ranked: [wss://peer.example.com, ws://peer-onion.onion]"
),
"{stdout}"
);

// NCC-11 policy data is reported for the operator to apply elsewhere.
let policy = r#"{"event_type": "TrustPolicy", "kind": 30067, "created_at": 1790380000, "content": "", "tags": [["d", "default"], ["rule", "ncc:02:key-pinning", "require"], ["rule", "ncc:05:stale-fallback", "deny"]]}"#;
let output = run(&["--event", policy]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("policy default: key pinning require, stale fallback deny"),
"{stdout}"
);

// A signed release from this service is judged an upgrade candidate.
let release = format!(
r#"{{"event_type": "ReleaseArtifactSet", "kind": 30063, "created_at": 1790380000, "content": "", "tags": [["d", "com.example.relay-ops@1.4.2"], ["a", "32267:{}:com.example.relay-ops"], ["version", "1.4.2"], ["version_scheme", "semver"], ["channel", "stable"]]}}"#,
"c".repeat(64)
);
let output = run(&["--event", &release]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("upgrade candidate true"), "{stdout}");

// NCC-09 as reader: a grant received naming this operator as delegate.
let received_grant = format!(
r#"{{"event_type": "AuthorityGrant", "kind": 30064, "created_at": 1790380000, "content": "", "tags": [["d", "peer-relay:{c}"], ["service", "peer-relay"], ["p", "{c}"], ["status", "active"], ["scope", "ncc:10:publish"], ["expiration", "1799999999"]]}}"#,
c = "c".repeat(64)
);
let output = run(&["--event", &received_grant]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("received a grant for peer-relay: scopes [ncc:10:publish]"),
"{stdout}"
);
}

#[test]
fn the_support_ticket_bot_labels_urgent_tickets_and_always_acknowledges() {
let path = repository_path("examples/support-ticket-bot.ns");
Expand Down
44 changes: 30 additions & 14 deletions crates/nscript-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3620,9 +3620,13 @@ fn pure_function(module: &str, function: &str) -> Option<PureFunction> {
("ncc13", "requires") => Some(ncc13_requires as PureFunction),
("ncc13", "optional_dependencies") => Some(ncc13_optional_dependencies as PureFunction),
("ncc13", "conflicts") => Some(ncc13_conflicts as PureFunction),
("ncc13", "operator_for_tag") => Some(ncc13_operator_for_tag as PureFunction),
("ncc13", "operator_project") => Some(ncc13_operator_project as PureFunction),
("ncc13", "operator_application") => Some(ncc13_operator_application as PureFunction),
("ncc13", "release_operator_for") => Some(ncc13_release_operator_for as PureFunction),
("ncc13", "release_operator_project") => {
Some(ncc13_release_operator_project as PureFunction)
}
("ncc13", "release_operator_application") => {
Some(ncc13_release_operator_application as PureFunction)
}
("ncc13", "semver_is_valid") => Some(ncc13_semver_is_valid as PureFunction),
("ncc13", "semver_compare") => Some(ncc13_semver_compare as PureFunction),
("ncc13", "version_satisfies") => Some(ncc13_version_satisfies as PureFunction),
Expand Down Expand Up @@ -5970,13 +5974,15 @@ fn ncc13_conflicts(arguments: &[OperationValue]) -> Result<OperationValue, Runti
/// NCC-13 §33: build the two-column `operator_for` tag's value — the
/// project's own pubkey and the application id it authorises release
/// automation to publish under, mirroring NCC-10's `operator_for` shape.
fn ncc13_operator_for_tag(arguments: &[OperationValue]) -> Result<OperationValue, RuntimeError> {
fn ncc13_release_operator_for(
arguments: &[OperationValue],
) -> Result<OperationValue, RuntimeError> {
let [
OperationValue::Text(project_pubkey),
OperationValue::Text(application_id),
] = arguments
else {
return Err(invalid("ncc13.operator_for_tag"));
return Err(invalid("ncc13.release_operator_for"));
};
Ok(OperationValue::Text(join_tag_columns(&[
project_pubkey,
Expand All @@ -5997,19 +6003,21 @@ fn ncc13_operator_for_columns(

/// NCC-13 §33: the principal a release names itself as publishing for, from
/// its `operator_for` tag's first column.
fn ncc13_operator_project(arguments: &[OperationValue]) -> Result<OperationValue, RuntimeError> {
fn ncc13_release_operator_project(
arguments: &[OperationValue],
) -> Result<OperationValue, RuntimeError> {
Ok(OperationValue::Text(
ncc13_operator_for_columns(arguments, "ncc13.operator_project")?.0,
ncc13_operator_for_columns(arguments, "ncc13.release_operator_project")?.0,
))
}

/// NCC-13 §33: the application id an `operator_for` tag names, from its
/// second column.
fn ncc13_operator_application(
fn ncc13_release_operator_application(
arguments: &[OperationValue],
) -> Result<OperationValue, RuntimeError> {
Ok(OperationValue::Text(
ncc13_operator_for_columns(arguments, "ncc13.operator_application")?.1,
ncc13_operator_for_columns(arguments, "ncc13.release_operator_application")?.1,
))
}

Expand Down Expand Up @@ -11881,22 +11889,30 @@ mod tests {
);

// §33: the two-column operator_for tag NCC-09 operator publication
// uses, mirroring NCC-10's shape.
// uses, mirroring NCC-10's shape (under its own name — NCC-10
// already exports `operator_for_tag`, and importing both modules
// in one program requires every declared name to be unique).
let operator_for = call(
"operator_for_tag",
"release_operator_for",
&[text("projectpubkey"), text("com.example.app")],
);
let OperationValue::Text(operator_for_text) = operator_for.clone() else {
panic!("operator_for_tag builder returns text");
panic!("release_operator_for builder returns text");
};
let operator_tags =
OperationValue::List(vec![text(&format!("operator_for={operator_for_text}"))]);
assert_eq!(
call("operator_project", std::slice::from_ref(&operator_tags)),
call(
"release_operator_project",
std::slice::from_ref(&operator_tags)
),
text("projectpubkey")
);
assert_eq!(
call("operator_application", std::slice::from_ref(&operator_tags)),
call(
"release_operator_application",
std::slice::from_ref(&operator_tags)
),
text("com.example.app")
);

Expand Down
Loading
Loading