diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2a7154c..5fa27e0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [master] pull_request: - branches: [master] merge_group: workflow_dispatch: diff --git a/.hellas-revision b/.hellas-revision index 63a58c5f..25ee9d08 100644 --- a/.hellas-revision +++ b/.hellas-revision @@ -1 +1 @@ -b4cdcd450d555f73fc8b3dd9d1af371917ff3a7c +9c342ba19bbcef6519c8065a19b0af76dc42f77b diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c7c96d0..410c2121 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ Prepare the sibling Hellas checkout at the revision in `.hellas-revision` as described in the README. Enter the pinned environment with `nix develop`, then run `make check` before submitting changes. The flake routes Rust build output -to `/tmp/hellas-gate-target` unless `CARGO_TARGET_DIR` is set, and supplies the +to `target/` unless `CARGO_TARGET_DIR` is set, and supplies the frontend tools directly; do not run `npm install` or create caches in this checkout. Commit regenerated `ui/src/generated` definitions when Rust DTOs change; CI rejects generated binding drift. diff --git a/Cargo.lock b/Cargo.lock index 4a66389c..a61d886d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,15 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -121,6 +130,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -476,6 +497,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.8.1" @@ -522,6 +549,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -610,6 +643,12 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -776,6 +815,31 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cmake" version = "0.1.58" @@ -810,10 +874,39 @@ dependencies = [ "memchr", ] +[[package]] +name = "commonware-actor" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "cfg-if", + "commonware-macros", + "commonware-runtime", + "crossbeam-queue", + "futures-util", + "parking_lot", +] + +[[package]] +name = "commonware-broadcast" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "commonware-actor", + "commonware-codec", + "commonware-cryptography", + "commonware-macros", + "commonware-p2p", + "commonware-runtime", + "commonware-utils", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "commonware-codec" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "bytes", "cfg-if", @@ -827,7 +920,7 @@ dependencies = [ [[package]] name = "commonware-codec-macros" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -835,10 +928,58 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "commonware-coding" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-macros", + "commonware-math", + "commonware-parallel", + "commonware-storage", + "commonware-utils", + "num-rational", + "rayon", + "thiserror 2.0.20", +] + +[[package]] +name = "commonware-consensus" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "bytes", + "cfg-if", + "commonware-actor", + "commonware-broadcast", + "commonware-codec", + "commonware-coding", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-math", + "commonware-p2p", + "commonware-parallel", + "commonware-resolver", + "commonware-runtime", + "commonware-storage", + "commonware-utils", + "futures", + "pin-project", + "rand 0.10.2", + "rand_core 0.10.1", + "rayon", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "commonware-cryptography" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "ahash", "anyhow", @@ -879,16 +1020,40 @@ dependencies = [ [[package]] name = "commonware-formatting" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "commonware-macros", "const-hex", ] +[[package]] +name = "commonware-glue" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "bytes", + "commonware-actor", + "commonware-codec", + "commonware-consensus", + "commonware-cryptography", + "commonware-macros", + "commonware-p2p", + "commonware-parallel", + "commonware-resolver", + "commonware-runtime", + "commonware-storage", + "commonware-utils", + "futures", + "prometheus-client", + "rand_core 0.10.1", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "commonware-macros" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "commonware-macros-impl", "tokio", @@ -897,7 +1062,7 @@ dependencies = [ [[package]] name = "commonware-macros-impl" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -909,7 +1074,7 @@ dependencies = [ [[package]] name = "commonware-math" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "bytes", "commonware-codec", @@ -919,10 +1084,36 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "commonware-p2p" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "commonware-actor", + "commonware-codec", + "commonware-cryptography", + "commonware-macros", + "commonware-parallel", + "commonware-runtime", + "commonware-stream", + "commonware-utils", + "either", + "futures", + "num-bigint", + "num-integer", + "num-rational", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", + "rand_distr", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "commonware-parallel" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "cfg-if", "commonware-macros", @@ -931,10 +1122,120 @@ dependencies = [ "rayon", ] +[[package]] +name = "commonware-resolver" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "bytes", + "commonware-actor", + "commonware-codec", + "commonware-cryptography", + "commonware-macros", + "commonware-p2p", + "commonware-runtime", + "commonware-stream", + "commonware-utils", + "futures", + "rand 0.10.2", + "rand_core 0.10.1", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "commonware-runtime" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "ahash", + "axum", + "bytes", + "cfg-if", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-parallel", + "commonware-runtime-macros", + "commonware-utils", + "criterion", + "crossbeam-utils", + "futures", + "getrandom 0.2.17", + "getrandom 0.4.3", + "governor", + "libc", + "prometheus-client", + "rand 0.10.2", + "rand_core 0.10.1", + "rayon", + "sha2 0.11.0", + "sysinfo", + "thiserror 2.0.20", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "commonware-runtime-macros" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "commonware-storage" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "ahash", + "anyhow", + "bytes", + "cfg-if", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-parallel", + "commonware-runtime", + "commonware-utils", + "futures", + "futures-util", + "hashbrown 0.17.1", + "thiserror 2.0.20", + "tracing", + "zstd", +] + +[[package]] +name = "commonware-stream" +version = "2026.7.0" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" +dependencies = [ + "chacha20poly1305", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "commonware-utils", + "futures", + "rand_core 0.10.1", + "thiserror 2.0.20", + "x25519-dalek", + "zeroize", +] + [[package]] name = "commonware-utils" version = "2026.7.0" -source = "git+https://github.com/commonwarexyz/monorepo?rev=4c3ebe4274d908557d91b637240e8b93609fb32f#4c3ebe4274d908557d91b637240e8b93609fb32f" +source = "git+https://github.com/georgewhewell/commonware-monorepo?rev=4da2958e095dbff2236091e7294f6890806dc800#4da2958e095dbff2236091e7294f6890806dc800" dependencies = [ "ahash", "bytes", @@ -1138,6 +1439,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1172,6 +1508,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1850,6 +2195,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -1926,6 +2280,17 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2006,6 +2371,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.12.0" @@ -2071,6 +2447,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2174,6 +2560,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -2481,6 +2873,29 @@ dependencies = [ "system-deps", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.5", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "group" version = "0.13.0" @@ -2606,6 +3021,17 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -2665,19 +3091,38 @@ dependencies = [ "base64ct", "bs58", "bytes", + "commonware-actor", "commonware-codec", + "commonware-consensus", "commonware-cryptography", + "commonware-glue", + "commonware-p2p", "commonware-parallel", + "commonware-resolver", + "commonware-runtime", + "commonware-storage", + "commonware-utils", + "dirs", + "futures", + "futures-util", "hellas-genesis", "hellas-kernel", "hellas-rpc", "hellas-wire", + "hellas-work", + "hex", + "iroh", "p256", + "prometheus-client", "rand 0.10.2", + "rand_core 0.10.1", "serde", "serde_json", "sha2 0.11.0", "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-tungstenite", "tracing", "url", ] @@ -2688,14 +3133,20 @@ version = "0.1.0" dependencies = [ "async-stream", "futures", + "hellas-adaptors", "hellas-attestation", + "hellas-kernel", "hellas-rpc", "hellas-wire", + "hellas-work", "iroh", "iroh-mdns-address-lookup", "rand 0.10.2", + "serde", + "serde_ipld_dagcbor", "thiserror 2.0.20", "tokio", + "tracing", ] [[package]] @@ -2705,8 +3156,8 @@ dependencies = [ "async-trait", "futures-core", "futures-util", - "hellas-chain", "hellas-kernel", + "hellas-private", "hellas-rpc", "hellas-wire", "hellas-work", @@ -2714,6 +3165,7 @@ dependencies = [ "prometheus-client", "rand 0.10.2", "serde", + "serde_ipld_dagcbor", "serde_json", "thiserror 2.0.20", "tokio", @@ -2728,9 +3180,12 @@ name = "hellas-gate" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "cc", "futures", "hellas-attestation", + "hellas-gateway", + "hellas-private", "hellas-rpc", "hellas-sdk", "hellas-wire", @@ -2751,25 +3206,32 @@ dependencies = [ name = "hellas-gateway" version = "0.1.0" dependencies = [ - "anyhow", "async-stream", "axum", + "base64 0.22.1", + "flate2", "futures", "hellas-adaptors", "hellas-client", "hellas-presentation", + "hellas-private", "hellas-rpc", "hellas-wire", + "httpdate", "iroh", + "libc", "prometheus-client", "rand 0.10.2", "reqwest", "serde", "serde_json", + "tempfile", "thiserror 2.0.20", "tokio", "tower", "tracing", + "url", + "zstd", ] [[package]] @@ -2786,6 +3248,8 @@ version = "0.1.0" dependencies = [ "hellas-xet", "k256", + "p256", + "sha2 0.11.0", ] [[package]] @@ -2793,33 +3257,58 @@ name = "hellas-presentation" version = "0.1.0" dependencies = [ "anyhow", + "hellas-adaptors", "hellas-rpc", + "jsonschema", + "serde_json", + "thiserror 2.0.20", "tokenizers", + "uuid", +] + +[[package]] +name = "hellas-private" +version = "0.1.0" +dependencies = [ + "libc", + "tempfile", + "windows-sys 0.61.2", ] [[package]] name = "hellas-providers" version = "0.1.0" dependencies = [ - "anyhow", "async-stream", + "base64 0.22.1", "futures", "hellas-adaptors", "hellas-executor", + "hellas-private", "hellas-rpc", "reqwest", + "rustls", "serde", "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", "tokio", "tracing", + "url", + "webpki-roots 1.0.9", + "x509-cert", ] [[package]] name = "hellas-rpc" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "base64ct", + "brotli", "bytes", "ed25519-dalek", + "flate2", "futures-core", "futures-util", "getrandom 0.2.17", @@ -2827,6 +3316,7 @@ dependencies = [ "hellas-wire", "hellas-xet", "k256", + "n0-future", "p256", "prettyplease", "proc-macro2", @@ -2843,6 +3333,7 @@ dependencies = [ "syn 2.0.119", "thiserror 2.0.20", "tracing", + "url", "web-time", ] @@ -2850,19 +3341,31 @@ dependencies = [ name = "hellas-sdk" version = "0.1.0" dependencies = [ - "anyhow", + "async-stream", + "futures", "futures-core", "hellas-attestation", + "hellas-chain", "hellas-client", "hellas-executor", "hellas-gateway", + "hellas-kernel", + "hellas-private", "hellas-providers", "hellas-rpc", "hellas-wire", + "hellas-work", + "hex", "iroh", + "prost", + "rand 0.10.2", + "serde", + "serde_json", "tempfile", + "thiserror 2.0.20", "tokio", "tracing", + "url", ] [[package]] @@ -2870,13 +3373,16 @@ name = "hellas-wire" version = "0.1.0" dependencies = [ "async-stream", + "blake3", "bytes", "data-encoding", "futures", "futures-core", + "hellas-private", "hellas-xet", "iroh", "iroh-mdns-address-lookup", + "libc", "mainline", "n0-future", "portable-atomic", @@ -2891,18 +3397,26 @@ dependencies = [ "tokio-tungstenite", "tracing", "web-time", + "windows-sys 0.61.2", ] [[package]] name = "hellas-work" version = "0.1.0" dependencies = [ + "async-stream", + "base64 0.22.1", + "futures", "hellas-kernel", + "hellas-private", "hellas-rpc", "hellas-wire", "hellas-xet", "prost", + "serde", + "serde_bytes", "thiserror 2.0.20", + "tokio", "tracing", ] @@ -3522,6 +4036,15 @@ dependencies = [ "ws_stream_wasm", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3731,6 +4254,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd94c1d7bfa9d30b5d4268df9fe8c5ed13fa600a6bd0dae02b04db86d575fc8a" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "k256" version = "0.13.4" @@ -3811,6 +4361,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.24" @@ -4478,6 +5034,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "noq" version = "1.3.0" @@ -4540,6 +5102,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4549,6 +5120,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -4559,6 +5144,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4574,6 +5174,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -4592,6 +5202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4770,6 +5381,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4781,6 +5402,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -4905,6 +5537,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -4923,6 +5561,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.14.0" @@ -4936,6 +5580,16 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.18.3" @@ -5166,6 +5820,34 @@ dependencies = [ "time", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -5469,7 +6151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -5488,7 +6170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5542,6 +6224,21 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -5694,6 +6391,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand 0.10.2", +] + [[package]] name = "rand_pcg" version = "0.10.2" @@ -5712,6 +6419,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5735,7 +6451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -5789,6 +6505,21 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "referencing" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1cb02ef237bd757aba02cd648a4ffa628cd8e5852e2b9bb89aabf93dc5dcc7" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -6646,6 +7377,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -6825,6 +7565,21 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.8.0" @@ -7259,6 +8014,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -7278,7 +8043,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -7307,6 +8072,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -7611,6 +8377,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -7621,12 +8397,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -7756,6 +8535,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -7876,6 +8661,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -7894,6 +8689,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" @@ -8905,3 +9706,31 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Makefile b/Makefile index 2d522385..2af6e601 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: bindings check dev ui-check ui-build -export CARGO_TARGET_DIR ?= /tmp/hellas-gate-target +export CARGO_TARGET_DIR ?= $(CURDIR)/target bindings: cargo test --locked -p hellas-gate --lib export_bindings diff --git a/README.md b/README.md index 24aca725..ae565175 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ plane, browser-side Rust, or second implementation of the Hellas protocol. The native process wires together the reusable pieces in `../hellas`: - a verified sealed-Fetch client with a stable caller and transport identity; -- an Apple App Attest OpenAI Responses provider; +- an Apple App Attest provider for OpenAI Responses and HTTPS resources; - a bearer-protected `/v1/responses` gateway bound to loopback; - private SQLite execution history; and - a same-user Unix control socket carrying the existing Hellas mux and the @@ -29,7 +29,7 @@ There is no npm package and no package publication in this repository. The flake is the canonical environment. It supplies Rust, Tauri, TypeScript, esbuild, and Node directly; there is no npm manifest, `npm install`, or `node_modules` directory. Rust 1.96.1 is shared with the pinned Hellas workspace. -Rust and frontend build output both stay under `/tmp`, off this shared checkout. +Rust and frontend build output stay under this worktree’s `target/` directory. Set `CARGO_TARGET_DIR` to override the Rust output location; the flake, Makefile, and CI all respect it. @@ -57,9 +57,7 @@ make dev `make check` runs binding generation, frontend typechecking and production bundling, Rust formatting, warnings-as-errors Clippy, and Rust tests with the -checked-in lockfile. Gate's old router, daemon, relay, TLS forwarder, and -Rust/Wasm frontend have intentionally been removed; equivalent protocol -behavior must be added to Hellas rather than copied back into this app. +checked-in lockfile. Shared protocol behavior lives in Hellas. ## Local data and secrets @@ -78,3 +76,121 @@ An attested provider requires a separately provisioned and signed build whose profile matches the app bundle identifier and grants the App Attest `CDhash` entitlement. Gate never substitutes software assurance. See `docs/SIGNING.md` for the two build modes and their provisioning boundary. + +### Work grants + +Authorized work uses Work grants. In Settings, copy the contact +enrollment to the provider operator. The operator pastes contact enrollments in +Serve, chooses the per-contact daily Requests allowance, starts the provider, and +exports one private Offer for each contact. Paste an Offer into Run or Gateway, choose Authorized, and select its resource name (normally `responses`). +Offers must bootstrap a new session within five minutes; export again if needed. +An active session refreshes its standing automatically. Removing contacts on the +next provider start permanently revokes their grants without resetting counters. + +In-app runs and the loopback gateway share one Work backend for the selected target. Stop the gateway +before changing the Offer's channel, addresses, or trust policy. Accepted work is +drained when the gateway stops. The upstream OpenAI key remains in native memory. +Gate's SQLite history remains local. + +Apple provider trust requires an independently trusted app ID and CDHash allowlist. +Builds can set `HELLAS_GATE_TRUST_APP_ID` and `HELLAS_GATE_TRUST_CDHASHES` for already +approved provider releases; otherwise fill both fields from trusted release +metadata. An Offer cannot choose its own allowed software. A binary cannot embed +its own final CDHash. Live App Attest provisioning and provider execution require +a provisioned macOS build; Linux and Windows run authorized and paid clients. + +Build artifacts stay under this checkout's `target/` directory, including UI +assets. Gate's provider configuration owns its contact grants. + +### Paid work + +Choose Paid in Run or Gateway, provide an absolute pool-file path and select a +provider endpoint ID from that pool. For an open HTTPS policy, also enter the +provider's Fetch service and method; a sealed policy supplies them. The pool uses the SDK's `load_pool_options` +format in `../hellas/docs/paid-gateway.md`: provider work config, client journal, +bond, payment coins, omission bond and mandatory `provider_genesis`. Use the +signed offer object exported by provisioning for `provider_genesis`; Gate derives +the pin and verifies Open before submitting work. Apple app ID and CDHashes for +paid targets come from the pool file; Assurance is selected in Gate. Account +credentials stay at the provider. + +The active backend snapshots its configuration. Stop the gateway and switch +targets, or restart Gate, to reload edited pool/work files. Each provider owns a +separate funded channel and exclusive client journal. Accepted work drains on +shutdown; the SDK recovers durable payment obligations on the next start. In-app +runs and `/v1/responses` use the same sessions. The Responses listener requires +a resource with the OpenAI Responses manifest; Run also accepts generic HTTPS +Fetch request JSON. + +To serve both fundings, set the paid Work config path alongside the contact +list. The Work config's paid Fetch policy must name a registered route. Gate +uses one provider identity, executor and Work listener; WorkSetup is added when +paid Work is configured. Fetch journals contain accounting metadata, while the +requesting user's history stores their own requests and results. + +To provision a bond, stop the provider and supply a paid offer JSON file: + +```json +{ + "work_config": "/absolute/path/provider-work.json", + "client": "CLIENT_SETTLEMENT_PUBLIC_KEY_HEX", + "stake_coins": ["PROVIDER_COIN_ID_HEX"], + "bond_timeout": 500, + "timeout_payout": 64, + "max_job_price": 40, + "addresses": ["192.0.2.10:31145"] +} +``` + +Preview derives the bond ID without reserving coins. Add that bond, the client's +transport endpoint and settlement key to the Work config's bilateral routes, +then choose Provision paid offer. Copy the returned `provider`, `bond` and +`provider_genesis` fields into the client's pool entry and add their payment +funding. Provisioning uses Gate's attested enrollment and its existing settlement +identity; it reserves the staked coins in the provider journal before exporting. + +### HTTP gateway + +Gateway offers two protocols. Responses uses the selected authorized or paid +Work target. HTTP uses a paid pool file and an HTTP routes file in the format +from `../hellas/docs/paid-gateway.md`, and forwards the configured HTTP routes +through that pool. Both listeners bind loopback and require the displayed bearer. +A missing or invalid pool leaves the listener stopped. + +HTTP archives request and response bodies under Gate's private `gateway-archive` +directory by default. Enable “Do not archive request or response bodies” to use +ZDR. Gateway configuration is retained when stopped; provider credentials remain +at upstream egress and never become gateway credentials. + +### HTTPS resources + +Serve accepts an optional HTTPS routes JSON array beside the OpenAI API key. +Each route has `service`, `method`, `account` (the SDK `HttpProviderConfig`) and +an optional authorized `resource` with `name` and `https` (`HttpsResource`). One +resource per route keeps URL and accounting policy unambiguous. For example: + +```json +[{ + "service": "lan", "method": "chat", + "account": {"allowed_hosts": ["glm.example.com"]}, + "resource": { + "name": "chat", + "https": { + "origin": "https://glm.example.com", + "paths": ["/v1/chat/completions"], "methods": ["POST"], + "credential": null, + "tls": {"roots": {"mode": "web_pki"}, "spki_sha256": []}, + "accounting": "openai-chat", "max_output_tokens": 1024, + "max_response_bytes": 65536 + } + } +}] +``` + +Private addresses and private certificate roots require explicit account +configuration. Credential aliases bind allowed origins, paths and methods to a +provider-owned secret file or environment variable. Authorized resources share +the contact's daily Requests allowance and five-minute deadline. Their envelope +bounds request bodies to 64 KiB, output to 1 MiB and spooling to 4 MiB. Clients +apply the selected resource's token ceiling and streaming usage policy before +signing; providers verify it and quarantine resources with invalid usage. diff --git a/docs/SIGNING.md b/docs/SIGNING.md index 65b71238..f8c7f18c 100644 --- a/docs/SIGNING.md +++ b/docs/SIGNING.md @@ -10,18 +10,24 @@ profile. For the attested provider to start, a provisioned build must use: +- macOS 27 and an executable linked against the macOS 27 SDK or newer; - bundle identifier `ai.hellas.gate`; - an Apple provisioning profile whose App ID matches that identifier; and - the `com.apple.developer.devicecheck.app-attest-opt-in` entitlement with the value `CDhash` in that profile. +The Nix shell uses the Xcode selected by `xcode-select`. Set +`GATE_DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer` before +`nix develop` to select another installation. An executable linked against an +older SDK can receive evidence without CDHash extensions; Gate rejects it. + Build the app ad-hoc first, then embed the provisioning profile and apply the final Developer ID signature in one validated step: ```sh APPLE_SIGNING_IDENTITY=- cargo tauri build ./macos/sign.sh path/to/gate.provisionprofile \ - /tmp/hellas-gate-target/release/bundle/macos/Hellas\ Gate.app + target/release/bundle/macos/Hellas\ Gate.app ``` `macos/sign.sh` embeds the profile and signs with the entitlements extracted @@ -39,3 +45,21 @@ App Attest keys are created and retained by Apple's service. Gate persists only the opaque key identifier and the canonical Hellas enrollment bundle in its private application data directory. A persisted enrollment is fully verified against the current Gate transport/caller identity before it is reused. + +## Native grant-session smoke test + +`state::tests::provisioned_provider_opens_pinned_grant_sessions_after_restart` +is ignored by normal tests because it calls Apple's enrollment service. Build +Gate's library test executable, place it at `Contents/MacOS/hellas-gate` in a +copy of the app bundle, and sign that bundle with the same provisioning profile +and hardened-runtime entitlements as Gate. Set `HELLAS_GATE_TEST_APP_ID` to the +profile's application identifier and `HELLAS_GATE_TEST_CDHASH` to the signed +test executable's full SHA-256 CodeDirectory hash. Run that test with `--ignored`. +It opens an authenticated remote grant session, restarts the provider, and opens +another session using the persisted enrollment. It uses temporary state and +makes no requests to the configured OpenAI backend. + +The SDK test +`provider::grant_tests::contact_offer_open_tls_responses_gateway_and_revocation_preserve_quota` +separately exercises real local TLS requests, verified responses, quota exhaustion, +restart and revocation on each native platform without external API credentials. diff --git a/flake.nix b/flake.nix index 139cc969..b9c0e599 100644 --- a/flake.nix +++ b/flake.nix @@ -45,9 +45,13 @@ webkitgtk_4_1 ]; shellHook = '' - export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-/tmp/hellas-gate-target}" + export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$PWD/target}" '' + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isDarwin '' - export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + export DEVELOPER_DIR="''${GATE_DEVELOPER_DIR:-$(/usr/bin/env -u DEVELOPER_DIR /usr/bin/xcode-select -p)}" + # Native archives must use the same LLVM as Xcode's linker. + export CC=/usr/bin/clang + export CXX=/usr/bin/clang++ + export AR=/usr/bin/ar export SDKROOT="$(/usr/bin/xcrun --sdk macosx --show-sdk-path)" export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/clang export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=/usr/bin/clang diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04f09ff0..0d6f3bbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,10 +21,13 @@ required-features = ["desktop"] [dependencies] anyhow = "1" +base64 = "0.22" +hellas-gateway = { path = "../../hellas/crates/gateway" } futures = "0.3" hellas-attestation = { path = "../../hellas/crates/attestation", features = ["apple-app-attest"] } -hellas-rpc = { path = "../../hellas/crates/rpc", features = ["host-control", "execute", "fetch", "courtesy"] } -hellas-sdk = { path = "../../hellas/crates/sdk", default-features = false, features = ["apple-verifier", "client", "gateway", "local-control", "provider"] } +hellas-private = { path = "../../hellas/crates/private" } +hellas-rpc = { path = "../../hellas/crates/rpc", features = ["host-control", "execute", "fetch", "work"] } +hellas-sdk = { path = "../../hellas/crates/sdk", default-features = false, features = ["apple-verifier", "client", "grant-gateway", "local-control", "grant-provider", "paid-gateway", "paid-provider"] } hellas-wire = { path = "../../hellas/crates/wire", features = ["unix"] } libc = "0.2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 65837b49..b497784d 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,6 @@ fn main() { + println!("cargo:rerun-if-env-changed=HELLAS_GATE_TRUST_APP_ID"); + println!("cargo:rerun-if-env-changed=HELLAS_GATE_TRUST_CDHASHES"); #[cfg(target_os = "macos")] { cc::Build::new() diff --git a/src-tauri/src/chain.rs b/src-tauri/src/chain.rs new file mode 100644 index 00000000..6c6afb16 --- /dev/null +++ b/src-tauri/src/chain.rs @@ -0,0 +1,38 @@ +//! One executing chain node shared by Gate's paid roles, started lazily. +use std::path::PathBuf; +use tokio::sync::OnceCell; + +pub struct ChainNode { + directory: PathBuf, + node: OnceCell, +} +impl ChainNode { + pub fn new(directory: PathBuf) -> Self { + Self { + directory, + node: OnceCell::new(), + } + } + pub async fn get( + &self, + config: &hellas_sdk::work_config::WorkConfig, + ) -> anyhow::Result { + let node = self + .node + .get_or_try_init(|| async { + Ok::<_, anyhow::Error>( + hellas_sdk::FullNode::start(config.node_config(self.directory.clone(), None)?) + .await?, + ) + }) + .await?; + config.check_node(node)?; + Ok(node.clone()) + } + pub async fn shutdown(&self) -> anyhow::Result<()> { + if let Some(node) = self.node.get() { + node.clone().shutdown().await?; + } + Ok(()) + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5e244361..0a4632a5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,7 +5,8 @@ use tauri::State; use tauri::ipc::Channel; use crate::dto::{ - AppStatus, ExecutionEvent, GatewayAccess, HistoryEntry, ProviderConfig, RunRequest, + AppStatus, ExecutionEvent, GatewayAccess, GatewayConfig, HistoryEntry, ProviderConfig, + RunRequest, }; use crate::error::{ApiError, ApiResult}; use crate::state::AppState; @@ -31,7 +32,7 @@ pub async fn set_provider_enabled( pub async fn set_gateway_enabled( state: State<'_, Arc>, enabled: bool, - config: Option, + config: Option, ) -> ApiResult { state .set_gateway_enabled(enabled, config) @@ -56,12 +57,6 @@ pub async fn run_request( if request.input.trim().is_empty() { return Err(ApiError::new("invalid_request", "Input cannot be empty")); } - if request.target.trim().is_empty() || request.trust_anchor.trim().is_empty() { - return Err(ApiError::new( - "trust_required", - "A target and explicit trust anchor are required", - )); - } let run_id = state.history.begin(&request).map_err(ApiError::internal)?; on_event @@ -90,9 +85,9 @@ pub async fn run_request( while let Some(event) = stream.next().await { match event { - Ok(hellas_sdk::client::FetchExecutionEvent::Chunk { event, .. }) => { - let record = serde_json::to_string(&event).map_err(ApiError::internal)?; + Ok(event) => { let text = render_output_event(&event).map_err(ApiError::internal)?; + let record = serde_json::to_string(&event).map_err(ApiError::internal)?; state .history .append_result(&run_id, &record) @@ -100,49 +95,25 @@ pub async fn run_request( on_event .send(ExecutionEvent::Output { text }) .map_err(ApiError::internal)?; - } - Ok(hellas_sdk::client::FetchExecutionEvent::Done( - hellas_sdk::client::FetchOutcome::Completed { terminal, .. }, - )) => { - let text = serde_json::to_string(&terminal.to_output_event()) - .map_err(ApiError::internal)?; - let verification = - "Provider identity, signatures, commitments, and Fetch transcript verified"; - state - .history - .append_result(&run_id, &text) - .and_then(|()| state.history.complete(&run_id, verification)) - .map_err(ApiError::internal)?; - on_event - .send(ExecutionEvent::Output { text }) - .and_then(|()| { - on_event.send(ExecutionEvent::Verification { + if matches!(event, hellas_rpc::output::OutputEvent::Finished { .. }) { + let verification = + "Provider Open, funding, signatures, and complete Work result verified"; + state + .history + .complete(&run_id, verification) + .map_err(ApiError::internal)?; + on_event + .send(ExecutionEvent::Verification { summary: verification.into(), }) - }) - .and_then(|()| { - on_event.send(ExecutionEvent::Finished { + .map_err(ApiError::internal)?; + on_event + .send(ExecutionEvent::Finished { run_id: run_id.clone(), }) - }) - .map_err(ApiError::internal)?; - return Ok(run_id); - } - Ok(hellas_sdk::client::FetchExecutionEvent::Done( - hellas_sdk::client::FetchOutcome::Failed { position, error }, - )) => { - let message = format!("Fetch failed at byte {position}: {error}"); - state - .history - .fail(&run_id, &message) - .map_err(ApiError::internal)?; - on_event - .send(ExecutionEvent::Failed { - run_id: run_id.clone(), - message, - }) - .map_err(ApiError::internal)?; - return Ok(run_id); + .map_err(ApiError::internal)?; + return Ok(run_id); + } } Err(error) => { let message = error.to_string(); @@ -202,3 +173,22 @@ pub async fn delete_history(state: State<'_, Arc>, id: String) -> ApiR pub async fn clear_history(state: State<'_, Arc>) -> ApiResult { state.history.clear().map_err(ApiError::internal) } + +#[tauri::command] +pub async fn export_offers( + state: State<'_, Arc>, +) -> ApiResult> { + state.export_offers().await.map_err(ApiError::internal) +} + +#[tauri::command] +pub async fn provision_paid_offer( + state: State<'_, Arc>, + path: String, + preview: bool, +) -> ApiResult { + state + .provision_paid_offer(std::path::Path::new(&path), preview) + .await + .map_err(ApiError::internal) +} diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs index 47379a75..07c6aa3b 100644 --- a/src-tauri/src/dto.rs +++ b/src-tauri/src/dto.rs @@ -30,6 +30,10 @@ pub enum ServiceState { pub struct IdentityStatus { pub producer_id: String, pub caller_public_key: String, + pub contact: String, + pub contact_id: String, + pub apple_app_id: String, + pub apple_cd_hashes: Vec, pub node_id: String, pub attestation: String, pub detail: String, @@ -54,17 +58,17 @@ pub struct AppStatus { #[serde(rename_all = "camelCase")] pub struct RunRequest { pub kind: RunKind, - pub target: String, - #[serde(default)] - pub node_addresses: Vec, pub input: String, - pub trust_anchor: String, - #[serde(default)] - pub service: String, - #[serde(default)] - pub method: String, - #[serde(default)] - pub execution_environment: String, + #[serde(flatten)] + pub client: WorkClientConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "desktop", derive(TS))] +#[cfg_attr(feature = "desktop", ts(export))] +#[serde(rename_all = "camelCase")] +pub struct WorkClientConfig { + pub target: WorkTarget, #[serde(default)] pub assurance: AssuranceInput, #[serde(default)] @@ -73,6 +77,58 @@ pub struct RunRequest { pub apple_cd_hashes: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "desktop", derive(TS))] +#[cfg_attr(feature = "desktop", ts(export))] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum GatewayConfig { + Responses { + #[serde(flatten)] + client: WorkClientConfig, + }, + Http { + paid_pool_path: String, + http_routes_path: String, + assurance: AssuranceInput, + zdr: bool, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "desktop", derive(TS))] +#[cfg_attr(feature = "desktop", ts(export))] +#[serde( + tag = "funding", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum WorkTarget { + Authorized { + offer: String, + resource: String, + }, + Paid { + pool_config: String, + provider: String, + #[cfg_attr(feature = "desktop", ts(optional))] + route: Option, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "desktop", derive(TS))] +#[cfg_attr(feature = "desktop", ts(export))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FetchRouteInput { + pub service: String, + pub method: String, +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "desktop", derive(TS))] #[cfg_attr(feature = "desktop", ts(export))] @@ -142,12 +198,26 @@ pub struct ProviderConfig { pub service: String, pub method: String, pub openai_api_key: String, + #[cfg_attr(feature = "desktop", ts(optional))] + pub work_config_path: Option, + #[cfg_attr(feature = "desktop", ts(optional))] + pub https_config: Option, #[serde(default)] - pub allowed_callers: Vec, + pub contacts: Vec, + pub requests_per_day: u32, #[cfg_attr(feature = "desktop", ts(optional))] pub port: Option, } +#[derive(Clone, Debug, Serialize)] +#[cfg_attr(feature = "desktop", derive(TS))] +#[cfg_attr(feature = "desktop", ts(export))] +#[serde(rename_all = "camelCase")] +pub struct ProviderOffer { + pub contact: String, + pub offer: String, +} + #[cfg(test)] mod tests { use super::ExecutionEvent; diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index c464adf9..2234be72 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -58,7 +58,11 @@ impl History { statement.bind_text(1, &id)?; statement.bind_i64(2, now)?; statement.bind_text(3, &format!("{:?}", request.kind))?; - statement.bind_text(4, &request.target)?; + statement.bind_text( + 4, + &hellas_rpc::ContentId::hash(serde_json::to_string(&request.client.target)?.as_bytes()) + .to_string(), + )?; statement.bind_text(5, &request_json)?; statement.done()?; Ok(id) @@ -363,16 +367,16 @@ mod tests { fn request() -> RunRequest { RunRequest { kind: RunKind::Fetch, - target: "provider-node".into(), - node_addresses: Vec::new(), - input: r#"{"model":"test","input":"hello"}"#.into(), - trust_anchor: "genesis".into(), - service: "openai".into(), - method: "responses".into(), - execution_environment: "environment".into(), - assurance: AssuranceInput::ProducerSigned, - apple_app_id: String::new(), - apple_cd_hashes: Vec::new(), + input: "hello".into(), + client: crate::dto::WorkClientConfig { + target: crate::dto::WorkTarget::Authorized { + offer: "fixture-offer".into(), + resource: "responses".into(), + }, + assurance: AssuranceInput::ProducerSigned, + apple_app_id: String::new(), + apple_cd_hashes: Vec::new(), + }, } } @@ -389,7 +393,7 @@ mod tests { assert_eq!(entries[0].status, "complete"); assert_eq!(entries[0].result, "chunk\n"); assert_eq!(entries[0].verification, "verified"); - assert!(entries[0].request.contains("trustAnchor")); + assert!(entries[0].request.contains("fixture-offer")); assert!(history.delete(&id).unwrap()); assert!(history.list(10).unwrap().is_empty()); } diff --git a/src-tauri/src/host_control.rs b/src-tauri/src/host_control.rs index 08cfcb9d..477be544 100644 --- a/src-tauri/src/host_control.rs +++ b/src-tauri/src/host_control.rs @@ -51,6 +51,16 @@ struct Handler(Arc); #[allow(refining_impl_trait)] impl HostControlHandler for Handler { + async fn grant_control( + &self, + _: hellas_rpc::pb::host::GrantControlRequest, + ) -> Result { + Err(WireStatus::new( + hellas_wire::WireCode::Unimplemented, + "grant terms are managed by Gate provider configuration", + )) + } + async fn get_host_status( &self, _request: GetHostStatusRequest, diff --git a/src-tauri/src/identity.rs b/src-tauri/src/identity.rs index fd8feac8..4a44df41 100644 --- a/src-tauri/src/identity.rs +++ b/src-tauri/src/identity.rs @@ -26,6 +26,34 @@ fn create(path: &Path) -> anyhow::Result { bytes.extend_from_slice(&identity.transport_secret_bytes()); bytes.extend_from_slice(&identity.caller_secret_bytes()); + decode(&persist(path, &bytes)?) +} + +pub fn contact( + path: &Path, + identity: &ClientIdentity, +) -> anyhow::Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == ErrorKind::NotFound => persist( + path, + &hellas_rpc::ProducerSigningKey::generate().to_secret_bytes(), + )?, + Err(error) => return Err(error.into()), + }; + let root = hellas_rpc::ProducerSigningKey::from_secret_bytes( + bytes + .try_into() + .map_err(|_| anyhow::anyhow!("invalid contact root"))?, + )?; + Ok( + hellas_rpc::protocol::work_grant::records::Principal::verify( + identity.contact_enrollment(&root)?, + )?, + ) +} + +fn persist(path: &Path, bytes: &[u8]) -> anyhow::Result> { let directory = path.parent().context("identity path has no parent")?; let mut temporary = tempfile::NamedTempFile::new_in(directory)?; #[cfg(unix)] @@ -33,16 +61,16 @@ fn create(path: &Path) -> anyhow::Result { use std::os::unix::fs::PermissionsExt; fs::Permissions::from_mode(0o600) })?; - temporary.write_all(&bytes)?; + temporary.write_all(bytes)?; temporary.flush()?; temporary.as_file().sync_all()?; match temporary.persist_noclobber(path) { Ok(_) => { #[cfg(unix)] fs::File::open(directory)?.sync_all()?; - Ok(identity) + Ok(bytes.to_vec()) } - Err(_) if path.exists() => decode(&fs::read(path)?), + Err(_) if path.exists() => Ok(fs::read(path)?), Err(error) => Err(error.error).context("persisting Gate identity failed"), } } @@ -97,6 +125,29 @@ mod tests { } } + #[test] + fn contact_root_survives_restart_and_is_distinct_from_the_producer() { + let directory = tempfile::tempdir().unwrap(); + let identity = load_or_create(&directory.path().join("identity")).unwrap(); + let path = directory.path().join("contact-root"); + let first = contact(&path, &identity).unwrap(); + let second = contact(&path, &identity).unwrap(); + assert_eq!(first, second); + assert_ne!( + first.bundle().genesis.statement.root_public_key, + identity.caller_key().public_key() + ); + assert_eq!(first.transport(), *identity.node_id().as_bytes()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + #[test] fn rejects_unknown_format() { assert!(decode(b"not an identity").is_err()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39c8edb6..ff410cf6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod apple; +pub mod chain; #[cfg(feature = "desktop")] pub mod commands; pub mod dto; @@ -7,5 +8,7 @@ pub mod history; #[cfg(unix)] pub mod host_control; pub mod identity; +pub mod paid; pub mod provider_identity; +pub mod provider_setup; pub mod state; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f6b7bcdc..886eb732 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,4 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#![recursion_limit = "256"] use std::sync::Arc; @@ -35,6 +36,8 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ commands::get_status, + commands::export_offers, + commands::provision_paid_offer, commands::set_provider_enabled, commands::set_gateway_enabled, commands::get_gateway_access, @@ -69,7 +72,20 @@ fn install_tray(app: &mut tauri::App) -> tauri::Result<()> { let _ = window.set_focus(); } } - "quit" => app.exit(0), + "quit" => { + let app = app.clone(); + let state = app.state::>().inner().clone(); + tauri::async_runtime::spawn(async move { + match state.shutdown().await { + Ok(false) => {} + Ok(true) => app.exit(0), + Err(error) => { + tracing::error!(%error, "Gate shutdown failed"); + app.exit(1); + } + } + }); + } _ => {} }); if let Some(icon) = app.default_window_icon() { diff --git a/src-tauri/src/paid.rs b/src-tauri/src/paid.rs new file mode 100644 index 00000000..6735e302 --- /dev/null +++ b/src-tauri/src/paid.rs @@ -0,0 +1,79 @@ +//! Gate's provider provisioning boundary. Sessions and funding remain in the SDK. +use anyhow::Context as _; +use hellas_sdk::kernel::{CoinId, Key, Secp256k1Signer}; +use std::path::{Path, PathBuf}; + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ProviderOffer { + work_config: PathBuf, + client: String, + stake_coins: Vec, + bond_timeout: u64, + timeout_payout: u64, + max_job_price: u64, + #[serde(default)] + addresses: Vec, +} + +pub async fn provision( + path: &Path, + identity: &hellas_sdk::ClientIdentity, + provider: hellas_rpc::ProviderEnrollmentBundle, + preview: bool, + chain: &crate::chain::ChainNode, +) -> anyhow::Result { + let file = std::fs::File::open(path)?; + use std::io::Read; + let mut bytes = Vec::new(); + file.take((1 << 20) + 1).read_to_end(&mut bytes)?; + anyhow::ensure!( + bytes.len() <= 1 << 20, + "offer configuration exceeds its byte limit" + ); + let config: ProviderOffer = + serde_json::from_slice(&bytes).context("invalid paid offer configuration")?; + let options = hellas_sdk::work_provision::ProvisionOptions { + work_config: hellas_sdk::work_config::load_work_config(&config.work_config)?, + settlement_key: Secp256k1Signer::from_secret_scalar(identity.caller_secret_bytes()) + .map_err(|_| anyhow::anyhow!("invalid settlement key"))?, + provider, + addresses: config.addresses.iter().map(ToString::to_string).collect(), + client: Key::from_bytes(hex(&config.client)?), + stake_coins: config + .stake_coins + .iter() + .map(|s| hex(s).map(CoinId::from_bytes)) + .collect::>()?, + bond_timeout: config.bond_timeout, + timeout_payout: config.timeout_payout, + max_job_price: config.max_job_price, + }; + if preview { + return Ok(encode_hex( + &hellas_sdk::work_provision::preview_bond(&options)?.to_bytes(), + )); + } + let node = chain.get(&options.work_config).await?; + let made = hellas_sdk::work_provision::provision_offer(options, &node).await?; + Ok(serde_json::to_string_pretty(&serde_json::json!({ + "provider": identity.node_id().to_string(), + "bond": encode_hex(&made.bond_edge.to_bytes()), + "provider_genesis": made.offer, + }))?) +} + +fn hex(value: &str) -> anyhow::Result<[u8; N]> { + anyhow::ensure!( + value.len() == N * 2 && value.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid hexadecimal identifier" + ); + let mut result = [0; N]; + for (index, slot) in result.iter_mut().enumerate() { + *slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)?; + } + Ok(result) +} +fn encode_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src-tauri/src/provider_identity.rs b/src-tauri/src/provider_identity.rs index 0f598489..32ac91fa 100644 --- a/src-tauri/src/provider_identity.rs +++ b/src-tauri/src/provider_identity.rs @@ -8,13 +8,13 @@ mod platform { use anyhow::{Context, bail}; use hellas_attestation::{ AnchorTime, AppleCredential, ApplePolicy, RegisteredAppleCredential, RootProver, - apple_app_attest_root_ca, apple_client_data_hash, apple_credential_identity, - register_apple, verify_apple_assertion, + apple_app_attest_root_ca, apple_credential_identity, register_apple, + verify_apple_provider_genesis, }; use hellas_rpc::{ AppleAppAttestEnrollment, DagCborDecoder, DagCborEncoder, Digest, PlatformCredential, PlatformEnrollment, ProviderEnrollmentBundle, ProviderGenesisStatement, PublicKey, - RootKind, RootProof, SignedProviderGenesis, + RootKind, SignedProviderGenesis, }; use hellas_sdk::ClientIdentity; @@ -60,13 +60,15 @@ mod platform { .duration_since(UNIX_EPOCH) .context("system clock is before the Unix epoch")? .as_secs(); - let credential_identity = apple_credential_identity(&credential.attestation)?; + let credential_identity = apple_credential_identity(&credential.attestation) + .context("reading Apple credential identity")?; let registered = register_apple( &credential, credential_identity.rp_id_hash, apple_app_attest_root_ca(), AnchorTime(validation_time), - )?; + ) + .context("registering Apple App Attest credential")?; let statement = provider_statement(client, installation_nonce, &credential, ®istered); let genesis = SignedProviderGenesis { root_proof: root.prove_statement(&statement.canonical_bytes()).await?, @@ -116,39 +118,31 @@ mod platform { attestation: platform.attestation_object.clone(), client_data_hash: platform.client_data_hash, }; - let credential_identity = apple_credential_identity(&credential.attestation)?; + let credential_identity = apple_credential_identity(&credential.attestation) + .context("reading persisted Apple credential identity")?; let registered = register_apple( &credential, credential_identity.rp_id_hash, apple_app_attest_root_ca(), AnchorTime(platform.validation_time), - )?; + ) + .context("registering persisted Apple App Attest credential")?; let statement = &enrollment.genesis.statement; anyhow::ensure!( - statement.root_kind == RootKind::SecureEnclave - && statement.root_public_key == PublicKey::P256(registered.public_key) - && statement.producer_public_key == client.caller_key().public_key() + statement.producer_public_key == client.caller_key().public_key() && statement.transport_public_key - == PublicKey::Ed25519(*client.node_id().as_bytes()) - && statement.platform_credential - == PlatformCredential::Registered(credential.content_id()), + == PublicKey::Ed25519(*client.node_id().as_bytes()), "persisted Apple provider identity does not match this Gate installation" ); - let RootProof::AppleAppAttest(assertion) = &enrollment.genesis.root_proof else { - bail!("Apple provider identity requires an App Attest root proof"); - }; - verify_apple_assertion( - assertion, - &apple_client_data_hash(&statement.canonical_bytes()), - &RegisteredAppleCredential { - id: credential.content_id(), - public_key: registered.public_key, - }, + verify_apple_provider_genesis( + &enrollment.genesis, + ®istered, &ApplePolicy { expected_rp_id_hash: credential_identity.rp_id_hash, allowed_cd_hashes: vec![credential_identity.cd_hash], }, - )?; + ) + .context("verifying persisted Apple provider genesis")?; Ok(()) } diff --git a/src-tauri/src/provider_setup.rs b/src-tauri/src/provider_setup.rs new file mode 100644 index 00000000..8b9d4b43 --- /dev/null +++ b/src-tauri/src/provider_setup.rs @@ -0,0 +1,196 @@ +//! Provider assembly is typechecked on every host platform. +use crate::{dto::ProviderConfig, state::decode_record}; +use anyhow::Context as _; +use hellas_rpc::protocol::work_grant::{ + budget::{Limit, Meter, Window}, + records::Principal, + resource::HttpsResource, +}; +use std::{ + num::NonZeroU64, + path::{Path, PathBuf}, + sync::Arc, +}; + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct HttpsRoute { + service: String, + method: String, + account: hellas_sdk::HttpProviderConfig, + resource: Option, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Resource { + name: String, + https: HttpsResource, +} + +pub async fn start( + config: ProviderConfig, + identity: hellas_sdk::ClientIdentity, + enrollment: hellas_rpc::ProviderEnrollmentBundle, + root: Arc, + state_directory: PathBuf, + chain: &crate::chain::ChainNode, +) -> anyhow::Result { + anyhow::ensure!( + config.contacts.len() <= 256, + "at most 256 contacts are allowed" + ); + let grantees = config + .contacts + .iter() + .map(|record| Ok(Principal::decode(&decode_record(record, 32 * 1024)?)?)) + .collect::>>()?; + let paid_work = config + .work_config_path + .as_deref() + .map(|path| hellas_sdk::work_config::load_work_config(Path::new(path))) + .transpose()?; + let paid_work = match paid_work { + Some(config) => { + let node = chain.get(&config).await?; + Some(hellas_sdk::PaidProviderOptions::new(config, node)?) + } + None => None, + }; + let mut routes = hellas_sdk::FetchRouteRegistry::new(); + let mut policies = vec![]; + if !config.openai_api_key.trim().is_empty() { + policies.push(hellas_sdk::responses_policy( + &config.service, + &config.method, + )?); + routes.register( + hellas_sdk::FetchRoute::new(&config.service, &config.method), + hellas_sdk::FetchRouteEntry::new( + Arc::new(hellas_sdk::OpenAiResponsesFetchProvider::with_bearer( + config.openai_api_key, + )?), + Arc::new(hellas_sdk::ResponsesFetchAdaptorFactory::new( + hellas_rpc::FetchEnvironment::OpenAiResponses, + )), + hellas_sdk::FetchRoutePolicy::default(), + )?, + )?; + } + if let Some(json) = config.https_config { + anyhow::ensure!( + json.len() <= 1 << 20, + "HTTPS configuration exceeds its byte limit" + ); + let entries: Vec = + serde_json::from_str(&json).context("invalid HTTPS routes configuration")?; + for entry in entries { + if let Some(resource) = entry.resource { + let mut policy = hellas_sdk::responses_policy(&entry.service, &entry.method)?; + policy.name = resource.name; + policy.https = Some(resource.https); + if let hellas_rpc::protocol::work_profile::WorkPolicy::Fetch { policy, .. } = + &mut policy.work + { + policy.allowed_environment = hellas_rpc::FetchEnvironment::Http.manifest_id(); + } + policies.push(policy); + } + routes.register( + hellas_sdk::FetchRoute::new(entry.service, entry.method), + entry + .account + .into_entry(hellas_sdk::FetchRoutePolicy::default())?, + )?; + } + } + // Keep the grant store mounted even with an empty contact list so removals + // revoke existing grants before the paid listener is opened. + hellas_sdk::start_fetch_provider(hellas_sdk::FetchProviderOptions { + port: config.port, + identity, + enrollment, + root, + state_directory, + routes, + grants: Some(hellas_sdk::GrantProviderOptions { + grantees, + policies, + limits: vec![Limit { + meter: Meter::Requests, + window: Window::Day, + amount: config.requests_per_day.into(), + }], + max_job_millis: NonZeroU64::new(300_000).unwrap(), + }), + paid_work, + fetch_max_in_flight: hellas_rpc::DEFAULT_FETCH_MAX_IN_FLIGHT, + fetch_queue_capacity: hellas_rpc::DEFAULT_FETCH_QUEUE_CAPACITY, + }) + .await + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + struct SoftwareRoot; + impl hellas_sdk::RootProver for SoftwareRoot { + async fn prove_statement( + &self, + _: &[u8], + ) -> Result { + panic!("enrollment already signed") + } + async fn prove_open_binding( + &self, + _: hellas_rpc::Digest, + ) -> Result { + panic!("software Open uses producer") + } + } + + #[tokio::test] + async fn provider_exports_responses_and_https_resources_from_one_runtime() { + let directory = tempfile::tempdir().unwrap(); + let identity = hellas_sdk::ClientIdentity::generate(); + let enrollment = identity + .contact_enrollment(&hellas_rpc::ProducerSigningKey::generate()) + .unwrap(); + let template = HttpsResource { + origin: "https://example.com".into(), + paths: vec!["/v1/chat/completions".into()], + methods: vec!["POST".into()], + credential: None, + tls: hellas_rpc::http_fetch::HttpTls { + roots: hellas_rpc::http_fetch::HttpTrustRoots::WebPki, + spki_sha256: vec![], + }, + accounting: hellas_rpc::http_usage::AccountingProfile::OpenaiChat, + max_output_tokens: 100, + max_response_bytes: 4096, + }; + let chain = crate::chain::ChainNode::new(directory.path().join("chain")); + let provider = start(ProviderConfig { + service: "openai".into(), method: "responses".into(), openai_api_key: "fixture".into(), + work_config_path: None, + https_config: Some(serde_json::json!([{ + "service": "lan", "method": "chat", "account": {"allowed_hosts":["example.com"]}, + "resource": {"name":"chat", "https":template} + }]).to_string()), + contacts: vec![STANDARD.encode(enrollment.canonical_bytes())], requests_per_day: 100, port: Some(0), + }, identity, enrollment, Arc::new(SoftwareRoot), directory.path().join("provider"), &chain).await.unwrap(); + let offers = provider.offers().unwrap(); + assert_eq!(offers.len(), 1); + let policies = &offers[0].offer().grant.policies; + assert_eq!(policies.len(), 2); + assert_eq!(policies[0].name, "responses"); + assert_eq!(policies[1].https.as_ref(), Some(&template)); + provider.shutdown().await.unwrap(); + assert!( + !directory.path().join("chain").exists(), + "authorized-only provider starts no chain node" + ); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 39831a86..7c918c86 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -2,36 +2,63 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use hellas_gateway::{WorkExecutionBackend, WorkFetchRequest}; +use hellas_rpc::protocol::{ + work_fetch::FetchRoutePolicy, + work_grant::{ + UnixMillis, + records::{Principal, SignedOffer}, + }, + work_profile::WorkPolicy, +}; +use hellas_sdk::grant_client::{GrantSessionOptions, GrantTransport, PinnedOffer, UnpinnedOffer}; +use hellas_sdk::grant_gateway::GrantGateway; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, OnceCell, RwLock}; use crate::apple; use crate::dto::{ - AppStatus, AssuranceInput, GatewayAccess, IdentityStatus, ProviderConfig, RunKind, RunRequest, - ServiceState, ServiceStatus, + AppStatus, AssuranceInput, GatewayAccess, GatewayConfig, IdentityStatus, ProviderConfig, + RunKind, RunRequest, ServiceState, ServiceStatus, WorkClientConfig, WorkTarget, }; use crate::history::History; use crate::identity; +#[derive(Clone)] +struct ClientTarget { + backend: Arc, + provider: hellas_sdk::iroh::EndpointId, + service: String, + method: String, + environment: hellas_rpc::ContentId, +} + struct RuntimeStatus { + stopping: bool, provider: ServiceStatus, provider_handle: Option, gateway: ServiceStatus, gateway_access: Option, gateway_handle: Option, - gateway_config: Option, + gateway_config: Option, } pub struct AppState { pub history: History, socket_path: PathBuf, counter_directory: PathBuf, + archive_directory: PathBuf, identity: hellas_sdk::ClientIdentity, + chain: crate::chain::ChainNode, #[cfg(target_os = "macos")] provider_identity_path: PathBuf, #[cfg(target_os = "macos")] provider_state_directory: PathBuf, provider_identity: OnceCell>, - client: OnceCell, + contact: Principal, + journal_root: PathBuf, + client: Mutex>, lifecycle: Mutex<()>, runtime: RwLock, } @@ -39,19 +66,25 @@ pub struct AppState { impl AppState { pub fn open(data_dir: &Path) -> anyhow::Result { let identity = identity::load_or_create(&data_dir.join("identity"))?; + let contact = identity::contact(&data_dir.join("contact-root"), &identity)?; Ok(Self { history: History::open(&data_dir.join("history.sqlite3"))?, socket_path: data_dir.join("gate.sock"), counter_directory: data_dir.join("apple-assertion-counters"), + archive_directory: data_dir.join("gateway-archive"), identity, + chain: crate::chain::ChainNode::new(data_dir.join("chain")), #[cfg(target_os = "macos")] provider_identity_path: data_dir.join("provider-identity"), #[cfg(target_os = "macos")] provider_state_directory: data_dir.join("provider"), provider_identity: OnceCell::new(), - client: OnceCell::new(), + contact, + journal_root: data_dir.join("work-channels"), + client: Mutex::new(None), lifecycle: Mutex::new(()), runtime: RwLock::new(RuntimeStatus { + stopping: false, provider: stopped("Not configured"), provider_handle: None, gateway: stopped("Not configured"), @@ -73,6 +106,18 @@ impl AppState { identity: IdentityStatus { producer_id: identity::producer_id(&self.identity), caller_public_key: identity::public_key(&self.identity), + contact: STANDARD.encode(self.contact.bundle().canonical_bytes()), + contact_id: self.contact.id().0.to_string(), + apple_app_id: option_env!("HELLAS_GATE_TRUST_APP_ID") + .unwrap_or_default() + .into(), + apple_cd_hashes: option_env!("HELLAS_GATE_TRUST_CDHASHES") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(), node_id: self.identity.node_id().to_string(), attestation: if self.provider_identity.get().is_some() { "apple-app-attest".into() @@ -102,6 +147,10 @@ impl AppState { config: Option, ) -> anyhow::Result { let _lifecycle = self.lifecycle.lock().await; + anyhow::ensure!( + !enabled || !self.runtime.read().await.stopping, + "Gate is shutting down" + ); if !enabled { let handle = { let mut runtime = self.runtime.write().await; @@ -111,8 +160,14 @@ impl AppState { }; runtime.provider_handle.take() }; - if let Some(handle) = handle { - handle.shutdown().await; + if let Some(handle) = handle + && let Err(error) = handle.shutdown().await + { + self.runtime.write().await.provider = ServiceStatus { + state: ServiceState::Failed, + detail: error.to_string(), + }; + return Err(error.into()); } self.runtime.write().await.provider = stopped("Stopped by user"); return Ok(self.status().await); @@ -124,7 +179,7 @@ impl AppState { drop(runtime); return Ok(self.status().await); } - config.context("configure an upstream and allowed caller before serving")? + config.context("configure an upstream and contacts before serving")? }; self.runtime.write().await.provider = ServiceStatus { state: ServiceState::Starting, @@ -163,23 +218,6 @@ impl AppState { &self, config: ProviderConfig, ) -> anyhow::Result { - anyhow::ensure!( - !config.service.trim().is_empty() && !config.method.trim().is_empty(), - "Fetch service and method must be non-empty" - ); - anyhow::ensure!( - !config.openai_api_key.trim().is_empty(), - "OpenAI API key must be non-empty" - ); - let callers = config - .allowed_callers - .iter() - .map(|key| decode_public_key(key)) - .collect::>>()?; - anyhow::ensure!( - !callers.is_empty(), - "at least one allowed caller is required" - ); let provider_identity = self .provider_identity .get_or_try_init(|| async { @@ -191,21 +229,14 @@ impl AppState { .map(Arc::new) }) .await?; - hellas_sdk::start_openai_provider(hellas_sdk::OpenAiProviderOptions { - port: config.port, - identity: self.identity.clone(), - enrollment: provider_identity.enrollment().clone(), - root: provider_identity.root(), - state_directory: self.provider_state_directory.clone(), - service: config.service, - method: config.method, - bearer_token: config.openai_api_key, - allowed_callers: callers, - fetch_max_in_flight: hellas_rpc::DEFAULT_FETCH_MAX_IN_FLIGHT, - fetch_queue_capacity: hellas_rpc::DEFAULT_FETCH_QUEUE_CAPACITY, - retained_transcript_capacity: hellas_rpc::DEFAULT_FETCH_RETAINED_TRANSCRIPT_CAPACITY, - fetch_replay_max_in_flight: hellas_rpc::DEFAULT_FETCH_REPLAY_MAX_IN_FLIGHT, - }) + crate::provider_setup::start( + config, + self.identity.clone(), + provider_identity.enrollment().clone(), + provider_identity.root(), + self.provider_state_directory.clone(), + &self.chain, + ) .await } @@ -220,9 +251,13 @@ impl AppState { pub async fn set_gateway_enabled( &self, enabled: bool, - config: Option, + config: Option, ) -> anyhow::Result { let _lifecycle = self.lifecycle.lock().await; + anyhow::ensure!( + !enabled || !self.runtime.read().await.stopping, + "Gate is shutting down" + ); if !enabled { let handle = { let mut runtime = self.runtime.write().await; @@ -235,6 +270,7 @@ impl AppState { }; if let Some(handle) = handle { handle.shutdown().await?; + self.client.lock().await.take(); } self.runtime.write().await.gateway = stopped("Stopped by user"); return Ok(self.status().await); @@ -254,12 +290,11 @@ impl AppState { .clone() .context("configure a trusted Fetch provider before starting the gateway")? }; - let options = self.fetch_gateway_options(&config)?; self.runtime.write().await.gateway = ServiceStatus { state: ServiceState::Starting, detail: "Binding a private loopback endpoint".into(), }; - match hellas_sdk::gateway::start_fetch(options).await { + match self.start_gateway(&config).await { Ok(handle) => { let access = GatewayAccess { address: format!("http://{}", handle.address()), @@ -268,7 +303,7 @@ impl AppState { let mut runtime = self.runtime.write().await; runtime.gateway = ServiceStatus { state: ServiceState::Running, - detail: format!("Responses endpoint at {}/v1/responses", access.address), + detail: format!("Gateway at {}", access.address), }; runtime.gateway_access = Some(access); runtime.gateway_handle = Some(handle); @@ -276,6 +311,7 @@ impl AppState { Ok(self.status().await) } Err(error) => { + self.client.lock().await.take(); self.runtime.write().await.gateway = ServiceStatus { state: ServiceState::Failed, detail: error.to_string(), @@ -285,102 +321,310 @@ impl AppState { } } + async fn start_gateway( + &self, + config: &GatewayConfig, + ) -> anyhow::Result { + match config { + GatewayConfig::Responses { client } => { + let target = self.client_for(client).await?; + anyhow::ensure!( + target.environment + == hellas_rpc::FetchEnvironment::OpenAiResponses.manifest_id(), + "the Responses gateway requires an OpenAI Responses resource" + ); + let options = hellas_gateway::FetchGatewayOptions { + host: "127.0.0.1".into(), + port: Some(0), + provider: target.provider, + service: target.service, + method: target.method, + request_overrides: Default::default(), + work: target.backend, + }; + + Ok(hellas_gateway::start_fetch(options).await?) + } + GatewayConfig::Http { + paid_pool_path, + http_routes_path, + assurance: required, + zdr, + } => { + let bytes = hellas_private::read_bounded_regular_file( + Path::new(http_routes_path), + 4 << 20, + )?; + let routes = serde_json::from_slice(&bytes).context("invalid HTTP routes")?; + let options = hellas_sdk::paid_gateway::load_pool_options( + Path::new(paid_pool_path), + assurance(*required), + )?; + let entry = options + .providers + .first() + .context("paid pool has no providers")?; + let node = self.chain.get(&entry.config).await?; + let paid = hellas_sdk::paid_gateway::PaidGateway::open( + options, + self.identity.clone(), + node, + ) + .await?; + Ok( + hellas_sdk::gateway::start_http(hellas_sdk::gateway::HttpGatewayOptions { + config: routes, + paid, + archive: hellas_sdk::gateway::ArchiveOptions { + directory: self.archive_directory.clone(), + zdr: *zdr, + }, + host: "127.0.0.1".into(), + port: Some(0), + bearer_token_file: None, + allow_remote: false, + wrap: None, + wrap_args: Vec::new(), + }) + .await?, + ) + } + } + } + pub async fn gateway_access(&self) -> Option { self.runtime.read().await.gateway_access.clone() } - fn fetch_gateway_options( - &self, - request: &RunRequest, - ) -> anyhow::Result { - if !matches!(request.kind, RunKind::Fetch) { - bail!("the minimal loopback gateway supports sealed Fetch Responses") - } - let (node_id, node_addrs, execution_environment, assurance, provider_trust) = - self.remote_fetch_parameters(request)?; - Ok(hellas_sdk::gateway::FetchGatewayOptions { - host: "127.0.0.1".into(), - port: Some(0), - node_id: Some(node_id), - node_addrs, - retries: 1, - service: request.service.clone(), - method: request.method.clone(), - execution_environment, - request_overrides: serde_json::Map::new(), - provider_trust, - caller_key: self.identity.caller_key().clone(), - assurance, - secret_key: self.identity.transport_key(), - }) + /// The first quit request drains every accepted operation before exit. + pub async fn shutdown(&self) -> anyhow::Result { + let _lifecycle = self.lifecycle.lock().await; + let (gateway, provider) = { + let mut runtime = self.runtime.write().await; + if runtime.stopping { + return Ok(false); + } + runtime.stopping = true; + ( + runtime.gateway_handle.take(), + runtime.provider_handle.take(), + ) + }; + let client = self.client.lock().await.take(); + let gateway_result = if let Some(gateway) = gateway { + gateway.shutdown().await.map_err(anyhow::Error::from) + } else if let Some((_, client)) = client { + client.backend.drain().await.map_err(anyhow::Error::from) + } else { + Ok(()) + }; + let provider_result = if let Some(provider) = provider { + provider.shutdown().await.map_err(anyhow::Error::from) + } else { + Ok(()) + }; + let chain_result = self.chain.shutdown().await; + gateway_result.and(provider_result).and(chain_result)?; + Ok(true) + } + + pub async fn export_offers(&self) -> anyhow::Result> { + self.runtime + .read() + .await + .provider_handle + .as_ref() + .context("start the provider before exporting Offers")? + .offers()? + .into_iter() + .map(|offer| { + Ok(crate::dto::ProviderOffer { + contact: offer.offer().grant.kind.principal().id().0.to_string(), + offer: STANDARD.encode(offer.encode()?), + }) + }) + .collect() } pub async fn fetch( &self, request: &RunRequest, - ) -> anyhow::Result< - std::pin::Pin< - Box< - dyn futures::Stream< - Item = hellas_sdk::client::ClientResult< - hellas_sdk::client::FetchExecutionEvent, - >, - > + Send, - >, - >, - > { - if !matches!(request.kind, RunKind::Fetch) { - bail!("causal-LM execution is not configured in this build") + ) -> anyhow::Result> { + let _lifecycle = self.lifecycle.lock().await; + anyhow::ensure!( + matches!(request.kind, RunKind::Fetch), + "select a Fetch target" + ); + let target = self.client_for(&request.client).await?; + Ok(target.backend.fetch(WorkFetchRequest { + provider: target.provider, + service: target.service, + method: target.method, + body: request.input.as_bytes().to_vec(), + })?) + } + + async fn client_for(&self, request: &WorkClientConfig) -> anyhow::Result { + anyhow::ensure!(!self.runtime.read().await.stopping, "Gate is shutting down"); + + let key = serde_json::to_string(&( + &request.target, + &request.assurance, + &request.apple_app_id, + &request.apple_cd_hashes, + ))?; + let mut client = self.client.lock().await; + if let Some((old, target)) = client.as_ref() + && old == &key + { + return Ok(target.clone()); } - let (node_id, node_addrs, execution_environment, assurance, provider_trust) = - self.remote_fetch_parameters(request)?; - let client = self - .client - .get_or_try_init(|| hellas_sdk::HellasClient::open_with_identity(self.identity.clone())) - .await?; - client - .fetch(hellas_sdk::RemoteFetchRequest { - node_id: Some(node_id), - node_addrs, - retries: 1, - service: request.service.clone(), - method: request.method.clone(), - execution_environment, - payload: request.input.as_bytes().to_vec(), - retention: hellas_rpc::Retention::Ephemeral, - assurance, - provider_trust, + anyhow::ensure!( + self.runtime.read().await.gateway_handle.is_none(), + "stop the gateway before switching target or trust policy" + ); + if let Some((_, previous)) = client.take() { + previous.backend.drain().await?; + } + let target = match &request.target { + WorkTarget::Authorized { + offer: record, + resource, + } => { + let offer = self.pinned_offer(request, record)?; + SignedOffer::decode( + &offer.signed().encode()?, + self.contact.id(), + UnixMillis(u64::try_from( + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(), + )?), + )?; + let policy = offer + .offer() + .grant + .policies + .iter() + .find(|p| p.name == *resource) + .context("Offer does not name this resource")?; + let (service, method, environment) = fetch_route(&policy.work)?; + let provider = hellas_sdk::iroh::EndpointId::from_bytes( + &offer.offer().provider.grant_transport()?, + )?; + let endpoint = + hellas_sdk::iroh::Endpoint::builder(hellas_sdk::iroh::endpoint::presets::N0) + .secret_key(self.identity.transport_key()) + .bind() + .await?; + let opened = GrantGateway::open( + GrantSessionOptions { + target: offer.clone(), + client: self.contact.clone(), + signer: Arc::new(self.identity.caller_key().clone()), + journal_root: self.journal_root.clone(), + timeout: Duration::from_millis(offer.offer().grant.max_job_millis.get()), + }, + GrantTransport::Remote(endpoint.clone()), + Some(resource.clone()), + ) + .await; + let backend = match opened { + Ok(backend) => backend, + Err(error) => { + endpoint.close().await; + return Err(error.into()); + } + }; + ClientTarget { + backend, + provider, + service, + method, + environment, + } + } + WorkTarget::Paid { + pool_config, + provider, + route, + } => { + let options = hellas_sdk::paid_gateway::load_pool_options( + Path::new(pool_config), + assurance(request.assurance), + )?; + let provider: hellas_sdk::iroh::EndpointId = provider.parse()?; + let entry = options + .providers + .iter() + .find(|p| p.provider == provider) + .context("pool does not name this provider")?; + let (service, method, environment) = + paid_route(&entry.config.work_policy, route.as_ref())?; + let node = self.chain.get(&entry.config).await?; + let backend = hellas_sdk::paid_gateway::PaidGateway::open( + options, + self.identity.clone(), + node, + ) + .await?; + ClientTarget { + backend, + provider, + service, + method, + environment, + } + } + }; + *client = Some((key, target.clone())); + Ok(target) + } + + pub async fn provision_paid_offer(&self, path: &Path, preview: bool) -> anyhow::Result { + let _lifecycle = self.lifecycle.lock().await; + anyhow::ensure!(!self.runtime.read().await.stopping, "Gate is shutting down"); + anyhow::ensure!( + self.runtime.read().await.provider_handle.is_none(), + "stop the provider before provisioning a bond" + ); + #[cfg(target_os = "macos")] + let provider = self + .provider_identity + .get_or_try_init(|| async { + crate::provider_identity::load_or_create( + &self.provider_identity_path, + &self.identity, + ) + .await + .map(Arc::new) }) - .map_err(Into::into) + .await?; + #[cfg(not(target_os = "macos"))] + let provider = self + .provider_identity + .get() + .context("the attested provider requires a provisioned macOS Gate build")?; + crate::paid::provision( + path, + &self.identity, + provider.enrollment().clone(), + preview, + &self.chain, + ) + .await } - fn remote_fetch_parameters( + fn pinned_offer( &self, - request: &RunRequest, - ) -> anyhow::Result<( - hellas_sdk::iroh::EndpointId, - Vec, - hellas_rpc::ContentId, - hellas_rpc::Assurance, - hellas_sdk::client::ProviderTrustAnchor, - )> { - let node_id = request - .target - .parse() - .context("invalid provider endpoint ID")?; - let node_addrs = request - .node_addresses - .iter() - .map(|address| address.parse().context("invalid provider socket address")) - .collect::>>()?; - let expected_genesis = request - .trust_anchor - .parse() - .context("invalid provider genesis content ID")?; - let execution_environment = request - .execution_environment - .parse() - .context("invalid execution-environment content ID")?; + request: &WorkClientConfig, + record: &str, + ) -> anyhow::Result { + let bytes = decode_record( + record, + hellas_rpc::protocol::work_grant::records::MAX_OFFER_BYTES, + )?; + let now = UnixMillis(0); // A running session refreshes standing independently of bootstrap expiry. + let signed = SignedOffer::decode(&bytes, self.contact.id(), now)?; + let expected_genesis = signed.offer().provider.content_id(); let assurance = match request.assurance { AssuranceInput::ProducerSigned => hellas_rpc::Assurance::ProducerSigned, AssuranceInput::AppleAppAttest => hellas_rpc::Assurance::AppleAppAttest, @@ -409,13 +653,7 @@ impl AppState { required_assurance: assurance, apple_app_attest, }; - Ok(( - node_id, - node_addrs, - execution_environment, - assurance, - provider_trust, - )) + Ok(UnpinnedOffer::decode(&bytes, self.contact.id(), now)?.pin(&provider_trust)?) } } @@ -438,16 +676,166 @@ fn decode_hash(value: &str) -> anyhow::Result<[u8; 32]> { Ok(output) } -#[cfg(target_os = "macos")] -fn decode_public_key(value: &str) -> anyhow::Result { - if value.len() != 66 { - bail!("allowed caller public keys must contain 66 hexadecimal characters") +pub(crate) fn decode_record(record: &str, max: usize) -> anyhow::Result> { + anyhow::ensure!( + record.len() <= max.div_ceil(3) * 4, + "imported record exceeds size limit" + ); + STANDARD + .decode(record.trim()) + .context("record must be base64 exported by Hellas") +} + +fn fetch_route(policy: &WorkPolicy) -> anyhow::Result<(String, String, hellas_rpc::ContentId)> { + match policy { + WorkPolicy::Fetch { + policy, + route: FetchRoutePolicy::SealedRoute { service, method }, + } => Ok((service.clone(), method.clone(), policy.allowed_environment)), + _ => bail!("target requires a sealed Fetch resource"), } - let mut output = [0_u8; 33]; - for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() { - let text = std::str::from_utf8(pair)?; - output[index] = - u8::from_str_radix(text, 16).context("allowed caller public key is not hexadecimal")?; +} + +fn assurance(value: AssuranceInput) -> hellas_rpc::Assurance { + match value { + AssuranceInput::ProducerSigned => hellas_rpc::Assurance::ProducerSigned, + AssuranceInput::AppleAppAttest => hellas_rpc::Assurance::AppleAppAttest, + } +} + +fn paid_route( + policy: &WorkPolicy, + selected: Option<&crate::dto::FetchRouteInput>, +) -> anyhow::Result<(String, String, hellas_rpc::ContentId)> { + let route = match policy { + WorkPolicy::Fetch { + policy, + route: FetchRoutePolicy::OpenFetch { .. }, + } => { + let selected = + selected.context("select the provider's HTTPS Fetch service and method")?; + FetchRoutePolicy::sealed_route(&selected.service, &selected.method)?; + ( + selected.service.clone(), + selected.method.clone(), + policy.allowed_environment, + ) + } + _ => fetch_route(policy)?, + }; + if let Some(selected) = selected { + anyhow::ensure!( + selected.service == route.0 && selected.method == route.1, + "selected route differs from the paid policy" + ); + } + Ok(route) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run the test executable inside a signed, provisioned .app. The pins come + /// from that app's signing metadata, not from the imported Offer. + #[cfg(target_os = "macos")] + #[tokio::test] + #[ignore = "requires a provisioned App Attest bundle and HELLAS_GATE_TEST_APP_ID/CDHASH"] + async fn provisioned_provider_opens_pinned_grant_sessions_after_restart() { + let directory = tempfile::tempdir().unwrap(); + let provider_dir = directory.path().join("provider"); + let client_dir = directory.path().join("client"); + std::fs::create_dir_all(&provider_dir).unwrap(); + std::fs::create_dir_all(&client_dir).unwrap(); + let client = AppState::open(&client_dir).unwrap(); + let mut enrollment = None; + for _ in 0..2 { + let provider = AppState::open(&provider_dir).unwrap(); + provider + .set_provider_enabled( + true, + Some(ProviderConfig { + service: "openai".into(), + method: "responses".into(), + openai_api_key: "unused-handshake-fixture".into(), + work_config_path: None, + https_config: None, + contacts: vec![STANDARD.encode(client.contact.bundle().canonical_bytes())], + requests_per_day: 2, + port: Some(0), + }), + ) + .await + .unwrap(); + let genesis = provider + .provider_identity + .get() + .unwrap() + .enrollment() + .content_id(); + if let Some(previous) = enrollment { + assert_eq!(previous, genesis, "restart preserves the enrolled identity"); + } + enrollment = Some(genesis); + let offers = provider.export_offers().await.unwrap(); + assert_eq!(offers.len(), 1); + let target = client + .client_for(&WorkClientConfig { + target: WorkTarget::Authorized { + offer: offers[0].offer.clone(), + resource: "responses".into(), + }, + assurance: AssuranceInput::AppleAppAttest, + apple_app_id: std::env::var("HELLAS_GATE_TEST_APP_ID").unwrap(), + apple_cd_hashes: vec![std::env::var("HELLAS_GATE_TEST_CDHASH").unwrap()], + }) + .await + .unwrap(); + assert_eq!( + target.environment, + hellas_rpc::FetchEnvironment::OpenAiResponses.manifest_id() + ); + target.backend.drain().await.unwrap(); + client.client.lock().await.take(); + provider.shutdown().await.unwrap(); + } + client.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn invalid_http_pool_exposes_no_listener_and_retains_configuration() { + let directory = tempfile::tempdir().unwrap(); + let routes = directory.path().join("routes.json"); + std::fs::write(&routes, r#"{"service":"http","method":"fetch"}"#).unwrap(); + let state = AppState::open(directory.path()).unwrap(); + let config = GatewayConfig::Http { + paid_pool_path: directory + .path() + .join("missing-pool.json") + .display() + .to_string(), + http_routes_path: routes.display().to_string(), + assurance: AssuranceInput::ProducerSigned, + zdr: true, + }; + let error = state + .set_gateway_enabled(true, Some(config)) + .await + .unwrap_err(); + assert!(error.to_string().contains("missing-pool.json"), "{error}"); + assert!(matches!( + state.status().await.gateway.state, + ServiceState::Failed + )); + assert!(state.gateway_access().await.is_none()); + assert!(state.runtime.read().await.gateway_handle.is_none()); + assert!(matches!( + state.runtime.read().await.gateway_config, + Some(GatewayConfig::Http { zdr: true, .. }) + )); + assert!(state.set_gateway_enabled(true, None).await.is_err()); + assert!(state.shutdown().await.unwrap()); + assert!(!state.shutdown().await.unwrap()); + assert!(!directory.path().join("chain").exists()); } - Ok(hellas_rpc::PublicKey::Secp256k1(output)) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d5d21a59..6cfcd810 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "beforeDevCommand": "node ui/dev.mjs", "beforeBuildCommand": "tsc --noEmit -p ui/tsconfig.json && node ui/build.mjs", "devUrl": "http://localhost:1420", - "frontendDist": "/tmp/hellas-gate-ui-dist" + "frontendDist": "../target/ui-dist" }, "app": { "withGlobalTauri": true, diff --git a/ui/build.mjs b/ui/build.mjs index 252e025f..9cb6b46b 100644 --- a/ui/build.mjs +++ b/ui/build.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; const source = dirname(fileURLToPath(import.meta.url)); -const output = "/tmp/hellas-gate-ui-dist"; +const output = join(source, "../target/ui-dist"); await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); diff --git a/ui/dev.mjs b/ui/dev.mjs index 57e5b81e..98aba805 100644 --- a/ui/dev.mjs +++ b/ui/dev.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; const source = dirname(fileURLToPath(import.meta.url)); -const output = "/tmp/hellas-gate-ui-dev"; +const output = join(source, "../target/ui-dev"); await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); diff --git a/ui/src/api.ts b/ui/src/api.ts index c076735a..a5dd17ae 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -5,8 +5,10 @@ import type { AppStatus, ExecutionEvent, GatewayAccess, + GatewayConfig, HistoryEntry, ProviderConfig, + ProviderOffer, RunRequest, } from "./types.ts"; @@ -47,9 +49,11 @@ async function call(command: string, args?: Record): Promise export const api = { status: (): Promise => call("get_status"), + provisionPaidOffer: (path: string, preview: boolean): Promise => call("provision_paid_offer", { path, preview }), + exportOffers: (): Promise => call("export_offers"), setProvider: (enabled: boolean, config?: ProviderConfig): Promise => call("set_provider_enabled", { enabled, config }), - setGateway: (enabled: boolean, config?: RunRequest): Promise => + setGateway: (enabled: boolean, config?: GatewayConfig): Promise => call("set_gateway_enabled", { enabled, config }), gatewayAccess: (): Promise => call("get_gateway_access"), history: (limit = 100): Promise => call("list_history", { limit }), diff --git a/ui/src/generated/FetchRouteInput.ts b/ui/src/generated/FetchRouteInput.ts new file mode 100644 index 00000000..0bb8a434 --- /dev/null +++ b/ui/src/generated/FetchRouteInput.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FetchRouteInput = { service: string, method: string, }; diff --git a/ui/src/generated/GatewayConfig.ts b/ui/src/generated/GatewayConfig.ts new file mode 100644 index 00000000..db7d24e2 --- /dev/null +++ b/ui/src/generated/GatewayConfig.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssuranceInput } from "./AssuranceInput.ts"; +import type { WorkTarget } from "./WorkTarget.ts"; + +export type GatewayConfig = { "kind": "responses", target: WorkTarget, assurance: AssuranceInput, appleAppId: string, appleCdHashes: Array, } | { "kind": "http", paidPoolPath: string, httpRoutesPath: string, assurance: AssuranceInput, zdr: boolean, }; diff --git a/ui/src/generated/IdentityStatus.ts b/ui/src/generated/IdentityStatus.ts index ef546584..af46f812 100644 --- a/ui/src/generated/IdentityStatus.ts +++ b/ui/src/generated/IdentityStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type IdentityStatus = { producerId: string, callerPublicKey: string, nodeId: string, attestation: string, detail: string, }; +export type IdentityStatus = { producerId: string, callerPublicKey: string, contact: string, contactId: string, appleAppId: string, appleCdHashes: Array, nodeId: string, attestation: string, detail: string, }; diff --git a/ui/src/generated/ProviderConfig.ts b/ui/src/generated/ProviderConfig.ts index 2dc70b99..7d5cfb62 100644 --- a/ui/src/generated/ProviderConfig.ts +++ b/ui/src/generated/ProviderConfig.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ProviderConfig = { service: string, method: string, openaiApiKey: string, allowedCallers: Array, port?: number, }; +export type ProviderConfig = { service: string, method: string, openaiApiKey: string, workConfigPath?: string, httpsConfig?: string, contacts: Array, requestsPerDay: number, port?: number, }; diff --git a/ui/src/generated/ProviderOffer.ts b/ui/src/generated/ProviderOffer.ts new file mode 100644 index 00000000..0b58d605 --- /dev/null +++ b/ui/src/generated/ProviderOffer.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProviderOffer = { contact: string, offer: string, }; diff --git a/ui/src/generated/RunRequest.ts b/ui/src/generated/RunRequest.ts index 1b029ff2..11a3466d 100644 --- a/ui/src/generated/RunRequest.ts +++ b/ui/src/generated/RunRequest.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AssuranceInput } from "./AssuranceInput.ts"; import type { RunKind } from "./RunKind.ts"; +import type { WorkTarget } from "./WorkTarget.ts"; -export type RunRequest = { kind: RunKind, target: string, nodeAddresses: Array, input: string, trustAnchor: string, service: string, method: string, executionEnvironment: string, assurance: AssuranceInput, appleAppId: string, appleCdHashes: Array, }; +export type RunRequest = { kind: RunKind, input: string, target: WorkTarget, assurance: AssuranceInput, appleAppId: string, appleCdHashes: Array, }; diff --git a/ui/src/generated/WorkClientConfig.ts b/ui/src/generated/WorkClientConfig.ts new file mode 100644 index 00000000..bc31a617 --- /dev/null +++ b/ui/src/generated/WorkClientConfig.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssuranceInput } from "./AssuranceInput.ts"; +import type { WorkTarget } from "./WorkTarget.ts"; + +export type WorkClientConfig = { target: WorkTarget, assurance: AssuranceInput, appleAppId: string, appleCdHashes: Array, }; diff --git a/ui/src/generated/WorkTarget.ts b/ui/src/generated/WorkTarget.ts new file mode 100644 index 00000000..b1e634c8 --- /dev/null +++ b/ui/src/generated/WorkTarget.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FetchRouteInput } from "./FetchRouteInput.ts"; + +export type WorkTarget = { "funding": "authorized", offer: string, resource: string, } | { "funding": "paid", poolConfig: string, provider: string, route?: FetchRouteInput, }; diff --git a/ui/src/main.ts b/ui/src/main.ts index 1328399d..21860ed7 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -4,6 +4,7 @@ import type { AppStatus, ExecutionEvent, GatewayAccess, + GatewayConfig, HistoryEntry, ProviderConfig, RunKind, @@ -66,32 +67,18 @@ function render(): void { function renderRun(content: HTMLElement): void { content.innerHTML = `

Verified execution

Run on Hellas

-

Choose the peer and trust anchor explicitly. Gate keeps the complete transcript locally.

+

Choose paid or authorized work and select its provider trust policy. Gate keeps the complete transcript locally.

-
- - -
-
- - -
-
- - -
-
- - -
- + ${offerForm("")} +
${escapeHtml(runState)}
Output
${escapeHtml(output || "No execution yet.")}
`; + bindFundingForm(content, ""); let kind: RunKind = "fetch"; content.querySelectorAll("[data-kind]").forEach((button) => { button.addEventListener("click", () => { @@ -101,24 +88,12 @@ function renderRun(content: HTMLElement): void { }); }); content.querySelector("#run")?.addEventListener("click", async () => { - const target = value("#target"); - const trustAnchor = value("#trust"); const input = value("#input"); output = ""; setRunState("Starting…"); try { await api.run({ - kind, - target, - trustAnchor, - input, - nodeAddresses: [], - executionEnvironment: value("#environment"), - service: value("#service"), - method: value("#method"), - assurance: value("#assurance") === "appleAppAttest" ? "appleAppAttest" : "producerSigned", - appleAppId: value("#apple-app-id"), - appleCdHashes: value("#apple-cdhashes").split(",").map((item) => item.trim()).filter(Boolean), + ...offerConfig(""), kind, input, }, receiveEvent); } catch (error) { setRunState(errorMessage(error)); @@ -140,7 +115,7 @@ function renderService(content: HTMLElement, service: "provider" | "gateway"): v const current = status?.[service]; const title = service === "provider" ? "Serve sealed Fetch" : "Loopback gateway"; const description = service === "provider" - ? "Offer OpenAI Responses through your attested Hellas identity." + ? "Serve paid and authorized Fetch through your attested Hellas identity." : "Expose an authenticated OpenAI-compatible endpoint only on this machine."; content.innerHTML = `

${service}

${title}

${description}

@@ -150,6 +125,37 @@ function renderService(content: HTMLElement, service: "provider" | "gateway"): v ${service === "provider" ? providerForm() : gatewayForm()}`; + if (service === "gateway") bindFundingForm(content, "gateway-"); + for (const [id, preview] of [["preview-bond", true], ["provision-paid-offer", false]] as const) { + content.querySelector(`#${id}`)?.addEventListener("click", async () => { + try { + const result = await api.provisionPaidOffer(value("#paid-offer-config"), preview); + const output = content.querySelector("#paid-offer-result"); + if (output) output.value = result; + } catch (error) { window.alert(errorMessage(error)); } + }); + } + content.querySelector("#export-offers")?.addEventListener("click", async () => { + try { + const offers = await api.exportOffers(); + const target = content.querySelector("#offers"); + if (target) target.innerHTML = `

Copy each Offer to its contact. Import within five minutes; export again to renew.

` + offers.map((item) => ``).join(""); + } catch (error) { window.alert(errorMessage(error)); } + }); + const protocol = content.querySelector("#gateway-kind"); + if (protocol) { + protocol.value = localStorage.getItem("gate.gateway-kind") ?? "responses"; + const assurance = content.querySelector("#gateway-http-assurance"); + if (assurance) assurance.value = localStorage.getItem("gate.http-assurance") ?? "appleAppAttest"; + const refreshProtocol = (): void => { + for (const kind of ["responses", "http"]) { + const fields = content.querySelector(`#gateway-${kind}`); + if (fields) fields.hidden = protocol.value !== kind; + } + }; + protocol.addEventListener("change", refreshProtocol); + refreshProtocol(); + } content.querySelector("#toggle")?.addEventListener("click", async () => { try { const running = status?.[service].state === "running"; @@ -177,52 +183,112 @@ function providerForm(): string {
- + + + + +

Each contact can use the configured accounts within this daily request allowance. Jobs have a five-minute deadline. To remove a contact, stop the provider and restart with the revised list. Removed grants remain revoked.

+ +
+ +

The API key crosses the typed IPC boundary once and remains only in native memory for this provider run.

`; } function providerConfig(): ProviderConfig { const rawPort = value("#provider-port"); + localStorage.setItem("gate.contacts", value("#provider-contacts")); + localStorage.setItem("gate.quota", value("#provider-quota")); return { service: value("#provider-service"), method: value("#provider-method"), openaiApiKey: value("#provider-api-key"), - allowedCallers: value("#provider-callers").split(/[,\n]/).map((item) => item.trim()).filter(Boolean), + workConfigPath: value("#provider-work-config") || undefined, + httpsConfig: value("#provider-https") || undefined, + contacts: value("#provider-contacts").split(/\n/).map((item) => item.trim()).filter(Boolean), + requestsPerDay: Number(value("#provider-quota")), port: rawPort ? Number(rawPort) : undefined, }; } function gatewayForm(): string { return `
Local access
-
-
-
-
-
-
-
-
+ +
${offerForm("gateway-")}
+
-
+

A fresh bearer is generated for each running instance.

${gatewayAccess ? `
Base URL${escapeHtml(gatewayAccess.address)}
Bearer${escapeHtml(gatewayAccess.bearer)}
` : ""}
`; } -function gatewayConfig(): RunRequest { +function gatewayConfig(): GatewayConfig { + localStorage.setItem("gate.gateway-kind", value("#gateway-kind")); + if (value("#gateway-kind") === "http") { + const paidPoolPath = value("#gateway-http-pool"); + const httpRoutesPath = value("#gateway-http-routes"); + const assurance = value("#gateway-http-assurance") === "producerSigned" ? "producerSigned" : "appleAppAttest"; + const zdr = document.querySelector("#gateway-zdr")?.checked ?? false; + localStorage.setItem("gate.http-pool", paidPoolPath); + localStorage.setItem("gate.http-routes", httpRoutesPath); + localStorage.setItem("gate.http-assurance", assurance); + localStorage.setItem("gate.zdr", String(zdr)); + return { kind: "http", paidPoolPath, httpRoutesPath, assurance, zdr }; + } + return { ...offerConfig("gateway-"), kind: "responses" }; +} + +function offerForm(prefix: string): string { + const appId = localStorage.getItem("gate.apple-app-id") ?? status?.identity.appleAppId ?? ""; + const hashes = localStorage.getItem("gate.apple-cdhashes") ?? status?.identity.appleCdHashes.join(", ") ?? ""; + const assurance = localStorage.getItem("gate.assurance") ?? "appleAppAttest"; + const funding = localStorage.getItem("gate.funding") ?? "authorized"; + return ` +
+ +
+ +
+
+

The pool file contains your payment coins and the provider's signed offer. Apple app ID and CDHashes for paid targets come from that file.

+ +
+
`; +} + +function offerConfig(prefix: string): Omit { + for (const field of ["funding", "resource", "pool-config", "paid-provider", "paid-service", "paid-method", "offer", "assurance", "apple-app-id", "apple-cdhashes"]) { + localStorage.setItem(`gate.${field}`, value(`#${prefix}${field}`)); + } return { - kind: "fetch", - target: value("#gateway-target"), - nodeAddresses: [], - input: "{}", - trustAnchor: value("#gateway-trust"), - service: value("#gateway-service"), - method: value("#gateway-method"), - executionEnvironment: value("#gateway-environment"), - assurance: value("#gateway-assurance") === "appleAppAttest" ? "appleAppAttest" : "producerSigned", - appleAppId: value("#gateway-apple-app-id"), - appleCdHashes: value("#gateway-apple-cdhashes").split(",").map((item) => item.trim()).filter(Boolean), + target: value(`#${prefix}funding`) === "paid" + ? { funding: "paid", poolConfig: value(`#${prefix}pool-config`), provider: value(`#${prefix}paid-provider`), + route: value(`#${prefix}paid-service`) || value(`#${prefix}paid-method`) + ? { service: value(`#${prefix}paid-service`), method: value(`#${prefix}paid-method`) } : undefined } + : { funding: "authorized", offer: value(`#${prefix}offer`), resource: value(`#${prefix}resource`) }, + assurance: value(`#${prefix}assurance`) === "appleAppAttest" ? "appleAppAttest" : "producerSigned", + appleAppId: value(`#${prefix}apple-app-id`), + appleCdHashes: value(`#${prefix}apple-cdhashes`).split(",").map((item) => item.trim()).filter(Boolean), + }; +} + +function bindFundingForm(content: HTMLElement, prefix: string): void { + const select = content.querySelector(`#${prefix}funding`); + const refresh = (): void => { + const paid = select?.value === "paid"; + for (const field of ["authorized-fields", "authorized-trust", "paid-fields"]) { + const element = content.querySelector(`#${prefix}${field}`); + if (element) element.hidden = field === "paid-fields" ? !paid : paid; + } }; + select?.addEventListener("change", refresh); + refresh(); } function renderHistory(content: HTMLElement): void { @@ -256,7 +322,9 @@ function renderSettings(content: HTMLElement): void { content.innerHTML = `

Host

Settings & diagnostics

App Attest${escapeHtml(identity?.attestation ?? "loading")}

${escapeHtml(identity?.detail ?? "")}

Local control socket${escapeHtml(status?.socketPath ?? "")}
-
Caller public key${escapeHtml(identity?.callerPublicKey ?? "")}
+
Contact ID${escapeHtml(identity?.contactId ?? "")}
+ +

Copy this public contact enrollment to a provider to request a grant.

Version${escapeHtml(status?.version ?? "")}
`; } diff --git a/ui/src/style.css b/ui/src/style.css index d2fa5540..3cb96ba1 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -44,3 +44,5 @@ button:hover { border-color: #56637a; }.primary { border-color: #6388c3; color: .pill { padding: 4px 8px; border-radius: 99px; color: #aeb7c7; background: #262d39; font-size: 11px; text-transform: uppercase; }.pill.failed { color: #ffaaa6; background: #402225; }.pill.complete { color: #8ae3b6; background: #183329; } .definition > div { display: flex; justify-content: space-between; gap: 24px; padding: 13px 0; border-bottom: 1px solid #292f3a; }.definition span { color: #8f98a9; }.definition code { overflow-wrap: anywhere; text-align: right; } @media (max-width: 850px) { .shell { grid-template-columns: 170px 1fr; } main { width: 100%; padding-inline: 24px; }.field-row { grid-template-columns: 1fr; } } + +[hidden] { display: none !important; } diff --git a/ui/src/types.ts b/ui/src/types.ts index 08b87282..e8f297d5 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -12,3 +12,9 @@ export type { RunKind } from "./generated/RunKind.ts"; export type { RunRequest } from "./generated/RunRequest.ts"; export type { ServiceState } from "./generated/ServiceState.ts"; export type { ServiceStatus } from "./generated/ServiceStatus.ts"; + +export type { ProviderOffer } from "./generated/ProviderOffer.ts"; +export type { WorkTarget } from "./generated/WorkTarget.ts"; + +export type { GatewayConfig } from "./generated/GatewayConfig.ts"; +export type { WorkClientConfig } from "./generated/WorkClientConfig.ts";