diff --git a/Cargo.lock b/Cargo.lock index 8f926d1..f324459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -47,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -94,6 +100,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -186,6 +198,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -278,6 +300,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -396,7 +427,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -449,7 +480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -464,6 +495,23 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -557,6 +605,22 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -679,6 +743,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "napi" version = "3.4.0" @@ -776,6 +850,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1007,6 +1087,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust_decimal" version = "1.43.0" @@ -1049,7 +1143,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1159,12 +1288,24 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1215,7 +1356,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1225,7 +1366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1305,6 +1446,7 @@ dependencies = [ "serde_json", "tempfile", "trustgraph-core", + "ureq", ] [[package]] @@ -1374,6 +1516,47 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1450,12 +1633,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1465,6 +1666,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" @@ -1506,6 +1771,12 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 494f1e1..ae0b6e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,8 @@ napi = { version = "3", default-features = false, features = ["napi6", "serde-js napi-derive = "3" napi-build = "2" thiserror = "2.0" +# Blocking HTTPS for `trust follow` / `trust pull` (rustls, no async runtime). +ureq = "3.4" assert_cmd = "2.2" hex = "0.4" diff --git a/README.md b/README.md index 5d4244d..4150c37 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ Everything rests on one pure Rust core, shipped as a command line tool outward with decreasing weight (the **Trust Cascade**). - **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. +- **Offline.** Nothing needs a server or a network connection. To share, + publish your atoms as static files on any web host and follow other + people's (see [Sharing](#sharing)). - **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). @@ -95,6 +97,10 @@ trust lens --topic sushi --rollup | trust sign | trust add | `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 | +| `trust publish [--out DIR] [--well-known] [--as KEY]` | Write your signed atoms as a static feed | +| `trust follow URL\|DOMAIN\|PATH` | Follow a feed: fetch it, verify every atom, store them | +| `trust pull [FEED]` | Fetch what's new from the feeds you follow (or one feed) | +| `trust following` / `trust unfollow FEED` | List or stop following feeds | | `trust info` | Show where keys and data live | | `trust completions SHELL` | Shell completions | @@ -103,6 +109,44 @@ or concatenated JSON. Add `--pretty` to any command for readable output. Keys and the store live in the platform data directory, or in `$TRUST_HOME`. `$TRUST_KEY` selects the key. +## Sharing + +Share your ratings by publishing them as a **feed**: two static files +(`index.json`, signed by you, and `atoms.ndjson`, your signed atoms) that any +web host can serve. Followers verify every signature before storing +anything, so the host doesn't need to be trusted. Details in +[doc/feeds.md](doc/feeds.md). + +With GitHub Pages, in a repository called `trust`: + +```sh +# Alice: publish, then push. The feed is at https://alice.github.io/trust/ +trust publish --out docs +git add docs && git commit -m "Update my trust feed" && git push +# (once: Settings → Pages → Deploy from a branch → main, /docs) + +# Bob: follow Alice, and check for updates whenever you like. +trust follow https://alice.github.io/trust/ +trust pull +trust lens --topic sushi # Alice's ratings now count, as far as Bob trusts her +``` + +With your own domain, publish to the site root with `--well-known`, and +people can follow the bare domain: + +```sh +trust publish --out site --well-known # site/.well-known/trust/{index.json,atoms.ndjson} +trust follow alice.example # → https://alice.example/.well-known/trust/index.json +``` + +Pulls are cheap: `trust` remembers each feed's `ETag`, so an unchanged feed +costs one `304 Not Modified`. It also pins each feed's owner and refuses to +go back to an older copy. A local directory or `file://` URL works in place +of a URL, which is handy for testing or for sharing over a synced folder. + +Feeds are signed by `did:key` identities today. Signing with a domain +(`did:web`) is planned. + ## Data model A Trust Atom: @@ -190,7 +234,7 @@ tg.lens([credential /* , …everyone else's atoms */], me.did, { topic: "sushi" | [`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), [roadmap](doc/plan/README.md), and the [original protocol text](doc/protocol/README.md) (historical) | +| [`doc/`](doc) | | [Architecture](doc/architecture.md), [feeds](doc/feeds.md), [roadmap](doc/plan/README.md), and the [original protocol text](doc/protocol/README.md) (historical) | | [`schema/`](schema) | JSON-LD | [`legacy-2017/`](schema/legacy-2017): the 2017 `TrustClaim` context (historical) | New projects go in this repo: diff --git a/bindings/trustgraph.d.ts b/bindings/trustgraph.d.ts index 80939b3..3ebbfe9 100644 --- a/bindings/trustgraph.d.ts +++ b/bindings/trustgraph.d.ts @@ -80,6 +80,39 @@ export interface LensEntry { raters: number; } +/** A feed's signed index (`index.json`). See doc/feeds.md. */ +export interface FeedIndex { + "@context": string[]; + type: "TrustFeedIndex"; + /** The owner's DID: it signs the index and issued every atom. */ + owner: string; + /** RFC 3339. */ + updated: string; + atoms: { + count: number; + /** SHA2-256 multihash (`Qm…`) of the exact bytes of `atoms.ndjson`. */ + digest: string; + }; + proof?: Credential["proof"]; +} + +/** A feed's two files: `index.json` (parsed) and `atoms.ndjson` (text). */ +export interface Feed { + index: FeedIndex; + atoms: string; +} + +export interface FeedVerification { + valid: boolean; + owner?: string; + updated?: string; + /** Content IDs, in file order. */ + ids?: string[]; + /** The verified atoms, in file order. */ + atoms?: TrustAtom[]; + error?: string; +} + /** Atoms and/or signed credentials. */ export type Item = TrustAtomInput | Credential; @@ -101,3 +134,7 @@ export function verify(credential: Credential): Verification; 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[]; +/** Builds a feed from credentials all signed by the key's owner. `updated` is RFC 3339. */ +export function buildFeed(credentials: Credential[], secretKeyMultibase: string, updated: string): Feed; +/** Verifies a feed: the index's signature, the digest of `atoms` (the exact text of `atoms.ndjson`), and every atom. */ +export function verifyFeed(index: FeedIndex, atoms: string): FeedVerification; diff --git a/crates/trustgraph-cli/Cargo.toml b/crates/trustgraph-cli/Cargo.toml index 4e3f921..ccd18ea 100644 --- a/crates/trustgraph-cli/Cargo.toml +++ b/crates/trustgraph-cli/Cargo.toml @@ -27,6 +27,7 @@ directories.workspace = true jiff = { workspace = true, features = ["std"] } serde.workspace = true serde_json.workspace = true +ureq.workspace = true [dev-dependencies] assert_cmd.workspace = true diff --git a/crates/trustgraph-cli/src/cli.rs b/crates/trustgraph-cli/src/cli.rs index c807f8a..15b7ac2 100644 --- a/crates/trustgraph-cli/src/cli.rs +++ b/crates/trustgraph-cli/src/cli.rs @@ -35,7 +35,9 @@ Examples: trust atom --target https://example.com/sushi-bar --content sushi --value 0.9 --sign | trust add trust query --topic sushi trust lens --topic sushi - trust lens --rollup | trust sign | trust add"; + trust lens --rollup | trust sign | trust add + trust publish --out site --well-known + trust follow alice.example && trust pull"; #[derive(Debug, Subcommand)] pub enum Command { @@ -68,6 +70,27 @@ pub enum Command { /// ratings of the agents they trust, cascading outward Lens(LensArgs), + /// Publish your signed atoms as a static feed that any web host can serve + Publish(PublishArgs), + + /// Follow a feed: fetch it, verify every atom, and add them to the store + Follow(FollowArgs), + + /// Stop following a feed (atoms already pulled stay in the store) + Unfollow { + /// The feed, as given to `trust follow` + feed: String, + }, + + /// List the feeds you follow + Following, + + /// Fetch new atoms from the feeds you follow, or from one feed + Pull { + /// A URL, domain, or local path [default: every feed you follow] + feed: Option, + }, + /// Show where keys and data are kept Info, @@ -160,6 +183,33 @@ pub struct AtomArgs { pub key: KeyArg, } +#[derive(Debug, Args)] +pub struct PublishArgs { + /// Directory to write the feed to + #[arg(long, short, default_value = "feed", value_name = "DIR")] + pub out: PathBuf, + + /// Write to DIR/.well-known/trust/, so `trust follow DOMAIN` finds it + /// when DIR is the root of the site + #[arg(long)] + pub well_known: bool, + + /// Name of the key whose atoms to publish + #[arg(long, visible_alias = "as", env = "TRUST_KEY", default_value = "default", value_parser = parse_key_name)] + pub key: String, +} + +#[derive(Debug, Args)] +pub struct FollowArgs { + /// The feed: an https:// URL, a bare domain (looks in + /// /.well-known/trust/), or a local path or file:// URL + pub feed: String, + + /// Only add the feed to the list; don't pull it now + #[arg(long)] + pub no_pull: bool, +} + #[derive(Debug, Args)] pub struct InputArgs { /// JSON or NDJSON input file; `-` or nothing reads stdin diff --git a/crates/trustgraph-cli/src/commands.rs b/crates/trustgraph-cli/src/commands.rs index e364139..90d6f67 100644 --- a/crates/trustgraph-cli/src/commands.rs +++ b/crates/trustgraph-cli/src/commands.rs @@ -1,17 +1,24 @@ //! What each subcommand does. +use std::fs; use std::io::{self, Read, Write}; +use std::path::Path; use anyhow::{Context, Result, bail}; use clap::CommandFactory; use jiff::{Timestamp, Unit}; use serde_json::{Value as Json, json}; -use trustgraph_core::api; use trustgraph_core::{Keypair, LensOptions, Query, Record, TrustAtom, TrustGraph, credential}; +use trustgraph_core::{api, feed}; -use crate::cli::{AtomArgs, Cli, Command, ConvertArgs, Format, InputArgs, KeyCommand, LensArgs, QueryArgs, SignArgs}; +use crate::cli::{ + AtomArgs, Cli, Command, ConvertArgs, FollowArgs, Format, InputArgs, KeyCommand, LensArgs, PublishArgs, QueryArgs, + SignArgs, +}; +use crate::feeds::{self, Fetch, Followed, Following, Location}; use crate::home::Home; use crate::io::{Output, read_json}; +use crate::store::Store; /// Whether the command succeeded. `Failed` means the command ran, but its /// answer was "no" (e.g. a signature did not verify). @@ -33,12 +40,18 @@ pub fn run(cli: Cli, out: &mut Output) -> Result { Command::Add(args) => add(&home, &args, out), Command::Query(args) => query(&home, args, out), Command::Lens(args) => lens(&home, args, out), + Command::Publish(args) => publish(&home, &args, out), + Command::Follow(args) => follow(&home, &args, out), + Command::Unfollow { feed } => unfollow(&home, &feed, out), + Command::Following => following(&home, out), + Command::Pull { feed } => pull(&home, feed.as_deref(), out), Command::Info => { out.json(&json!({ "version": env!("CARGO_PKG_VERSION"), "home": home.dir(), "keys": home.keys_dir(), "store": home.store_path(), + "following": home.following_path(), }))?; Ok(Outcome::Success) } @@ -224,3 +237,165 @@ fn lens(home: &Home, args: LensArgs, out: &mut Output) -> Result(home: &Home, args: &PublishArgs, out: &mut Output) -> Result { + let keypair = home.load_key(&args.key)?; + let did = keypair.did(); + let store = home.open_store()?; + let mine = Query { source: Some(did.to_string()), ..Query::default() }; + let (mut credentials, mut unsigned) = (Vec::new(), 0); + for record in store.query(&mine) { + match &record.credential { + Some(credential) => credentials.push(credential.clone()), + None => unsigned += 1, + } + } + if unsigned > 0 { + eprintln!("note: skipped {unsigned} unsigned atom(s); `trust sign` them and `trust add` them to publish"); + } + let feed = feed::build(&credentials, &keypair, now())?; + + let dir = if args.well_known { args.out.join(feed::WELL_KNOWN_DIR) } else { args.out.clone() }; + fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + if args.well_known { + // GitHub Pages (Jekyll) skips dot-directories such as .well-known unless the site has a .nojekyll file. + let nojekyll = args.out.join(".nojekyll"); + if !nojekyll.exists() { + fs::write(&nojekyll, "").with_context(|| format!("writing {}", nojekyll.display()))?; + } + } + // Atoms first, then the index that vouches for them. + let (atoms_path, index_path) = (dir.join(feed::ATOMS_FILE), dir.join(feed::INDEX_FILE)); + write_replacing(&atoms_path, feed.atoms.as_bytes())?; + let mut index = serde_json::to_string_pretty(&feed.index)?; + index.push('\n'); + write_replacing(&index_path, index.as_bytes())?; + + out.json(&json!({ + "owner": did, + "count": feed.index["atoms"]["count"], + "digest": feed.index["atoms"]["digest"], + "index": index_path, + "atoms": atoms_path, + }))?; + Ok(Outcome::Success) +} + +/// Writes a file via a temporary file and a rename, so readers never see +/// half of it. +fn write_replacing(path: &Path, bytes: &[u8]) -> Result<()> { + let tmp = path.with_extension("tmp"); + fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display())) +} + +fn follow(home: &Home, args: &FollowArgs, out: &mut Output) -> Result { + let location = Location::parse(&args.feed)?; + let path = home.following_path(); + let mut following = Following::load(&path)?; + let mut entry = Followed { feed: location.name(), ..Followed::default() }; + if following.get(&entry.feed).is_some() { + bail!("already following {}; use `trust pull` to update it", entry.feed); + } + if args.no_pull { + out.json(&json!({ "feed": entry.feed, "following": true }))?; + } else { + // Only follow feeds that verify. + let report = pull_one(&location, &mut entry, &mut home.open_store()?)?; + out.json(&report)?; + } + following.feeds.push(entry); + following.save(&path)?; + Ok(Outcome::Success) +} + +fn unfollow(home: &Home, feed: &str, out: &mut Output) -> Result { + let path = home.following_path(); + let mut following = Following::load(&path)?; + // Match what was typed, or what it resolves to. + let resolved = Location::parse(feed).map(|l| l.name()).ok(); + let before = following.feeds.len(); + following.feeds.retain(|f| f.feed != feed && Some(&f.feed) != resolved.as_ref()); + if following.feeds.len() == before { + bail!("not following {feed}; see `trust following`"); + } + following.save(&path)?; + out.json(&json!({ "feed": resolved.as_deref().unwrap_or(feed), "following": false }))?; + Ok(Outcome::Success) +} + +fn following(home: &Home, out: &mut Output) -> Result { + for feed in Following::load(&home.following_path())?.feeds { + out.json(&feed)?; + } + Ok(Outcome::Success) +} + +fn pull(home: &Home, feed: Option<&str>, out: &mut Output) -> Result { + let path = home.following_path(); + let mut following = Following::load(&path)?; + let mut store = home.open_store()?; + if let Some(feed) = feed { + let location = Location::parse(feed)?; + let report = if let Some(entry) = following.get_mut(&location.name()) { + let report = pull_one(&location, entry, &mut store); + following.save(&path)?; + report? + } else { + // A one-off pull of a feed you don't follow: nothing is remembered. + pull_one(&location, &mut Followed { feed: location.name(), ..Followed::default() }, &mut store)? + }; + out.json(&report)?; + return Ok(Outcome::Success); + } + + let mut outcome = Outcome::Success; + for entry in &mut following.feeds { + match Location::parse(&entry.feed).and_then(|location| pull_one(&location, entry, &mut store)) { + Ok(report) => out.json(&report)?, + Err(err) => { + outcome = Outcome::Failed; + eprintln!("error: {}: {err:#}", entry.feed); + out.json(&json!({ "feed": entry.feed, "error": format!("{err:#}") }))?; + } + } + } + following.save(&path)?; + Ok(outcome) +} + +/// Fetches one feed, verifies all of it, and adds its atoms to the store. +/// Updates `entry` only if everything verified. +fn pull_one(location: &Location, entry: &mut Followed, store: &mut Store) -> Result { + let unchanged = |entry: &Followed| json!({ "feed": entry.feed, "owner": entry.owner, "status": "unchanged", "atoms": 0, "added": 0 }); + let fetched = match feeds::fetch(location, Some(entry))? { + Fetch::NotModified => return Ok(unchanged(entry)), + Fetch::Fetched(fetched) => fetched, + }; + let Some(atoms) = fetched.atoms else { + // Same digest as last time: nothing new to verify or store. + entry.etag = fetched.etag; + return Ok(unchanged(entry)); + }; + let verified = feed::verify(&fetched.index, &atoms)?; + let owner = verified.owner.to_string(); + if let Some(pinned) = entry.owner.as_deref().filter(|pinned| *pinned != owner) { + bail!("feed is now signed by {owner}, but it belonged to {pinned}; unfollow and follow it again to accept"); + } + if let Some(last) = entry.updated.filter(|last| verified.index.updated < *last) { + bail!("feed was updated {}, before the copy already pulled ({last})", verified.index.updated); + } + let count = verified.records.len(); + let mut added = 0; + for record in verified.records { + if store.add(record)? { + added += 1; + } + } + entry.owner = Some(owner); + entry.updated = Some(verified.index.updated); + entry.digest = Some(verified.index.atoms.digest.to_string()); + entry.etag = fetched.etag; + entry.pulled = Some(now()); + Ok(json!({ "feed": entry.feed, "owner": entry.owner, "status": "updated", "atoms": count, "added": added })) +} diff --git a/crates/trustgraph-cli/src/feeds.rs b/crates/trustgraph-cli/src/feeds.rs new file mode 100644 index 0000000..89082aa --- /dev/null +++ b/crates/trustgraph-cli/src/feeds.rs @@ -0,0 +1,322 @@ +//! Fetching feeds over HTTPS (or from local files), and remembering the +//! ones you follow. +//! +//! The feed format and its verification live in `trustgraph_core::feed`; +//! this module only finds and moves the bytes. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use trustgraph_core::feed::{ATOMS_FILE, INDEX_FILE, WELL_KNOWN_DIR}; + +/// The largest `index.json` we will read. +const INDEX_LIMIT: u64 = 1024 * 1024; +/// The largest `atoms.ndjson` we will read. +const ATOMS_LIMIT: u64 = 256 * 1024 * 1024; + +/// Where a feed's `index.json` lives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Location { + /// An `http://` or `https://` URL. + Web(String), + /// A file on disk. + Local(PathBuf), +} + +impl Location { + /// Resolves what the user typed into the location of a feed's index: + /// + /// - `https://host` (no path) → `https://host/.well-known/trust/index.json` + /// - `https://host/dir/` or `https://host/dir` → `…/dir/index.json` + /// - `https://host/…/name.json` → as is + /// - `file:///path` or an existing local path (a directory or an index file) + /// - `example.com` (a bare domain) → `https://example.com/.well-known/trust/index.json` + pub fn parse(input: &str) -> Result { + let input = input.trim(); + if let Some(path) = strip_prefix_ignore_case(input, "file://") { + return Self::local(Path::new(path)); + } + for scheme in ["https://", "http://"] { + if let Some(rest) = strip_prefix_ignore_case(input, scheme) { + return web(&format!("{scheme}{rest}"), scheme.len()); + } + } + if input.contains("://") { + bail!("`{input}`: only https://, http:// and file:// feeds are supported"); + } + let path = Path::new(input); + if path.exists() { + return Self::local(path); + } + let looks_like_domain = !input.is_empty() + && input.contains('.') + && !input.starts_with('.') + && input.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':')); + if looks_like_domain { + return Ok(Self::Web(format!("https://{input}/{WELL_KNOWN_DIR}/{INDEX_FILE}"))); + } + bail!("`{input}` is not a URL, a domain, or an existing file or directory") + } + + fn local(path: &Path) -> Result { + let path = fs::canonicalize(path).with_context(|| format!("{}: no such file or directory", path.display()))?; + if !path.is_dir() { + return Ok(Self::Local(path)); + } + let well_known = path.join(WELL_KNOWN_DIR).join(INDEX_FILE); + if !path.join(INDEX_FILE).exists() && well_known.exists() { + return Ok(Self::Local(well_known)); + } + Ok(Self::Local(path.join(INDEX_FILE))) + } + + /// The location's canonical name, as stored in `following.json`. + pub fn name(&self) -> String { + match self { + Self::Web(url) => url.clone(), + Self::Local(path) => format!("file://{}", path.display()), + } + } +} + +fn strip_prefix_ignore_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| &s[prefix.len()..]) +} + +fn web(url: &str, scheme_len: usize) -> Result { + let url = url.split(['#', '?']).next().unwrap_or_default(); + let (authority, path) = match url[scheme_len..].find('/') { + Some(slash) => url.split_at(scheme_len + slash), + None => (url, ""), + }; + if authority.len() == scheme_len { + bail!("`{url}` has no host"); + } + let index = if path.is_empty() || path == "/" { + format!("{authority}/{WELL_KNOWN_DIR}/{INDEX_FILE}") + } else if Path::new(path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { + url.to_owned() + } else { + format!("{authority}{}/{INDEX_FILE}", path.trim_end_matches('/')) + }; + Ok(Location::Web(index)) +} + +/// A feed's files, as fetched. +#[derive(Debug)] +pub struct Fetched { + /// The parsed `index.json`. + pub index: Json, + /// The exact text of `atoms.ndjson`, unless it was skipped because the + /// index's digest had not changed. + pub atoms: Option, + /// The index's `ETag`, if the server sent one. + pub etag: Option, +} + +/// What a fetch found. +#[derive(Debug)] +pub enum Fetch { + /// The server said the index has not changed (`304 Not Modified`). + NotModified, + /// New files. + Fetched(Fetched), +} + +/// Fetches a feed. `cached` is what the last pull saw: its `ETag` is sent as +/// `If-None-Match`, and if the index still has the same digest, the atoms +/// are not downloaded again. +pub fn fetch(location: &Location, cached: Option<&Followed>) -> Result { + match location { + Location::Local(path) => { + let index = read_index(&fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?) + .with_context(|| path.display().to_string())?; + let atoms_path = path.with_file_name(ATOMS_FILE); + let atoms = if unchanged(&index, cached) { + None + } else { + Some(fs::read_to_string(&atoms_path).with_context(|| format!("reading {}", atoms_path.display()))?) + }; + Ok(Fetch::Fetched(Fetched { index, atoms, etag: None })) + } + Location::Web(url) => { + let agent: ureq::Agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_connect(Some(Duration::from_secs(15))) + .timeout_global(Some(Duration::from_secs(300))) + .user_agent(concat!("trust/", env!("CARGO_PKG_VERSION"))) + .build() + .into(); + let etag = cached.and_then(|c| c.etag.as_deref()); + let Some((body, etag)) = get(&agent, url, etag, INDEX_LIMIT)? else { + return Ok(Fetch::NotModified); + }; + let index = read_index(&body).with_context(|| url.clone())?; + let atoms = if unchanged(&index, cached) { + None + } else { + let atoms_url = format!("{}/{ATOMS_FILE}", url.rsplit_once('/').map_or(url.as_str(), |(dir, _)| dir)); + let (atoms, _) = get(&agent, &atoms_url, None, ATOMS_LIMIT)? + .with_context(|| format!("{atoms_url}: unexpected 304 Not Modified"))?; + Some(atoms) + }; + Ok(Fetch::Fetched(Fetched { index, atoms, etag })) + } + } +} + +/// True if the index has the same digest as the last pull, so the atoms +/// need not be fetched again. +fn unchanged(index: &Json, cached: Option<&Followed>) -> bool { + let digest = index.pointer("/atoms/digest").and_then(Json::as_str); + digest.is_some() && cached.and_then(|c| c.digest.as_deref()) == digest +} + +fn read_index(text: &str) -> Result { + serde_json::from_str(text).context("index is not valid JSON") +} + +/// GETs `url`. Returns `None` on `304 Not Modified`. +fn get(agent: &ureq::Agent, url: &str, etag: Option<&str>, limit: u64) -> Result)>> { + let mut request = agent.get(url); + if let Some(etag) = etag { + request = request.header("If-None-Match", etag); + } + let mut response = request.call().with_context(|| format!("fetching {url}"))?; + match response.status().as_u16() { + 200 => {} + 304 if etag.is_some() => return Ok(None), + status => bail!("fetching {url}: HTTP {status}"), + } + let etag = response.headers().get("etag").and_then(|v| v.to_str().ok()).map(str::to_owned); + let body = + response.body_mut().with_config().limit(limit).read_to_string().with_context(|| format!("reading {url}"))?; + Ok(Some((body, etag))) +} + +/// A feed you follow, and what the last pull saw. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Followed { + /// The feed's index location (see [`Location::name`]). + pub feed: String, + /// The owner's DID, pinned on the first pull: a later index signed by + /// anyone else is rejected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// The `updated` time of the last pulled index. Older indexes are + /// rejected, so a feed can't be rolled back. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated: Option, + /// The digest of the last pulled `atoms.ndjson`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, + /// The index's `ETag`, sent back as `If-None-Match`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub etag: Option, + /// When the feed was last pulled successfully. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pulled: Option, +} + +/// The feeds you follow, kept in `following.json` in the home directory. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Following { + /// Followed feeds, in the order they were followed. + pub feeds: Vec, +} + +impl Following { + /// Loads the list. A missing file is an empty list. + pub fn load(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(json) => serde_json::from_str(&json).with_context(|| format!("parsing {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + /// Saves the list, replacing the file atomically. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let mut json = serde_json::to_string_pretty(self)?; + json.push('\n'); + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, json).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display())) + } + + pub fn get(&self, feed: &str) -> Option<&Followed> { + self.feeds.iter().find(|f| f.feed == feed) + } + + pub fn get_mut(&mut self, feed: &str) -> Option<&mut Followed> { + self.feeds.iter_mut().find(|f| f.feed == feed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn web_url(input: &str) -> String { + match Location::parse(input).unwrap() { + Location::Web(url) => url, + Location::Local(path) => panic!("{input} parsed as {}", path.display()), + } + } + + #[test] + fn resolves_urls_and_domains() { + let well_known = "https://example.com/.well-known/trust/index.json"; + assert_eq!(web_url("example.com"), well_known); + assert_eq!(web_url("https://example.com"), well_known); + assert_eq!(web_url("HTTPS://example.com/"), well_known); + assert_eq!(web_url("https://alice.github.io/trust"), "https://alice.github.io/trust/index.json"); + assert_eq!(web_url("https://alice.github.io/trust/"), "https://alice.github.io/trust/index.json"); + assert_eq!(web_url("https://h.example/f/feed.json?x=1"), "https://h.example/f/feed.json"); + assert_eq!(web_url("http://127.0.0.1:8080/feed"), "http://127.0.0.1:8080/feed/index.json"); + assert_eq!(web_url("localhost.test:8443"), "https://localhost.test:8443/.well-known/trust/index.json"); + for bad in ["ftp://example.com", "https://", "not a thing", "./missing", "nodots"] { + assert!(Location::parse(bad).is_err(), "{bad}"); + } + } + + #[test] + fn resolves_local_paths() { + let dir = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + assert_eq!(Location::parse(dir.path().to_str().unwrap()).unwrap(), Location::Local(root.join(INDEX_FILE))); + + let well_known = root.join(WELL_KNOWN_DIR); + fs::create_dir_all(&well_known).unwrap(); + fs::write(well_known.join(INDEX_FILE), "{}").unwrap(); + let found = Location::parse(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, Location::Local(well_known.join(INDEX_FILE))); + + let file_url = format!("file://{}", well_known.join(INDEX_FILE).display()); + assert_eq!(Location::parse(&file_url).unwrap(), found); + assert_eq!(Location::parse(&found.name()).unwrap(), found, "names round-trip"); + } + + #[test] + fn following_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("following.json"); + assert_eq!(Following::load(&path).unwrap().feeds.len(), 0); + let mut following = Following::default(); + following.feeds.push(Followed { feed: "https://a.example/index.json".into(), ..Followed::default() }); + following.save(&path).unwrap(); + let loaded = Following::load(&path).unwrap(); + assert_eq!(loaded.feeds, following.feeds); + assert!(loaded.get("https://a.example/index.json").is_some()); + } +} diff --git a/crates/trustgraph-cli/src/home.rs b/crates/trustgraph-cli/src/home.rs index 626bf37..ca089b7 100644 --- a/crates/trustgraph-cli/src/home.rs +++ b/crates/trustgraph-cli/src/home.rs @@ -51,6 +51,10 @@ impl Home { self.dir.join("atoms.ndjson") } + pub fn following_path(&self) -> PathBuf { + self.dir.join("following.json") + } + pub fn open_store(&self) -> Result { Store::open(self.store_path()) } diff --git a/crates/trustgraph-cli/src/main.rs b/crates/trustgraph-cli/src/main.rs index a7db5e8..3ee2453 100644 --- a/crates/trustgraph-cli/src/main.rs +++ b/crates/trustgraph-cli/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod commands; +mod feeds; mod home; mod io; mod store; diff --git a/crates/trustgraph-cli/tests/feeds.rs b/crates/trustgraph-cli/tests/feeds.rs new file mode 100644 index 0000000..028c66b --- /dev/null +++ b/crates/trustgraph-cli/tests/feeds.rs @@ -0,0 +1,424 @@ +//! End-to-end tests for sharing: `trust publish`, `follow`, `unfollow`, +//! `following` and `pull`, over local paths and a tiny in-process HTTP +//! server. No external network. + +#![allow(clippy::unwrap_used)] // Panicking is how tests fail. + +use std::collections::hash_map::DefaultHasher; +use std::fmt::Write as _; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::{Value as Json, json}; +use tempfile::TempDir; +use trustgraph_core::Keypair; +use trustgraph_core::feed::{self, FeedIndex}; + +/// One `trust` user, with their own home directory. +struct User { + home: TempDir, + did: String, +} + +impl User { + fn new() -> Self { + let mut user = Self { home: tempfile::tempdir().unwrap(), did: String::new() }; + user.json_lines(&["key", "new"])[0]["did"].as_str().unwrap().clone_into(&mut user.did); + user + } + + fn cmd(&self) -> Command { + let mut cmd = Command::cargo_bin("trust").unwrap(); + cmd.env("TRUST_HOME", self.home.path()).env_remove("TRUST_KEY"); + // Talk to the local test server directly, never through a proxy. + for var in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] { + cmd.env_remove(var); + } + cmd + } + + fn run(&self, args: &[&str]) -> String { + let out = self.cmd().args(args).write_stdin("").assert().success().get_output().stdout.clone(); + String::from_utf8(out).unwrap() + } + + fn json_lines(&self, args: &[&str]) -> Vec { + self.run(args).lines().map(|l| serde_json::from_str(l).unwrap()).collect() + } + + fn rate(&self, target: &str, value: &str) { + let signed = self.run(&["atom", "-t", target, "-c", "sushi", "-v", value, "--sign"]); + self.cmd().arg("add").write_stdin(signed).assert().success(); + } + + fn publish(&self, out: &Path, extra: &[&str]) -> Json { + let mut args = vec!["publish", "--out", out.to_str().unwrap()]; + args.extend_from_slice(extra); + self.json_lines(&args).remove(0) + } + + fn targets(&self) -> Vec { + self.json_lines(&["query"]).iter().map(|a| a["target"].as_str().unwrap().to_owned()).collect() + } + + fn keypair(&self) -> Keypair { + Keypair::from_secret_multibase(self.run(&["key", "export"]).trim()).unwrap() + } +} + +#[test] +fn publish_writes_a_verifiable_feed_of_your_own_signed_atoms() { + let alice = User::new(); + alice.rate("https://sushi.example", "0.9"); + alice.rate("https://ramen.example", "0.5"); + // Unsigned atoms and other people's atoms are not published. + let unsigned = alice.run(&["atom", "-t", "https://unsigned.example", "-v", "1"]); + alice.cmd().arg("add").write_stdin(unsigned).assert().success(); + let bob = User::new(); + let bobs = bob.run(&["atom", "-t", "https://bob.example", "-v", "1", "--sign"]); + alice.cmd().arg("add").write_stdin(bobs).assert().success(); + + let site = tempfile::tempdir().unwrap(); + let out = site.path().join("feed"); + let mut cmd = alice.cmd(); + cmd.args(["publish", "--out", out.to_str().unwrap()]) + .assert() + .success() + .stderr(predicate::str::contains("skipped 1 unsigned")); + let report = alice.publish(&out, &["--as", "default"]); + assert_eq!(report["owner"], alice.did.as_str()); + assert_eq!(report["count"], 2); + + let index: Json = serde_json::from_str(&fs::read_to_string(out.join("index.json")).unwrap()).unwrap(); + let atoms = fs::read_to_string(out.join("atoms.ndjson")).unwrap(); + assert_eq!(atoms.lines().count(), 2); + assert_eq!(index["type"], "TrustFeedIndex"); + assert_eq!(index["atoms"]["digest"], report["digest"]); + let verified = feed::verify(&index, &atoms).unwrap(); + assert_eq!(verified.owner.as_str(), alice.did); + // Every line verifies on its own, too. + assert_eq!(alice.run(&["verify", out.join("atoms.ndjson").to_str().unwrap()]).lines().count(), 2); + + // --well-known lays the feed out for the root of a site. + let report = alice.publish(site.path(), &["--well-known"]); + assert!(report["index"].as_str().unwrap().ends_with("index.json")); + assert!(site.path().join(".well-known/trust/index.json").is_file()); + assert!(site.path().join(".well-known/trust/atoms.ndjson").is_file()); + assert!(site.path().join(".nojekyll").is_file(), "GitHub Pages serves .well-known only without Jekyll"); + + let nobody = tempfile::tempdir().unwrap(); + Command::cargo_bin("trust") + .unwrap() + .env("TRUST_HOME", nobody.path()) + .env_remove("TRUST_KEY") + .args(["publish", "--out", out.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("trust key new")); +} + +#[test] +fn follow_pull_and_unfollow_a_local_feed() { + let (alice, bob) = (User::new(), User::new()); + alice.rate(&bob.did, "1"); + alice.rate("https://sushi.example", "0.9"); + let dir = tempfile::tempdir().unwrap(); + alice.publish(dir.path(), &[]); + + let followed = &bob.json_lines(&["follow", dir.path().to_str().unwrap()])[0]; + assert_eq!(followed["owner"], alice.did.as_str()); + assert_eq!( + (followed["status"].clone(), followed["atoms"].clone(), followed["added"].clone()), + (json!("updated"), json!(2), json!(2)) + ); + let feed_name = followed["feed"].as_str().unwrap().to_owned(); + assert!(feed_name.starts_with("file://") && feed_name.ends_with("index.json"), "{feed_name}"); + assert_eq!(bob.targets(), [bob.did.as_str(), "https://sushi.example"]); + + // Bob trusts Alice, so her sushi rating shows up in his lens. + bob.rate(&alice.did, "1"); + let lens = bob.json_lines(&["lens", "--topic", "sushi"]); + assert!(lens.iter().any(|e| e["target"] == "https://sushi.example"), "{lens:?}"); + + let listed = bob.json_lines(&["following"]); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0]["owner"], alice.did.as_str()); + assert!(listed[0]["digest"].as_str().unwrap().starts_with("Qm")); + bob.cmd() + .args(["follow", dir.path().to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("already following")); + + // Nothing new: the digest is unchanged, so the atoms are not even re-read. + assert_eq!(bob.json_lines(&["pull"])[0]["status"], "unchanged"); + + alice.rate("https://ramen.example", "0.4"); + alice.publish(dir.path(), &[]); + let pulled = &bob.json_lines(&["pull"])[0]; + assert_eq!((pulled["atoms"].clone(), pulled["added"].clone()), (json!(3), json!(1))); + assert!(bob.targets().contains(&"https://ramen.example".to_owned())); + + // Unfollow by the path as typed; pulled atoms stay. + assert_eq!(bob.json_lines(&["unfollow", dir.path().to_str().unwrap()])[0]["following"], false); + assert_eq!(bob.run(&["following"]), ""); + assert_eq!(bob.run(&["pull"]), "", "nothing to pull"); + assert_eq!(bob.targets().len(), 4); + bob.cmd().args(["unfollow", &feed_name]).assert().failure().stderr(predicate::str::contains("not following")); +} + +#[test] +fn follow_finds_well_known_feeds_and_supports_no_pull() { + let (alice, bob) = (User::new(), User::new()); + alice.rate("https://sushi.example", "0.9"); + let site = tempfile::tempdir().unwrap(); + alice.publish(site.path(), &["--well-known"]); + + let followed = &bob.json_lines(&["follow", "--no-pull", site.path().to_str().unwrap()])[0]; + assert_eq!(followed["following"], true); + // Local paths are shown with the platform's separators. + assert!(followed["feed"].as_str().unwrap().replace('\\', "/").ends_with(".well-known/trust/index.json")); + assert_eq!(bob.run(&["query"]), ""); + assert_eq!(bob.json_lines(&["pull"])[0]["added"], 1); + + // A one-off pull of a feed you don't follow stores atoms but remembers nothing. + let carol = User::new(); + assert_eq!(carol.json_lines(&["pull", site.path().to_str().unwrap()])[0]["added"], 1); + assert_eq!(carol.run(&["following"]), ""); +} + +/// Re-signs a feed's index with `key` after changing its atoms, so only +/// the atoms' own signatures can catch the change. +fn resign(dir: &Path, key: &Keypair, atoms: &str) { + let index: Json = serde_json::from_str(&fs::read_to_string(dir.join("index.json")).unwrap()).unwrap(); + let mut unsigned = index.clone(); + unsigned.as_object_mut().unwrap().remove("proof"); + let mut index: FeedIndex = serde_json::from_value(unsigned).unwrap(); + index.atoms.digest = feed::digest(atoms); + let signed = trustgraph_core::credential::sign(&serde_json::to_value(&index).unwrap(), key, index.updated).unwrap(); + fs::write(dir.join("index.json"), signed.to_string()).unwrap(); + fs::write(dir.join("atoms.ndjson"), atoms).unwrap(); +} + +#[test] +fn rejects_tampered_feeds_and_stores_nothing() { + let (alice, bob) = (User::new(), User::new()); + alice.rate("https://sushi.example", "0.9"); + alice.rate("https://ramen.example", "0.5"); + let dir = tempfile::tempdir().unwrap(); + alice.publish(dir.path(), &[]); + let path = dir.path().to_str().unwrap(); + let original = fs::read_to_string(dir.path().join("atoms.ndjson")).unwrap(); + + // atoms.ndjson changed after the index was signed: the digest no longer matches. + let tampered = original.replace("\"value\":\"0.5\"", "\"value\":\"-1\""); + assert_ne!(tampered, original); + fs::write(dir.path().join("atoms.ndjson"), &tampered).unwrap(); + bob.cmd().args(["follow", path]).assert().failure().stderr(predicate::str::contains("digest")); + + // A tampered atom under a correctly signed index: the atom's own proof fails. + resign(dir.path(), &alice.keypair(), &tampered); + bob.cmd() + .args(["follow", path]) + .assert() + .failure() + .stderr(predicate::str::contains("verification failed").and(predicate::str::contains("line 2"))); + + // Someone else's atom slipped into Alice's feed. + let mallory = User::new(); + let theirs = mallory.run(&["atom", "-t", "https://evil.example", "-v", "1", "--sign"]); + resign(dir.path(), &alice.keypair(), &format!("{original}{theirs}")); + bob.cmd().args(["follow", path]).assert().failure().stderr(predicate::str::contains("not the feed owner")); + + // All or nothing: no atoms stored, nothing followed. + assert_eq!(bob.run(&["query"]), ""); + assert_eq!(bob.run(&["following"]), ""); +} + +#[test] +fn pins_the_owner_and_refuses_rollbacks() { + let (alice, bob) = (User::new(), User::new()); + alice.rate("https://sushi.example", "0.9"); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_str().unwrap(); + alice.publish(dir.path(), &[]); + let old = (fs::read(dir.path().join("index.json")).unwrap(), fs::read(dir.path().join("atoms.ndjson")).unwrap()); + bob.run(&["follow", path]); + + // The newer feed is pulled; the older one can't be replayed. + thread::sleep(std::time::Duration::from_millis(1100)); // `updated` has one-second resolution + alice.rate("https://ramen.example", "0.5"); + alice.publish(dir.path(), &[]); + bob.run(&["pull"]); + fs::write(dir.path().join("index.json"), &old.0).unwrap(); + fs::write(dir.path().join("atoms.ndjson"), &old.1).unwrap(); + bob.cmd().arg("pull").assert().code(1).stderr(predicate::str::contains("before the copy already pulled")); + + // Someone else publishing at the same place is not Alice. + let mallory = User::new(); + mallory.rate("https://evil.example", "1"); + mallory.publish(dir.path(), &[]); + let out = bob + .cmd() + .arg("pull") + .assert() + .code(1) + .stderr(predicate::str::contains("belonged to")) + .get_output() + .stdout + .clone(); + let report: Json = serde_json::from_slice(&out).unwrap(); + assert!(report["error"].as_str().unwrap().contains(&alice.did)); + assert!(!bob.targets().contains(&"https://evil.example".to_owned())); +} + +#[test] +fn follow_rejects_unknown_locations() { + let bob = User::new(); + for (feed, message) in [ + ("ftp://example.com", "only https://"), + ("./no/such/dir", "not a URL"), + ("file:///no/such/trust/feed", "no such file"), + ] { + bob.cmd().args(["follow", feed]).assert().failure().stderr(predicate::str::contains(message)); + } +} + +/// A request the test server saw. +#[derive(Debug, Clone)] +struct Seen { + path: String, + if_none_match: Option, + status: u16, +} + +/// A minimal static HTTP server with `ETag` support, on a random local port. +struct Server { + url: String, + seen: Arc>>, +} + +impl Server { + fn serve(root: PathBuf) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = Arc::clone(&seen); + thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let path = request_line.split_whitespace().nth(1).unwrap_or("/").to_owned(); + let mut if_none_match = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line.trim().is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("if-none-match") { + if_none_match = Some(value.trim().to_owned()); + } + } + } + let (status, etag, body) = match fs::read(root.join(path.trim_start_matches('/'))) { + Ok(body) => { + let mut hasher = DefaultHasher::new(); + body.hash(&mut hasher); + let etag = format!("\"{:x}\"", hasher.finish()); + if if_none_match.as_deref() == Some(etag.as_str()) { + (304, Some(etag), Vec::new()) + } else { + (200, Some(etag), body) + } + } + Err(_) => (404, None, b"not found".to_vec()), + }; + log.lock().unwrap().push(Seen { path, if_none_match, status }); + let reason = match status { + 200 => "OK", + 304 => "Not Modified", + _ => "Not Found", + }; + let mut head = format!("HTTP/1.1 {status} {reason}\r\nConnection: close\r\n"); + if let Some(etag) = etag { + write!(head, "ETag: {etag}\r\n").unwrap(); + } + if status != 304 { + write!(head, "Content-Length: {}\r\n", body.len()).unwrap(); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + } + }); + Self { url, seen } + } + + fn take(&self) -> Vec { + std::mem::take(&mut *self.seen.lock().unwrap()) + } +} + +#[test] +fn follows_feeds_over_http_with_etags() { + let (alice, bob) = (User::new(), User::new()); + alice.rate("https://sushi.example", "0.9"); + let site = tempfile::tempdir().unwrap(); + alice.publish(site.path(), &["--well-known"]); + let server = Server::serve(site.path().to_path_buf()); + + // A bare origin means the well-known location. + let followed = &bob.json_lines(&["follow", &server.url])[0]; + assert_eq!(followed["feed"], format!("{}/.well-known/trust/index.json", server.url)); + assert_eq!(followed["added"], 1); + let seen = server.take(); + assert_eq!( + seen.iter().map(|s| (s.path.as_str(), s.status)).collect::>(), + [("/.well-known/trust/index.json", 200), ("/.well-known/trust/atoms.ndjson", 200)] + ); + assert!(seen[0].if_none_match.is_none()); + let etag = bob.json_lines(&["following"])[0]["etag"].as_str().unwrap().to_owned(); + + // Unchanged: one conditional request, answered 304. + assert_eq!(bob.json_lines(&["pull"])[0]["status"], "unchanged"); + let seen = server.take(); + assert_eq!(seen.len(), 1); + assert_eq!((seen[0].if_none_match.as_deref(), seen[0].status), (Some(etag.as_str()), 304)); + + // Republished: a new ETag, and the new atom. + alice.rate("https://ramen.example", "0.5"); + alice.publish(site.path(), &["--well-known"]); + let pulled = &bob.json_lines(&["pull", &server.url])[0]; + assert_eq!((pulled["status"].clone(), pulled["added"].clone()), (json!("updated"), json!(1))); + assert_eq!(server.take().iter().map(|s| s.status).collect::>(), [200, 200]); + let state = bob.json_lines(&["following"]).remove(0); + assert_ne!(state["etag"], etag.as_str()); + assert_eq!(bob.targets().len(), 2); + + // A new index whose atoms were tampered with in transit or on the host: + // rejected, nothing stored, and the cached state (ETag, digest) is kept. + alice.rate("https://udon.example", "0.7"); + alice.publish(site.path(), &["--well-known"]); + let atoms = site.path().join(".well-known/trust/atoms.ndjson"); + fs::write(&atoms, fs::read_to_string(&atoms).unwrap().replace("\"0.9\"", "\"0.1\"")).unwrap(); + bob.cmd().arg("pull").assert().code(1).stderr(predicate::str::contains("digest")); + assert_eq!(bob.json_lines(&["following"])[0], state); + assert_eq!(bob.targets().len(), 2); + + // Missing feeds are an HTTP error. + bob.cmd() + .args(["follow", &format!("{}/nobody/", server.url)]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 404")); +} diff --git a/crates/trustgraph-core/README.md b/crates/trustgraph-core/README.md index 75b1ad1..ecfba83 100644 --- a/crates/trustgraph-core/README.md +++ b/crates/trustgraph-core/README.md @@ -7,6 +7,7 @@ The [Trust Graph](https://trustgraph.net) protocol in pure Rust, with **no I/O** - `credential`: W3C Verifiable Credentials 2.0 with `eddsa-jcs-2022` proofs - `TrustGraph::lens`: the Agent Lens / Trust Cascade - `Record` / `Query`: verified atoms and filters +- `feed`: build and verify signed static feeds ([format](../../doc/feeds.md)) - `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 index ee4002f..31236fd 100644 --- a/crates/trustgraph-core/src/api.rs +++ b/crates/trustgraph-core/src/api.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value as Json; +use crate::feed::{self, Feed}; use crate::{Error, Keypair, LensEntry, LensOptions, Result, TrustAtom, TrustGraph, credential}; /// The core's version. @@ -118,7 +119,7 @@ pub fn sign_atom(atom: Json, secret_key_multibase: &str, created: &str) -> Resul } 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")))?; + let created = parse_time(created)?; credential::sign_atom(&atom, &keypair, created) } @@ -158,6 +159,71 @@ pub fn verify(credential: &Json) -> Verification { } } +/// Builds a feed (`{index, atoms}`: the contents of `index.json` and +/// `atoms.ndjson`) from signed credentials, all issued by the key, stamped +/// `updated` (RFC 3339). See [`feed::build`]. +/// +/// # Errors +/// +/// Fails if the key or time is invalid, or a credential does not verify or +/// was issued by someone else. +pub fn build_feed(credentials: &[Json], secret_key_multibase: &str, updated: &str) -> Result { + let keypair = Keypair::from_secret_multibase(secret_key_multibase)?; + feed::build(credentials, &keypair, parse_time(updated)?) +} + +/// The result of [`verify_feed`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedVerification { + /// Whether the whole feed verified: index, digest and every atom. + pub valid: bool, + /// The owner's DID, if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// When the feed was published (RFC 3339), if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated: Option, + /// The atoms' content IDs, in file order, if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ids: Option>, + /// The atoms, in file order, if valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub atoms: Option>, + /// Why verification failed, if it did. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Verifies a feed: `index` is the parsed `index.json`, `atoms` the exact +/// text of `atoms.ndjson`. Never fails: an invalid feed is reported in the +/// result, and is all-or-nothing (one bad atom invalidates the feed). +#[must_use] +pub fn verify_feed(index: &Json, atoms: &str) -> FeedVerification { + match feed::verify(index, atoms) { + Ok(verified) => FeedVerification { + valid: true, + owner: Some(verified.owner.to_string()), + updated: Some(verified.index.updated.to_string()), + ids: Some(verified.records.iter().map(|r| r.id.to_string()).collect()), + atoms: Some(verified.records.into_iter().map(|r| r.atom).collect()), + error: None, + }, + Err(err) => FeedVerification { + valid: false, + owner: None, + updated: None, + ids: None, + atoms: None, + error: Some(err.to_string()), + }, + } +} + +fn parse_time(at: &str) -> Result { + at.parse().map_err(|_| Error::InvalidInput(format!("`{at}` is not an RFC 3339 time"))) +} + /// Options for [`lens`] and [`rollup`]. Missing fields take their defaults. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -242,7 +308,7 @@ pub fn lens(items: Vec, root: &str, request: &LensRequest) -> Result, root: &str, request: &LensRequest, at: &str) -> Result> { - let at = at.parse().map_err(|_| Error::InvalidInput(format!("`{at}` is not an RFC 3339 time")))?; + let at = parse_time(at)?; let entries = lens(items, root, request)?; TrustGraph::rollup(root, &entries, &request.options()?, at) } @@ -360,6 +426,29 @@ mod tests { assert!(request.signed_only); } + #[test] + fn build_and_verify_feed() { + let items = vec![signed("https://a.example", "0.5"), signed("https://b.example", "-0.5")]; + let key = alice().secret_key_multibase; + let feed = build_feed(&items, &key, "2026-01-01T00:00:00Z").unwrap(); + let json = serde_json::to_value(&feed).unwrap(); + assert_eq!(json.as_object().unwrap().keys().collect::>(), ["index", "atoms"]); + + let result = verify_feed(&feed.index, &feed.atoms); + assert!(result.valid, "{result:?}"); + assert_eq!(result.owner.as_deref(), Some(alice().did.as_str())); + assert_eq!(result.updated.as_deref(), Some("2026-01-01T00:00:00Z")); + assert_eq!(result.ids.unwrap()[0], atom_id(items[0].clone()).unwrap()); + assert_eq!(result.atoms.unwrap()[1].target, "https://b.example"); + + let tampered = verify_feed(&feed.index, &feed.atoms.replace("-0.5", "0.5")); + assert!(!tampered.valid); + assert!(tampered.error.unwrap().contains("digest")); + assert!(build_feed(&items, &key, "soon").is_err()); + let bob = keypair_from_seed(&[2; 32]).unwrap(); + assert!(build_feed(&items, &bob.secret_key_multibase, "2026-01-01T00:00:00Z").is_err()); + } + #[test] fn everything_is_deterministic() { let items = vec![signed("https://a.example", "0.5"), signed("https://b.example", "-0.5")]; diff --git a/crates/trustgraph-core/src/error.rs b/crates/trustgraph-core/src/error.rs index 6d6caea..680d525 100644 --- a/crates/trustgraph-core/src/error.rs +++ b/crates/trustgraph-core/src/error.rs @@ -26,6 +26,10 @@ pub enum Error { #[error("invalid credential: {0}")] InvalidCredential(String), + /// A feed's index or atoms file is malformed. + #[error("invalid feed: {0}")] + InvalidFeed(String), + /// A credential is well formed but its proof does not verify. #[error("verification failed: {0}")] Verification(String), diff --git a/crates/trustgraph-core/src/feed.rs b/crates/trustgraph-core/src/feed.rs new file mode 100644 index 0000000..5e3914c --- /dev/null +++ b/crates/trustgraph-core/src/feed.rs @@ -0,0 +1,346 @@ +//! Feeds: one agent's signed atoms, published as static files. +//! +//! A feed is a directory with two files (see `doc/feeds.md`): +//! +//! - [`ATOMS_FILE`] (`atoms.ndjson`): signed Trust Atom credentials, one per +//! line, all issued by the feed's owner. +//! - [`INDEX_FILE`] (`index.json`): a small document naming the owner, the +//! number of atoms, a SHA2-256 digest of `atoms.ndjson`, and when the feed +//! was last updated, signed by the owner. +//! +//! Any static host can serve a feed. Integrity comes from the signatures, not +//! from the transport: [`verify`] checks the index's proof, that the digest +//! matches the atoms byte for byte, and every atom's own proof. +//! +//! The index is secured with the same Data Integrity proof as atoms +//! (`eddsa-jcs-2022`, see [`credential::sign`]). Securing goes through +//! two private functions only, `secure` and `check`, so a later format +//! version can change it in one place. +//! +//! Like the rest of the core, nothing here does I/O: callers fetch and write +//! the files, and pass in the time. + +use std::collections::HashSet; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; + +use crate::{ContentId, Did, Error, Keypair, Record, Result, credential}; + +/// The file holding a feed's signed atoms, one per line. +pub const ATOMS_FILE: &str = "atoms.ndjson"; + +/// The file holding a feed's signed index. +pub const INDEX_FILE: &str = "index.json"; + +/// Where a domain publishes its feed, relative to the site root: +/// `https://example.com/.well-known/trust/index.json`. +pub const WELL_KNOWN_DIR: &str = ".well-known/trust"; + +/// The `type` of a feed index. +pub const FEED_INDEX_TYPE: &str = "TrustFeedIndex"; + +/// A feed's index, without its proof. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedIndex { + /// JSON-LD context: the Trust Graph context. + #[serde(rename = "@context")] + pub context: Vec, + /// Always [`FEED_INDEX_TYPE`]. + #[serde(rename = "type")] + pub kind: String, + /// The DID of the feed's owner. It signs the index, and issued every atom. + pub owner: String, + /// When the feed was last published. + pub updated: Timestamp, + /// What is in [`ATOMS_FILE`]. + pub atoms: AtomsSummary, +} + +/// The index's description of [`ATOMS_FILE`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AtomsSummary { + /// The number of atoms (non-empty lines). + pub count: usize, + /// The SHA2-256 multihash (`Qm…`) of the file's exact bytes. + pub digest: ContentId, +} + +/// A feed, ready to write out: [`INDEX_FILE`] and [`ATOMS_FILE`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Feed { + /// The signed index document. + pub index: Json, + /// The contents of `atoms.ndjson`. + pub atoms: String, +} + +/// A feed that passed [`verify`]. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedFeed { + /// The index, without its proof. + pub index: FeedIndex, + /// The owner's DID. + pub owner: Did, + /// Every atom, verified, in file order. + pub records: Vec, +} + +/// The digest of an `atoms.ndjson` file, as recorded in the index. +#[must_use] +pub fn digest(atoms: &str) -> ContentId { + ContentId::of_bytes(atoms.as_bytes()) +} + +/// Builds a feed from signed credentials, all issued by `keypair`, stamped +/// `updated`. Duplicates (same content ID) are dropped; order is kept. +/// +/// # Errors +/// +/// Fails if a credential does not verify, or was issued by anyone else. +pub fn build(credentials: &[Json], keypair: &Keypair, updated: Timestamp) -> Result { + let owner = keypair.did(); + let mut seen = HashSet::new(); + let mut atoms = String::new(); + for (n, json) in credentials.iter().enumerate() { + let item = |e: Error| Error::InvalidFeed(format!("atom {}: {e}", n + 1)); + if json.get("proof").is_none() { + return Err(item(Error::InvalidCredential("not signed".into()))); + } + let atom = credential::verify_atom(json).map_err(item)?; + if atom.source != owner.as_str() { + return Err(item(Error::InvalidAtom(format!("issued by `{}`, not the feed owner `{owner}`", atom.source)))); + } + if seen.insert(atom.id()?) { + atoms.push_str(&serde_json::to_string(json)?); + atoms.push('\n'); + } + } + let index = FeedIndex { + context: vec![credential::TRUSTGRAPH_CONTEXT.into()], + kind: FEED_INDEX_TYPE.into(), + owner: owner.to_string(), + updated, + atoms: AtomsSummary { count: seen.len(), digest: digest(&atoms) }, + }; + Ok(Feed { index: secure(&serde_json::to_value(&index)?, keypair, updated)?, atoms }) +} + +/// Verifies a feed: the index's proof and owner, the digest of `atoms` +/// (the exact bytes of `atoms.ndjson`), the count, and every atom's proof and +/// issuer. +/// +/// # Errors +/// +/// Returns [`Error::Verification`] if any signature, the digest or the count +/// does not match, or an atom was issued by someone other than the owner; +/// [`Error::InvalidFeed`] if the index or a line is malformed. +pub fn verify(index: &Json, atoms: &str) -> Result { + let bad = |msg: String| Error::InvalidFeed(msg); + let signer = check(index)?; + let mut unsigned = index.clone(); + if let Some(doc) = unsigned.as_object_mut() { + doc.remove("proof"); + } + let index: FeedIndex = serde_json::from_value(unsigned).map_err(|e| bad(format!("index: {e}")))?; + if index.kind != FEED_INDEX_TYPE { + return Err(bad(format!("index `type` must be `{FEED_INDEX_TYPE}`"))); + } + if index.owner != signer.as_str() { + return Err(Error::Verification(format!( + "index owner `{}` did not sign it (signed by `{signer}`)", + index.owner + ))); + } + if digest(atoms) != index.atoms.digest { + return Err(Error::Verification(format!("{ATOMS_FILE} does not match the digest in {INDEX_FILE}"))); + } + + let mut records = Vec::new(); + for (n, line) in atoms.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + let at = |e: Error| match e { + Error::Verification(msg) => Error::Verification(format!("{ATOMS_FILE} line {}: {msg}", n + 1)), + other => bad(format!("{ATOMS_FILE} line {}: {other}", n + 1)), + }; + let json: Json = serde_json::from_str(line).map_err(|e| at(e.into()))?; + if json.get("proof").is_none() { + return Err(at(Error::InvalidCredential("not signed".into()))); + } + let record = Record::from_json(json).map_err(at)?; + if record.atom.source != signer.as_str() { + return Err(at(Error::Verification(format!( + "issued by `{}`, not the feed owner `{signer}`", + record.atom.source + )))); + } + records.push(record); + } + if records.len() != index.atoms.count { + return Err(Error::Verification(format!( + "{INDEX_FILE} says {} atoms, {ATOMS_FILE} has {}", + index.atoms.count, + records.len() + ))); + } + Ok(VerifiedFeed { index, owner: signer, records }) +} + +/// Secures a feed document. Today: an `eddsa-jcs-2022` Data Integrity +/// proof, exactly as for atoms. +fn secure(document: &Json, keypair: &Keypair, created: Timestamp) -> Result { + credential::sign(document, keypair, created) +} + +/// Checks a secured feed document and returns who secured it. +fn check(secured: &Json) -> Result { + credential::verify(secured) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TrustAtom; + use serde_json::json; + + fn alice() -> Keypair { + Keypair::from_seed(&[1; 32]) + } + + fn signed(key: &Keypair, target: &str) -> Json { + let atom = TrustAtom::new(key.did().to_string(), target).with_value("0.5".parse().unwrap()); + credential::sign_atom(&atom, key, Timestamp::UNIX_EPOCH).unwrap() + } + + fn at() -> Timestamp { + "2026-01-01T00:00:00Z".parse().unwrap() + } + + fn feed() -> Feed { + let a = signed(&alice(), "https://a.example"); + build(&[a.clone(), signed(&alice(), "https://b.example"), a], &alice(), at()).unwrap() + } + + #[test] + fn build_then_verify() { + let feed = feed(); + assert_eq!(feed.atoms.lines().count(), 2, "duplicates dropped"); + assert_eq!(feed.index["type"], FEED_INDEX_TYPE); + assert_eq!(feed.index["owner"], alice().did().as_str()); + assert_eq!(feed.index["updated"], "2026-01-01T00:00:00Z"); + assert_eq!(feed.index["atoms"]["count"], 2); + assert_eq!(feed.index["atoms"]["digest"], digest(&feed.atoms).to_string()); + assert_eq!(feed.index["proof"]["cryptosuite"], "eddsa-jcs-2022"); + + let verified = verify(&feed.index, &feed.atoms).unwrap(); + assert_eq!(verified.owner, alice().did()); + assert_eq!(verified.index.updated, at()); + assert_eq!(verified.records.len(), 2); + assert!(verified.records.iter().all(Record::is_signed)); + assert_eq!(verified.records[1].atom.target, "https://b.example"); + } + + #[test] + fn empty_feed_is_valid() { + let feed = build(&[], &alice(), at()).unwrap(); + assert_eq!(feed.atoms, ""); + assert_eq!(verify(&feed.index, &feed.atoms).unwrap().records.len(), 0); + } + + #[test] + fn build_rejects_other_issuers_and_unsigned_atoms() { + let bob = Keypair::from_seed(&[2; 32]); + let err = build(&[signed(&bob, "x")], &alice(), at()).unwrap_err(); + assert!(err.to_string().contains("not the feed owner"), "{err}"); + let unsigned = credential::to_credential(&TrustAtom::new(alice().did().to_string(), "x")).unwrap(); + assert!(build(&[unsigned], &alice(), at()).is_err()); + let mut forged = signed(&alice(), "x"); + forged["credentialSubject"]["value"] = json!("1"); + assert!(build(&[forged], &alice(), at()).is_err()); + } + + #[test] + fn rejects_a_tampered_atom_even_with_a_matching_digest() { + let feed = feed(); + let atoms = feed.atoms.replacen("\"0.5\"", "\"1\"", 1); + // Re-sign the index over the tampered bytes, so only the atom's own proof can catch it. + let mut index: FeedIndex = serde_json::from_value(feed.index.clone()).unwrap(); + index.atoms.digest = digest(&atoms); + let resigned = secure(&serde_json::to_value(&index).unwrap(), &alice(), at()).unwrap(); + let err = verify(&resigned, &atoms).unwrap_err(); + assert!(matches!(err, Error::Verification(_)), "{err}"); + assert!(err.to_string().contains("line 1"), "{err}"); + } + + #[test] + fn rejects_digest_mismatch() { + let feed = feed(); + for atoms in [feed.atoms.replacen("\"0.5\"", "\"1\"", 1), format!("{}\n", feed.atoms), String::new()] { + let err = verify(&feed.index, &atoms).unwrap_err(); + assert!(err.to_string().contains("digest"), "{err}"); + } + } + + #[test] + fn rejects_tampered_or_foreign_index() { + let feed = feed(); + let mut tampered = feed.index.clone(); + tampered["atoms"]["count"] = json!(1); + assert!(matches!(verify(&tampered, &feed.atoms), Err(Error::Verification(_)))); + + // Mallory re-signs Alice's feed under her own name: the atoms aren't hers. + let mallory = Keypair::from_seed(&[9; 32]); + let mut index: FeedIndex = serde_json::from_value(feed.index.clone()).unwrap(); + index.owner = mallory.did().to_string(); + let stolen = secure(&serde_json::to_value(&index).unwrap(), &mallory, at()).unwrap(); + let err = verify(&stolen, &feed.atoms).unwrap_err(); + assert!(err.to_string().contains("not the feed owner"), "{err}"); + + // Signed by Mallory, but claiming to be Alice's. + let mut index: FeedIndex = serde_json::from_value(feed.index.clone()).unwrap(); + index.updated = "2030-01-01T00:00:00Z".parse().unwrap(); + let claimed = secure(&serde_json::to_value(&index).unwrap(), &mallory, at()).unwrap(); + assert!(verify(&claimed, &feed.atoms).unwrap_err().to_string().contains("did not sign")); + + let mut unsigned = feed.index.clone(); + unsigned.as_object_mut().unwrap().remove("proof"); + assert!(verify(&unsigned, &feed.atoms).is_err()); + } + + #[test] + fn rejects_wrong_count_and_type() { + let mut index: FeedIndex = serde_json::from_value(feed().index).unwrap(); + let atoms = feed().atoms; + index.atoms.count = 3; + let wrong_count = secure(&serde_json::to_value(&index).unwrap(), &alice(), at()).unwrap(); + assert!(verify(&wrong_count, &atoms).unwrap_err().to_string().contains("says 3 atoms")); + + index.atoms.count = 2; + index.kind = "Other".into(); + let wrong_type = secure(&serde_json::to_value(&index).unwrap(), &alice(), at()).unwrap(); + assert!(matches!(verify(&wrong_type, &atoms), Err(Error::InvalidFeed(_)))); + } + + #[test] + fn rejects_bad_lines() { + let owner = alice(); + let index_for = |atoms: &str, count: usize| { + let index = FeedIndex { + context: vec![credential::TRUSTGRAPH_CONTEXT.into()], + kind: FEED_INDEX_TYPE.into(), + owner: owner.did().to_string(), + updated: at(), + atoms: AtomsSummary { count, digest: digest(atoms) }, + }; + secure(&serde_json::to_value(&index).unwrap(), &owner, at()).unwrap() + }; + for atoms in ["{oops\n", "{\"source\":\"a\",\"target\":\"b\"}\n"] { + let err = verify(&index_for(atoms, 1), atoms).unwrap_err(); + assert!(matches!(err, Error::InvalidFeed(_)), "{err}"); + } + let bob = format!("{}\n", signed(&Keypair::from_seed(&[2; 32]), "x")); + assert!(verify(&index_for(&bob, 1), &bob).unwrap_err().to_string().contains("not the feed owner")); + } +} diff --git a/crates/trustgraph-core/src/lib.rs b/crates/trustgraph-core/src/lib.rs index c67470c..8844641 100644 --- a/crates/trustgraph-core/src/lib.rs +++ b/crates/trustgraph-core/src/lib.rs @@ -11,6 +11,7 @@ //! - [`TrustGraph`]: the **Agent Lens**, everything one agent can see //! through the **Trust Cascade** of the agents they trust. //! - [`Record`] / [`Query`]: verified atoms and filters over them. +//! - [`feed`]: one agent's signed atoms as a static, signed feed. //! - [`api`]: the JSON-shaped API that the CLI, WebAssembly and Node //! wrappers all expose. //! @@ -38,6 +39,7 @@ pub mod atom; pub mod canonical; pub mod credential; pub mod error; +pub mod feed; pub mod graph; pub mod id; pub mod keys; diff --git a/crates/trustgraph-node/src/lib.rs b/crates/trustgraph-node/src/lib.rs index fec2650..618a5d0 100644 --- a/crates/trustgraph-node/src/lib.rs +++ b/crates/trustgraph-node/src/lib.rs @@ -89,6 +89,19 @@ pub fn verify(credential: Value) -> Result { json(&api::verify(&credential)) } +/// Builds a feed, `{index, atoms}`, from credentials signed by the key's +/// owner, stamped `updated` (RFC 3339). +#[napi] +pub fn build_feed(credentials: Vec, secret_key_multibase: String, updated: String) -> Result { + json(&core(api::build_feed(&credentials, &secret_key_multibase, &updated))?) +} + +/// Verifies a feed: `{valid, owner?, updated?, ids?, atoms?, error?}`. +#[napi] +pub fn verify_feed(index: Value, atoms: String) -> Result { + json(&api::verify_feed(&index, &atoms)) +} + /// The Agent Lens for `root` over `items`, best first. #[napi] pub fn lens(items: Vec, root: String, options: Option) -> Result { diff --git a/crates/trustgraph-wasm/src/lib.rs b/crates/trustgraph-wasm/src/lib.rs index 41a519a..c388387 100644 --- a/crates/trustgraph-wasm/src/lib.rs +++ b/crates/trustgraph-wasm/src/lib.rs @@ -121,6 +121,31 @@ pub fn verify(credential: JsValue) -> JsResult { to_js(&api::verify(&from_js::(credential, "credential")?)) } +/// Builds a feed, `{index, atoms}`: the contents of `index.json` and +/// `atoms.ndjson`. `credentials` must all be signed by the key's owner; +/// `updated` is an RFC 3339 time. +/// +/// # Errors +/// +/// Throws if the key or time is invalid, or a credential does not verify or +/// was issued by someone else. +#[wasm_bindgen(js_name = buildFeed)] +pub fn build_feed(credentials: JsValue, secret_key_multibase: &str, updated: &str) -> JsResult { + let credentials: Vec = from_js(credentials, "credentials")?; + to_js(&core(api::build_feed(&credentials, secret_key_multibase, updated))?) +} + +/// Verifies a feed: `index` is the parsed `index.json`, `atoms` the exact +/// text of `atoms.ndjson`. Returns `{valid, owner?, updated?, ids?, atoms?, error?}`. +/// +/// # Errors +/// +/// Throws only if `index` cannot be read as JSON. +#[wasm_bindgen(js_name = verifyFeed)] +pub fn verify_feed(index: JsValue, atoms: &str) -> JsResult { + to_js(&api::verify_feed(&from_js::(index, "index")?, atoms)) +} + /// The Agent Lens: everything `root` can see in `items` (atoms and/or /// signed credentials), best first. `options` is optional: /// `{depth?, decay?, topic?, signedOnly?, limit?}`. diff --git a/doc/architecture.md b/doc/architecture.md index 0defdea..2683bfe 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -45,6 +45,7 @@ pass everything in: | 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) | +| Feeds to verify | The text of `index.json` and `atoms.ndjson`, fetched however the host likes (the CLI uses a small blocking HTTPS client) | Two things follow from this: @@ -84,6 +85,8 @@ between them: | `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 | +| `buildFeed(credentials, secret, updated)` | A [feed](feeds.md) (`{index, atoms}`) of your own signed atoms | +| `verifyFeed(index, atoms)` | `{valid, owner, updated, ids, atoms}` or `{valid: false, error}`, all or nothing | 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). diff --git a/doc/feeds.md b/doc/feeds.md new file mode 100644 index 0000000..bbdf4b4 --- /dev/null +++ b/doc/feeds.md @@ -0,0 +1,161 @@ +# Feeds + +A **feed** is one agent's signed Trust Atoms, published as two static files +that any web host can serve: GitHub Pages, Netlify, an S3 bucket, or a +folder on a file share. There is no server to run and no API. Integrity +comes from signatures, not from the host or the transport, so a feed can be +mirrored or cached anywhere. + +`trust publish` writes a feed, `trust follow` and `trust pull` read them. The +format and its verification live in +[`trustgraph_core::feed`](../crates/trustgraph-core/src/feed.rs) (pure, no +I/O) and are exposed to JavaScript as `buildFeed` and `verifyFeed`. + +> Status: provisional, like the rest of the data format before 1.0 +> (roadmap Phase 1). + +## Layout + +A feed is a directory with two files: + +```text +feed/ +├── index.json # signed: owner, updated, count and digest of atoms.ndjson +└── atoms.ndjson # signed Trust Atom credentials, one per line +``` + +A site can publish its feed at a well-known location, so it can be found +from the domain alone: + +```text +https://example.com/.well-known/trust/index.json +https://example.com/.well-known/trust/atoms.ndjson +``` + +`atoms.ndjson` always sits next to `index.json`: a reader replaces the last +path segment of the index URL with `atoms.ndjson`. + +## `atoms.ndjson` + +One signed Trust Atom credential per line (W3C VC 2.0 with an +`eddsa-jcs-2022` Data Integrity proof), exactly as `trust atom --sign` +produces and `trust add` stores. Every atom's issuer (its `source`) must be +the feed's owner: a feed only carries its owner's own statements. To pass on +what others say, sign rollups (`trust lens --rollup | trust sign`). + +Blank lines are ignored. Duplicate atoms are dropped when publishing. + +## `index.json` + +```json +{ + "@context": ["https://trustgraph.net/ns/v1"], + "type": "TrustFeedIndex", + "owner": "did:key:z6MkqJ11EWUyVZoCziAN3vqK9Dp2uSYhUmj3rKe4WgD6HsV7", + "updated": "2026-10-08T15:04:08Z", + "atoms": { + "count": 1, + "digest": "QmYqYz1eCtgGmsmWmPM9MpGibFjKuC6PWHmGcreyWJmYZK" + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "created": "2026-10-08T15:04:08Z", + "verificationMethod": "did:key:z6MkqJ11…#z6MkqJ11…", + "proofPurpose": "assertionMethod", + "@context": ["https://trustgraph.net/ns/v1"], + "proofValue": "z5eHM32Re6cVd…" + } +} +``` + +| Field | Meaning | +|---|---| +| `type` | Always `TrustFeedIndex` | +| `owner` | The DID that signs the index and issued every atom | +| `updated` | When the feed was published (RFC 3339, whole seconds). Passed in by the publisher; the core never reads a clock | +| `atoms.count` | Number of non-blank lines in `atoms.ndjson` | +| `atoms.digest` | SHA2-256 multihash (`Qm…`, base58btc) of the exact bytes of `atoms.ndjson` | +| `proof` | An `eddsa-jcs-2022` Data Integrity proof by `owner`, made with the same code that signs atoms | + +The index is small and changes whenever the atoms do, so it is the only file +a reader needs to poll. + +## Verifying a feed + +A feed is accepted only if **all** of these hold. One failure rejects the +whole feed, and nothing from it is stored. + +1. The index's proof verifies, and was made by `owner`. +2. `type` is `TrustFeedIndex`. +3. The SHA2-256 multihash of the bytes of `atoms.ndjson` equals `atoms.digest`. +4. Every line of `atoms.ndjson` is a signed Trust Atom credential whose proof + verifies and whose issuer is `owner`. +5. The number of atoms equals `atoms.count`. + +The digest check (3) catches any change to the atoms file after the index +was signed. The per-atom check (4) means even a re-signed index can't vouch +for a forged or foreign atom. + +## Following feeds with `trust` + +```sh +trust follow https://alice.github.io/trust/ # → …/trust/index.json +trust follow alice.example # → https://alice.example/.well-known/trust/index.json +trust follow ./alice-feed # a local directory (or file://), handy for testing +trust following # the feeds you follow, and what was last pulled +trust pull # fetch every feed you follow +trust pull https://alice.github.io/trust/ # fetch one (followed or not) +trust unfollow alice.example +``` + +How a location is resolved: + +| You type | Index fetched | +|---|---| +| `example.com` (bare domain) | `https://example.com/.well-known/trust/index.json` | +| `https://example.com` or `https://example.com/` | `https://example.com/.well-known/trust/index.json` | +| `https://host/some/dir` or `https://host/some/dir/` | `https://host/some/dir/index.json` | +| `https://host/some/feed.json` | as given | +| A local directory or `file://` URL | `DIR/index.json`, or `DIR/.well-known/trust/index.json` if only that exists | + +Plain `http://` URLs are accepted too, since signatures, not TLS, protect the +content; bare domains always use HTTPS. Proxies are taken from the usual +`HTTPS_PROXY` / `NO_PROXY` environment variables. + +`trust` keeps what it learns about each feed in `following.json` in its home +directory (`trust info` shows where): + +- **Caching.** The index's `ETag` is sent back as `If-None-Match`, so an + unchanged feed costs one small `304 Not Modified` request. If a host sends + no `ETag`, an index with the same digest as last time is still recognised, + and `atoms.ndjson` is not downloaded again. +- **Owner pinning.** The owner's DID is recorded on the first pull. A later + index signed by anyone else is rejected (unfollow and follow again to + accept a new owner). +- **No rollbacks.** An index older than the last one pulled is rejected, so + an old copy of a feed can't be replayed. + +State is only updated after a feed verifies completely. Atoms already pulled +stay in the store after `trust unfollow` (the store is append-only). + +## Publishing with `trust` + +```sh +trust publish --out feed # feed/index.json, feed/atoms.ndjson +trust publish --out site --well-known # site/.well-known/trust/…, plus site/.nojekyll +trust publish --as work # publish another key's atoms (same as --key) +``` + +`trust publish` writes every signed atom in your store whose source is your +DID. Unsigned atoms are skipped (with a note on stderr). With +`--well-known` it also creates an empty `.nojekyll` at the site root, because +GitHub Pages (Jekyll) does not serve directories that start with a dot. + +## Not yet + +- **`did:web`**, so an organisation can sign its feed with its own domain + rather than a `did:key`. +- Incremental feeds (paging or appending for very large feeds), and + revocation of published atoms. +- Other transports, such as IPFS (atom IDs are already SHA2-256 multihashes). diff --git a/doc/plan/README.md b/doc/plan/README.md index ef8a4cf..9c3cb42 100644 --- a/doc/plan/README.md +++ b/doc/plan/README.md @@ -105,6 +105,7 @@ Everything below this line is supporting detail. | Shared TypeScript types; one smoke test across all JS builds ([PR #15](https://github.com/trustgraph/trustgraph-rust-cli/pull/15)) | ✅ | | `lens` on 100k atoms (Node 24 LTS): 0.54 s WebAssembly, 0.43 s native ([PR #15](https://github.com/trustgraph/trustgraph-rust-cli/pull/15)) | ✅ | | CI: purity check, WebAssembly size budget, native addon on 3 OSes, latest Node LTS plus Node 22 ([PR #15](https://github.com/trustgraph/trustgraph-rust-cli/pull/15)) | ✅ | +| Signed static feeds: `trust publish` (incl. `.well-known/trust/`), `follow` / `unfollow` / `following` / `pull` over HTTPS with ETags, owner pinning and rollback protection; `buildFeed` / `verifyFeed` in JS ([doc/feeds.md](../feeds.md)) | ✅ (`did:web` and IPFS still to do) | | Old repos imported with full history: protocol README → `doc/protocol/`, 2017 JSON-LD → `schema/legacy-2017/` | ✅ | ## Design principles diff --git a/tests/js/smoke.mjs b/tests/js/smoke.mjs index 5629705..e34eb74 100644 --- a/tests/js/smoke.mjs +++ b/tests/js/smoke.mjs @@ -55,6 +55,17 @@ 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); +const feed = tg.buildFeed([items[0]], alice.secretKeyMultibase, now); +assert.equal(feed.index.owner, alice.did); +assert.equal(feed.index.atoms.count, 1); +const fed = tg.verifyFeed(feed.index, feed.atoms); +assert.equal(fed.valid, true); +assert.equal(fed.owner, alice.did); +assert.deepEqual(fed.ids, [tg.atomId(items[0])]); +assert.equal(tg.verifyFeed(feed.index, feed.atoms.replace('"value":"1"', '"value":"-1"')).valid, false); +assert.match(tg.verifyFeed(feed.index, "").error, /digest/); +assert.throws(() => tg.buildFeed([items[1]], alice.secretKeyMultibase, now), /feed owner/); + 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/); diff --git a/tests/js/types.ts b/tests/js/types.ts index 04ff940..1d5496f 100644 --- a/tests/js/types.ts +++ b/tests/js/types.ts @@ -17,8 +17,11 @@ const view: TG.LensEntry[] = tg.lens([credential, { source: "a", target: "b", va 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]!); +const feed: TG.Feed = tg.buildFeed([credential], me.secretKeyMultibase, new Date().toISOString()); +const fed: TG.FeedVerification = tg.verifyFeed(feed.index, feed.atoms); +const owner: string | undefined = fed.owner; // @ts-expect-error depth must be a number tg.lens([], me.did, { depth: "3" }); -export { atom, best, id, fromSeed }; +export { atom, best, id, fromSeed, owner };