diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c7ecf5e..d0c417e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,11 @@ updates: directory: / schedule: interval: weekly + ignore: + # Its ref is the Rust toolchain (`@stable`, `@1.85` for the MSRV job), + # not an action version, so "upgrading" it would change what we test. + - dependency-name: dtolnay/rust-toolchain + - package-ecosystem: npm # also covers pnpm-lock.yaml + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05f659d..a6f5f9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,17 +15,21 @@ env: jobs: lint: - name: Format, Clippy & Docs + name: Format, Clippy, Docs & Purity runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets --locked + - name: Core alone, default features (no randomness) + run: cargo clippy -p trustgraph-core --all-targets --locked - run: cargo doc --workspace --no-deps --locked + - name: trustgraph-core does no I/O + run: scripts/check-core-purity.sh test: name: Test (${{ matrix.os }}) @@ -35,7 +39,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace --locked @@ -44,7 +48,60 @@ jobs: name: MSRV (1.85) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@1.85 - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace --locked + + wasm: + name: WebAssembly package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: wasm-bindgen version from Cargo.lock + id: wasm-bindgen + run: echo "version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p')" >> "$GITHUB_OUTPUT" + - uses: taiki-e/install-action@v2 + with: + tool: wasm-bindgen@${{ steps.wasm-bindgen.outputs.version }} + - uses: pnpm/action-setup@v4 # version from packageManager in package.json + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc # latest LTS + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: cargo clippy --target wasm32-unknown-unknown -p trustgraph-core -p trustgraph-wasm --locked + - run: scripts/build-wasm-package.sh + - run: node tests/js/smoke.mjs target/npm/trustgraph-wasm/node/trustgraph_wasm.cjs + - run: node tests/js/smoke.mjs target/npm/trustgraph-wasm/web/trustgraph_wasm.js + - name: TypeScript types + run: pnpm run typecheck + - name: Benchmark (informational) + run: node tests/js/bench.mjs target/npm/trustgraph-wasm/node/trustgraph_wasm.cjs + + node: + name: Native Node addon (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: pnpm/action-setup@v4 # version from packageManager in package.json + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc # latest LTS + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build:node + - run: pnpm run test:node + - name: Oldest supported Node (22) + if: runner.os == 'Linux' + run: npx -y node@22 tests/js/smoke.mjs crates/trustgraph-node/index.js diff --git a/.gitignore b/.gitignore index ea8c4bf..9b621ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +node_modules/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/Cargo.lock b/Cargo.lock index e531926..8f926d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -278,6 +287,22 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -374,6 +399,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "ed25519" version = "3.0.0" @@ -439,6 +479,30 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -469,8 +533,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "wasm-bindgen", ] [[package]] @@ -531,12 +597,10 @@ dependencies = [ "defmt", "jiff-core", "jiff-static", - "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", ] [[package]] @@ -562,18 +626,14 @@ dependencies = [ ] [[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" +name = "js-sys" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "jiff-tzdb", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] @@ -582,6 +642,16 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.25" @@ -609,6 +679,70 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "napi" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a1135cfe16ca43ac82ac05858554fc39c037d8e4592f2b4a83d7ef8e822f43" +dependencies = [ + "bitflags 2.13.2", + "ctor", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "napi-build" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae82775d1b06f3f07efd0666e59bbc175da8383bc372051031d7a447e94fbea" + +[[package]] +name = "napi-derive" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78665d6bdf10e9a4e6b38123efb0f66962e6197c1aea2f07cff3f159a374696d" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d55d01423e7264de3acc13b258fa48ca7cf38a4d25db848908ec3c1304a85a" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 2.0.119", +] + +[[package]] +name = "napi-sys" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed8f0e23a62a3ce0fbb6527cdc056e9282ddd9916b068c46f8923e18eed5ee6" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -642,6 +776,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -884,6 +1024,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -946,6 +1092,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1008,6 +1165,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -1128,7 +1291,7 @@ dependencies = [ ] [[package]] -name = "trust-cli" +name = "trustgraph-cli" version = "0.1.0" dependencies = [ "anyhow", @@ -1141,11 +1304,11 @@ dependencies = [ "serde", "serde_json", "tempfile", - "trustgraph", + "trustgraph-core", ] [[package]] -name = "trustgraph" +name = "trustgraph-core" version = "0.1.0" dependencies = [ "bs58", @@ -1163,6 +1326,30 @@ dependencies = [ "thiserror", ] +[[package]] +name = "trustgraph-node" +version = "0.1.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "serde", + "serde_json", + "trustgraph-core", +] + +[[package]] +name = "trustgraph-wasm" +version = "0.1.0" +dependencies = [ + "getrandom 0.4.3", + "serde", + "serde-wasm-bindgen", + "serde_json", + "trustgraph-core", + "wasm-bindgen", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1181,6 +1368,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 5b2cbd4..494f1e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/trustgraph", "crates/trust-cli"] +members = ["crates/*"] [workspace.package] version = "0.1.0" @@ -12,7 +12,7 @@ homepage = "https://trustgraph.net" authors = ["Trust Graph contributors"] [workspace.dependencies] -trustgraph = { path = "crates/trustgraph", version = "0.1.0" } +trustgraph-core = { path = "crates/trustgraph-core", version = "0.1.0" } anyhow = "1.0" bs58 = "0.5" @@ -21,12 +21,17 @@ clap_complete = "4.6" directories = "6.0" ed25519-dalek = "3.0" getrandom = "0.4" -jiff = { version = "0.2", features = ["serde"] } +jiff = { version = "0.2", default-features = false, features = ["std", "serde"] } rust_decimal = "1.43" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } serde_json_canonicalizer = "0.3" sha2 = "0.11" +serde-wasm-bindgen = "0.6" +wasm-bindgen = "0.2" +napi = { version = "3", default-features = false, features = ["napi6", "serde-json"] } +napi-derive = "3" +napi-build = "2" thiserror = "2.0" assert_cmd = "2.2" @@ -50,3 +55,10 @@ module_name_repetitions = "allow" lto = "thin" codegen-units = 1 strip = true + +# Small, fast-loading WebAssembly: `cargo build --profile wasm --target wasm32-unknown-unknown -p trustgraph-wasm` +[profile.wasm] +inherits = "release" +opt-level = "z" +lto = "fat" +panic = "abort" diff --git a/README.md b/README.md index dda6ddb..d9fe551 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ -# ŦRUSŦ GRΔPH CLI +# ŦRUSŦ GRΔPH [![CI](https://github.com/trustgraph/trustgraph-rust-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/trustgraph/trustgraph-rust-cli/actions/workflows/ci.yml) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -`trust` is the command line interface and Rust library for -[Trust Graph](https://trustgraph.net), an open protocol for sourcing and -rendering trust relationships. +The [Trust Graph](https://trustgraph.net) monorepo: the protocol's reference +implementation and every project built on it. Trust Graph is an open protocol +for sourcing and rendering trust relationships. + +Everything rests on one pure Rust core, shipped as a command line tool +(`trust`), a WebAssembly package and a native Node.js module. See +[what's in this repo](#whats-in-this-repo). - **Trust Atoms.** Every rating, vouch or review is one small statement: *source* trusts *target*, about *content*, this much (`-1` to `1`). @@ -16,24 +20,29 @@ rendering trust relationships. - **Agent-centric.** There is no global score. You see the world through your **Agent Lens**: your own ratings, plus those of the people you trust, cascading outward with decreasing weight (the **Trust Cascade**). -- **Interoperable.** Reads and writes the link-tag format of - [trustgraph-holochain](https://github.com/trustgraph/trustgraph-holochain). - **Unix-friendly.** JSON in, JSON out, one item per line. Commands pipe into each other and into `jq`. - **Offline.** Nothing needs a server or a network connection. +- **Runs everywhere.** The same core runs on the command line, in browsers, + Cloudflare Workers, Deno, Node, and inside reactive database queries such as + Convex's. See [architecture](doc/architecture.md). > Status: early but solid. The data formats may still change before 1.0. See the > [roadmap](doc/plan/README.md). ## Install -Requires [Rust](https://rustup.rs) 1.85 or newer. +The CLI needs [Rust](https://rustup.rs) 1.85 or newer: ```sh -cargo install --git https://github.com/trustgraph/trustgraph-rust-cli trust-cli +cargo install --git https://github.com/trustgraph/trustgraph-rust-cli trustgraph-cli ``` -Or from a checkout: `cargo install --path crates/trust-cli`. +Or from a checkout: `cargo install --path crates/trustgraph-cli`. + +The JavaScript packages, `@trustgraph/trustgraph-wasm` (WebAssembly) and +`@trustgraph/trustgraph` (native), are not on npm yet. To build them locally, see +[architecture](doc/architecture.md#building). ## Quick start @@ -69,7 +78,7 @@ trust lens --topic sushi --rollup | trust sign | trust add | `trust sign [FILE]` | Sign atoms as Verifiable Credentials | | `trust verify [FILE]` | Verify credentials. Exits 1 if any are invalid | | `trust id [FILE]` | Print content IDs (`Qm…` SHA2-256 multihashes) | -| `trust convert --to atom\|credential\|canonical\|holochain [FILE]` | Convert between formats | +| `trust convert --to atom\|credential\|canonical [FILE]` | Convert between formats | | `trust add [FILE]` | Add atoms or signed credentials to the local store | | `trust query [--source] [--target] [--topic] [--signed-only]` | Search the local store | | `trust lens [AGENT] [--topic] [--depth] [--decay] [--rollup]` | View the graph through an agent's lens | @@ -112,22 +121,25 @@ multiplies by `--decay` (default 0.5). A target's score is the agent's own rating if there is one. Otherwise it is the average of the ratings by agents the agent can reach, weighted by how much the agent trusts each of them. Distrust is shown but never passed along. With `--topic`, only trust about -that topic is followed. Details are in [`graph.rs`](crates/trustgraph/src/graph.rs). +that topic is followed. Details are in [`graph.rs`](crates/trustgraph-core/src/graph.rs). ## Library -The protocol lives in the [`trustgraph`](crates/trustgraph) crate, which has no -CLI dependencies so other components can embed it: +### Rust + +The protocol lives in [`trustgraph-core`](crates/trustgraph-core). It does no +I/O at all: you pass in data, seeds and timestamps, and get data back. The +optional `random` feature can generate keys for you. ```rust -use trustgraph::{credential, Keypair, LensOptions, TrustAtom, TrustGraph}; +use trustgraph_core::{credential, Keypair, LensOptions, TrustAtom, TrustGraph}; -fn main() -> Result<(), trustgraph::Error> { - let alice = Keypair::generate()?; +fn main() -> Result<(), trustgraph_core::Error> { + let alice = Keypair::from_seed(&[7; 32]); // or Keypair::generate() with the `random` feature let atom = TrustAtom::new(alice.did().to_string(), "https://sushi.example") .with_content("sushi") .with_value("0.9".parse()?); - let signed = credential::sign_atom(&atom, &alice, jiff::Timestamp::now())?; + let signed = credential::sign_atom(&atom, &alice, "2026-01-01T00:00:00Z".parse().unwrap())?; assert_eq!(credential::verify_atom(&signed)?, atom); let graph: TrustGraph = [atom].iter().collect(); @@ -137,22 +149,72 @@ fn main() -> Result<(), trustgraph::Error> { } ``` +### JavaScript and TypeScript + +Both npm packages have the same API ([types](bindings/trustgraph.d.ts)): + +```ts +import * as tg from "@trustgraph/trustgraph-wasm"; // or "@trustgraph/trustgraph" (native) + +const me = tg.generateKeypair(); // or tg.keypairFromSeed(yourOwn32Bytes) +const credential = tg.signAtom( + { source: me.did, target: "https://sushi.example", content: "sushi", value: 0.9 }, + me.secretKeyMultibase, + new Date().toISOString(), +); +tg.verify(credential); // { valid: true, id: "Qm…", issuer: "did:key:…", atom: {…} } +tg.lens([credential /* , …everyone else's atoms */], me.did, { topic: "sushi" }); +``` + +## What's in this repo + +| Path | Language | What it is | +|---|---|---| +| [`crates/trustgraph-core`](crates/trustgraph-core) | Rust | The protocol: atoms, values, IDs, keys, credentials, lens, and the shared `api`. No I/O | +| [`crates/trustgraph-cli`](crates/trustgraph-cli) | Rust | The `trust` binary: files, stdin/stdout, keystore, local store, OS randomness | +| [`crates/trustgraph-wasm`](crates/trustgraph-wasm) | Rust → npm | `@trustgraph/trustgraph-wasm` (wasm-bindgen) | +| [`crates/trustgraph-node`](crates/trustgraph-node) | Rust → npm | `@trustgraph/trustgraph` (napi-rs) | +| [`bindings/`](bindings) | TypeScript | Types shared by both npm packages | +| [`tests/js/`](tests/js) | JavaScript | One smoke test run against every JavaScript build, plus a benchmark | +| [`scripts/`](scripts) | Shell | Core purity check, WebAssembly packaging | +| [`doc/`](doc) | | [Architecture](doc/architecture.md) and [roadmap](doc/plan/README.md) | + +New projects go in this repo: + +- **Rust crates** go in `crates/`. The Cargo workspace picks up + `crates/*` automatically. +- **TypeScript packages** go in `packages/`. The pnpm workspace + picks up `packages/*` automatically. + +### Languages: Rust first, TypeScript where it fits + +- **Rust** for anything that implements the protocol: data formats, + cryptography, scoring, storage, networking, and tools. Protocol logic lives + in `trustgraph-core` and nowhere else. +- **TypeScript** where it is the natural fit: web front ends, Convex functions, + browser extensions, glue code. TypeScript packages call the core through + `@trustgraph/trustgraph-wasm` or `@trustgraph/trustgraph`. They never + re-implement signing, IDs or scoring, so behaviour can't drift between + languages. + ## Development ```sh -cargo test --workspace # unit, property, end-to-end, and doc tests +# Rust +cargo test --workspace # unit, property, end-to-end and doc tests cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check +scripts/check-core-purity.sh # the core must stay free of I/O + +# JavaScript / TypeScript (pnpm workspace, from the repo root; latest Node LTS, see .nvmrc) +nvm use # or fnm / volta: Node 24 today +pnpm install +pnpm run build:node && pnpm run test:node # native addon +scripts/build-wasm-package.sh # WebAssembly package → target/npm/trustgraph-wasm +pnpm run typecheck ``` -| Path | Contents | -|---|---| -| `crates/trustgraph/` | Protocol library: atoms, values, IDs, keys, credentials, Holochain tags, graph, store | -| `crates/trust-cli/` | The `trust` binary | -| `doc/plan/` | Roadmap | - -The signing code is checked against the W3C `eddsa-jcs-2022` test vectors, and -the Holochain encoding against `trustgraph-holochain`'s own test cases. +The signing code is checked against the W3C `eddsa-jcs-2022` test vectors. ## License diff --git a/bindings/trustgraph.d.ts b/bindings/trustgraph.d.ts new file mode 100644 index 0000000..80939b3 --- /dev/null +++ b/bindings/trustgraph.d.ts @@ -0,0 +1,103 @@ +// TypeScript types for the Trust Graph JavaScript packages. +// +// `@trustgraph/trustgraph` (native, napi-rs) and `@trustgraph/trustgraph-wasm` +// (WebAssembly) share this API exactly: both wrap `trustgraph_core::api`. + +/** One statement of trust: `source` trusts `target`, about `content`, `value` much. */ +export interface TrustAtom { + source: string; + target: string; + content?: string; + /** A decimal in -1..=1, as a string (e.g. "0.9"). */ + value?: string; + /** RFC 3339, e.g. "2026-01-01T00:00:00Z". */ + timestamp?: string; + extra?: Record; +} + +/** An atom as accepted on input: `value` may also be a number. */ +export type TrustAtomInput = Omit & { value?: string | number }; + +/** A Trust Atom as a W3C Verifiable Credential 2.0, optionally signed. */ +export interface Credential { + "@context": string[]; + type: string[]; + issuer: string; + validFrom?: string; + credentialSubject: { + id: string; + content?: string; + value?: string; + extra?: Record; + }; + proof?: { + type: "DataIntegrityProof"; + cryptosuite: "eddsa-jcs-2022"; + created: string; + verificationMethod: string; + proofPurpose: "assertionMethod"; + "@context"?: string[]; + proofValue: string; + }; +} + +export interface KeyInfo { + /** `did:key:z6Mk…` */ + did: string; + publicKeyMultibase: string; + /** Keep this secret. */ + secretKeyMultibase: string; +} + +export interface Verification { + valid: boolean; + id?: string; + issuer?: string; + atom?: TrustAtom; + error?: string; +} + +export interface LensOptions { + /** Maximum hops, 1..=10. Default 3. */ + depth?: number; + /** Weight of each hop after the first, 0..=1. Default 0.5. */ + decay?: number; + /** Only follow and score trust about this topic. */ + topic?: string; + /** Ignore unsigned atoms. */ + signedOnly?: boolean; + /** Return at most this many entries. */ + limit?: number; +} + +export interface LensEntry { + target: string; + /** -1..=1 */ + score: number; + /** 0..=1: how much the root trusts the most trusted rater (1 = own rating). */ + confidence: number; + hops: number; + raters: number; +} + +/** Atoms and/or signed credentials. */ +export type Item = TrustAtomInput | Credential; + +export function version(): string; +/** Generates a new identity from a secure random source. Not deterministic: call it in a client or action, not inside a reactive query. */ +export function generateKeypair(): KeyInfo; +/** Derives an identity from a 32-byte seed you supply (deterministic). */ +export function keypairFromSeed(seed: Uint8Array): KeyInfo; +export function parseAtom(input: Item): TrustAtom; +/** Content ID (`Qm…`). */ +export function atomId(input: Item): string; +/** Canonical JSON (RFC 8785), exactly as hashed. */ +export function canonicalAtom(input: Item): string; +export function toCredential(atom: TrustAtomInput): Credential; +/** `created` is RFC 3339, e.g. `new Date().toISOString()`. */ +export function signAtom(atom: TrustAtomInput, secretKeyMultibase: string, created: string): Credential; +export function verify(credential: Credential): Verification; +/** The Agent Lens: everything `root` can see in `items`, best first. */ +export function lens(items: Item[], root: string, options?: LensOptions | null): LensEntry[]; +/** Rollup atoms (unsigned) for `root`'s lens, timestamped `at` (RFC 3339). */ +export function rollup(items: Item[], root: string, options: LensOptions | null | undefined, at: string): TrustAtom[]; diff --git a/crates/trust-cli/Cargo.toml b/crates/trustgraph-cli/Cargo.toml similarity index 85% rename from crates/trust-cli/Cargo.toml rename to crates/trustgraph-cli/Cargo.toml index 4fc1a3f..4e3f921 100644 --- a/crates/trust-cli/Cargo.toml +++ b/crates/trustgraph-cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "trust-cli" +name = "trustgraph-cli" description = "`trust`: the Trust Graph command line interface" readme = "../../README.md" keywords = ["trust", "reputation", "did", "cli", "trustgraph"] @@ -18,13 +18,13 @@ name = "trust" path = "src/main.rs" [dependencies] -trustgraph.workspace = true +trustgraph-core = { workspace = true, features = ["random"] } anyhow.workspace = true clap.workspace = true clap_complete.workspace = true directories.workspace = true -jiff.workspace = true +jiff = { workspace = true, features = ["std"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/trust-cli/src/cli.rs b/crates/trustgraph-cli/src/cli.rs similarity index 96% rename from crates/trust-cli/src/cli.rs rename to crates/trustgraph-cli/src/cli.rs index bfe6096..c807f8a 100644 --- a/crates/trust-cli/src/cli.rs +++ b/crates/trustgraph-cli/src/cli.rs @@ -5,8 +5,8 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use jiff::Timestamp; -use trustgraph::Value; -use trustgraph::value::Decimal; +use trustgraph_core::Value; +use trustgraph_core::value::Decimal; /// trust: sign, share and explore trust relationships. /// @@ -188,8 +188,6 @@ pub enum Format { Credential, /// The atom's canonical JSON (RFC 8785), exactly as hashed Canonical, - /// Holochain link tags, as used by trustgraph-holochain - Holochain, } #[derive(Debug, Args)] @@ -198,10 +196,6 @@ pub struct ConvertArgs { #[arg(long, value_enum)] pub to: Format, - /// Bucket digits for Holochain tags [default: random] - #[arg(long, value_name = "DIGITS")] - pub bucket: Option, - #[command(flatten)] pub input: InputArgs, } @@ -273,7 +267,7 @@ fn parse_value(s: &str) -> Result { let (rating, best) = (parse(rating)?, parse(best)?); return Value::from_scale(rating, Decimal::ZERO, best).map_err(|e| e.to_string()); } - s.parse().map_err(|e: trustgraph::Error| e.to_string()) + s.parse().map_err(|e: trustgraph_core::Error| e.to_string()) } fn parse_decay(s: &str) -> Result { diff --git a/crates/trust-cli/src/commands.rs b/crates/trustgraph-cli/src/commands.rs similarity index 73% rename from crates/trust-cli/src/commands.rs rename to crates/trustgraph-cli/src/commands.rs index ec7924d..e364139 100644 --- a/crates/trust-cli/src/commands.rs +++ b/crates/trustgraph-cli/src/commands.rs @@ -6,8 +6,8 @@ use anyhow::{Context, Result, bail}; use clap::CommandFactory; use jiff::{Timestamp, Unit}; use serde_json::{Value as Json, json}; -use trustgraph::holochain::{self, Direction, LinkTag}; -use trustgraph::{Keypair, LensOptions, Query, Record, TrustAtom, TrustGraph, credential}; +use trustgraph_core::api; +use trustgraph_core::{Keypair, LensOptions, Query, Record, TrustAtom, TrustGraph, credential}; use crate::cli::{AtomArgs, Cli, Command, ConvertArgs, Format, InputArgs, KeyCommand, LensArgs, QueryArgs, SignArgs}; use crate::home::Home; @@ -117,33 +117,23 @@ fn atom(home: &Home, args: AtomArgs, out: &mut Output) -> Result Result> { - read_json(input.input.as_deref())? - .into_iter() - .enumerate() - .map(|(n, json)| parse_atom(json).with_context(|| format!("item {}", n + 1))) - .collect() + read_items(input)?.into_iter().map(|(n, json)| api::parse_atom(json).with_context(|| format!("item {n}"))).collect() } -fn parse_atom(json: Json) -> Result { - let atom = if json.get("@context").is_some() { - credential::from_credential(&json)? - } else { - serde_json::from_value(json)? - }; - atom.validate()?; - Ok(atom) +/// Reads input items, numbered from 1 for error messages. +fn read_items(input: &InputArgs) -> Result> { + Ok(read_json(input.input.as_deref())?.into_iter().enumerate().map(|(n, json)| (n + 1, json)).collect()) } fn sign(home: &Home, args: &SignArgs, out: &mut Output) -> Result { let keypair = home.load_key(&args.key.key)?; let created = args.created.unwrap_or_else(now); - for (n, json) in read_json(args.input.input.as_deref())?.into_iter().enumerate() { + for (n, json) in read_items(&args.input)? { if json.get("proof").is_some() { - bail!("item {}: already signed", n + 1); + bail!("item {n}: already signed"); } - let atom: TrustAtom = - serde_json::from_value(json).with_context(|| format!("item {}: not a Trust Atom", n + 1))?; - let signed = credential::sign_atom(&atom, &keypair, created).with_context(|| format!("item {}", n + 1))?; + let atom: TrustAtom = serde_json::from_value(json).with_context(|| format!("item {n}: not a Trust Atom"))?; + let signed = credential::sign_atom(&atom, &keypair, created).with_context(|| format!("item {n}"))?; out.json(&signed)?; } Ok(Outcome::Success) @@ -151,14 +141,12 @@ fn sign(home: &Home, args: &SignArgs, out: &mut Output) -> Result(args: &InputArgs, out: &mut Output) -> Result { let mut outcome = Outcome::Success; - for json in read_json(args.input.as_deref())? { - match credential::verify_atom(&json) { - Ok(atom) => out.json(&json!({ "valid": true, "id": atom.id()?, "issuer": atom.source, "atom": atom }))?, - Err(err) => { - outcome = Outcome::Failed; - out.json(&json!({ "valid": false, "error": err.to_string() }))?; - } + for (_, json) in read_items(args)? { + let result = api::verify(&json); + if !result.valid { + outcome = Outcome::Failed; } + out.json(&result)?; } Ok(outcome) } @@ -171,44 +159,21 @@ fn id(args: &InputArgs, out: &mut Output) -> Result { } fn convert(args: &ConvertArgs, out: &mut Output) -> Result { - for atom in atoms_from(&args.input)? { + for (n, json) in read_items(&args.input)? { + let context = || format!("item {n}"); match args.to { - Format::Atom => out.json(&atom)?, - Format::Credential => out.json(&credential::to_credential(&atom)?)?, - Format::Canonical => out.line(&atom.canonical_json()?)?, - Format::Holochain => { - let bucket = match &args.bucket { - Some(bucket) => bucket.clone(), - None => holochain::random_bucket()?, - }; - let tag = |direction| -> Result { - let bytes = LinkTag::for_atom(&atom, direction, Some(bucket.clone()), None).encode()?; - Ok(json!({ "tag": String::from_utf8_lossy(&bytes), "hex": to_hex(&bytes) })) - }; - out.json(&json!({ - "source": atom.source, - "target": atom.target, - "forward": tag(Direction::Forward)?, - "reverse": tag(Direction::Reverse)?, - }))?; - } + Format::Atom => out.json(&api::parse_atom(json).with_context(context)?)?, + Format::Credential => out.json(&api::to_credential(json).with_context(context)?)?, + Format::Canonical => out.line(&api::canonical_atom(json).with_context(context)?)?, } } Ok(Outcome::Success) } -fn to_hex(bytes: &[u8]) -> String { - use std::fmt::Write as _; - bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| { - let _ = write!(s, "{b:02x}"); - s - }) -} - fn add(home: &Home, args: &InputArgs, out: &mut Output) -> Result { let mut store = home.open_store()?; - for (n, json) in read_json(args.input.as_deref())?.into_iter().enumerate() { - let record = Record::from_json(json).with_context(|| format!("item {}", n + 1))?; + for (n, json) in read_items(args)? { + let record = Record::from_json(json).with_context(|| format!("item {n}"))?; let (id, signed) = (record.id, record.is_signed()); let added = store.add(record)?; out.json(&json!({ "id": id, "added": added, "signed": signed }))?; diff --git a/crates/trust-cli/src/home.rs b/crates/trustgraph-cli/src/home.rs similarity index 93% rename from crates/trust-cli/src/home.rs rename to crates/trustgraph-cli/src/home.rs index a106013..626bf37 100644 --- a/crates/trust-cli/src/home.rs +++ b/crates/trustgraph-cli/src/home.rs @@ -6,7 +6,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use trustgraph::{Did, Keypair, Store}; +use trustgraph_core::{Did, Keypair}; + +use crate::store::Store; /// Paths under the home directory. #[derive(Debug, Clone)] @@ -50,7 +52,7 @@ impl Home { } pub fn open_store(&self) -> Result { - Store::open(self.store_path()).with_context(|| format!("opening store {}", self.store_path().display())) + Store::open(self.store_path()) } fn key_path(&self, name: &str) -> PathBuf { @@ -132,11 +134,11 @@ mod tests { assert_eq!(home.list_keys().unwrap(), []); assert!(home.load_key("default").is_err()); - let keypair = Keypair::generate().unwrap(); + let keypair = Keypair::from_seed(&[1; 32]); home.save_key("default", &keypair, false).unwrap(); assert_eq!(home.load_key("default").unwrap().did(), keypair.did()); - assert!(home.save_key("default", &Keypair::generate().unwrap(), false).is_err()); - home.save_key("default", &Keypair::generate().unwrap(), true).unwrap(); + assert!(home.save_key("default", &Keypair::from_seed(&[2; 32]), false).is_err()); + home.save_key("default", &Keypair::from_seed(&[2; 32]), true).unwrap(); assert_ne!(home.load_key("default").unwrap().did(), keypair.did()); #[cfg(unix)] diff --git a/crates/trust-cli/src/io.rs b/crates/trustgraph-cli/src/io.rs similarity index 100% rename from crates/trust-cli/src/io.rs rename to crates/trustgraph-cli/src/io.rs diff --git a/crates/trust-cli/src/main.rs b/crates/trustgraph-cli/src/main.rs similarity index 98% rename from crates/trust-cli/src/main.rs rename to crates/trustgraph-cli/src/main.rs index ac9eed6..a7db5e8 100644 --- a/crates/trust-cli/src/main.rs +++ b/crates/trustgraph-cli/src/main.rs @@ -4,6 +4,7 @@ mod cli; mod commands; mod home; mod io; +mod store; use std::process::ExitCode; diff --git a/crates/trustgraph-cli/src/store.rs b/crates/trustgraph-cli/src/store.rs new file mode 100644 index 0000000..eca7430 --- /dev/null +++ b/crates/trustgraph-cli/src/store.rs @@ -0,0 +1,119 @@ +//! A local, append-only store of trust records. +//! +//! Records are kept one per line (NDJSON) in a single file. The format is +//! readable with standard tools such as `jq` and `grep`, can be synced or +//! versioned like any text file, and never rewrites existing lines. +//! +//! This lives in the CLI, not in `trustgraph-core`, because the core does no +//! I/O: other callers keep records wherever suits them. + +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use trustgraph_core::{ContentId, Query, Record}; + +/// An append-only NDJSON file of [`Record`]s. +#[derive(Debug)] +pub struct Store { + path: PathBuf, + records: Vec, + ids: HashSet, +} + +impl Store { + /// Opens the store at `path`. A missing file is an empty store. + pub fn open(path: impl Into) -> Result { + let path = path.into(); + let mut store = Self { path, records: Vec::new(), ids: HashSet::new() }; + match File::open(&store.path) { + Ok(file) => { + for (n, line) in BufReader::new(file).lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let record: Record = serde_json::from_str(&line) + .with_context(|| format!("{}:{}: not a valid record", store.path.display(), n + 1))?; + if store.ids.insert(record.id) { + store.records.push(record); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("opening {}", store.path.display())), + } + Ok(store) + } + + /// Adds a record. Returns `false` (and writes nothing) if an atom with + /// the same ID is already stored. + pub fn add(&mut self, record: Record) -> Result { + if self.ids.contains(&record.id) { + return Ok(false); + } + if let Some(dir) = self.path.parent().filter(|d| !d.as_os_str().is_empty()) { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let mut line = serde_json::to_string(&record)?; + line.push('\n'); + OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .and_then(|mut f| f.write_all(line.as_bytes())) + .with_context(|| format!("writing {}", self.path.display()))?; + self.ids.insert(record.id); + self.records.push(record); + Ok(true) + } + + /// Records matching `query`, in the order they were added. + pub fn query<'a>(&'a self, query: &'a Query) -> impl Iterator + 'a { + self.records.iter().filter(move |r| query.matches(r)) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.records.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trustgraph_core::TrustAtom; + + fn record(source: &str, target: &str) -> Record { + let atom = TrustAtom::new(source, target).with_value("0.5".parse().unwrap()); + Record::from_json(serde_json::to_value(atom).unwrap()).unwrap() + } + + #[test] + fn add_persists_and_deduplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/atoms.ndjson"); + let mut store = Store::open(&path).unwrap(); + assert_eq!(store.len(), 0); + assert!(store.add(record("a", "b")).unwrap()); + assert!(!store.add(record("a", "b")).unwrap()); + assert!(store.add(record("a", "c")).unwrap()); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.len(), 2); + assert_eq!(reopened.records, store.records); + assert_eq!(fs::read_to_string(&path).unwrap().lines().count(), 2); + assert_eq!(reopened.query(&Query { target: Some("c".into()), ..Query::default() }).count(), 1); + } + + #[test] + fn reports_corrupt_lines_with_location() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.ndjson"); + fs::write(&path, "\n{not json}\n").unwrap(); + let err = format!("{:#}", Store::open(&path).unwrap_err()); + assert!(err.contains("s.ndjson:2"), "{err}"); + } +} diff --git a/crates/trust-cli/tests/cli.rs b/crates/trustgraph-cli/tests/cli.rs similarity index 95% rename from crates/trust-cli/tests/cli.rs rename to crates/trustgraph-cli/tests/cli.rs index 262329b..2ce07e6 100644 --- a/crates/trust-cli/tests/cli.rs +++ b/crates/trustgraph-cli/tests/cli.rs @@ -161,12 +161,7 @@ fn convert_formats() { assert!(credential.contains("TrustAtomCredential")); assert_eq!(env.run(&["convert", "--to", "atom"], &credential), format!("{atom}\n")); - let holo = &env.json_lines(&["convert", "--to", "holochain", "--bucket", "892412523"], atom)[0]; - assert_eq!(holo["forward"]["tag"], "Ŧ→sushi\u{0}.999999999\u{0}892412523\u{0}"); - assert_eq!(holo["reverse"]["tag"], "Ŧ↩sushi\u{0}.999999999\u{0}892412523\u{0}"); - assert!(holo["forward"]["hex"].as_str().unwrap().starts_with("c5a6e28692")); - - env.cmd().args(["convert", "--to", "holochain", "--bucket", "12"]).write_stdin(atom).assert().failure(); + env.cmd().args(["convert", "--to", "xml"]).write_stdin(atom).assert().code(2); } #[test] diff --git a/crates/trustgraph/Cargo.toml b/crates/trustgraph-core/Cargo.toml similarity index 62% rename from crates/trustgraph/Cargo.toml rename to crates/trustgraph-core/Cargo.toml index 80396be..84bcd54 100644 --- a/crates/trustgraph/Cargo.toml +++ b/crates/trustgraph-core/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "trustgraph" -description = "Trust Graph protocol: Trust Atoms, signed trust claims, and agent-centric trust graphs" +name = "trustgraph-core" +description = "Trust Graph protocol core: Trust Atoms, signed trust claims and agent-centric trust graphs, with no I/O" readme = "README.md" -keywords = ["trust", "reputation", "did", "verifiable-credentials", "holochain"] +keywords = ["trust", "reputation", "did", "verifiable-credentials", "web-of-trust"] categories = ["cryptography", "data-structures"] version.workspace = true edition.workspace = true @@ -16,7 +16,7 @@ publish = false [dependencies] bs58.workspace = true ed25519-dalek.workspace = true -getrandom.workspace = true +getrandom = { workspace = true, optional = true } jiff.workspace = true rust_decimal.workspace = true serde.workspace = true @@ -25,6 +25,12 @@ serde_json_canonicalizer.workspace = true sha2.workspace = true thiserror.workspace = true +[features] +# Opt-in OS / browser randomness: `Keypair::generate` and `api::generate_keypair`. +# Off by default so the core stays free of I/O; the CLI, WebAssembly and Node +# wrappers turn it on. +random = ["dep:getrandom"] + [dev-dependencies] hex.workspace = true proptest.workspace = true diff --git a/crates/trustgraph-core/README.md b/crates/trustgraph-core/README.md new file mode 100644 index 0000000..75b1ad1 --- /dev/null +++ b/crates/trustgraph-core/README.md @@ -0,0 +1,12 @@ +# trustgraph-core + +The [Trust Graph](https://trustgraph.net) protocol in pure Rust, with **no I/O**: + +- `TrustAtom`: *source* trusts *target*, about *content*, to degree *value* (`-1..=1`) +- `Keypair` / `Did`: Ed25519 identities as `did:key` +- `credential`: W3C Verifiable Credentials 2.0 with `eddsa-jcs-2022` proofs +- `TrustGraph::lens`: the Agent Lens / Trust Cascade +- `Record` / `Query`: verified atoms and filters +- `api`: the JSON-shaped API shared by the CLI, WebAssembly and Node wrappers + +See the [architecture](../../doc/architecture.md). diff --git a/crates/trustgraph-core/src/api.rs b/crates/trustgraph-core/src/api.rs new file mode 100644 index 0000000..ee4002f --- /dev/null +++ b/crates/trustgraph-core/src/api.rs @@ -0,0 +1,375 @@ +//! The JSON-shaped API that every wrapper exposes. +//! +//! The CLI, the WebAssembly package and the native Node module all call +//! these functions, so they behave identically everywhere. Inputs and +//! outputs are plain serde values (JSON objects in JavaScript); field names +//! are camelCase. +//! +//! Like the rest of the core, nothing here does I/O: no files, network, +//! clock or randomness. Callers pass in seeds and timestamps, and keep +//! records wherever they like. Every function is deterministic (except +//! `generate_keypair`, from the opt-in `random` feature), which is +//! what lets the WebAssembly build run inside reactive database queries +//! (such as Convex queries and mutations). + +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; + +use crate::{Error, Keypair, LensEntry, LensOptions, Result, TrustAtom, TrustGraph, credential}; + +/// The core's version. +#[must_use] +pub fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// An identity, as returned by [`keypair_from_seed`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyInfo { + /// The `did:key` DID. + pub did: String, + /// The public key (`z6Mk…`). + pub public_key_multibase: String, + /// The secret key (`z3u2…`). Keep it safe. + pub secret_key_multibase: String, +} + +/// Derives an identity from a 32-byte seed. Callers supply the randomness +/// (e.g. `crypto.getRandomValues(new Uint8Array(32))`). +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if the seed is not 32 bytes. +pub fn keypair_from_seed(seed: &[u8]) -> Result { + let seed: &[u8; 32] = + seed.try_into().map_err(|_| Error::InvalidInput(format!("seed must be 32 bytes, got {}", seed.len())))?; + let keypair = Keypair::from_seed(seed); + Ok(KeyInfo { + did: keypair.did().to_string(), + public_key_multibase: keypair.public().to_multibase(), + secret_key_multibase: keypair.to_secret_multibase(), + }) +} + +/// Generates a new identity from a secure random seed. Not deterministic: +/// don't call it inside a reactive query. +/// +/// # Errors +/// +/// Returns [`Error::Random`] if no secure random source is available. +#[cfg(feature = "random")] +pub fn generate_keypair() -> Result { + keypair_from_seed(&crate::random::bytes::<32>()?) +} + +/// Parses an atom, or extracts it from a credential (without checking the +/// proof), and validates it. +/// +/// # Errors +/// +/// Fails if the input is neither a valid atom nor a Trust Atom credential. +pub fn parse_atom(input: Json) -> Result { + let atom = if input.get("@context").is_some() { + credential::from_credential(&input)? + } else { + serde_json::from_value(input)? + }; + atom.validate()?; + Ok(atom) +} + +/// The content ID (`Qm…`) of an atom or credential's atom. +/// +/// # Errors +/// +/// See [`parse_atom`]. +pub fn atom_id(input: Json) -> Result { + Ok(parse_atom(input)?.id()?.to_string()) +} + +/// The canonical JSON (RFC 8785) of an atom: exactly the bytes that are hashed. +/// +/// # Errors +/// +/// See [`parse_atom`]. +pub fn canonical_atom(input: Json) -> Result { + parse_atom(input)?.canonical_json() +} + +/// Converts an atom into an unsigned Verifiable Credential. +/// +/// # Errors +/// +/// See [`parse_atom`]. +pub fn to_credential(input: Json) -> Result { + credential::to_credential(&parse_atom(input)?) +} + +/// Signs an atom with a secret key, at time `created` (RFC 3339). +/// +/// # Errors +/// +/// Fails if the atom, key or time is invalid, or the atom's source is not the +/// key's DID. +pub fn sign_atom(atom: Json, secret_key_multibase: &str, created: &str) -> Result { + if atom.get("proof").is_some() { + return Err(Error::InvalidInput("already signed".into())); + } + let atom = parse_atom(atom)?; + let keypair = Keypair::from_secret_multibase(secret_key_multibase)?; + let created = created.parse().map_err(|_| Error::InvalidInput(format!("`{created}` is not an RFC 3339 time")))?; + credential::sign_atom(&atom, &keypair, created) +} + +/// The result of [`verify`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Verification { + /// Whether the credential verified. + pub valid: bool, + /// The atom's content ID, if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The issuer's DID, if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, + /// The atom, if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub atom: Option, + /// Why verification failed, if it did. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Verifies a signed Trust Atom credential. Never fails: an invalid +/// credential is reported in the result. +#[must_use] +pub fn verify(credential: &Json) -> Verification { + match credential::verify_atom(credential).and_then(|atom| Ok((atom.id()?, atom))) { + Ok((id, atom)) => Verification { + valid: true, + id: Some(id.to_string()), + issuer: Some(atom.source.clone()), + atom: Some(atom), + error: None, + }, + Err(err) => Verification { valid: false, id: None, issuer: None, atom: None, error: Some(err.to_string()) }, + } +} + +/// Options for [`lens`] and [`rollup`]. Missing fields take their defaults. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LensRequest { + /// Maximum hops, `1..=10` (default 3). + pub depth: usize, + /// Weight of each hop after the first, `0..=1` (default 0.5). + pub decay: f64, + /// Only follow and score trust about this topic. + pub topic: Option, + /// Ignore unsigned atoms. + pub signed_only: bool, + /// Return at most this many entries. + pub limit: Option, +} + +impl Default for LensRequest { + fn default() -> Self { + let defaults = LensOptions::default(); + Self { depth: defaults.depth, decay: defaults.decay, topic: None, signed_only: false, limit: None } + } +} + +impl LensRequest { + /// Validates the request and converts it to [`LensOptions`]. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if `depth` or `decay` is out of range. + pub fn options(&self) -> Result { + if !(1..=10).contains(&self.depth) { + return Err(Error::InvalidInput(format!("depth must be 1..=10, got {}", self.depth))); + } + if !(0.0..=1.0).contains(&self.decay) { + return Err(Error::InvalidInput(format!("decay must be 0..=1, got {}", self.decay))); + } + Ok(LensOptions { depth: self.depth, decay: self.decay, topic: self.topic.clone() }) + } +} + +/// Builds a graph from `items` (atoms and/or signed credentials). +/// Credentials are verified; with `signed_only`, plain atoms are skipped. +/// +/// For speed in hot paths (such as reactive queries), verify credentials +/// once when they are written, store the atoms, and pass plain atoms here. +fn graph(items: Vec, signed_only: bool) -> Result { + let mut graph = TrustGraph::new(); + for (n, item) in items.into_iter().enumerate() { + let item_error = |e: Error| Error::InvalidInput(format!("item {}: {e}", n + 1)); + // Unlike `Record::from_json`, skip computing content IDs: scoring does not need them. + if item.get("proof").is_some() { + graph.insert(&credential::verify_atom(&item).map_err(item_error)?); + } else if item.get("@context").is_some() { + return Err(item_error(Error::InvalidCredential("credential is not signed".into()))); + } else if !signed_only { + let atom: TrustAtom = serde_json::from_value(item).map_err(|e| item_error(e.into()))?; + atom.validate().map_err(item_error)?; + graph.insert(&atom); + } + } + Ok(graph) +} + +/// The Agent Lens: everything `root` can see in `items`, best first. +/// +/// # Errors +/// +/// Fails if the request is out of range or an item is invalid. +pub fn lens(items: Vec, root: &str, request: &LensRequest) -> Result> { + let options = request.options()?; + let mut entries = graph(items, request.signed_only)?.lens(root, &options); + if let Some(limit) = request.limit { + entries.truncate(limit); + } + Ok(entries) +} + +/// Rollup atoms (unsigned) for `root`'s lens, timestamped `at` (RFC 3339). +/// Sign them with [`sign_atom`] to publish them. +/// +/// # Errors +/// +/// Fails like [`lens`], or if `at` is not an RFC 3339 time. +pub fn rollup(items: Vec, root: &str, request: &LensRequest, at: &str) -> Result> { + let at = at.parse().map_err(|_| Error::InvalidInput(format!("`{at}` is not an RFC 3339 time")))?; + let entries = lens(items, root, request)?; + TrustGraph::rollup(root, &entries, &request.options()?, at) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn alice() -> KeyInfo { + keypair_from_seed(&[1; 32]).unwrap() + } + + fn signed(target: &str, value: &str) -> Json { + let atom = json!({ "source": alice().did, "target": target, "content": "sushi", "value": value }); + sign_atom(atom, &alice().secret_key_multibase, "2024-01-01T00:00:00Z").unwrap() + } + + #[test] + fn keypair_from_seed_is_deterministic_and_checks_length() { + assert_eq!(alice(), keypair_from_seed(&[1; 32]).unwrap()); + assert!(alice().did.starts_with("did:key:z6Mk")); + assert_eq!(format!("did:key:{}", alice().public_key_multibase), alice().did); + assert!(keypair_from_seed(&[1; 31]).is_err()); + let json = serde_json::to_value(alice()).unwrap(); + assert!(json.get("secretKeyMultibase").is_some(), "camelCase for JavaScript"); + } + + #[cfg(feature = "random")] + #[test] + fn generate_keypair_gives_fresh_identities() { + let a = generate_keypair().unwrap(); + assert!(a.did.starts_with("did:key:z6Mk")); + assert_ne!(a, generate_keypair().unwrap()); + } + + #[test] + fn sign_then_verify() { + let credential = signed("https://sushi.example", "0.9"); + let result = verify(&credential); + assert!(result.valid); + assert_eq!(result.issuer.as_deref(), Some(alice().did.as_str())); + assert_eq!(result.id, Some(atom_id(credential.clone()).unwrap())); + + let mut forged = credential; + forged["credentialSubject"]["value"] = json!("-1"); + let result = verify(&forged); + assert!(!result.valid); + assert!(result.error.unwrap().contains("signature")); + assert_eq!( + serde_json::to_value(verify(&json!({}))).unwrap().as_object().unwrap().keys().collect::>(), + ["valid", "error"] + ); + } + + #[test] + fn sign_rejects_bad_input() { + let atom = json!({ "source": alice().did, "target": "x" }); + let key = alice().secret_key_multibase; + assert!(sign_atom(atom.clone(), &key, "yesterday").is_err()); + assert!(sign_atom(atom.clone(), "zNotAKey", "2024-01-01T00:00:00Z").is_err()); + let signed = sign_atom(atom, &key, "2024-01-01T00:00:00Z").unwrap(); + assert!(sign_atom(signed, &key, "2024-01-01T00:00:00Z").is_err()); + assert!(sign_atom(json!({ "source": "someone-else", "target": "x" }), &key, "2024-01-01T00:00:00Z").is_err()); + } + + #[test] + fn ids_and_canonical_json_agree_across_forms() { + let atom = json!({ "target": "b", "source": "a", "value": 1 }); + assert_eq!(canonical_atom(atom.clone()).unwrap(), r#"{"source":"a","target":"b","value":"1"}"#); + let credential = to_credential(atom.clone()).unwrap(); + assert_eq!(atom_id(credential).unwrap(), atom_id(atom).unwrap()); + } + + #[test] + fn lens_over_mixed_items() { + let bob = keypair_from_seed(&[2; 32]).unwrap(); + let bob_rates = sign_atom( + json!({ "source": bob.did, "target": "https://sushi.example", "content": "sushi", "value": "0.8" }), + &bob.secret_key_multibase, + "2024-01-01T00:00:00Z", + ) + .unwrap(); + let items = vec![ + signed(&bob.did, "1"), + bob_rates, + json!({ "source": bob.did, "target": "https://unsigned.example", "content": "sushi", "value": "1" }), + ]; + let all = lens(items.clone(), &alice().did, &LensRequest::default()).unwrap(); + assert_eq!(all.len(), 3); + let signed_only = LensRequest { signed_only: true, ..LensRequest::default() }; + let entries = lens(items.clone(), &alice().did, &signed_only).unwrap(); + assert_eq!( + entries.iter().map(|e| e.target.as_str()).collect::>(), + [bob.did.as_str(), "https://sushi.example"] + ); + let limited = LensRequest { limit: Some(1), ..LensRequest::default() }; + assert_eq!(lens(items.clone(), &alice().did, &limited).unwrap().len(), 1); + + let rollups = rollup(items, &alice().did, &signed_only, "2024-02-01T00:00:00Z").unwrap(); + assert_eq!(rollups.len(), 2); + assert!(rollups.iter().all(|a| a.source == alice().did && a.extra["rollup"] == "agent-lens")); + } + + #[test] + fn lens_rejects_bad_requests_and_items() { + let bad_depth = LensRequest { depth: 0, ..LensRequest::default() }; + assert!(lens(vec![], "a", &bad_depth).is_err()); + let bad_decay = LensRequest { decay: 1.5, ..LensRequest::default() }; + assert!(lens(vec![], "a", &bad_decay).is_err()); + let err = lens(vec![json!({"source": "a"})], "a", &LensRequest::default()).unwrap_err(); + assert!(err.to_string().contains("item 1"), "{err}"); + let request: LensRequest = serde_json::from_str(r#"{"topic":"sushi","signedOnly":true}"#).unwrap(); + assert_eq!(request.depth, 3); + assert!(request.signed_only); + } + + #[test] + fn everything_is_deterministic() { + let items = vec![signed("https://a.example", "0.5"), signed("https://b.example", "-0.5")]; + let first = + serde_json::to_string(&lens(items.clone(), &alice().did, &LensRequest::default()).unwrap()).unwrap(); + for _ in 0..5 { + let again = + serde_json::to_string(&lens(items.clone(), &alice().did, &LensRequest::default()).unwrap()).unwrap(); + assert_eq!(again, first); + } + assert_eq!(signed("x", "1"), signed("x", "1"), "Ed25519 signatures are deterministic"); + } +} diff --git a/crates/trustgraph/src/atom.rs b/crates/trustgraph-core/src/atom.rs similarity index 98% rename from crates/trustgraph/src/atom.rs rename to crates/trustgraph-core/src/atom.rs index 0440e36..e174f69 100644 --- a/crates/trustgraph/src/atom.rs +++ b/crates/trustgraph-core/src/atom.rs @@ -14,13 +14,13 @@ use crate::{ContentId, Error, Result, Value, canonical}; /// as in the [Trust Graph protocol](https://github.com/trustgraph/trustgraph). /// /// ``` -/// use trustgraph::{TrustAtom, Value}; +/// use trustgraph_core::{TrustAtom, Value}; /// /// let atom = TrustAtom::new("did:key:z6MkAlice", "https://ipfs.io") /// .with_content("content addressable graph infrastructure") /// .with_value("0.99".parse::()?); /// atom.validate()?; -/// # Ok::<(), trustgraph::Error>(()) +/// # Ok::<(), trustgraph_core::Error>(()) /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/trustgraph/src/canonical.rs b/crates/trustgraph-core/src/canonical.rs similarity index 100% rename from crates/trustgraph/src/canonical.rs rename to crates/trustgraph-core/src/canonical.rs diff --git a/crates/trustgraph/src/credential.rs b/crates/trustgraph-core/src/credential.rs similarity index 100% rename from crates/trustgraph/src/credential.rs rename to crates/trustgraph-core/src/credential.rs diff --git a/crates/trustgraph/src/error.rs b/crates/trustgraph-core/src/error.rs similarity index 75% rename from crates/trustgraph/src/error.rs rename to crates/trustgraph-core/src/error.rs index d71fd54..6d6caea 100644 --- a/crates/trustgraph/src/error.rs +++ b/crates/trustgraph-core/src/error.rs @@ -1,7 +1,7 @@ //! Error type shared by the whole crate. /// Everything that can go wrong while building, encoding, signing or -/// verifying Trust Graph data. +/// verifying Trust Graph data. There are no I/O errors: the core does no I/O. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { @@ -30,22 +30,19 @@ pub enum Error { #[error("verification failed: {0}")] Verification(String), - /// A Holochain link tag could not be encoded or decoded. - #[error("invalid Holochain link tag: {0}")] - InvalidLinkTag(String), - - /// The operating system's random number generator failed. + /// No secure random source was available (`random` feature only). + #[cfg(feature = "random")] #[error("random number generator failed: {0}")] Random(String), + /// An argument to an API call was out of range or malformed. + #[error("invalid input: {0}")] + InvalidInput(String), + /// JSON (de)serialization failed. #[error(transparent)] Json(#[from] serde_json::Error), - - /// Reading or writing a file failed. - #[error(transparent)] - Io(#[from] std::io::Error), } -/// Shorthand for `Result`. +/// Shorthand for `Result`. pub type Result = std::result::Result; diff --git a/crates/trustgraph/src/graph.rs b/crates/trustgraph-core/src/graph.rs similarity index 97% rename from crates/trustgraph/src/graph.rs rename to crates/trustgraph-core/src/graph.rs index 2e1ecb7..9488375 100644 --- a/crates/trustgraph/src/graph.rs +++ b/crates/trustgraph-core/src/graph.rs @@ -150,6 +150,12 @@ impl TrustGraph { return Vec::new(); } + // Each source's edges, computed once (each round revisits agents). + let edges: HashMap<&str, BTreeMap<&str, f64>> = + self.ratings.keys().map(|source| (source.as_str(), self.edges(source, topic))).collect(); + let no_edges = BTreeMap::new(); + let edges_of = |agent: &str| edges.get(agent).unwrap_or(&no_edges); + // Reach: best weight to each agent using at most depth - 1 hops // (bounded Bellman-Ford, maximizing the product of weights). let mut reach: BTreeMap<&str, (f64, usize)> = BTreeMap::from([(root, (1.0, 0))]); @@ -158,7 +164,7 @@ impl TrustGraph { let mut next = reach.clone(); for (&agent, &(weight, _)) in &reach { let factor = if agent == root { 1.0 } else { decay }; - for (target, value) in self.edges(agent, topic) { + for (&target, &value) in edges_of(agent) { if value <= 0.0 || target == root { continue; } @@ -178,7 +184,7 @@ impl TrustGraph { } // Scores. - let direct = self.edges(root, topic); + let direct = edges_of(root); let mut sums: BTreeMap<&str, (f64, f64, f64, usize, usize)> = BTreeMap::new(); // (Σw·v, Σw, max w, min hops, raters) for (&agent, &(weight, hops)) in &reach { if agent == root || weight <= 0.0 { @@ -188,7 +194,7 @@ impl TrustGraph { if voice <= 0.0 { continue; } - for (target, value) in self.edges(agent, topic) { + for (&target, &value) in edges_of(agent) { if target == root || direct.contains_key(target) { continue; } diff --git a/crates/trustgraph/src/id.rs b/crates/trustgraph-core/src/id.rs similarity index 100% rename from crates/trustgraph/src/id.rs rename to crates/trustgraph-core/src/id.rs diff --git a/crates/trustgraph/src/keys.rs b/crates/trustgraph-core/src/keys.rs similarity index 94% rename from crates/trustgraph/src/keys.rs rename to crates/trustgraph-core/src/keys.rs index 0b606bc..a9afc60 100644 --- a/crates/trustgraph/src/keys.rs +++ b/crates/trustgraph-core/src/keys.rs @@ -21,19 +21,10 @@ const DID_KEY_PREFIX: &str = "did:key:"; pub struct Keypair(SigningKey); impl Keypair { - /// Generates a new key pair using the operating system's secure random - /// number generator. - /// - /// # Errors - /// - /// Returns [`Error::Random`] if the OS cannot provide randomness. - pub fn generate() -> Result { - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).map_err(|e| Error::Random(e.to_string()))?; - Ok(Self::from_seed(&seed)) - } - /// Builds a key pair from a 32-byte Ed25519 seed. + /// + /// Callers supply a seed from a secure source, or enable the `random` + /// feature and use `Keypair::generate`. #[must_use] pub fn from_seed(seed: &[u8; 32]) -> Self { Self(SigningKey::from_bytes(seed)) @@ -234,9 +225,9 @@ mod tests { } #[test] - fn generated_keys_are_unique_and_round_trip() { - let a = Keypair::generate().unwrap(); - let b = Keypair::generate().unwrap(); + fn different_seeds_give_different_keys_that_round_trip() { + let a = Keypair::from_seed(&[1; 32]); + let b = Keypair::from_seed(&[2; 32]); assert_ne!(a.did(), b.did()); let restored = Keypair::from_secret_multibase(&a.to_secret_multibase()).unwrap(); assert_eq!(restored.did(), a.did()); diff --git a/crates/trustgraph/src/lib.rs b/crates/trustgraph-core/src/lib.rs similarity index 58% rename from crates/trustgraph/src/lib.rs rename to crates/trustgraph-core/src/lib.rs index a39f32c..c67470c 100644 --- a/crates/trustgraph/src/lib.rs +++ b/crates/trustgraph-core/src/lib.rs @@ -1,4 +1,4 @@ -//! # Trust Graph +//! # Trust Graph core //! //! An open protocol for sourcing and rendering trust relationships //! ([trustgraph.net](https://trustgraph.net)). @@ -10,31 +10,40 @@ //! the `eddsa-jcs-2022` cryptosuite. //! - [`TrustGraph`]: the **Agent Lens**, everything one agent can see //! through the **Trust Cascade** of the agents they trust. -//! - [`holochain`]: the link-tag encoding used by `trustgraph-holochain`. -//! - [`Store`]: a local append-only store of atoms. +//! - [`Record`] / [`Query`]: verified atoms and filters over them. +//! - [`api`]: the JSON-shaped API that the CLI, WebAssembly and Node +//! wrappers all expose. +//! +//! The core does **no I/O**: no files, network, clock or randomness. Callers +//! pass in seeds, timestamps and data, and get data back. The one opt-in +//! exception is the `random` feature, which adds [`Keypair::generate`]. That keeps it +//! portable (native, WebAssembly, embedded in other runtimes) and +//! deterministic (it can run inside reactive database queries). //! //! ``` -//! use trustgraph::{Keypair, TrustAtom, credential}; +//! use trustgraph_core::{Keypair, TrustAtom, credential}; //! -//! let alice = Keypair::generate()?; +//! let alice = Keypair::from_seed(&[7; 32]); // use a random seed in practice //! let atom = TrustAtom::new(alice.did().to_string(), "https://example.com/sushi-bar") //! .with_content("sushi") //! .with_value("0.9".parse()?); //! -//! let signed = credential::sign_atom(&atom, &alice, jiff::Timestamp::now())?; +//! let signed = credential::sign_atom(&atom, &alice, "2026-01-01T00:00:00Z".parse().unwrap())?; //! assert_eq!(credential::verify_atom(&signed)?, atom); -//! # Ok::<(), trustgraph::Error>(()) +//! # Ok::<(), trustgraph_core::Error>(()) //! ``` +pub mod api; pub mod atom; pub mod canonical; pub mod credential; pub mod error; pub mod graph; -pub mod holochain; pub mod id; pub mod keys; -pub mod store; +#[cfg(feature = "random")] +pub mod random; +pub mod record; pub mod value; pub use atom::TrustAtom; @@ -42,7 +51,7 @@ pub use error::{Error, Result}; pub use graph::{LensEntry, LensOptions, TrustGraph}; pub use id::ContentId; pub use keys::{Did, Keypair, PublicKey}; -pub use store::{Query, Record, Store}; +pub use record::{Query, Record}; pub use value::Value; /// Compiles and runs the Rust examples in the repository README, so they diff --git a/crates/trustgraph-core/src/random.rs b/crates/trustgraph-core/src/random.rs new file mode 100644 index 0000000..4a21bf7 --- /dev/null +++ b/crates/trustgraph-core/src/random.rs @@ -0,0 +1,51 @@ +//! Optional randomness, behind the `random` cargo feature. +//! +//! This is the one deliberate exception to "the core does no I/O": with the +//! feature on, the core can ask the operating system (or, in WebAssembly, the +//! browser's `crypto.getRandomValues`) for random bytes. It is off by default; +//! without it, callers pass seeds in, as everywhere else. +//! +//! Generated values are, by definition, not deterministic: don't generate keys +//! inside a reactive query (such as a Convex query or mutation). Generate them +//! in a client or an action and store the result. + +use crate::{Error, Keypair, Result}; + +/// `N` cryptographically secure random bytes. +/// +/// # Errors +/// +/// Returns [`Error::Random`] if no secure random source is available. +pub fn bytes() -> Result<[u8; N]> { + let mut buf = [0u8; N]; + getrandom::fill(&mut buf).map_err(|e| Error::Random(e.to_string()))?; + Ok(buf) +} + +impl Keypair { + /// Generates a new key pair from a secure random seed. + /// + /// # Errors + /// + /// Returns [`Error::Random`] if no secure random source is available. + pub fn generate() -> Result { + Ok(Self::from_seed(&bytes()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_are_random() { + let a: [u8; 32] = bytes().unwrap(); + let b: [u8; 32] = bytes().unwrap(); + assert_ne!(a, b); + } + + #[test] + fn generated_keys_are_unique() { + assert_ne!(Keypair::generate().unwrap().did(), Keypair::generate().unwrap().did()); + } +} diff --git a/crates/trustgraph-core/src/record.rs b/crates/trustgraph-core/src/record.rs new file mode 100644 index 0000000..77dd70b --- /dev/null +++ b/crates/trustgraph-core/src/record.rs @@ -0,0 +1,137 @@ +//! Records: verified atoms, ready to store or score, and filters over them. +//! +//! Storage itself is the caller's job (a file in the CLI, database rows in +//! a server, `IndexedDB` in a browser). This module only decides what a valid +//! record is and whether it matches a query. + +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; + +use crate::{ContentId, Error, Result, TrustAtom, credential}; + +/// An atom, its ID, and the signed credential it came from, if any. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Record { + /// The atom's [`ContentId`]. + pub id: ContentId, + /// The atom. + pub atom: TrustAtom, + /// The signed credential the atom came from, if any. Already verified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, +} + +impl Record { + /// Builds a record from JSON: either a plain [`TrustAtom`], or a signed + /// Trust Atom credential (which is verified). + /// + /// # Errors + /// + /// Fails if the JSON is neither, the atom is invalid, or the + /// credential's proof does not verify. + pub fn from_json(json: Json) -> Result { + if json.get("proof").is_some() { + let atom = credential::verify_atom(&json)?; + Ok(Self { id: atom.id()?, atom, credential: Some(json) }) + } else if json.get("@context").is_some() { + Err(Error::InvalidCredential("credential is not signed".into())) + } else { + let atom: TrustAtom = serde_json::from_value(json)?; + atom.validate()?; + Ok(Self { id: atom.id()?, atom, credential: None }) + } + } + + /// True if this record carries a verified signature. + #[must_use] + pub fn is_signed(&self) -> bool { + self.credential.is_some() + } +} + +/// Filters over records. Empty fields match everything. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Query { + /// Only atoms from this source. + pub source: Option, + /// Only atoms about this target. + pub target: Option, + /// Only atoms about this topic (see [`TrustAtom::matches_topic`]). + pub topic: Option, + /// Only atoms whose content starts with this prefix. + pub content_prefix: Option, + /// Only signed atoms. + pub signed_only: bool, +} + +impl Query { + /// True if `record` passes every filter. + #[must_use] + pub fn matches(&self, record: &Record) -> bool { + let atom = &record.atom; + self.source.as_ref().is_none_or(|s| *s == atom.source) + && self.target.as_ref().is_none_or(|t| *t == atom.target) + && self.topic.as_deref().is_none_or(|t| atom.matches_topic(t)) + && self.content_prefix.as_deref().is_none_or(|p| atom.content.as_deref().is_some_and(|c| c.starts_with(p))) + && (!self.signed_only || record.is_signed()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Keypair; + use jiff::Timestamp; + + fn atom(source: &str, target: &str, content: &str) -> TrustAtom { + TrustAtom::new(source, target).with_content(content).with_value("0.5".parse().unwrap()) + } + + fn record(source: &str, target: &str, content: &str) -> Record { + Record::from_json(serde_json::to_value(atom(source, target, content)).unwrap()).unwrap() + } + + #[test] + fn signed_credentials_are_verified_on_the_way_in() { + let key = Keypair::from_seed(&[3; 32]); + let signed = credential::sign_atom(&atom(key.did().as_str(), "t", "x"), &key, Timestamp::UNIX_EPOCH).unwrap(); + let record = Record::from_json(signed.clone()).unwrap(); + assert!(record.is_signed()); + assert_eq!(record.id, atom(key.did().as_str(), "t", "x").id().unwrap()); + + let mut forged = signed; + forged["credentialSubject"]["value"] = "1".into(); + assert!(Record::from_json(forged.clone()).is_err()); + forged.as_object_mut().unwrap().remove("proof"); + assert!(Record::from_json(forged).is_err(), "unsigned credentials are rejected"); + } + + #[test] + fn rejects_invalid_atoms() { + assert!(Record::from_json(serde_json::json!({"source": "", "target": "b"})).is_err()); + assert!(Record::from_json(serde_json::json!({"target": "b"})).is_err()); + } + + #[test] + fn queries_filter_records() { + let records = [ + record("alice", "bob", "sushi, ramen"), + record("alice", "carol", "rust"), + record("bob", "carol", "rust programming"), + ]; + let count = |q: Query| records.iter().filter(|r| q.matches(r)).count(); + assert_eq!(count(Query::default()), 3); + assert_eq!(count(Query { source: Some("alice".into()), ..Query::default() }), 2); + assert_eq!(count(Query { target: Some("carol".into()), ..Query::default() }), 2); + assert_eq!(count(Query { topic: Some("ramen".into()), ..Query::default() }), 1); + assert_eq!(count(Query { content_prefix: Some("rust".into()), ..Query::default() }), 2); + assert_eq!(count(Query { signed_only: true, ..Query::default() }), 0); + } + + #[test] + fn query_deserializes_from_camel_case_json() { + let q: Query = serde_json::from_str(r#"{"topic":"sushi","signedOnly":true}"#).unwrap(); + assert_eq!(q, Query { topic: Some("sushi".into()), signed_only: true, ..Query::default() }); + } +} diff --git a/crates/trustgraph/src/value.rs b/crates/trustgraph-core/src/value.rs similarity index 73% rename from crates/trustgraph/src/value.rs rename to crates/trustgraph-core/src/value.rs index 132acd5..cfaaf82 100644 --- a/crates/trustgraph/src/value.rs +++ b/crates/trustgraph-core/src/value.rs @@ -9,8 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::{Error, Result}; -/// Number of significant figures a [`Value`] keeps. This matches the -/// `trustgraph-holochain` reference implementation. +/// Number of significant figures a [`Value`] keeps. pub const SIGNIFICANT_FIGURES: u32 = 9; /// A trust rating in the range `-1..=1`. @@ -94,35 +93,6 @@ impl Value { pub fn is_positive(self) -> bool { self.0.is_sign_positive() && !self.0.is_zero() } - - /// The fixed-width form used in Holochain link tags: no leading zero, - /// nine decimal places, and `±1` stored as `±.999999999` - /// (e.g. `.900000000`, `-.250000000`). - #[must_use] - pub fn to_holochain_string(self) -> String { - let clamped = self.0.clamp(Decimal::new(-999_999_999, 9), Decimal::new(999_999_999, 9)); - // The reference implementation rounds to nine *significant figures*. - let rounded = clamped - .round_sf_with_strategy(SIGNIFICANT_FIGURES, RoundingStrategy::MidpointAwayFromZero) - .unwrap_or(clamped); - let s = strip_leading_zero(rounded.to_string()); - if s.len() <= MAX_HOLOCHAIN_VALUE_CHARS { - return s; - } - // For tiny values (|v| < 0.01) that gives more than the 12 characters - // a tag allows (e.g. `-.00000000100000000`), so use nine decimal - // places instead. - let mut fixed = clamped.round_dp_with_strategy(9, RoundingStrategy::MidpointAwayFromZero); - fixed.rescale(9); - strip_leading_zero(fixed.to_string()) - } -} - -/// Maximum length of the value chunk in a Holochain link tag. -const MAX_HOLOCHAIN_VALUE_CHARS: usize = 12; - -fn strip_leading_zero(s: String) -> String { - if s.starts_with("0.") || s.starts_with("-0.") { s.replacen("0.", ".", 1) } else { s } } fn invalid(input: String, reason: &'static str) -> Error { @@ -234,7 +204,6 @@ mod tests { assert_eq!(v("-0.9000000005").to_string(), "-0.900000001"); } - // Ported from trustgraph-holochain `test_normalize_value__values_out_of_range`. #[test] fn rejects_out_of_range() { for s in ["2", "-2", "1.000000005", "1.00000001", "-1.00000001", "-1.000000005", "100000000000000000"] { @@ -242,7 +211,6 @@ mod tests { } } - // Ported from trustgraph-holochain `test_normalize_value__values_not_numeric`. #[test] fn rejects_non_numeric() { for s in [ @@ -274,52 +242,6 @@ mod tests { } } - // Ported from trustgraph-holochain `test_normalize_value`. - #[test] - fn holochain_string_matches_reference_implementation() { - for (input, expected) in [ - ("-.9", "-.900000000"), - ("-.9000", "-.900000000"), - ("-.900000000", "-.900000000"), - ("-.9000000004", "-.900000000"), - ("-.9000000005", "-.900000001"), - ("-0.900000000", "-.900000000"), - ("0.8999999995", ".900000000"), - ("0.7999999995", ".800000000"), - ("-0.8999999995", "-.900000000"), - ("-0.7999999995", "-.800000000"), - ("0.8999999994", ".899999999"), - ("0.7999999994", ".799999999"), - ("-0.8999999994", "-.899999999"), - ("-0.7999999994", "-.799999999"), - (".9", ".900000000"), - (".9000", ".900000000"), - (".900000000", ".900000000"), - ("0.900000000", ".900000000"), - ("1", ".999999999"), - ("1.0", ".999999999"), - ("-1", "-.999999999"), - ("-1.0", "-.999999999"), - ] { - assert_eq!(v(input).to_holochain_string(), expected, "input {input}"); - } - } - - #[test] - fn holochain_string_of_zero() { - assert_eq!(Value::ZERO.to_holochain_string(), "0"); - } - - #[test] - fn holochain_string_of_tiny_values_fits_twelve_chars() { - // Nine significant figures would give `-.00000000100000000`. - assert_eq!(v("-0.000000001").to_holochain_string(), "-.000000001"); - assert_eq!(v("0.00123456789").to_holochain_string(), ".00123456789"); - assert_eq!(v("0.000123456789").to_holochain_string(), ".000123457"); - // Small values that fit keep the reference format. - assert_eq!(v("0.05").to_holochain_string(), ".0500000000"); - } - #[test] fn serializes_as_string_and_accepts_numbers() { assert_eq!(serde_json::to_string(&v("0.25")).unwrap(), r#""0.25""#); diff --git a/crates/trustgraph/tests/properties.proptest-regressions b/crates/trustgraph-core/tests/properties.proptest-regressions similarity index 100% rename from crates/trustgraph/tests/properties.proptest-regressions rename to crates/trustgraph-core/tests/properties.proptest-regressions diff --git a/crates/trustgraph/tests/properties.rs b/crates/trustgraph-core/tests/properties.rs similarity index 75% rename from crates/trustgraph/tests/properties.rs rename to crates/trustgraph-core/tests/properties.rs index 07d0604..74793d0 100644 --- a/crates/trustgraph/tests/properties.rs +++ b/crates/trustgraph-core/tests/properties.rs @@ -4,9 +4,8 @@ use jiff::Timestamp; use proptest::prelude::*; -use trustgraph::holochain::{Direction, LinkTag}; -use trustgraph::value::Decimal; -use trustgraph::{Keypair, LensOptions, TrustAtom, TrustGraph, Value, credential}; +use trustgraph_core::value::Decimal; +use trustgraph_core::{Keypair, LensOptions, TrustAtom, TrustGraph, Value, credential}; fn value() -> impl Strategy { (-1_000_000_000i64..=1_000_000_000).prop_map(|n| Value::new(Decimal::new(n, 9)).unwrap()) @@ -47,14 +46,6 @@ proptest! { prop_assert_eq!(serde_json::from_str::(&json).unwrap(), v); } - #[test] - fn holochain_value_string_is_within_twelve_chars_and_reparses(v in value()) { - let s = v.to_holochain_string(); - prop_assert!(s.len() <= 12, "{} is too long", s); - let back: Value = s.parse().unwrap(); - prop_assert!((back.as_f64() - v.as_f64()).abs() <= 1e-9); - } - #[test] fn values_outside_range_are_rejected(n in prop_oneof![1_000_000_001i64..i64::MAX / 2, i64::MIN / 2..-1_000_000_000]) { prop_assert!(Value::new(Decimal::new(n, 9)).is_err()); @@ -97,22 +88,6 @@ proptest! { prop_assert!(credential::verify_atom(&tampered).is_err()); } - #[test] - fn holochain_tags_round_trip(atom in atom(), reverse in any::(), bucket in "[0-9]{9}") { - let direction = if reverse { Direction::Reverse } else { Direction::Forward }; - let tag = LinkTag::for_atom(&atom, direction, Some(bucket), None); - if let Ok(bytes) = tag.encode() { - let decoded = LinkTag::decode(&bytes).unwrap(); - prop_assert_eq!(decoded.direction, direction); - prop_assert_eq!(decoded.content.clone(), atom.content.clone().filter(|c| !c.is_empty())); - prop_assert_eq!(decoded.bucket.clone(), tag.bucket); - let (base, target) = if reverse { (&atom.target, &atom.source) } else { (&atom.source, &atom.target) }; - let rebuilt = decoded.to_atom(base, target); - prop_assert_eq!(&rebuilt.source, &atom.source); - prop_assert_eq!(&rebuilt.target, &atom.target); - } - } - #[test] fn lens_scores_are_bounded_and_root_is_never_scored( edges in proptest::collection::vec((0u8..6, 0u8..6, value()), 0..40), diff --git a/crates/trustgraph-node/.gitignore b/crates/trustgraph-node/.gitignore new file mode 100644 index 0000000..10b710a --- /dev/null +++ b/crates/trustgraph-node/.gitignore @@ -0,0 +1,5 @@ +*.node +index.js +# Generated by `npm run build`: +index.d.ts +trustgraph.d.ts diff --git a/crates/trustgraph-node/Cargo.toml b/crates/trustgraph-node/Cargo.toml new file mode 100644 index 0000000..1e86046 --- /dev/null +++ b/crates/trustgraph-node/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "trustgraph-node" +description = "Native Node.js bindings for trustgraph-core (napi-rs), for Node and Convex Node actions" +readme = "README.md" +keywords = ["trust", "reputation", "napi", "did", "verifiable-credentials"] +categories = ["api-bindings", "cryptography"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] +# A napi addon only links inside a Node process; it is tested from Node. +test = false +doctest = false + +[dependencies] +trustgraph-core = { workspace = true, features = ["random"] } + +napi.workspace = true +napi-derive.workspace = true +serde.workspace = true +serde_json.workspace = true + +[build-dependencies] +napi-build.workspace = true + +# Same as the workspace lints, except `unsafe_code` is `deny` rather than +# `forbid`: the `#[napi]` macro emits FFI glue under `#[allow(unsafe_code)]`, +# which `forbid` would reject. Hand-written unsafe code is still an error. +[lints.rust] +unsafe_code = "deny" +missing_debug_implementations = "warn" +missing_docs = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +unwrap_used = "warn" +module_name_repetitions = "allow" diff --git a/crates/trustgraph-node/README.md b/crates/trustgraph-node/README.md new file mode 100644 index 0000000..351f264 --- /dev/null +++ b/crates/trustgraph-node/README.md @@ -0,0 +1,10 @@ +# @trustgraph/trustgraph + +[Trust Graph](https://trustgraph.net) as a native Node.js module (napi-rs). +Use it for heavy work in Node, including Convex Node actions via +`externalPackages`. Same API as `@trustgraph/trustgraph-wasm`; see the +[types](../../bindings/trustgraph.d.ts) and the [architecture](../../doc/architecture.md). + +```sh +pnpm install && pnpm run build:node && pnpm run test:node # from the repo root +``` diff --git a/crates/trustgraph-node/build.rs b/crates/trustgraph-node/build.rs new file mode 100644 index 0000000..d1db256 --- /dev/null +++ b/crates/trustgraph-node/build.rs @@ -0,0 +1,5 @@ +//! Sets up linking for a Node.js addon. + +fn main() { + napi_build::setup(); +} diff --git a/crates/trustgraph-node/package.json b/crates/trustgraph-node/package.json new file mode 100644 index 0000000..0c61b52 --- /dev/null +++ b/crates/trustgraph-node/package.json @@ -0,0 +1,48 @@ +{ + "name": "@trustgraph/trustgraph", + "version": "0.1.0", + "description": "Trust Graph for Node.js: native bindings to trustgraph-core (napi-rs)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/trustgraph/trustgraph-rust-cli.git", + "directory": "crates/trustgraph-node" + }, + "homepage": "https://trustgraph.net", + "keywords": [ + "trust", + "reputation", + "did", + "verifiable-credentials", + "napi-rs" + ], + "main": "index.js", + "types": "trustgraph.d.ts", + "files": [ + "index.js", + "trustgraph.d.ts" + ], + "engines": { + "node": ">= 22" + }, + "napi": { + "binaryName": "trustgraph", + "targets": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + }, + "scripts": { + "build": "napi build --platform --release && node -e \"require('fs').copyFileSync('../../bindings/trustgraph.d.ts', 'trustgraph.d.ts')\"", + "test": "node ../../tests/js/smoke.mjs index.js" + }, + "devDependencies": { + "@napi-rs/cli": "^3.0.0" + } +} diff --git a/crates/trustgraph-node/src/lib.rs b/crates/trustgraph-node/src/lib.rs new file mode 100644 index 0000000..fec2650 --- /dev/null +++ b/crates/trustgraph-node/src/lib.rs @@ -0,0 +1,102 @@ +//! Native Node.js bindings for [`trustgraph_core`], built with napi-rs. +//! +//! Same functions, names and shapes as the WebAssembly package, but compiled +//! to native code: use it for heavy batch work in Node (including Convex +//! Node actions, via `externalPackages`). Inside Convex queries and +//! mutations, use the WebAssembly package instead. +//! +//! Every function throws a JavaScript `Error` with the core's message on +//! invalid input; the API is documented in `bindings/trustgraph.d.ts`. + +// napi-rs passes JavaScript values in as owned Rust values, and errors become +// thrown JS exceptions (documented above), so these pedantic lints don't apply. +#![allow(clippy::needless_pass_by_value, clippy::missing_errors_doc)] + +use napi::bindgen_prelude::Uint8Array; +use napi_derive::napi; +use serde_json::Value; +use trustgraph_core::api::{self, LensRequest}; + +type Result = napi::Result; + +fn core(result: trustgraph_core::Result) -> Result { + result.map_err(|e| napi::Error::from_reason(e.to_string())) +} + +fn json(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| napi::Error::from_reason(e.to_string())) +} + +fn request(options: Option) -> Result { + match options { + None | Some(Value::Null) => Ok(LensRequest::default()), + Some(value) => serde_json::from_value(value).map_err(|e| napi::Error::from_reason(format!("options: {e}"))), + } +} + +/// The library version. +#[napi] +#[must_use] +pub fn version() -> String { + api::version().to_owned() +} + +/// Derives an identity from 32 random bytes. +#[napi] +pub fn keypair_from_seed(seed: Uint8Array) -> Result { + json(&core(api::keypair_from_seed(&seed))?) +} + +/// Generates a new identity from the OS random number generator. +#[napi] +pub fn generate_keypair() -> Result { + json(&core(api::generate_keypair())?) +} + +/// Validates an atom (or extracts it from a credential) and returns it. +#[napi] +pub fn parse_atom(input: Value) -> Result { + json(&core(api::parse_atom(input))?) +} + +/// The content ID (`Qm…`) of an atom or credential. +#[napi] +pub fn atom_id(input: Value) -> Result { + core(api::atom_id(input)) +} + +/// The canonical JSON (RFC 8785) of an atom, exactly as hashed. +#[napi] +pub fn canonical_atom(input: Value) -> Result { + core(api::canonical_atom(input)) +} + +/// Converts an atom into an unsigned Verifiable Credential. +#[napi] +pub fn to_credential(atom: Value) -> Result { + core(api::to_credential(atom)) +} + +/// Signs an atom at time `created` (RFC 3339). +#[napi] +pub fn sign_atom(atom: Value, secret_key_multibase: String, created: String) -> Result { + core(api::sign_atom(atom, &secret_key_multibase, &created)) +} + +/// Verifies a signed credential: `{valid, id?, issuer?, atom?, error?}`. +#[napi] +pub fn verify(credential: Value) -> Result { + json(&api::verify(&credential)) +} + +/// The Agent Lens for `root` over `items`, best first. +#[napi] +pub fn lens(items: Vec, root: String, options: Option) -> Result { + json(&core(api::lens(items, &root, &request(options)?))?) +} + +/// Rollup atoms (unsigned) for `root`'s lens, timestamped `at` (RFC 3339). +#[napi] +pub fn rollup(items: Vec, root: String, options: Option, at: String) -> Result { + json(&core(api::rollup(items, &root, &request(options)?, &at))?) +} diff --git a/crates/trustgraph-wasm/Cargo.toml b/crates/trustgraph-wasm/Cargo.toml new file mode 100644 index 0000000..1849a97 --- /dev/null +++ b/crates/trustgraph-wasm/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "trustgraph-wasm" +description = "WebAssembly build of trustgraph-core, for browsers, Cloudflare Workers, Deno and Convex queries" +readme = "README.md" +keywords = ["trust", "reputation", "wasm", "did", "verifiable-credentials"] +categories = ["wasm", "cryptography"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +trustgraph-core = { workspace = true, features = ["random"] } + +serde.workspace = true +serde_json.workspace = true +serde-wasm-bindgen.workspace = true +wasm-bindgen.workspace = true + +# Randomness for `generateKeypair` comes from `crypto.getRandomValues`. +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true, features = ["wasm_js"] } + +[lints] +workspace = true diff --git a/crates/trustgraph-wasm/README.md b/crates/trustgraph-wasm/README.md new file mode 100644 index 0000000..145a2a6 --- /dev/null +++ b/crates/trustgraph-wasm/README.md @@ -0,0 +1,8 @@ +# @trustgraph/trustgraph-wasm + +[Trust Graph](https://trustgraph.net) compiled to WebAssembly. Runs in +browsers, Cloudflare Workers, Deno, Node, and inside Convex queries and +mutations. Same API as the native `@trustgraph/trustgraph` package; see the +[types](../../bindings/trustgraph.d.ts) and the [architecture](../../doc/architecture.md). + +Build with `scripts/build-wasm-package.sh` (output in `target/npm/trustgraph-wasm`). diff --git a/crates/trustgraph-wasm/package.json b/crates/trustgraph-wasm/package.json new file mode 100644 index 0000000..33867b5 --- /dev/null +++ b/crates/trustgraph-wasm/package.json @@ -0,0 +1,38 @@ +{ + "name": "@trustgraph/trustgraph-wasm", + "version": "0.1.0", + "description": "Trust Graph in WebAssembly: runs in browsers, Cloudflare Workers, Deno, Node and Convex queries", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/trustgraph/trustgraph-rust-cli.git", + "directory": "crates/trustgraph-wasm" + }, + "homepage": "https://trustgraph.net", + "keywords": [ + "trust", + "reputation", + "did", + "verifiable-credentials", + "wasm" + ], + "type": "module", + "types": "./trustgraph.d.ts", + "exports": { + ".": { + "types": "./trustgraph.d.ts", + "node": "./node/trustgraph_wasm.cjs", + "default": "./web/trustgraph_wasm.js" + }, + "./trustgraph_wasm_bg.wasm": "./web/trustgraph_wasm_bg.wasm" + }, + "files": [ + "web/", + "node/", + "trustgraph.d.ts" + ], + "sideEffects": false, + "engines": { + "node": ">= 22" + } +} diff --git a/crates/trustgraph-wasm/src/lib.rs b/crates/trustgraph-wasm/src/lib.rs new file mode 100644 index 0000000..41a519a --- /dev/null +++ b/crates/trustgraph-wasm/src/lib.rs @@ -0,0 +1,144 @@ +//! WebAssembly bindings for [`trustgraph_core`], built with `wasm-bindgen`. +//! +//! Every function maps one-to-one onto [`trustgraph_core::api`] and takes +//! and returns plain JavaScript objects. The core is pure and +//! deterministic, so this build can run anywhere WebAssembly runs: browsers, +//! Cloudflare Workers, Deno, Node, and inside Convex queries and mutations +//! (where native addons cannot). + +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value as Json; +use trustgraph_core::api::{self, LensRequest}; +use wasm_bindgen::prelude::*; + +type JsResult = Result; + +fn to_js(value: &T) -> JsResult { + value.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(|e| JsError::new(&e.to_string())) +} + +fn from_js(value: JsValue, what: &str) -> JsResult { + serde_wasm_bindgen::from_value(value).map_err(|e| JsError::new(&format!("{what}: {e}"))) +} + +fn request(value: JsValue) -> JsResult { + Ok(from_js::>(value, "options")?.unwrap_or_default()) +} + +fn core(result: trustgraph_core::Result) -> JsResult { + result.map_err(|e| JsError::new(&e.to_string())) +} + +/// The library version. +#[wasm_bindgen] +#[must_use] +pub fn version() -> String { + api::version().to_owned() +} + +/// Derives an identity (`{did, publicKeyMultibase, secretKeyMultibase}`) +/// from 32 random bytes, e.g. `crypto.getRandomValues(new Uint8Array(32))`. +/// +/// # Errors +/// +/// Throws if the seed is not 32 bytes. +#[wasm_bindgen(js_name = keypairFromSeed)] +pub fn keypair_from_seed(seed: &[u8]) -> JsResult { + to_js(&core(api::keypair_from_seed(seed))?) +} + +/// Generates a new identity using `crypto.getRandomValues`. Not +/// deterministic: don't call it inside a reactive query. +/// +/// # Errors +/// +/// Throws if no secure random source is available. +#[wasm_bindgen(js_name = generateKeypair)] +pub fn generate_keypair() -> JsResult { + to_js(&core(api::generate_keypair())?) +} + +/// Validates an atom (or extracts it from a credential) and returns it. +/// +/// # Errors +/// +/// Throws if the input is not a valid atom or Trust Atom credential. +#[wasm_bindgen(js_name = parseAtom)] +pub fn parse_atom(input: JsValue) -> JsResult { + to_js(&core(api::parse_atom(from_js(input, "atom")?))?) +} + +/// The content ID (`Qm…`) of an atom or credential. +/// +/// # Errors +/// +/// Throws if the input is not a valid atom or Trust Atom credential. +#[wasm_bindgen(js_name = atomId)] +pub fn atom_id(input: JsValue) -> JsResult { + core(api::atom_id(from_js(input, "atom")?)) +} + +/// The canonical JSON (RFC 8785) of an atom, exactly as hashed. +/// +/// # Errors +/// +/// Throws if the input is not a valid atom or Trust Atom credential. +#[wasm_bindgen(js_name = canonicalAtom)] +pub fn canonical_atom(input: JsValue) -> JsResult { + core(api::canonical_atom(from_js(input, "atom")?)) +} + +/// Converts an atom into an unsigned Verifiable Credential. +/// +/// # Errors +/// +/// Throws if the input is not a valid atom. +#[wasm_bindgen(js_name = toCredential)] +pub fn to_credential(atom: JsValue) -> JsResult { + to_js(&core(api::to_credential(from_js(atom, "atom")?))?) +} + +/// Signs an atom, producing a Verifiable Credential. `created` is an +/// RFC 3339 time such as `new Date().toISOString()`. +/// +/// # Errors +/// +/// Throws if the atom, key or time is invalid, or the atom's source is not +/// the key's DID. +#[wasm_bindgen(js_name = signAtom)] +pub fn sign_atom(atom: JsValue, secret_key_multibase: &str, created: &str) -> JsResult { + to_js(&core(api::sign_atom(from_js(atom, "atom")?, secret_key_multibase, created))?) +} + +/// Verifies a signed credential: `{valid, id?, issuer?, atom?, error?}`. +/// +/// # Errors +/// +/// Throws only if the input cannot be read as JSON. +#[wasm_bindgen] +pub fn verify(credential: JsValue) -> JsResult { + to_js(&api::verify(&from_js::(credential, "credential")?)) +} + +/// The Agent Lens: everything `root` can see in `items` (atoms and/or +/// signed credentials), best first. `options` is optional: +/// `{depth?, decay?, topic?, signedOnly?, limit?}`. +/// +/// # Errors +/// +/// Throws if the options are out of range or an item is invalid. +#[wasm_bindgen] +pub fn lens(items: JsValue, root: &str, options: JsValue) -> JsResult { + to_js(&core(api::lens(from_js(items, "items")?, root, &request(options)?))?) +} + +/// Rollup atoms (unsigned) for `root`'s lens, timestamped `at` (RFC 3339). +/// +/// # Errors +/// +/// Throws like [`lens`], or if `at` is not an RFC 3339 time. +#[wasm_bindgen] +pub fn rollup(items: JsValue, root: &str, options: JsValue, at: &str) -> JsResult { + to_js(&core(api::rollup(from_js(items, "items")?, root, &request(options)?, at))?) +} diff --git a/crates/trustgraph/README.md b/crates/trustgraph/README.md deleted file mode 100644 index d68c4c1..0000000 --- a/crates/trustgraph/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# trustgraph - -Rust implementation of the [Trust Graph](https://trustgraph.net) protocol: - -- `TrustAtom`: *source* trusts *target*, about *content*, to degree *value* (`-1..=1`) -- `Keypair` / `Did`: Ed25519 identities as `did:key` -- `credential`: W3C Verifiable Credentials 2.0 with `eddsa-jcs-2022` proofs -- `TrustGraph::lens`: the Agent Lens / Trust Cascade -- `holochain`: the `trustgraph-holochain` link-tag codec -- `Store`: an append-only NDJSON store - -See the [repository README](../../README.md) for an overview. diff --git a/crates/trustgraph/src/holochain.rs b/crates/trustgraph/src/holochain.rs deleted file mode 100644 index 62577ed..0000000 --- a/crates/trustgraph/src/holochain.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! The Holochain link-tag encoding used by -//! [`trustgraph-holochain`](https://github.com/trustgraph/trustgraph-holochain). -//! -//! A Trust Atom is stored on Holochain as two links: a forward link -//! `source → target` and a reverse link `target → source`. Both carry a tag: -//! -//! ```text -//! Ŧ→ content NUL value NUL bucket NUL extra -//! Ŧ↩ content NUL value NUL bucket NUL extra -//! ``` -//! -//! Holochain can search links by tag prefix, so this layout makes atoms -//! searchable by topic (`Ŧ→sushi`) and by topic and value -//! (`Ŧ→sushi\0.9`). The source and target are the link's base and target -//! hashes, so they are not part of the tag. - -use crate::{Error, Result, TrustAtom, Value}; - -/// `Ŧ` (U+0166), the first two bytes of every Trust Atom link tag. -pub const HEADER: &str = "Ŧ"; -/// `→`: the link points from source to target. -pub const ARROW_FORWARD: &str = "→"; -/// `↩`: the link points from target back to source. -pub const ARROW_REVERSE: &str = "↩"; -/// Separates the chunks of a tag. -pub const SEPARATOR: char = '\0'; -/// Maximum size of `content`, in bytes. -pub const MAX_CONTENT_BYTES: usize = 900; -/// Maximum size of a whole tag, in bytes. -pub const MAX_TAG_BYTES: usize = 999; -/// Number of digits in a bucket. -pub const BUCKET_DIGITS: usize = 9; - -/// Which way a link points. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Direction { - /// Base is the atom's source; target is the atom's target. - Forward, - /// Base is the atom's target; target is the atom's source. - Reverse, -} - -impl Direction { - fn arrow(self) -> &'static str { - match self { - Self::Forward => ARROW_FORWARD, - Self::Reverse => ARROW_REVERSE, - } - } -} - -/// A decoded Trust Atom link tag. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct LinkTag { - /// Which way the link points. - pub direction: Direction, - /// The atom's content. - pub content: Option, - /// The atom's value. - pub value: Option, - /// Nine random digits, so that many links with the same tag prefix can - /// be spread over buckets. - pub bucket: Option, - /// The hash of an entry holding extra fields (as a string, e.g. `uhCEk…`). - pub extra: Option, -} - -impl LinkTag { - /// Builds the tag for `atom` in the given direction. - /// - /// The atom's `extra` fields are not part of the tag: on Holochain they - /// live in a separate entry whose hash is passed as `extra_hash`. - #[must_use] - pub fn for_atom( - atom: &TrustAtom, - direction: Direction, - bucket: Option, - extra_hash: Option, - ) -> Self { - Self { direction, content: atom.content.clone(), value: atom.value, bucket, extra: extra_hash } - } - - /// Encodes the tag as bytes. - /// - /// # Errors - /// - /// Returns [`Error::InvalidLinkTag`] if a chunk contains a NUL byte, the - /// content is longer than 900 bytes, the bucket is not nine digits, or - /// the tag is longer than 999 bytes. - pub fn encode(&self) -> Result> { - let content = self.content.as_deref().unwrap_or_default(); - if content.len() > MAX_CONTENT_BYTES { - return Err(bad(format!("content is {} bytes; the maximum is {MAX_CONTENT_BYTES}", content.len()))); - } - if let Some(bucket) = - self.bucket.as_deref().filter(|b| b.len() != BUCKET_DIGITS || !b.bytes().all(|d| d.is_ascii_digit())) - { - return Err(bad(format!("bucket must be {BUCKET_DIGITS} digits, got `{bucket}`"))); - } - let value = self.value.map(Value::to_holochain_string); - let chunks = [Some(content), value.as_deref(), self.bucket.as_deref(), self.extra.as_deref()]; - if chunks.iter().flatten().any(|chunk| chunk.contains(SEPARATOR)) { - return Err(bad("chunks must not contain NUL bytes".into())); - } - - let mut tag = String::from(HEADER); - tag.push_str(self.direction.arrow()); - for (i, chunk) in chunks.iter().enumerate() { - if i > 0 { - tag.push(SEPARATOR); - } - tag.push_str(chunk.unwrap_or_default()); - } - if tag.len() > MAX_TAG_BYTES { - return Err(bad(format!("tag is {} bytes; the maximum is {MAX_TAG_BYTES}", tag.len()))); - } - Ok(tag.into_bytes()) - } - - /// Decodes a tag. - /// - /// # Errors - /// - /// Returns [`Error::InvalidLinkTag`] if the bytes are not a Trust Atom - /// link tag. - pub fn decode(bytes: &[u8]) -> Result { - let tag = std::str::from_utf8(bytes).map_err(|_| bad("tag is not UTF-8".into()))?; - let rest = tag.strip_prefix(HEADER).ok_or_else(|| bad("tag does not start with `Ŧ`".into()))?; - let (direction, rest) = if let Some(rest) = rest.strip_prefix(ARROW_FORWARD) { - (Direction::Forward, rest) - } else if let Some(rest) = rest.strip_prefix(ARROW_REVERSE) { - (Direction::Reverse, rest) - } else { - return Err(bad("tag has no direction arrow".into())); - }; - - let mut chunks = rest.split(SEPARATOR); - let mut next = || chunks.next().filter(|s| !s.is_empty()).map(str::to_owned); - let content = next(); - let value = next().map(|v| v.parse::()).transpose()?; - let bucket = next(); - let extra = next(); - if chunks.next().is_some() { - return Err(bad("tag has too many chunks".into())); - } - Ok(Self { direction, content, value, bucket, extra }) - } - - /// Rebuilds the atom, given the link's base and target (as strings). - /// For a [`Direction::Reverse`] link they are swapped. - #[must_use] - pub fn to_atom(&self, link_base: &str, link_target: &str) -> TrustAtom { - let (source, target) = match self.direction { - Direction::Forward => (link_base, link_target), - Direction::Reverse => (link_target, link_base), - }; - TrustAtom { content: self.content.clone(), value: self.value, ..TrustAtom::new(source, target) } - } -} - -/// Generates a random bucket of nine digits, as the reference implementation does. -/// -/// # Errors -/// -/// Returns [`Error::Random`] if the OS cannot provide randomness. -pub fn random_bucket() -> Result { - let mut bytes = [0u8; BUCKET_DIGITS]; - getrandom::fill(&mut bytes).map_err(|e| Error::Random(e.to_string()))?; - Ok(bucket_from_bytes(&bytes)) -} - -fn bucket_from_bytes(bytes: &[u8]) -> String { - bytes.iter().map(|b| char::from(b'0' + b % 10)).collect() -} - -fn bad(msg: String) -> Error { - Error::InvalidLinkTag(msg) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn tag(content: Option<&str>, value: Option<&str>, bucket: Option<&str>, extra: Option<&str>) -> LinkTag { - LinkTag { - direction: Direction::Forward, - content: content.map(Into::into), - value: value.map(|v| v.parse().unwrap()), - bucket: bucket.map(Into::into), - extra: extra.map(Into::into), - } - } - - #[test] - fn header_bytes_match_reference_implementation() { - assert_eq!(HEADER.as_bytes(), [0xC5, 0xA6]); - assert_eq!(ARROW_FORWARD.as_bytes(), [0xE2, 0x86, 0x92]); - assert_eq!(ARROW_REVERSE.as_bytes(), [0xE2, 0x86, 0xA9]); - } - - // Matches the "Full Example Link Tags" in the trustgraph-holochain README. - #[test] - fn encodes_readme_example() { - let forward = tag(Some("sushi"), Some("1"), Some("892412523"), Some("uhCEkUFnFF")); - assert_eq!(forward.encode().unwrap(), "Ŧ→sushi\0.999999999\0892412523\0uhCEkUFnFF".as_bytes()); - let reverse = LinkTag { direction: Direction::Reverse, ..forward }; - assert_eq!(reverse.encode().unwrap(), "Ŧ↩sushi\0.999999999\0892412523\0uhCEkUFnFF".as_bytes()); - } - - #[test] - fn missing_chunks_leave_empty_slots() { - assert_eq!(tag(None, None, None, None).encode().unwrap(), "Ŧ→\0\0\0".as_bytes()); - assert_eq!( - tag(Some("x"), None, Some("000000000"), None).encode().unwrap(), - "Ŧ→x\u{0}\u{0}000000000\u{0}".as_bytes() - ); - } - - #[test] - fn decode_round_trips() { - for t in [ - tag(Some("sushi"), Some("0.9"), Some("123456789"), Some("uhCEk")), - tag(Some("🍣 Ŧ"), Some("-1"), None, None), - tag(None, None, None, None), - LinkTag { direction: Direction::Reverse, ..tag(Some("a"), Some("0"), None, None) }, - ] { - let decoded = LinkTag::decode(&t.encode().unwrap()).unwrap(); - // ±1 is stored as ±0.999999999. - let expected_value = t.value.map(|v| v.to_holochain_string().parse::().unwrap()); - assert_eq!(decoded, LinkTag { value: expected_value, ..t }); - } - } - - #[test] - fn decodes_tags_without_trailing_chunks() { - // Older tags may stop after the value. - let t = LinkTag::decode("Ŧ→sushi\0.5".as_bytes()).unwrap(); - assert_eq!(t.content.as_deref(), Some("sushi")); - assert_eq!(t.value.unwrap().to_string(), "0.5"); - assert_eq!(t.bucket, None); - } - - #[test] - fn rejects_bad_tags() { - for bytes in [ - &b""[..], - b"sushi", - "Ŧsushi".as_bytes(), - "Ŧ→sushi\0two\0".as_bytes(), - "Ŧ→a\u{0}.5\u{0}123456789\u{0}x\u{0}extra".as_bytes(), - &[0xC5, 0xA6, 0xFF], - ] { - assert!(LinkTag::decode(bytes).is_err(), "{bytes:?}"); - } - } - - #[test] - fn enforces_size_limits() { - assert!(tag(Some(&"a".repeat(900)), None, None, None).encode().is_ok()); - assert!(tag(Some(&"a".repeat(901)), None, None, None).encode().is_err()); - let long_extra = "e".repeat(200); - assert!(tag(Some(&"a".repeat(900)), Some("0.5"), Some("123456789"), Some(&long_extra)).encode().is_err()); - assert!(tag(Some("nul\0"), None, None, None).encode().is_err()); - assert!(tag(None, None, Some("12345"), None).encode().is_err()); - } - - #[test] - fn rebuilds_atoms_in_both_directions() { - let forward = tag(Some("sushi"), Some("0.5"), None, None); - let atom = forward.to_atom("agent", "restaurant"); - assert_eq!((atom.source.as_str(), atom.target.as_str()), ("agent", "restaurant")); - let reverse = LinkTag { direction: Direction::Reverse, ..forward }; - let atom = reverse.to_atom("restaurant", "agent"); - assert_eq!((atom.source.as_str(), atom.target.as_str()), ("agent", "restaurant")); - } - - // Ported from trustgraph-holochain `test_bucket_val`. - #[test] - fn bucket_digits_match_reference_implementation() { - assert_eq!(bucket_from_bytes(&[9, 10, 11, 12, 13, 14, 15, 16, 17]), "901234567"); - let bucket = random_bucket().unwrap(); - assert_eq!(bucket.len(), 9); - assert!(bucket.bytes().all(|b| b.is_ascii_digit())); - } -} diff --git a/crates/trustgraph/src/store.rs b/crates/trustgraph/src/store.rs deleted file mode 100644 index 450a25f..0000000 --- a/crates/trustgraph/src/store.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! A local, append-only store of trust atoms. -//! -//! Records are kept one per line (NDJSON) in a single file. The format is -//! readable with standard tools such as `jq` and `grep`, can be synced or -//! versioned like any text file, and never rewrites existing lines. - -use std::collections::HashSet; -use std::fs::{self, File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value as Json; - -use crate::{ContentId, Error, Result, TrustAtom, credential}; - -/// A stored atom, with its signed credential if it has one. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Record { - /// The atom's [`ContentId`]. - pub id: ContentId, - /// The atom. - pub atom: TrustAtom, - /// The signed credential the atom came from, if any. Already verified. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential: Option, -} - -impl Record { - /// Builds a record from JSON: either a plain [`TrustAtom`], or a signed - /// Trust Atom credential (which is verified). - /// - /// # Errors - /// - /// Fails if the JSON is neither, the atom is invalid, or the - /// credential's proof does not verify. - pub fn from_json(json: Json) -> Result { - if json.get("proof").is_some() { - let atom = credential::verify_atom(&json)?; - Ok(Self { id: atom.id()?, atom, credential: Some(json) }) - } else if json.get("@context").is_some() { - Err(Error::InvalidCredential("credential is not signed".into())) - } else { - let atom: TrustAtom = serde_json::from_value(json)?; - atom.validate()?; - Ok(Self { id: atom.id()?, atom, credential: None }) - } - } - - /// True if this record carries a verified signature. - #[must_use] - pub fn is_signed(&self) -> bool { - self.credential.is_some() - } -} - -/// Filters for [`Store::query`]. Empty fields match everything. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Query { - /// Only atoms from this source. - pub source: Option, - /// Only atoms about this target. - pub target: Option, - /// Only atoms about this topic (see [`TrustAtom::matches_topic`]). - pub topic: Option, - /// Only atoms whose content starts with this prefix. - pub content_prefix: Option, - /// Only signed atoms. - pub signed_only: bool, -} - -impl Query { - /// True if `record` passes every filter. - #[must_use] - pub fn matches(&self, record: &Record) -> bool { - let atom = &record.atom; - self.source.as_ref().is_none_or(|s| *s == atom.source) - && self.target.as_ref().is_none_or(|t| *t == atom.target) - && self.topic.as_deref().is_none_or(|t| atom.matches_topic(t)) - && self.content_prefix.as_deref().is_none_or(|p| atom.content.as_deref().is_some_and(|c| c.starts_with(p))) - && (!self.signed_only || record.is_signed()) - } -} - -/// An append-only NDJSON file of [`Record`]s. -#[derive(Debug)] -pub struct Store { - path: PathBuf, - records: Vec, - ids: HashSet, -} - -impl Store { - /// Opens the store at `path`, creating it (and its directory) if needed. - /// - /// # Errors - /// - /// Fails if the file cannot be read, or a line in it is not a valid record. - pub fn open(path: impl Into) -> Result { - let path = path.into(); - let mut store = Self { path, records: Vec::new(), ids: HashSet::new() }; - match File::open(&store.path) { - Ok(file) => { - for (n, line) in BufReader::new(file).lines().enumerate() { - let line = line?; - if line.trim().is_empty() { - continue; - } - let record: Record = serde_json::from_str(&line) - .map_err(|e| Error::InvalidAtom(format!("{}:{}: {e}", store.path.display(), n + 1)))?; - if store.ids.insert(record.id) { - store.records.push(record); - } - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e.into()), - } - Ok(store) - } - - /// Where the store lives. - #[must_use] - pub fn path(&self) -> &Path { - &self.path - } - - /// Adds a record. Returns `false` (and writes nothing) if an atom with - /// the same ID is already stored. - /// - /// # Errors - /// - /// Fails if the file cannot be written. - pub fn add(&mut self, record: Record) -> Result { - if self.ids.contains(&record.id) { - return Ok(false); - } - if let Some(dir) = self.path.parent().filter(|d| !d.as_os_str().is_empty()) { - fs::create_dir_all(dir)?; - } - let mut line = serde_json::to_string(&record)?; - line.push('\n'); - OpenOptions::new().create(true).append(true).open(&self.path)?.write_all(line.as_bytes())?; - self.ids.insert(record.id); - self.records.push(record); - Ok(true) - } - - /// All records, in the order they were added. - #[must_use] - pub fn records(&self) -> &[Record] { - &self.records - } - - /// Records matching `query`, in the order they were added. - pub fn query<'a>(&'a self, query: &'a Query) -> impl Iterator + 'a { - self.records.iter().filter(move |r| query.matches(r)) - } - - /// Number of records. - #[must_use] - pub fn len(&self) -> usize { - self.records.len() - } - - /// True if the store is empty. - #[must_use] - pub fn is_empty(&self) -> bool { - self.records.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Keypair; - use jiff::Timestamp; - - fn atom(source: &str, target: &str, content: &str) -> TrustAtom { - TrustAtom::new(source, target).with_content(content).with_value("0.5".parse().unwrap()) - } - - fn record(source: &str, target: &str, content: &str) -> Record { - Record::from_json(serde_json::to_value(atom(source, target, content)).unwrap()).unwrap() - } - - #[test] - fn add_persists_and_deduplicates() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nested/atoms.ndjson"); - let mut store = Store::open(&path).unwrap(); - assert_eq!(store.len(), 0); - assert!(store.add(record("a", "b", "x")).unwrap()); - assert!(!store.add(record("a", "b", "x")).unwrap()); - assert!(store.add(record("a", "c", "x")).unwrap()); - - let reopened = Store::open(&path).unwrap(); - assert_eq!(reopened.len(), 2); - assert_eq!(reopened.records(), store.records()); - assert_eq!(fs::read_to_string(&path).unwrap().lines().count(), 2); - } - - #[test] - fn signed_credentials_are_verified_on_the_way_in() { - let key = Keypair::from_seed(&[3; 32]); - let signed = credential::sign_atom(&atom(key.did().as_str(), "t", "x"), &key, Timestamp::UNIX_EPOCH).unwrap(); - let record = Record::from_json(signed.clone()).unwrap(); - assert!(record.is_signed()); - assert_eq!(record.id, atom(key.did().as_str(), "t", "x").id().unwrap()); - - let mut forged = signed; - forged["credentialSubject"]["value"] = "1".into(); - assert!(Record::from_json(forged.clone()).is_err()); - forged.as_object_mut().unwrap().remove("proof"); - assert!(Record::from_json(forged).is_err(), "unsigned credentials are rejected"); - } - - #[test] - fn rejects_invalid_atoms() { - assert!(Record::from_json(serde_json::json!({"source": "", "target": "b"})).is_err()); - assert!(Record::from_json(serde_json::json!({"target": "b"})).is_err()); - } - - #[test] - fn queries_filter_records() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("s.ndjson")).unwrap(); - store.add(record("alice", "bob", "sushi, ramen")).unwrap(); - store.add(record("alice", "carol", "rust")).unwrap(); - store.add(record("bob", "carol", "rust programming")).unwrap(); - - let count = |q: Query| store.query(&q).count(); - assert_eq!(count(Query::default()), 3); - assert_eq!(count(Query { source: Some("alice".into()), ..Query::default() }), 2); - assert_eq!(count(Query { target: Some("carol".into()), ..Query::default() }), 2); - assert_eq!(count(Query { topic: Some("ramen".into()), ..Query::default() }), 1); - assert_eq!(count(Query { content_prefix: Some("rust".into()), ..Query::default() }), 2); - assert_eq!(count(Query { signed_only: true, ..Query::default() }), 0); - } - - #[test] - fn reports_corrupt_lines_with_location() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("s.ndjson"); - fs::write(&path, "\n{not json}\n").unwrap(); - let err = Store::open(&path).unwrap_err().to_string(); - assert!(err.contains("s.ndjson:2"), "{err}"); - } -} diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..0defdea --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,183 @@ +# Architecture + +## TL;DR + +One pure Rust core with three thin wrappers. The core never does I/O, so the +same code runs on the command line, in Node, in browsers and Workers, and +inside reactive database queries (including Convex's), with identical results +everywhere. + +``` +trustgraph-core pure Rust: no I/O, no async runtime, data in → data out +├── trustgraph-cli `trust` binary: files, stdin/stdout, keystore, OS randomness +├── trustgraph-wasm @trustgraph/trustgraph-wasm (wasm-bindgen): browsers, Workers, Deno, Convex queries +└── trustgraph-node @trustgraph/trustgraph (napi-rs): native speed in Node and Convex Node actions +``` + +This is the standard layout for Rust tools that ship to JavaScript (swc, +Biome, oxc, Lightning CSS, resvg). It follows the CoreNexus architecture pass: +write the libraries in Rust, and don't shape them around any one host; Convex +is just another caller. + +## A monorepo, Rust first + +This repository is the Trust Graph monorepo. Every Trust Graph project lives +here, so the core and everything built on it change together, in one PR, with +one CI run: + +- **`crates/*`** is a Cargo workspace. All protocol logic is Rust, in + `trustgraph-core`. +- **`packages/*`** (plus the npm-facing crates) is a pnpm workspace, for + TypeScript where it fits: web front ends, Convex functions, browser + extensions, glue. +- **The rule across languages:** TypeScript calls the Rust core through the + WebAssembly or native package. It never re-implements signing, IDs, + canonicalization or scoring. One implementation means one behaviour. + +## The one rule: the core does no I/O + +`trustgraph-core` never touches files, the network, processes, environment +variables, the clock, or OS randomness, and it has no async runtime. Callers +pass everything in: + +| The core needs | Callers supply it | +|---|---| +| Randomness (key seeds) | 32 random bytes, **or** the opt-in `random` feature (see below) | +| The current time (signing, rollups) | An RFC 3339 string or `jiff::Timestamp` | +| Data to score | Atoms and credentials as values: from files (CLI), database rows (servers), IndexedDB (browsers) | + +Two things follow from this: + +- **Portable.** The core builds unchanged for `wasm32-unknown-unknown`. +- **Deterministic.** The same inputs always give byte-identical outputs, + including signatures (Ed25519 is deterministic). That is what reactive + queries require (Convex re-runs queries and caches their results). + +**Optional randomness.** The `random` cargo feature (off by default, on in all +three wrappers) adds `Keypair::generate` and `generateKeypair()`, using the OS +random number generator, or `crypto.getRandomValues` in WebAssembly. It is the +one deliberate exception, kept in `src/random.rs`. Generated keys are not +deterministic, so generate them in a client or an action, never inside a +reactive query. Passing your own seed to `keypairFromSeed` always works. + +`scripts/check-core-purity.sh` enforces the rule in CI. It fails if the core's +source uses `std::fs`, `std::net`, `std::env`, `Timestamp::now`, `getrandom` +and the like (outside the feature-gated `src/random.rs`), or if its default +dependency tree gains `getrandom`, `rand`, `tokio`, `libc` or an HTTP client. +CI also builds the core alone with default features. + +## One API, three wrappers + +`trustgraph_core::api` is a JSON-shaped API (plain objects, camelCase fields). +Each wrapper exposes exactly these functions, so behaviour cannot drift +between them: + +| Function | Purpose | +|---|---| +| `generateKeypair()` | New identity (`did:key`) from a secure random source; the only non-deterministic function | +| `keypairFromSeed(seed)` | Identity (`did:key`) from a 32-byte seed you supply | +| `parseAtom(item)` | Validate an atom, or extract it from a credential | +| `atomId(item)` | Content ID (`Qm…`) | +| `canonicalAtom(item)` | Canonical JSON (RFC 8785), exactly as hashed | +| `toCredential(atom)` | Unsigned W3C Verifiable Credential | +| `signAtom(atom, secret, created)` | Signed credential (`eddsa-jcs-2022`) | +| `verify(credential)` | `{valid, id, issuer, atom}` or `{valid: false, error}` | +| `lens(items, root, options)` | The Agent Lens / Trust Cascade | +| `rollup(items, root, options, at)` | Lens results as atoms, ready to sign and share | + +The CLI calls the same functions for `verify` and `convert`. TypeScript types +for both npm packages are in [`bindings/trustgraph.d.ts`](../bindings/trustgraph.d.ts). +A single smoke test ([`tests/js/smoke.mjs`](../tests/js/smoke.mjs)) runs against +the native addon, the WebAssembly Node build and the WebAssembly web build, so +the packages are proven to behave identically. + +## Where each build fits + +| Need | Use | Why | +|---|---|---| +| Trust scores inside live, reactive queries (e.g. a Convex query, so the UI updates on its own) | **WebAssembly** | Native addons can't run in Convex's default runtime; WebAssembly can, and is deterministic | +| Browsers, Cloudflare Workers, Deno | **WebAssembly** | Runs anywhere WebAssembly runs; 540 KiB (226 KiB gzipped) | +| Heavy batch work: full-graph recomputes, crawling, mass verification | **Native** in a Node process or Convex Node action, or the **CLI** outside Convex writing results back over HTTP | Faster than WebAssembly (up to 3–4× on verification); more memory headroom | +| People, scripts and other projects | **CLI** | No JavaScript or Convex involved | + +### Convex specifics + +- **Default runtime** (queries, mutations): use `@trustgraph/trustgraph-wasm`. + Initialize it from the `.wasm` bytes with `initSync({ module })`; the web + build never needs `fetch`. +- **Node runtime** (`"use node"` actions only): use `@trustgraph/trustgraph` + and list it in `node.externalPackages` in `convex.json`, as CoreNexus does + for `@resvg/resvg-js`. Convex then installs the matching Linux binary at + deploy time, so the package must be on npm. +- **Don't** shell out to the `trust` binary from an action; use a library + binding. +- For speed, verify credentials once when they are written, store the atoms, + and pass plain atoms to `lens` in queries. + +## Measured performance + +From [`tests/js/bench.mjs`](../tests/js/bench.mjs) in a Linux x86-64 cloud +container with Node 24 (the current LTS), on a synthetic graph with a topic +filter, averaged over two runs. CI prints the same benchmark on every run: + +| | WebAssembly | Native | +|---|---|---| +| Module load | 4.5 ms | 2.7 ms | +| `lens`, 1,000 atoms | 8.3 ms | 3.5 ms | +| `lens`, 10,000 atoms | 51 ms | 35 ms | +| `lens`, 100,000 atoms | 0.54 s | 0.43 s | +| `verify`, one credential | 0.26 ms | 0.07 ms | +| Package size | 540 KiB wasm (226 KiB gzipped) | 1.2 MB (Linux x64) | + +Against Convex's limits for queries and mutations (1 s, 64 MiB, 32 MiB +bundle): graphs up to tens of thousands of atoms fit comfortably. Beyond about +100,000 atoms per query, precompute with the native build and store +**rollups** (the lens as atoms), then read those in queries. Verifying +signatures in bulk inside a query is the expensive part; do it on write. + +CI fails if the wasm grows past 1 MiB. + +## Node versions + +- **Development and CI** use the latest Node LTS: `.nvmrc` says `lts/*` + (Node 24 today), so CI moves to the next LTS automatically when it is + promoted. The root `package.json` asks for Node 24 or newer. +- **The published packages** support Node 22 and newer, the oldest release + line still maintained, so they work in every Convex Node runtime still + receiving updates. CI runs the smoke test on Node 22 to keep that promise. + +## Building + +```sh +# CLI +cargo install --path crates/trustgraph-cli + +# JavaScript tooling (pnpm workspace, from the repo root) +pnpm install + +# WebAssembly package → target/npm/trustgraph-wasm (web/ + node/ builds) +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version +scripts/build-wasm-package.sh + +# Native package for this machine → crates/trustgraph-node +pnpm run build:node && pnpm run test:node +``` + +The native package uses napi-rs's per-platform layout (one small npm package +per OS/CPU, installed as optional dependencies, chosen by the generated +`index.js`), the same pattern as `@resvg/resvg-js`. Targets are listed in +[`crates/trustgraph-node/package.json`](../crates/trustgraph-node/package.json). +CI builds and tests it on Linux, macOS and Windows. + +## Open items + +1. **Publishing.** Convex's `externalPackages` installs from npm at deploy + time, so `@trustgraph/trustgraph` must be published. Decide whether the + `@trustgraph` scope is public; if it must be private, first test that Convex + can install from a private registry. Then add the napi-rs cross-compile and + publish matrix to the release workflow. +2. **A real Convex spike.** The numbers above come from Node. Before relying on + it, deploy a small query that imports `@trustgraph/trustgraph-wasm` and + runs `lens`, and confirm the bundle size and cold-start time in Convex + itself. diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1026a1 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "trustgraph-monorepo", + "private": true, + "description": "Trust Graph monorepo: Rust crates in crates/, TypeScript packages in packages/ (pnpm workspace)", + "license": "Apache-2.0", + "packageManager": "pnpm@12.9.1", + "engines": { + "node": ">= 24" + }, + "scripts": { + "build:node": "pnpm --filter @trustgraph/trustgraph build", + "test:node": "pnpm --filter @trustgraph/trustgraph test", + "typecheck": "tsc --noEmit --strict --lib es2022,dom tests/js/types.ts" + }, + "devDependencies": { + "typescript": "^5.9.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..64bfc11 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1373 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.9.1 + version: 12.9.1 + +packages: + + '@pnpm/exe.android-arm64@12.9.1': + resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.9.1': + resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.9.1': + resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.9.1': + resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.9.1': + resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.9.1': + resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.9.1': + resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.9.1': + resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.9.1': + resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.9.1': + resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.9.1': + resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.9.1': + resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.9.1': + resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.9.1': + resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} + cpu: [x64] + os: [win32] + + pnpm@12.9.1: + resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.9.1': + optional: true + + '@pnpm/exe.android-x64@12.9.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.9.1': + optional: true + + '@pnpm/exe.darwin-x64@12.9.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.9.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.9.1': + optional: true + + '@pnpm/exe.linux-arm64@12.9.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.9.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.9.1': + optional: true + + '@pnpm/exe.linux-s390x@12.9.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.9.1': + optional: true + + '@pnpm/exe.linux-x64@12.9.1': + optional: true + + '@pnpm/exe.win32-arm64@12.9.1': + optional: true + + '@pnpm/exe.win32-x64@12.9.1': + optional: true + + pnpm@12.9.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.9.1 + '@pnpm/exe.android-x64': 12.9.1 + '@pnpm/exe.darwin-arm64': 12.9.1 + '@pnpm/exe.darwin-x64': 12.9.1 + '@pnpm/exe.freebsd-x64': 12.9.1 + '@pnpm/exe.linux-arm64': 12.9.1 + '@pnpm/exe.linux-arm64-musl': 12.9.1 + '@pnpm/exe.linux-ppc64': 12.9.1 + '@pnpm/exe.linux-riscv64': 12.9.1 + '@pnpm/exe.linux-s390x': 12.9.1 + '@pnpm/exe.linux-x64': 12.9.1 + '@pnpm/exe.linux-x64-musl': 12.9.1 + '@pnpm/exe.win32-arm64': 12.9.1 + '@pnpm/exe.win32-x64': 12.9.1 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + crates/trustgraph-node: + devDependencies: + '@napi-rs/cli': + specifier: ^3.0.0 + version: 3.10.7(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + +packages: + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@inquirer/ansi@2.0.9': + resolution: {integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.6': + resolution: {integrity: sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.3.3': + resolution: {integrity: sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@12.0.4': + resolution: {integrity: sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.3.4': + resolution: {integrity: sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.6': + resolution: {integrity: sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.6': + resolution: {integrity: sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.7': + resolution: {integrity: sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.2.4': + resolution: {integrity: sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.2.3': + resolution: {integrity: sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.7.3': + resolution: {integrity: sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.6': + resolution: {integrity: sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.3.4': + resolution: {integrity: sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.6': + resolution: {integrity: sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.2.0': + resolution: {integrity: sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@napi-rs/cli@3.10.7': + resolution: {integrity: sha512-wTocvxyQeqqvKVOzocHMUs2X27Nc2EcRnpH0ojK8uSnzUAYSehfGSYgRheZNcU1ltY2yqJ/R18Zt2YbKXqYMmw==} + engines: {node: ^20.17.0 || ^22.13.0 || >= 23.5.0} + hasBin: true + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + emnapi: ^1.7.1 || ^2.0.0-alpha.4 + peerDependenciesMeta: + '@emnapi/core': + optional: true + '@emnapi/runtime': + optional: true + emnapi: + optional: true + + '@napi-rs/cross-toolchain@1.0.3': + resolution: {integrity: sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==} + peerDependencies: + '@napi-rs/cross-toolchain-arm64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-x86_64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-x86_64': ^1.0.3 + peerDependenciesMeta: + '@napi-rs/cross-toolchain-arm64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-arm64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-arm64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-arm64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-arm64-target-x86_64': + optional: true + '@napi-rs/cross-toolchain-x64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-x64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-x64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-x64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-x64-target-x86_64': + optional: true + + '@napi-rs/lzma-android-arm-eabi@1.5.1': + resolution: {integrity: sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.5.1': + resolution: {integrity: sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.5.1': + resolution: {integrity: sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.5.1': + resolution: {integrity: sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.5.1': + resolution: {integrity: sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.5.1': + resolution: {integrity: sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.5.1': + resolution: {integrity: sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-arm64-musl@1.5.1': + resolution: {integrity: sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-linux-ppc64-gnu@1.5.1': + resolution: {integrity: sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-riscv64-gnu@1.5.1': + resolution: {integrity: sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-s390x-gnu@1.5.1': + resolution: {integrity: sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-musl@1.5.1': + resolution: {integrity: sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-wasm32-wasi@1.5.1': + resolution: {integrity: sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.5.1': + resolution: {integrity: sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.5.1': + resolution: {integrity: sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.5.1': + resolution: {integrity: sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.5.1': + resolution: {integrity: sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==} + engines: {node: ^22.20 || ^24.12 || >=25} + + '@napi-rs/tar-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/tar-android-arm64@1.1.1': + resolution: {integrity: sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/tar-darwin-arm64@1.1.1': + resolution: {integrity: sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/tar-darwin-x64@1.1.1': + resolution: {integrity: sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/tar-freebsd-x64@1.1.1': + resolution: {integrity: sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/tar-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-wasm32-wasi@1.1.1': + resolution: {integrity: sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/tar-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/tar-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/tar-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/tar@1.1.1': + resolution: {integrity: sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@napi-rs/wasm-tools-android-arm-eabi@1.1.0': + resolution: {integrity: sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==} + engines: {node: '>= 12.22.0'} + cpu: [arm] + os: [android] + + '@napi-rs/wasm-tools-android-arm64@1.1.0': + resolution: {integrity: sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==} + engines: {node: '>= 12.22.0'} + cpu: [arm64] + os: [android] + + '@napi-rs/wasm-tools-darwin-arm64@1.1.0': + resolution: {integrity: sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==} + engines: {node: '>= 12.22.0'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/wasm-tools-darwin-x64@1.1.0': + resolution: {integrity: sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==} + engines: {node: '>= 12.22.0'} + cpu: [x64] + os: [darwin] + + '@napi-rs/wasm-tools-freebsd-x64@1.1.0': + resolution: {integrity: sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==} + engines: {node: '>= 12.22.0'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.1.0': + resolution: {integrity: sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==} + engines: {node: '>= 12.22.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-arm64-musl@1.1.0': + resolution: {integrity: sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==} + engines: {node: '>= 12.22.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-linux-x64-gnu@1.1.0': + resolution: {integrity: sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg==} + engines: {node: '>= 12.22.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-x64-musl@1.1.0': + resolution: {integrity: sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA==} + engines: {node: '>= 12.22.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-wasm32-wasi@1.1.0': + resolution: {integrity: sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.1.0': + resolution: {integrity: sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==} + engines: {node: '>= 12.22.0'} + cpu: [arm64] + os: [win32] + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.1.0': + resolution: {integrity: sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==} + engines: {node: '>= 12.22.0'} + cpu: [ia32] + os: [win32] + + '@napi-rs/wasm-tools-win32-x64-msvc@1.1.0': + resolution: {integrity: sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==} + engines: {node: '>= 12.22.0'} + cpu: [x64] + os: [win32] + + '@napi-rs/wasm-tools@1.1.0': + resolution: {integrity: sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w==} + engines: {node: '>= 12.22.0'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.8': + resolution: {integrity: sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.5': + resolution: {integrity: sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.5': + resolution: {integrity: sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/openapi-types@29.0.1': + resolution: {integrity: sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.2': + resolution: {integrity: sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.16': + resolution: {integrity: sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@octokit/types@18.0.0': + resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipanion@4.0.0-rc.4: + resolution: {integrity: sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==} + peerDependencies: + typanion: '*' + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} + engines: {node: '>=22'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + hasBin: true + + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + +snapshots: + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@inquirer/ansi@2.0.9': {} + + '@inquirer/checkbox@5.2.6': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0 + + '@inquirer/confirm@6.3.3': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/type': 4.2.0 + + '@inquirer/core@12.0.4': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0 + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + + '@inquirer/editor@5.3.4': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/external-editor': 3.0.6 + '@inquirer/type': 4.2.0 + + '@inquirer/expand@5.1.6': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/type': 4.2.0 + + '@inquirer/external-editor@3.0.6': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + + '@inquirer/figures@2.0.9': {} + + '@inquirer/input@5.1.7': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/type': 4.2.0 + + '@inquirer/number@4.2.4': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/type': 4.2.0 + + '@inquirer/password@5.2.3': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4 + '@inquirer/type': 4.2.0 + + '@inquirer/prompts@8.7.3': + dependencies: + '@inquirer/checkbox': 5.2.6 + '@inquirer/confirm': 6.3.3 + '@inquirer/editor': 5.3.4 + '@inquirer/expand': 5.1.6 + '@inquirer/input': 5.1.7 + '@inquirer/number': 4.2.4 + '@inquirer/password': 5.2.3 + '@inquirer/rawlist': 5.3.6 + '@inquirer/search': 4.3.4 + '@inquirer/select': 5.2.6 + + '@inquirer/rawlist@5.3.6': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/type': 4.2.0 + + '@inquirer/search@4.3.4': + dependencies: + '@inquirer/core': 12.0.4 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0 + + '@inquirer/select@5.2.6': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0 + + '@inquirer/type@4.2.0': {} + + '@napi-rs/cli@3.10.7(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@inquirer/prompts': 8.7.3 + '@napi-rs/cross-toolchain': 1.0.3 + '@napi-rs/wasm-tools': 1.1.0 + '@octokit/rest': 22.0.1 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + colorette: 2.0.20 + es-toolkit: 1.52.0 + js-yaml: 5.4.2 + obug: 3.0.0 + semver: 7.8.5 + typanion: 3.14.0 + typescript: 6.0.3 + optionalDependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + transitivePeerDependencies: + - '@napi-rs/cross-toolchain-arm64-target-aarch64' + - '@napi-rs/cross-toolchain-arm64-target-armv7' + - '@napi-rs/cross-toolchain-arm64-target-ppc64le' + - '@napi-rs/cross-toolchain-arm64-target-s390x' + - '@napi-rs/cross-toolchain-arm64-target-x86_64' + - '@napi-rs/cross-toolchain-x64-target-aarch64' + - '@napi-rs/cross-toolchain-x64-target-armv7' + - '@napi-rs/cross-toolchain-x64-target-ppc64le' + - '@napi-rs/cross-toolchain-x64-target-s390x' + - '@napi-rs/cross-toolchain-x64-target-x86_64' + - '@types/node' + - supports-color + + '@napi-rs/cross-toolchain@1.0.3': + dependencies: + '@napi-rs/lzma': 1.5.1 + '@napi-rs/tar': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/lzma-android-arm-eabi@1.5.1': + optional: true + + '@napi-rs/lzma-android-arm64@1.5.1': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.5.1': + optional: true + + '@napi-rs/lzma-darwin-x64@1.5.1': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.5.1': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.5.1': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.5.1': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.5.1': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.5.1': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.5.1': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.5.1': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.5.1': + optional: true + + '@napi-rs/lzma@1.5.1': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.5.1 + '@napi-rs/lzma-android-arm64': 1.5.1 + '@napi-rs/lzma-darwin-arm64': 1.5.1 + '@napi-rs/lzma-darwin-x64': 1.5.1 + '@napi-rs/lzma-freebsd-x64': 1.5.1 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.5.1 + '@napi-rs/lzma-linux-arm64-gnu': 1.5.1 + '@napi-rs/lzma-linux-arm64-musl': 1.5.1 + '@napi-rs/lzma-linux-ppc64-gnu': 1.5.1 + '@napi-rs/lzma-linux-riscv64-gnu': 1.5.1 + '@napi-rs/lzma-linux-s390x-gnu': 1.5.1 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@napi-rs/lzma-linux-x64-musl': 1.5.1 + '@napi-rs/lzma-wasm32-wasi': 1.5.1 + '@napi-rs/lzma-win32-arm64-msvc': 1.5.1 + '@napi-rs/lzma-win32-ia32-msvc': 1.5.1 + '@napi-rs/lzma-win32-x64-msvc': 1.5.1 + + '@napi-rs/tar-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/tar-android-arm64@1.1.1': + optional: true + + '@napi-rs/tar-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/tar-darwin-x64@1.1.1': + optional: true + + '@napi-rs/tar-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/tar-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/tar-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/tar-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/tar-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/tar-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/tar-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/tar-wasm32-wasi@1.1.1': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@napi-rs/tar-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/tar-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/tar-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/tar@1.1.1': + optionalDependencies: + '@napi-rs/tar-android-arm-eabi': 1.1.1 + '@napi-rs/tar-android-arm64': 1.1.1 + '@napi-rs/tar-darwin-arm64': 1.1.1 + '@napi-rs/tar-darwin-x64': 1.1.1 + '@napi-rs/tar-freebsd-x64': 1.1.1 + '@napi-rs/tar-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/tar-linux-arm64-gnu': 1.1.1 + '@napi-rs/tar-linux-arm64-musl': 1.1.1 + '@napi-rs/tar-linux-ppc64-gnu': 1.1.1 + '@napi-rs/tar-linux-s390x-gnu': 1.1.1 + '@napi-rs/tar-linux-x64-gnu': 1.1.1 + '@napi-rs/tar-linux-x64-musl': 1.1.1 + '@napi-rs/tar-wasm32-wasi': 1.1.1 + '@napi-rs/tar-win32-arm64-msvc': 1.1.1 + '@napi-rs/tar-win32-ia32-msvc': 1.1.1 + '@napi-rs/tar-win32-x64-msvc': 1.1.1 + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-tools-android-arm-eabi@1.1.0': + optional: true + + '@napi-rs/wasm-tools-android-arm64@1.1.0': + optional: true + + '@napi-rs/wasm-tools-darwin-arm64@1.1.0': + optional: true + + '@napi-rs/wasm-tools-darwin-x64@1.1.0': + optional: true + + '@napi-rs/wasm-tools-freebsd-x64@1.1.0': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.1.0': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-musl@1.1.0': + optional: true + + '@napi-rs/wasm-tools-linux-x64-gnu@1.1.0': + optional: true + + '@napi-rs/wasm-tools-linux-x64-musl@1.1.0': + optional: true + + '@napi-rs/wasm-tools-wasm32-wasi@1.1.0': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.1.0': + optional: true + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.1.0': + optional: true + + '@napi-rs/wasm-tools-win32-x64-msvc@1.1.0': + optional: true + + '@napi-rs/wasm-tools@1.1.0': + optionalDependencies: + '@napi-rs/wasm-tools-android-arm-eabi': 1.1.0 + '@napi-rs/wasm-tools-android-arm64': 1.1.0 + '@napi-rs/wasm-tools-darwin-arm64': 1.1.0 + '@napi-rs/wasm-tools-darwin-x64': 1.1.0 + '@napi-rs/wasm-tools-freebsd-x64': 1.1.0 + '@napi-rs/wasm-tools-linux-arm64-gnu': 1.1.0 + '@napi-rs/wasm-tools-linux-arm64-musl': 1.1.0 + '@napi-rs/wasm-tools-linux-x64-gnu': 1.1.0 + '@napi-rs/wasm-tools-linux-x64-musl': 1.1.0 + '@napi-rs/wasm-tools-wasm32-wasi': 1.1.0 + '@napi-rs/wasm-tools-win32-arm64-msvc': 1.1.0 + '@napi-rs/wasm-tools-win32-ia32-msvc': 1.1.0 + '@napi-rs/wasm-tools-win32-x64-msvc': 1.1.0 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.8': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.5 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.5': + dependencies: + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.5': + dependencies: + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/openapi-types@29.0.1': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.2': + dependencies: + '@octokit/types': 18.0.0 + + '@octokit/request@10.0.16': + dependencies: + '@octokit/endpoint': 11.0.5 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + content-type: 3.1.1 + json-with-bigint: 3.5.12 + universal-user-agent: 7.0.3 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.8) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.8) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@octokit/types@18.0.0': + dependencies: + '@octokit/openapi-types': 29.0.1 + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + argparse@2.0.1: {} + + before-after-hook@4.0.0: {} + + chardet@2.2.0: {} + + cli-width@4.1.0: {} + + clipanion@4.0.0-rc.4(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + + colorette@2.0.20: {} + + content-type@3.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + es-toolkit@1.52.0: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + js-yaml@5.4.2: + dependencies: + argparse: 2.0.1 + + json-with-bigint@3.5.12: {} + + ms@2.1.3: {} + + mute-stream@3.0.0: {} + + obug@3.0.0: {} + + safer-buffer@2.1.2: {} + + semver@7.8.5: {} + + signal-exit@4.1.0: {} + + tslib@2.8.1: + optional: true + + typanion@3.14.0: {} + + typescript@5.9.3: {} + + typescript@6.0.3: {} + + universal-user-agent@7.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7ad3cdd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# Trust Graph monorepo: JavaScript and TypeScript packages. +# Rust crates are in the Cargo workspace (Cargo.toml, crates/*). +packages: + - crates/trustgraph-node + - packages/* diff --git a/scripts/build-wasm-package.sh b/scripts/build-wasm-package.sh new file mode 100755 index 0000000..cbd233d --- /dev/null +++ b/scripts/build-wasm-package.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Builds the @trustgraph/trustgraph-wasm npm package into target/npm/trustgraph-wasm. +# +# web/ ES module. Call `initSync({ module })` with the .wasm bytes, or +# `await init()` where `fetch` can load the .wasm (browsers, Workers). +# node/ CommonJS for Node; loads the .wasm itself. +# +# Needs: rustup target add wasm32-unknown-unknown, and wasm-bindgen-cli at +# the same version as the wasm-bindgen crate in Cargo.lock. +set -euo pipefail +cd "$(dirname "$0")/.." + +out=target/npm/trustgraph-wasm +wasm=target/wasm32-unknown-unknown/wasm/trustgraph_wasm.wasm + +cargo build --locked --profile wasm --target wasm32-unknown-unknown -p trustgraph-wasm +rm -rf "$out" +wasm-bindgen --target web --out-dir "$out/web" "$wasm" +wasm-bindgen --target nodejs --out-dir "$out/node" "$wasm" +# The package is "type": "module"; the Node build is CommonJS. +mv "$out/node/trustgraph_wasm.js" "$out/node/trustgraph_wasm.cjs" +cp crates/trustgraph-wasm/package.json bindings/trustgraph.d.ts "$out/" + +bytes=$(wc -c <"$out/web/trustgraph_wasm_bg.wasm") +gzipped=$(gzip -9 -c "$out/web/trustgraph_wasm_bg.wasm" | wc -c) +echo "built $out: $((bytes / 1024)) KiB wasm ($((gzipped / 1024)) KiB gzipped)" + +# Size budget: Convex bundles are capped at 32 MiB; stay far below so the +# module loads fast in queries and pages. +budget=$((1024 * 1024)) +if [ "$bytes" -gt "$budget" ]; then + echo "error: wasm is $bytes bytes, over the $budget byte budget" >&2 + exit 1 +fi diff --git a/scripts/check-core-purity.sh b/scripts/check-core-purity.sh new file mode 100755 index 0000000..2ed4a91 --- /dev/null +++ b/scripts/check-core-purity.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# The core does no I/O: no files, network, processes, environment, clock or +# OS randomness. That is what lets it run unchanged in WebAssembly (browsers, +# Workers, Convex queries) and stay deterministic. This check fails CI if +# that ever changes. +set -euo pipefail +cd "$(dirname "$0")/.." + +status=0 + +# 1. No I/O in the core's source. The one exception is src/random.rs, the +# opt-in `random` feature (off by default), which must stay feature-gated. +forbidden='std::fs|std::net|std::process|std::env|std::io::std(in|out|err)|std::thread|SystemTime|Instant::now|Timestamp::now|getrandom|thread_rng|OsRng|tokio|async fn' +# (Comment lines are ignored: docs may mention where randomness comes from.) +if grep -rnE "$forbidden" crates/trustgraph-core/src --exclude=random.rs | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//'; then + echo "error: trustgraph-core must not do I/O (matched: $forbidden)" >&2 + status=1 +fi + +if ! grep -q '^#\[cfg(feature = "random")\]$' crates/trustgraph-core/src/lib.rs; then + echo "error: the random module must stay behind #[cfg(feature = \"random\")]" >&2 + status=1 +fi + +# 2. No I/O-capable crates among its dependencies (default features). +deps=$(cargo tree --quiet --package trustgraph-core --edges normal --prefix none | awk '{print $1}' | sort -u) +for crate in getrandom rand rand_core tokio async-std mio reqwest hyper ureq libc; do + if grep -qx "$crate" <<<"$deps"; then + echo "error: trustgraph-core depends on \`$crate\`" >&2 + status=1 + fi +done + +[ "$status" -eq 0 ] && echo "trustgraph-core is pure: no I/O in source or dependencies" +exit "$status" diff --git a/tests/js/bench.mjs b/tests/js/bench.mjs new file mode 100644 index 0000000..0bec7dd --- /dev/null +++ b/tests/js/bench.mjs @@ -0,0 +1,47 @@ +// Timing spike for the JavaScript bindings: how long it takes to load the +// module, verify credentials and compute an Agent Lens, compared with the +// Convex query budget (1 s, 64 MiB). +// +// node tests/js/bench.mjs target/wasm-node/trustgraph_wasm.js +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const t0 = performance.now(); +const tg = createRequire(import.meta.url)(resolve(process.argv[2])); +const loadMs = performance.now() - t0; + +// A synthetic social graph: `agents` agents each rating `perAgent` others. +function graph(agents, perAgent) { + const atoms = []; + let seed = 42; + const rand = () => ((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31); + for (let a = 0; a < agents; a++) { + for (let i = 0; i < perAgent; i++) { + const t = Math.floor(rand() * agents); + if (t === a) continue; + atoms.push({ source: `agent${a}`, target: `agent${t}`, content: "sushi", value: (rand() * 2 - 1).toFixed(3) }); + } + } + return atoms; +} + +function time(fn, runs = 5) { + fn(); // warm up + const start = performance.now(); + for (let i = 0; i < runs; i++) fn(); + return (performance.now() - start) / runs; +} + +const rows = [["module load", `${loadMs.toFixed(1)} ms`]]; +for (const [agents, perAgent] of [[100, 10], [1000, 10], [5000, 20]]) { + const atoms = graph(agents, perAgent); + const ms = time(() => tg.lens(atoms, "agent0", { topic: "sushi" })); + rows.push([`lens, ${atoms.length} atoms`, `${ms.toFixed(1)} ms`]); +} +const key = tg.keypairFromSeed(new Uint8Array(32).fill(7)); +const signed = tg.signAtom({ source: key.did, target: "x", value: 1 }, key.secretKeyMultibase, "2026-01-01T00:00:00Z"); +rows.push(["verify one credential", `${time(() => tg.verify(signed), 200).toFixed(3)} ms`]); +rows.push(["heap used", `${(process.memoryUsage().heapUsed / 2 ** 20).toFixed(1)} MiB`]); + +for (const [what, value] of rows) console.log(`${what.padEnd(24)} ${value}`); diff --git a/tests/js/smoke.mjs b/tests/js/smoke.mjs new file mode 100644 index 0000000..5629705 --- /dev/null +++ b/tests/js/smoke.mjs @@ -0,0 +1,62 @@ +// Smoke test for the JavaScript bindings. The WebAssembly package and the +// native addon expose the same API, so this one test runs against all builds: +// +// node tests/js/smoke.mjs crates/trustgraph-node/index.js # napi-rs +// node tests/js/smoke.mjs target/npm/trustgraph-wasm/node/trustgraph_wasm.cjs # wasm, Node +// node tests/js/smoke.mjs target/npm/trustgraph-wasm/web/trustgraph_wasm.js # wasm, web/Workers +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const path = resolve(process.argv[2]); +let tg; +if (path.endsWith(".js") && path.includes("/web/")) { + // The web build: initialize from bytes, as a Worker or Convex query would (no fetch). + tg = await import(pathToFileURL(path).href); + tg.initSync({ module: readFileSync(path.replace(/\.js$/, "_bg.wasm")) }); +} else { + tg = createRequire(import.meta.url)(path); +} + +assert.match(tg.version(), /^\d+\.\d+\.\d+/); + +const alice = tg.generateKeypair(); +assert.notEqual(tg.generateKeypair().did, alice.did); +assert.deepEqual(tg.keypairFromSeed(new Uint8Array(32).fill(1)), tg.keypairFromSeed(new Uint8Array(32).fill(1))); +const bob = tg.keypairFromSeed(randomBytes(32)); +assert.match(alice.did, /^did:key:z6Mk/); +assert.throws(() => tg.keypairFromSeed(randomBytes(31)), /32 bytes/); + +const now = new Date().toISOString(); +const items = [ + tg.signAtom({ source: alice.did, target: bob.did, content: "sushi", value: 1 }, alice.secretKeyMultibase, now), + tg.signAtom({ source: bob.did, target: "https://sushi.example", content: "sushi", value: "0.8" }, bob.secretKeyMultibase, now), +]; +for (const credential of items) { + const result = tg.verify(credential); + assert.equal(result.valid, true); + assert.equal(result.id, tg.atomId(credential)); +} +const forged = structuredClone(items[1]); +forged.credentialSubject.value = "-1"; +assert.equal(tg.verify(forged).valid, false); + +const view = tg.lens(items, alice.did, { topic: "sushi" }); +assert.deepEqual(view.map((e) => e.target), [bob.did, "https://sushi.example"]); +assert.equal(view[1].score, 0.8); +assert.equal(view[1].confidence, 0.5); +assert.deepEqual(tg.lens(items, alice.did), tg.lens(items, alice.did, null)); +assert.throws(() => tg.lens(items, alice.did, { decay: 2 }), /decay/); + +const rollups = tg.rollup(items, alice.did, { topic: "sushi" }, now); +assert.equal(rollups.length, 2); +assert.equal(tg.verify(tg.signAtom(rollups[0], alice.secretKeyMultibase, now)).valid, true); + +assert.equal(tg.canonicalAtom({ target: "b", source: "a" }), '{"source":"a","target":"b"}'); +assert.equal(tg.toCredential({ source: "a", target: "b" }).type[1], "TrustAtomCredential"); +assert.throws(() => tg.parseAtom({ source: "a" }), /target/); + +console.log(`smoke test passed: ${process.argv[2]}`); diff --git a/tests/js/types.ts b/tests/js/types.ts new file mode 100644 index 0000000..04ff940 --- /dev/null +++ b/tests/js/types.ts @@ -0,0 +1,24 @@ +// Compile-only check that bindings/trustgraph.d.ts describes a usable API: +// npx -p typescript tsc --noEmit --strict tests/js/types.ts +import type * as TG from "../../bindings/trustgraph"; + +declare const tg: typeof TG; + +const me: TG.KeyInfo = tg.generateKeypair(); +const fromSeed: TG.KeyInfo = tg.keypairFromSeed(crypto.getRandomValues(new Uint8Array(32))); +const credential: TG.Credential = tg.signAtom( + { source: me.did, target: "https://sushi.example", content: "sushi", value: 0.9 }, + me.secretKeyMultibase, + new Date().toISOString(), +); +const check: TG.Verification = tg.verify(credential); +const atom: TG.TrustAtom | undefined = check.atom; +const view: TG.LensEntry[] = tg.lens([credential, { source: "a", target: "b", value: "1" }], me.did, { topic: "sushi" }); +const best: number = view[0]?.score ?? 0; +const rollups: TG.TrustAtom[] = tg.rollup([credential], me.did, null, new Date().toISOString()); +const id: string = tg.atomId(rollups[0]!); + +// @ts-expect-error depth must be a number +tg.lens([], me.did, { depth: "3" }); + +export { atom, best, id, fromSeed };