diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..6678069 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,115 @@ +name: coverage + +# Rust test coverage via cargo-llvm-cov. +# +# Blocking on the new UI-bridge endpoints (PR-C). The threshold is +# per-file via cargo-llvm-cov's --fail-under-lines: +# --fail-under-lines 60 workspace-wide minimum +# The ui_bridge.rs module specifically is expected to stay >80% +# (drift triggers a follow-up to add the missing test). +# +# To inspect locally: +# cargo install cargo-llvm-cov +# cargo llvm-cov --workspace --html # opens target/llvm-cov/html/index.html +# +# Generates: +# - lcov.info — for codecov / coveralls (uploaded as artifact today) +# - cobertura.xml — for GitHub PR coverage diff (future) +# - html/index.html — human-readable browseable report (artifact) +# +# Local equivalent: +# cargo install cargo-llvm-cov +# cargo llvm-cov --workspace --lcov --output-path lcov.info +# cargo llvm-cov --workspace --html # opens target/llvm-cov/html/index.html + +on: + push: + branches: [main] + pull_request: + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/coverage.yml' + +permissions: + contents: read + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: true + +jobs: + llvm-cov: + name: cargo-llvm-cov (non-blocking) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-cargo-llvm-cov-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-llvm-cov- + ${{ runner.os }}-cargo- + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate lcov + html (blocking; --fail-under-lines 60) + id: cov + run: | + set -euo pipefail + cargo llvm-cov --workspace --lcov --output-path lcov.info -- --test-threads=1 + cargo llvm-cov --workspace --html -- --test-threads=1 + cargo llvm-cov report --workspace --summary-only -- --test-threads=1 \ + | tee coverage-summary.txt + # Workspace-wide floor. Set conservatively (60%); per-file + # discipline lives in the test list in each module under + # `mod tests`. Bump in follow-up PRs as tests catch up. + cargo llvm-cov report --workspace --fail-under-lines 60 -- --test-threads=1 + + - name: Upload lcov artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: lcov.info + if-no-files-found: warn + retention-days: 14 + + - name: Upload html artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: target/llvm-cov/html/ + if-no-files-found: warn + retention-days: 14 + + - name: Post coverage summary to job summary + if: always() + run: | + { + echo "## Coverage (cargo-llvm-cov)" + echo + echo "Workspace floor: 60% lines. Failing this gate blocks merge." + echo + echo '```' + cat coverage-summary.txt 2>/dev/null || echo "(coverage-summary.txt not produced — see job logs)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Cargo.lock b/Cargo.lock index e2407cb..1d6fdb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,7 +52,7 @@ dependencies = [ "aws-sdk-sesv2", "aws-sdk-sts", "axum", - "base64", + "base64 0.22.1", "clap", "futures-util", "getrandom 0.2.17", @@ -63,7 +63,7 @@ dependencies = [ "k256", "p256 0.13.2", "pkcs8 0.10.2", - "rand_core", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -93,7 +93,7 @@ dependencies = [ "async-trait", "aws-credential-types", "axum", - "base64", + "base64 0.22.1", "ciborium", "clap", "hex", @@ -101,7 +101,7 @@ dependencies = [ "hyper-util", "p256 0.13.2", "predicates", - "rand_core", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -126,15 +126,15 @@ dependencies = [ "aws-credential-types", "aws-sdk-s3", "axum", - "base64", + "base64 0.22.1", "ciborium", "getrandom 0.2.17", "hex", "hmac 0.12.1", "k256", "keyring", - "rand", - "rand_core", + "rand 0.8.5", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -157,24 +157,29 @@ dependencies = [ "agentkeys-types", "anyhow", "axum", - "base64", + "base64 0.22.1", "clap", "ed25519-dalek", + "futures-util", "hex", "http-body-util", "hyper 1.9.0", "hyper-util", "libc", - "rand", + "rand 0.8.5", "reqwest", "rusqlite", "serde", "serde_json", "tokio", + "tokio-stream", "tower 0.4.13", + "tower-http 0.5.2", "tower-service", "tracing", "tracing-subscriber", + "url", + "webauthn-rs", ] [[package]] @@ -201,7 +206,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "clap", "futures-util", "hex", @@ -228,7 +233,7 @@ dependencies = [ "agentkeys-types", "async-trait", "axum", - "base64", + "base64 0.22.1", "ciborium", "clap", "ed25519-dalek", @@ -240,8 +245,8 @@ dependencies = [ "jsonwebtoken", "k256", "p256 0.13.2", - "rand", - "rand_core", + "rand 0.8.5", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -318,12 +323,12 @@ dependencies = [ "aws-credential-types", "aws-sdk-s3", "axum", - "base64", + "base64 0.22.1", "clap", "hex", "p256 0.13.2", "pkcs8 0.10.2", - "rand_core", + "rand_core 0.6.4", "reqwest", "serde", "serde_json", @@ -363,7 +368,7 @@ dependencies = [ "aws-config", "aws-sdk-s3", "axum", - "base64", + "base64 0.22.1", "clap", "hex", "reqwest", @@ -458,6 +463,45 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "assert_cmd" version = "2.2.0" @@ -1199,6 +1243,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1221,6 +1271,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1557,7 +1618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -1569,7 +1630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -1581,7 +1642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -1666,6 +1727,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1777,7 +1852,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -1804,7 +1879,7 @@ dependencies = [ "generic-array", "group 0.12.1", "pkcs8 0.9.0", - "rand_core", + "rand_core 0.6.4", "sec1 0.3.0", "subtle", "zeroize", @@ -1824,7 +1899,7 @@ dependencies = [ "group 0.13.0", "pem-rfc7468", "pkcs8 0.10.2", - "rand_core", + "rand_core 0.6.4", "sec1 0.7.3", "subtle", "zeroize", @@ -1947,7 +2022,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1957,7 +2032,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2185,7 +2260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" dependencies = [ "ff 0.12.1", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2196,7 +2271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff 0.13.1", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2520,7 +2595,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2746,7 +2821,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -2934,6 +3009,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -2974,6 +3055,16 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -3068,6 +3159,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3198,13 +3298,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3454,8 +3560,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3465,7 +3581,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3477,6 +3603,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3527,7 +3662,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -3621,6 +3756,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.37.28" @@ -3809,7 +3953,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand", + "rand 0.8.5", "serde", "sha2 0.10.9", "zbus", @@ -3867,6 +4011,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4020,7 +4174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -4030,7 +4184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -4357,6 +4511,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.23.1" @@ -4573,7 +4739,7 @@ dependencies = [ "http 1.4.0", "httparse", "log", - "rand", + "rand 0.8.5", "rustls 0.23.37", "rustls-pki-types", "sha1 0.10.6", @@ -4636,6 +4802,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4670,6 +4837,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4844,6 +5012,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.4", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5179,6 +5415,23 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -5245,7 +5498,7 @@ dependencies = [ "nix", "once_cell", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1 0.10.6", diff --git a/apps/parent-control/.gitignore b/apps/parent-control/.gitignore new file mode 100644 index 0000000..3e8c24e --- /dev/null +++ b/apps/parent-control/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +out/ +build/ +dist/ +*.tsbuildinfo +.env*.local +.vercel diff --git a/apps/parent-control/README.md b/apps/parent-control/README.md new file mode 100644 index 0000000..edfa787 --- /dev/null +++ b/apps/parent-control/README.md @@ -0,0 +1,98 @@ +# AgentKeys · parent control (M1) + +Phase 1 mobile-responsive web UI for the AgentKeys M1 demo. Resolves [issue #110](https://github.com/litentry/agentKeys/issues/110). + +Design handoff source: Claude Design — iii.dev-inspired aesthetic (IBM Plex Mono + Serif, cream/ink palette, hairline rules, ASCII separators, per-section accent hues). + +## Pages + +- **actors** — HDKD tree + devices/agents table with stats strip +- **actor detail** — per-namespace scope toggles (deny / read / read+write), payment-cap inputs, live cap-tokens table with per-cap revoke +- **audit feed** — live SSE stream filterable by worker, click any row for full event detail +- **anchor status** — countdown to next tier-2 batch + recent Merkle roots with explorer links +- **workers** — five worker cards (memory, credentials, audit, email, payment) with per-actor usage share; click a card to see trust profile +- **onboarding** — first-run wizard mirroring [`harness/v2-stage1-demo.sh`](../../harness/v2-stage1-demo.sh) steps (real WebAuthn lands in PR-B) +- **onboarding/mobile** — stub for adding a second master device via QR pairing (real cross-device WebAuthn lands in M5) +- **logo** — six Bedlington Terrier variants (profile, front-cute, cloud, monogram, seal, icon) for brand exploration + +## Data layer + +All reads + writes flow through a single [`AgentKeysClient`](lib/client/types.ts) interface implemented under [`lib/client/`](lib/client/). The default implementation is `EmptyBackend` — every call returns a `{ ok: false, status: { kind: 'disconnected', reason: 'no-backend-configured' } }` discriminant, and the UI renders explicit empty states with copy explaining what's missing. + +| Backend | When | Status | +|---|---|---| +| `EmptyBackend` | `NEXT_PUBLIC_AGENTKEYS_BACKEND=empty` (default) | shipped | +| `DaemonBackend` | `NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon` | PR-C (calls agentkeys-daemon HTTP surface) | + +No mock data lives anywhere in the codebase. To see populated views, run a real daemon and switch the backend env var. + +## Demo Act 3 (revocation) + +Open a device → "revoke device" → K11 WebAuthn modal renders the intent context with mock Touch ID scan → on confirm, actor flips to revoked and a `device.revoked` event appears at the top of the audit feed within ~200ms. + +## Stack + +- Next.js 14 (App Router) +- React 18 +- TypeScript +- Plain CSS (no Tailwind — the design uses hairline-precise raw CSS variables) +- IBM Plex Mono + Serif via Google Fonts + +No backend in this project — the UI is a thin client. Mock data is inlined for the M1 demo; M2 wires to the broker session JWT + audit-service SSE feed (per [issue #109](https://github.com/litentry/agentKeys/issues/109)). + +Port `3113` matches the canonical web-UI port in [`docs/arch.md`](../../docs/arch.md) §22c.1 (the bundled-app surface). When this UI is later folded into the Rust daemon's `agentkeys web` subcommand, the URL stays identical. + +## Develop + +```sh +cd apps/parent-control +npm install +npm run dev # http://localhost:3113 (UI only, EmptyBackend) +npm run dev:stack # UI + agentkeys-daemon --ui-bridge in one terminal +npm run build # production build +npm run typecheck # tsc --noEmit +``` + +### `dev:stack` — single-terminal dev stack + +The entry script lives at the repo root: [`dev.sh`](../../dev.sh). It starts the daemon on `127.0.0.1:3114` and the Next.js dev server on `localhost:3113`, multiplexing both stdouts into one terminal with per-process color prefixes: + +``` +[dev] bold yellow — the dev script's own status lines +[daemon] magenta — agentkeys-daemon --ui-bridge +[ui] cyan — npx next dev +``` + +You can invoke it from anywhere: + +```sh +bash dev.sh # from the repo root +./dev.sh # from the repo root, same +cd apps/parent-control && npm run dev:stack # from this app dir +``` + +The script auto-rebuilds the daemon if any `.rs` source under `crates/agentkeys-daemon/` is newer than the existing binary, waits for `GET /healthz` before bringing up the UI, and pre-sets `NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon` + `NEXT_PUBLIC_AGENTKEYS_DAEMON_URL=http://127.0.0.1:3114` so the UI talks to the daemon by default. Ctrl-C cleans up both processes; stale processes on either port are killed before binding. + +Overrides via env: `UI_PORT`, `DAEMON_PORT`, `DAEMON_ORIGIN`, `DAEMON_RP_ID`, `DAEMON_RP_NAME` — see the comment block at the top of [`dev.sh`](../../dev.sh). + +## Deploy (M1) + +Vercel. Point the project at `apps/parent-control` and the build settles itself. + +## File layout + +``` +apps/parent-control/ + app/ + layout.tsx · root layout + IBM Plex fonts + page.tsx · server entry; mounts the SPA + globals.css · iii.dev styles (ported from styles.css) + _components/ + types.ts · Actor, AuditEvent, Worker + data.ts · INITIAL_ACTORS, INITIAL_EVENTS, SIM_EVENTS + shared.tsx · Chip, Dot, Panel, Modal, WebAuthnModal, … + pages.tsx · Actors, ActorDetail, Audit, Anchor + workers.tsx · Workers page + worker detail + logos.tsx · 6 Bedlington variants + LogoPage + App.tsx · main App (routing, SSE sim, revoke flows) +``` diff --git a/apps/parent-control/app/_components/App.tsx b/apps/parent-control/app/_components/App.tsx new file mode 100644 index 0000000..6294def --- /dev/null +++ b/apps/parent-control/app/_components/App.tsx @@ -0,0 +1,591 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useClient, useConnectionStatus } from '@/lib/ClientProvider'; +import type { CapToken } from '@/lib/client/types'; +import { HarnessPage } from './harness'; +import { LogoPage } from './logos'; +import { OnboardingPage } from './onboarding'; +import { ActorDetailPage, ActorsPage, AnchorPage, AuditPage } from './pages'; +import { Modal, PageHead, Panel, WebAuthnModal } from './shared'; +import type { Actor, AuditEvent, PendingAction, Route } from './types'; +import { WorkersPage } from './workers'; + +function nowTs(d: Date = new Date()) { + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; +} + +export function App() { + const client = useClient(); + const status = useConnectionStatus(); + + const [actors, setActors] = useState([]); + const [events, setEvents] = useState([]); + const [capTokens, setCapTokens] = useState>({}); + const [route, setRoute] = useState({ page: 'actors', actorId: null }); + const [sideOpen, setSideOpen] = useState(false); + const [paused, setPaused] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [eventDetail, setEventDetail] = useState(null); + const [toast, setToast] = useState(null); + + // ─── Initial fetch ───────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + (async () => { + const [actorsResult, eventsResult] = await Promise.all([ + client.listActors(), + client.listRecentAuditEvents({ limit: 50 }), + ]); + if (cancelled) return; + if (actorsResult.ok) setActors(actorsResult.data); + if (eventsResult.ok) setEvents(eventsResult.data); + })(); + return () => { + cancelled = true; + }; + }, [client]); + + // ─── Cap-token fetch on actor detail ─────────────────────────── + useEffect(() => { + if (route.page !== 'detail' || !route.actorId) return; + const actorId = route.actorId; + if (capTokens[actorId]) return; + let cancelled = false; + (async () => { + const r = await client.listCapTokens(actorId); + if (cancelled || !r.ok) return; + setCapTokens((prev) => ({ ...prev, [actorId]: r.data })); + })(); + return () => { + cancelled = true; + }; + }, [route, client, capTokens]); + + // ─── SSE subscription ────────────────────────────────────────── + useEffect(() => { + if (paused) return; + const unsub = client.streamAudit( + (incoming) => { + const tagged: AuditEvent = { ...incoming, _isNew: true }; + setEvents((prev) => [tagged, ...prev].slice(0, 80)); + setTimeout(() => { + setEvents((prev) => + prev.map((e) => (e.id === tagged.id ? { ...e, _isNew: false } : e)), + ); + }, 1500); + }, + () => { + /* status changes propagate via context; no-op here */ + }, + ); + return unsub; + }, [client, paused]); + + const showToast = useCallback((msg: string) => { + setToast(msg); + setTimeout(() => setToast(null), 2600); + }, []); + + const updateActor = useCallback( + async (id: string, patch: Partial) => { + const previous = actors.find((a) => a.id === id); + if (!previous) return; + setActors((prev) => prev.map((a) => (a.id === id ? { ...a, ...patch } : a))); + if (patch.scope) { + const changedNs = Object.keys(patch.scope).find( + (k) => previous.scope?.[k as keyof typeof previous.scope] !== patch.scope![k as keyof typeof patch.scope], + ); + if (changedNs) { + const ns = changedNs as keyof typeof patch.scope; + const r = await client.updateScope(id, ns, patch.scope[ns]); + if (!r.ok) { + showToast(`scope update rejected · ${r.status.reason}`); + setActors((prev) => prev.map((a) => (a.id === id ? previous : a))); + return; + } + showToast('scope updated · K11 assertion queued for next save'); + return; + } + } + if (patch.paymentCap) { + const r = await client.updatePaymentCap( + id, + patch.paymentCap.perTx, + patch.paymentCap.daily, + ); + if (!r.ok) { + showToast(`payment cap rejected · ${r.status.reason}`); + setActors((prev) => prev.map((a) => (a.id === id ? previous : a))); + return; + } + showToast('payment cap updated · K11 assertion queued for next save'); + } + }, + [actors, client, showToast], + ); + + const handleRevokeDevice = (actor: Actor) => { + setPendingAction({ + kind: 'revoke-device', + actor, + intent: { + text: `Revoke device · ${actor.label}`, + fields: [ + ['actor_omni', actor.omni], + ['device_pubkey', actor.devicePubkey.slice(0, 22) + '…'], + ['mutation', 'SidecarRegistry.revoke_device'], + ['propagation', 'SSE drop + cache zero'], + ['scope effect', 'all caps invalidated · ttl 0s'], + ], + }, + }); + }; + + const handleRevokeScope = (actor: Actor, capName: string) => { + setPendingAction({ + kind: 'revoke-scope', + actor, + capName, + intent: { + text: 'Revoke cap-token', + fields: [ + ['actor', actor.label], + ['cap', capName], + ['actor_omni', actor.omni.slice(0, 30) + '…'], + ['mutation', 'broker.revoke_cap + chain commit'], + ['effect', 'next call returns 403 · ≤200ms'], + ], + }, + }); + }; + + const confirmAction = useCallback(async () => { + const action = pendingAction; + setPendingAction(null); + if (!action) return; + + const ts = nowTs(); + + if (action.kind === 'revoke-device') { + const r = await client.revokeDevice(action.actor.id, action.intent); + if (!r.ok) { + showToast(`revoke rejected · ${r.status.reason}`); + return; + } + setActors((prev) => + prev.map((a) => + a.id === action.actor.id + ? { ...a, status: 'bad', lastActive: 'revoked', label: a.label + ' (revoked)' } + : a, + ), + ); + setEvents((prev) => [ + { + id: `e-live-${Date.now()}`, + ts, + actorId: 'master', + actor: 'Sara (master)', + kind: 'device.revoked', + detail: `${action.actor.label} · ${action.actor.devicePubkey.slice(0, 18)}… · K11 assertion ok`, + chip: 'revoke', + sev: 'bad', + _isNew: true, + }, + ...prev, + ]); + showToast(`${action.actor.label} revoked. SSE drop event broadcast.`); + setRoute({ page: 'audit', actorId: null }); + return; + } + + if (action.kind === 'revoke-scope') { + const r = await client.revokeCap(action.actor.id, action.capName, action.intent); + if (!r.ok) { + showToast(`revoke rejected · ${r.status.reason}`); + return; + } + setEvents((prev) => [ + { + id: `e-live-${Date.now()}`, + ts, + actorId: 'master', + actor: 'Sara (master)', + kind: 'cap.revoked', + detail: `${action.actor.label} · ${action.capName} · K11 ok`, + chip: 'revoke', + sev: 'bad', + _isNew: true, + }, + ...prev, + ]); + showToast(`${action.capName} revoked for ${action.actor.label}.`); + } + }, [client, pendingAction, showToast]); + + const go = (page: Route['page'], actorId: string | null = null) => { + if (page === 'detail' && actorId) { + setRoute({ page: 'detail', actorId }); + } else { + setRoute({ page, actorId: null } as Route); + } + setSideOpen(false); + if (typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'instant' }); + } + }; + + const currentActor = route.actorId ? actors.find((a) => a.id === route.actorId) : null; + const sectionAttr = (['audit', 'anchor', 'workers', 'logo', 'onboarding', 'harness'] as const).includes( + route.page as never, + ) + ? route.page + : undefined; + + const connectionLabel = + status.kind === 'connected' + ? `${status.via} · ${status.endpoint}` + : status.reason === 'no-backend-configured' + ? 'backend not configured' + : status.reason === 'unauthorized' + ? 'unauthorized' + : 'unreachable'; + + return ( +
+
+
+ +
+ agentKeys + parent control · m1 +
+
+
+ + {connectionLabel} + + + + {actors.find((a) => a.role === 'master')?.label ?? 'no master enrolled'} + + +
+
+ + + +
+ {route.page === 'actors' && go('detail', id)} />} + {route.page === 'detail' && currentActor && ( + go('actors')} + onRevoke={handleRevokeDevice} + onRevokeScope={handleRevokeScope} + recentEvents={events} + capTokens={capTokens[currentActor.id] ?? []} + /> + )} + {route.page === 'audit' && ( + setPaused((p) => !p)} + /> + )} + {route.page === 'anchor' && } + {route.page === 'workers' && ( + go('detail', id)} /> + )} + {route.page === 'onboarding' && ( + go('actors')} /> + )} + {route.page === 'onboarding-mobile' && go('onboarding')} />} + {route.page === 'harness' && go('actors')} />} + {route.page === 'logo' && } +
+ + {pendingAction && ( + setPendingAction(null)} + /> + )} + + {eventDetail && ( + setEventDetail(null)} + footer={ + <> + + { + e.preventDefault(); + setEventDetail(null); + }} + > + view on chain ↗ + + + } + > +
+
timestamp
+
{eventDetail.ts}
+
actor
+
{eventDetail.actor}
+
kind
+
{eventDetail.kind}
+
detail
+
{eventDetail.detail}
+
worker
+
{eventDetail.chip}-service
+
tier
+
tier-1 (sse) · pending tier-2 anchor
+
event id
+
{eventDetail.id}
+
+
+ )} + + {toast && ( +
+ {toast} +
+ )} +
+ ); +} + +function MobileStub({ onBack }: { onBack: () => void }) { + const fakeQR = Array.from({ length: 21 * 21 }, (_, i) => { + const x = i % 21; + const y = Math.floor(i / 21); + const corner = + (x < 7 && y < 7) || (x >= 14 && y < 7) || (x < 7 && y >= 14); + const cornerInner = + ((x >= 2 && x < 5 && y >= 2 && y < 5)) || + ((x >= 16 && x < 19 && y >= 2 && y < 5)) || + ((x >= 2 && x < 5 && y >= 16 && y < 19)); + const cornerFrame = + (x === 0 || x === 6 || y === 0 || y === 6) && x < 7 && y < 7; + const cornerFrameTR = + (x === 14 || x === 20 || y === 0 || y === 6) && x >= 14 && y < 7; + const cornerFrameBL = + (x === 0 || x === 6 || y === 14 || y === 20) && x < 7 && y >= 14; + if (corner) return cornerInner || cornerFrame || cornerFrameTR || cornerFrameBL ? 1 : 0; + return Math.abs(((x * 31) ^ (y * 17)) % 2); + }); + + return ( + <> + + + onboarding + {' '} + / mobile + + } + title={ + <> + / mobile · second master + + } + desc="Stub. Real cross-device WebAuthn (FIDO CTAP 2.2 hybrid transport) ships in M5 after the vendor pilot signs. This page exists to show what the operator will see, not to perform the ceremony." + actions={ + + } + /> + +
+ stub + + arch.md §10.5 1-of-2 recovery is a real architectural commitment. This page is a stub today — no QR + scanning, no companion-daemon negotiation. Tracked for M5 (issue TBD). + +
+ + +
+
+ {fakeQR.map((bit, i) => ( +
+ ))} +
+
+
+ scan with iPad or Android +
+
+ When the real flow ships, this QR encodes the cross-device WebAuthn hybrid-transport + challenge. The phone's platform authenticator generates K11, signs the master-binding + ceremony, and registers as the second device on SidecarRegistry. +
+
+ role on chain · CAP_MINT | RECOVERY (no SCOPE_MGMT) +
+ quorum · 1-of-2 (operator-configurable per arch.md §10.6) +
+ ceremony · v2-stage2-demo.sh steps 4-6 +
+
+
+ + + +
    +
  1. operator scans QR on second device → cross-device WebAuthn opens
  2. +
  3. phone generates its own K10 in the device's Secure Enclave / StrongBox
  4. +
  5. phone runs WebAuthn ceremony → produces local K11 (sealed in TEE)
  6. +
  7. existing master signs `register_companion_master(D_pub_phone, K11_credId_phone)` on-chain
  8. +
  9. SidecarRegistry adds the phone as a second master with `CAP_MINT | RECOVERY` roles
  10. +
  11. recoveryThreshold automatically bumps to 2 once two K11s are registered
  12. +
+
+ + ); +} diff --git a/apps/parent-control/app/_components/harness.tsx b/apps/parent-control/app/_components/harness.tsx new file mode 100644 index 0000000..f54ffe3 --- /dev/null +++ b/apps/parent-control/app/_components/harness.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { PageHead, Panel } from './shared'; + +interface HarnessStep { + num: number; + title: string; + source: string; // file:line reference in the harness script + desc: string; + invariant?: string; +} + +const STAGE2_STEPS: HarnessStep[] = [ + { + num: 1, + title: 'build agentkeys CLI + agentkeys-daemon (release)', + source: 'harness/v2-stage2-demo.sh:121', + desc: 'cargo build --release -p agentkeys-cli agentkeys-daemon — stage-2 binaries.', + }, + { + num: 2, + title: 'forge test suite (P256 + K11 + AgentKeysV1)', + source: 'harness/v2-stage2-demo.sh:153', + desc: '28 forge tests gate the stage-2 deploy. Run before any chain mutation.', + invariant: 'P256Verifier.verify(r,s,e,Qx,Qy) === reference vector', + }, + { + num: 3, + title: 'deploy stage-2 contracts', + source: 'harness/v2-stage2-demo.sh:201', + desc: 'P256Verifier, K11Verifier, fresh SidecarRegistry + AgentKeysScope ABI for on-chain K11 verify.', + }, + { + num: 4, + title: 'bootstrap primary master', + source: 'harness/v2-stage2-demo.sh:267', + desc: 'register_master_device(D_pub, K11_credId) on the new SidecarRegistry. Reuses stage-1 D_pub.', + }, + { + num: 5, + title: 'spin up companion daemon', + source: 'harness/v2-stage2-demo.sh:319', + desc: 'Second daemon instance on 127.0.0.1:9091. Holds a distinct K10 + K11 on rp_id "companion.localhost".', + }, + { + num: 6, + title: 'register companion as 2nd master', + source: 'harness/v2-stage2-demo.sh:402', + desc: 'agentkeys device add — primary signs the registration; companion now appears in SidecarRegistry with CAP_MINT | RECOVERY.', + }, + { + num: 7, + title: 'set recoveryThreshold = 2', + source: 'harness/v2-stage2-demo.sh:471', + desc: 'M-of-N quorum bumps to 2. Any single master can still mint caps, but recovery + device-revoke now requires both.', + invariant: 'recoveryThreshold == 2 && registered_masters.length == 2', + }, + { + num: 8, + title: 'recovery sanity-check ceremony', + source: 'harness/v2-stage2-demo.sh:524', + desc: 'Revoke-device-via-both-masters exercise. Both K11 assertions must land in the same extrinsic.', + }, +]; + +const STAGE3_STEPS: HarnessStep[] = [ + { + num: 1, + title: 'build CLI (if --skip-build absent)', + source: 'harness/v2-stage3-demo.sh:80', + desc: 'cargo build -p agentkeys-cli.', + }, + { + num: 2, + title: 'STS assume-role-with-web-identity', + source: 'harness/v2-stage3-demo.sh:215', + desc: 'Exchange session JWT for AWS temp creds for vault + memory roles.', + invariant: 'PrincipalTag/agentkeys_actor_omni == request actor_omni', + }, + { + num: 3, + title: 'positive write — vault bucket', + source: 'harness/v2-stage3-demo.sh:286', + desc: 'write own credentials envelope to s3://vault/bots//credentials/test.json. Must 200.', + }, + { + num: 4, + title: 'NEGATIVE write — cross-actor vault prefix', + source: 'harness/v2-stage3-demo.sh:354', + desc: 'Try to write under bots//credentials/. Must 403 (PrincipalTag interp).', + invariant: 'AccessDenied required — leakage = stage-3 fail', + }, + { + num: 5, + title: 'NEGATIVE list — cross-actor vault prefix', + source: 'harness/v2-stage3-demo.sh:412', + desc: 's3:ListBucket with prefix=bots//credentials/. Must 403 (bucket policy condition).', + invariant: 'AccessDenied required — directory enumeration impossible', + }, + { + num: 6, + title: 'positive write — memory bucket', + source: 'harness/v2-stage3-demo.sh:470', + desc: 'write own memory envelope to s3://memory/bots//memory/.', + }, + { + num: 7, + title: 'NEGATIVE write — cross-actor memory', + source: 'harness/v2-stage3-demo.sh:538', + desc: 'mirror of step 4 but on memory bucket. Must 403.', + }, + { + num: 8, + title: 'NEGATIVE list — cross-actor memory', + source: 'harness/v2-stage3-demo.sh:596', + desc: 'mirror of step 5 but on memory bucket. Must 403.', + }, + { + num: 9, + title: 'cross-bucket isolation', + source: 'harness/v2-stage3-demo.sh:649', + desc: 'Vault creds tried on memory bucket (and reverse). Both must 403.', + invariant: 'IAM role scoped per data class (arch.md §17.2)', + }, + { + num: 10, + title: 'credential worker roundtrip', + source: 'harness/v2-stage3-demo.sh:718', + desc: 'cap-mint /v1/cap/cred-store → worker /v1/cred/store → cap-mint /v1/cap/cred-fetch → worker /v1/cred/fetch.', + invariant: 'plaintext_fetched === plaintext_stored', + }, + { + num: 11, + title: 'memory worker roundtrip', + source: 'harness/v2-stage3-demo.sh:823', + desc: 'mirror of step 10 for /v1/memory/put + /v1/memory/get.', + }, + { + num: 12, + title: 'cleanup test data', + source: 'harness/v2-stage3-demo.sh:932', + desc: 'admin-creds delete under bots//* on both buckets.', + }, + { + num: 13, + title: 'NEGATIVE cap-mint with cross-actor operator_omni', + source: 'harness/v2-stage3-demo.sh:1003', + desc: 'broker cap-mint must reject when JWT operator_omni ≠ requested operator_omni. Defense-in-depth layer 1.', + invariant: 'HTTP 4xx; broker rejects before any worker is contacted', + }, + { + num: 14, + title: 'NEGATIVE cred-class cap → memory worker', + source: 'harness/v2-stage3-demo.sh:1027', + desc: 'Submit a Credentials-class cap to the memory worker. Worker must reject with cap_data_class_mismatch.', + invariant: 'Cap-layer isolation between data classes (arch.md cap-tokens-data-class-explicit)', + }, + { + num: 15, + title: 'NEGATIVE memory-class cap → cred worker', + source: 'harness/v2-stage3-demo.sh:1051', + desc: 'Symmetric to step 14.', + }, +]; + +export function HarnessPage({ onClose }: { onClose: () => void }) { + return ( + <> + + / harness flows + + } + desc="Mirrors harness/v2-stage2-demo.sh and harness/v2-stage3-demo.sh as readable step lists. These are the real CI gates — every step here either appears on the parent-control dashboard once the daemon's audit feed catches it (live SSE) or runs as a shell script the operator launches outside the browser. PR-D will add 'live status' badges per step." + actions={ + + } + /> + +
+ why these matter + + The 4-layer isolation invariants table from arch.md (broker cap-mint, worker chain-verify, AWS IAM PrincipalTag, per-data-class bucket separation) is whatever survives these scripts running green. A new PR that adds a worker, a data class, or a broker auth method MUST extend these flows with new negative tests. + +
+ + + + + + + + + + + + + {STAGE2_STEPS.map((s) => ( + + + + + + + ))} + +
#stepsourceinvariant
{s.num} +
{s.title}
+
+ {s.desc} +
+
+ {s.source} + + {s.invariant ?? '—'} +
+
+ + + + + + + + + + + + + {STAGE3_STEPS.map((s) => ( + + + + + + + ))} + +
#stepsourceinvariant
{s.num} +
{s.title}
+
+ {s.desc} +
+
+ {s.source} + + {s.invariant ?? '—'} +
+
+ + +
+

+ Run the full chain against a real broker: +

+
+{`# stage 1 — identity + K10 + K11 + chain bring-up + first device register
+AGENTKEYS_CHAIN=heima bash harness/v2-stage1-demo.sh
+
+# stage 2 — companion daemon + recovery threshold 2
+AGENTKEYS_CHAIN=heima bash harness/v2-stage2-demo.sh
+
+# stage 3 — 4-layer isolation (cap-mint, worker chain-verify, IAM, bucket)
+AGENTKEYS_CHAIN=heima bash harness/v2-stage3-demo.sh`}
+          
+

+ Each script is idempotent (see CLAUDE.md idempotent-remote-setup-rule). Re-running picks up + where it left off; partial state on chain or in AWS is detected and skipped. +

+
+
+ + ); +} diff --git a/apps/parent-control/app/_components/logos.tsx b/apps/parent-control/app/_components/logos.tsx new file mode 100644 index 0000000..31071d4 --- /dev/null +++ b/apps/parent-control/app/_components/logos.tsx @@ -0,0 +1,379 @@ +'use client'; + +import { useState } from 'react'; +import { Panel, PageHead } from './shared'; + +type MarkProps = { size?: number; color?: string; stroke?: number }; + +// ─── V1 — Profile (the iconic Bedlington view) ─────────────────── +function MarkProfile({ size = 320, color = '#1a1815', stroke = 1.5 }: MarkProps) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// ─── V2 — Front-cute ───────────────────────────────────────────── +function MarkFrontCute({ size = 320, color = '#1a1815', stroke = 1.5 }: MarkProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// ─── V3 — Cloud ────────────────────────────────────────────────── +function MarkCloud({ size = 320, color = '#1a1815', stroke = 1.5 }: MarkProps) { + return ( + + + + + + + + + + + + + + ); +} + +// ─── V4 — Monogram ────────────────────────────────────────────── +function MarkMonogram({ size = 320, color = '#1a1815' }: MarkProps) { + return ( + + + k + + + + + + + + ); +} + +// ─── V5 — Seal ─────────────────────────────────────────────────── +function MarkSeal({ size = 320, color = '#1a1815', stroke = 1.5 }: MarkProps) { + return ( + + + + + + + + + + + + + + + + + agentkeys · sovereign keys for agents · + + + + ); +} + +// ─── V6 — Icon ─────────────────────────────────────────────────── +function MarkIcon({ size = 320, color = '#1a1815' }: MarkProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +type VariantId = 'profile' | 'front' | 'cloud' | 'monogram' | 'seal' | 'icon'; +type BgId = 'cream' | 'ink' | 'amber' | 'sage' | 'indigo'; + +const VARIANTS: { id: VariantId; name: string; sub: string; comp: (p: MarkProps) => JSX.Element }[] = [ + { id: 'profile', name: 'profile', sub: 'side view · iconic', comp: MarkProfile }, + { id: 'front', name: 'front-cute', sub: 'big eyes · sheep face', comp: MarkFrontCute }, + { id: 'cloud', name: 'cloud', sub: 'minimal · just fluff', comp: MarkCloud }, + { id: 'monogram', name: 'monogram', sub: 'serif K · topknot curl', comp: MarkMonogram }, + { id: 'seal', name: 'seal', sub: 'badge · circular', comp: MarkSeal }, + { id: 'icon', name: 'icon', sub: 'solid · for apps', comp: MarkIcon }, +]; + +const BG_MAP: Record = { + cream: { bg: '#f6f3ec', ink: '#1a1815' }, + ink: { bg: '#1a1815', ink: '#f6f3ec' }, + amber: { bg: 'oklch(0.55 0.15 50)', ink: '#f6f3ec' }, + sage: { bg: 'oklch(0.5 0.12 145)', ink: '#f6f3ec' }, + indigo: { bg: 'oklch(0.5 0.12 240)', ink: '#f6f3ec' }, +}; + +export function LogoPage() { + const [selected, setSelected] = useState('profile'); + const [bg, setBg] = useState('cream'); + + const current = VARIANTS.find((v) => v.id === selected)!; + const Big = current.comp; + const palette = BG_MAP[bg]; + + return ( + <> + + / bedlington + + } + desc="Six directions for the AgentKeys mark. Profile is the most Bedlington-recognizable — the high topknot and arched roman nose only read in side view. Pick a direction and we'll refine." + /> + +
+ {VARIANTS.map((v) => { + const C = v.comp; + const isSelected = selected === v.id; + return ( + + ); + })} +
+ + + {(Object.keys(BG_MAP) as BgId[]).map((b) => ( + + ))} +
+ } + > +
+ +
+
+ +
+ {[96, 48, 32, 16].map((s) => ( + +
+ +
+
+ ))} +
+ + +
+ +
+
+ agentKeys +
+
+ sovereign keys · for agents +
+
+
+
+ + +
+ The Bedlington Terrier was bred by Northumbrian miners to guard livestock and hunt vermin underground. It + looks like a lamb. It moves like a greyhound. It fights like a terrier. The whole brand promise of AgentKeys + lives in that contradiction — your agents look soft, the master holds the teeth, the keys never leave their + hardware. +
+
+ The mark commits to side profile as primary because that's where the arched roman nose + and the towering topknot do the work. Front view, cloud, monogram, seal, and solid icon are derived + application forms. +
+
+ + ); +} diff --git a/apps/parent-control/app/_components/onboarding.tsx b/apps/parent-control/app/_components/onboarding.tsx new file mode 100644 index 0000000..47dda1b --- /dev/null +++ b/apps/parent-control/app/_components/onboarding.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useClient } from '@/lib/ClientProvider'; +import { + credentialToFinishPayload, + jsonToCreationOptions, + platformAuthenticatorAvailable, + webauthnAvailable, +} from '@/lib/webauthn'; +import { Panel, PageHead } from './shared'; + +type StepStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped'; + +interface Step { + id: string; + num: number; + title: string; + desc: string; + detail?: string; + status: StepStatus; + error?: string; +} + +const INITIAL_STEPS: Step[] = [ + { + id: 'identity', + num: 1, + title: 'identity ceremony', + desc: 'email-link / OAuth — broker returns binding_nonce', + detail: 'Stubbed in PR-B. The CLI command agentkeys init runs this today.', + status: 'pending', + }, + { + id: 'k10', + num: 2, + title: 'K10 device key', + desc: 'generate secp256k1 keypair in Secure Enclave', + detail: 'Stubbed in PR-B. The CLI command agentkeys signer derive runs this today.', + status: 'pending', + }, + { + id: 'k11', + num: 3, + title: 'K11 WebAuthn enrollment', + desc: 'navigator.credentials.create() → platform authenticator → daemon → SidecarRegistry', + detail: 'Live. Browser drives the real WebAuthn ceremony. Daemon /v1/k11/enroll/{begin,finish}.', + status: 'pending', + }, + { + id: 'siwe', + num: 4, + title: 'SIWE → session JWT', + desc: 'sign Sign-In-With-Ethereum message, exchange for broker K6 token', + detail: 'Stubbed in PR-B.', + status: 'pending', + }, + { + id: 'sts', + num: 5, + title: 'STS assume-role-with-web-identity', + desc: 'exchange session JWT for AWS temp creds (vault + memory)', + detail: 'Stubbed in PR-B. The harness step 7 in v2-stage1-demo.sh runs this against the real broker.', + status: 'pending', + }, + { + id: 'provision', + num: 6, + title: 'provision vault + memory buckets', + desc: 'one-shot idempotent S3 bucket + IAM role bring-up', + detail: 'Stubbed in PR-B. The harness step 7 in v2-stage1-demo.sh runs this.', + status: 'pending', + }, + { + id: 'chain', + num: 7, + title: 'chain bring-up', + desc: 'deploy SidecarRegistry + AgentKeysScope + K3EpochCounter + CredentialAudit', + detail: 'Stubbed in PR-B. Real chain deploy lives in scripts/heima-bring-up.sh.', + status: 'pending', + }, + { + id: 'register', + num: 8, + title: 'register master device on chain', + desc: 'SidecarRegistry.register_master_device(D_pub, K11_credId)', + detail: 'Stubbed in PR-B. Real chain submission lands in PR-C alongside the audit-service feed.', + status: 'pending', + }, +]; + +export function OnboardingPage({ onClose }: { onClose: () => void }) { + const client = useClient(); + const [steps, setSteps] = useState(INITIAL_STEPS); + const [platformOk, setPlatformOk] = useState(null); + const [username, setUsername] = useState('sara@example.com'); + const [displayName, setDisplayName] = useState('Sara (master)'); + + useEffect(() => { + if (!webauthnAvailable()) { + setPlatformOk(false); + return; + } + platformAuthenticatorAvailable().then(setPlatformOk); + }, []); + + const setStatus = (id: string, status: StepStatus, error?: string) => { + setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, status, error } : s))); + }; + + const runStubStep = async (id: string) => { + setStatus(id, 'running'); + await new Promise((r) => setTimeout(r, 400)); + setStatus(id, 'skipped', 'Stubbed in PR-B; real flow lands in PR-C / v2-stage1 harness.'); + }; + + const runK11Enroll = async () => { + if (!webauthnAvailable()) { + setStatus('k11', 'failed', 'WebAuthn not available in this browser.'); + return; + } + setStatus('k11', 'running'); + try { + const beginResult = await client.enrollK11Begin({ + userName: username, + userDisplayName: displayName, + }); + if (!beginResult.ok) { + setStatus('k11', 'failed', `begin failed: ${beginResult.status.detail ?? beginResult.status.reason}`); + return; + } + const begin = beginResult.data; + + const creationOptions = jsonToCreationOptions({ + rp: { id: begin.rpId, name: begin.rpName }, + user: { id: begin.userId, name: begin.userName, displayName: begin.userDisplayName }, + challenge: begin.challenge, + pubKeyCredParams: begin.pubKeyCredParams, + timeout: begin.timeout, + attestation: 'none', + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + residentKey: 'preferred', + }, + }); + + const cred = (await navigator.credentials.create({ publicKey: creationOptions })) as PublicKeyCredential | null; + if (!cred) { + setStatus('k11', 'failed', 'navigator.credentials.create() returned null'); + return; + } + const payload = credentialToFinishPayload(cred); + + const finishResult = await client.enrollK11Finish({ + credentialId: payload.credentialId, + attestationObject: payload.attestationObject, + clientDataJSON: payload.clientDataJSON, + bindingNonce: begin.userId, + }); + + if (!finishResult.ok) { + setStatus('k11', 'failed', `finish failed: ${finishResult.status.detail ?? finishResult.status.reason}`); + return; + } + setStatus('k11', 'done'); + } catch (err) { + const msg = (err as Error).message ?? String(err); + setStatus('k11', 'failed', `ceremony aborted: ${msg}`); + } + }; + + const runStep = (s: Step) => { + if (s.id === 'k11') return runK11Enroll(); + return runStubStep(s.id); + }; + + return ( + <> + + / first-run onboarding + + } + desc="Mirrors harness/v2-stage1-demo.sh as an interactive wizard. Step 3 (K11 WebAuthn) is live and runs a real browser ceremony against the daemon's /v1/k11/enroll endpoints. The other steps are stubbed in PR-B; real implementations land in PR-C." + actions={ + + } + /> + + +
+
+ WebAuthn supported +
+
{platformOk === null ? '…' : platformOk ? 'yes · platform authenticator' : 'no'}
+
+ daemon backend +
+
{process.env.NEXT_PUBLIC_AGENTKEYS_BACKEND ?? 'empty'} · expected `daemon`
+
+ ui-bridge URL +
+
{process.env.NEXT_PUBLIC_AGENTKEYS_DAEMON_URL ?? 'http://localhost:3114'}
+
+
+ + +
+
+
username
+
passed to navigator.credentials.create as user.name
+
+ setUsername(e.target.value)} + style={{ + width: 240, + padding: '4px 8px', + fontFamily: 'inherit', + fontSize: 13, + border: '1px solid var(--rule)', + background: 'var(--bg)', + color: 'var(--ink)', + }} + /> +
+
+
+
display name
+
shown by the platform authenticator UI
+
+ setDisplayName(e.target.value)} + style={{ + width: 240, + padding: '4px 8px', + fontFamily: 'inherit', + fontSize: 13, + border: '1px solid var(--rule)', + background: 'var(--bg)', + color: 'var(--ink)', + }} + /> +
+
+ + + + + + + + + + + + + + {steps.map((s) => ( + + + + + + + + ))} + +
#stepdescstatus
{s.num} + {s.title} + {s.detail && ( +
+ {s.detail} +
+ )} + {s.error && ( +
+ {s.error} +
+ )} +
{s.desc} + + + +
+
+ +
+ scope + + Step 3 is the only step with a real implementation in PR-B — a genuine browser WebAuthn ceremony backed by the + daemon's ui-bridge mode. PR-C wires steps 1, 2, 4–8 to the broker + chain. To run step 3, start the daemon + with agentkeys-daemon --ui-bridge and set NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon{' '} + in .env.local. + +
+ + ); +} + +function StatusChip({ status }: { status: StepStatus }) { + if (status === 'pending') return pending; + if (status === 'running') return running…; + if (status === 'done') return done; + if (status === 'skipped') return stubbed; + return failed; +} diff --git a/apps/parent-control/app/_components/pages.tsx b/apps/parent-control/app/_components/pages.tsx new file mode 100644 index 0000000..6b5106f --- /dev/null +++ b/apps/parent-control/app/_components/pages.tsx @@ -0,0 +1,675 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { NAMESPACES } from '@/lib/constants'; +import type { CapToken, ConnectionStatus } from '@/lib/client/types'; +import { ActorTree, Chip, Dot, EmptyState, Panel, PageHead, TripleToggle } from './shared'; +import type { Actor, AuditEvent, ChipKind, Namespace, ScopeBits } from './types'; + +// ─── Page: Actors list ─────────────────────────────────────────── +export function ActorsPage({ + actors, + status, + onPick, +}: { + actors: Actor[]; + status: ConnectionStatus; + onPick: (id: string) => void; +}) { + const master = actors.find((a) => a.role === 'master'); + const agents = actors.filter((a) => a.role === 'agent'); + const active = agents.filter((a) => a.lastActive === 'now' || a.lastActive.endsWith('m ago')).length; + const isEmpty = actors.length === 0; + + return ( + <> + + / actors + + } + desc="Devices and agents bound to your actor tree. Each row is an HDKD child of your master — its own omni, its own scope, its own wallet." + /> + + {isEmpty ? ( + + Once a master device runs the v2-stage1 onboarding (identity + K11 + on-chain + device-register), it appears here. See harness/v2-stage1-demo.sh. + + } + /> + ) : ( + <> +
+
+
{agents.length}
+
agents bound
+
live from daemon /v1/actors
+
+
+
{active}
+
active now
+
SSE feed live · tier-1
+
+
+
+
events / 2-min batch
+
populated by /v1/anchor/status
+
+
+
0
+
pending approvals
+
no high-risk caps queued
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + {master && ( + onPick(master.id)}> + + + + + + + + + )} + {agents.map((a) => ( + onPick(a.id)}> + + + + + + + + + ))} + +
actorderivationvendordevicelast active
+ + + + {master.label} + +
+ {master.omni} · {master.omniHex} +
+
/ (root)self{master.device}now + master +
+ + + {a.label} +
{a.omni}
+
{a.derivation}{a.vendor}{a.device}{a.lastActive} + +
+
+ +
+ tip + + One-tap revoke surfaces inside any actor row. Sensitive mutations (revoke, scope grant, payment cap) require K11 + biometric re-auth on this device. + +
+ + )} + + ); +} + +// ─── Page: Actor detail ────────────────────────────────────────── +export function ActorDetailPage({ + actor, + onUpdate, + onBack, + onRevoke, + onRevokeScope, + recentEvents, + capTokens, +}: { + actor: Actor; + onUpdate: (id: string, patch: Partial) => void; + onBack: () => void; + onRevoke: (a: Actor) => void; + onRevokeScope: (a: Actor, cap: string) => void; + recentEvents: AuditEvent[]; + capTokens: CapToken[]; +}) { + if (actor.role === 'master') { + return ; + } + + const events = recentEvents.filter((e) => e.actorId === actor.id).slice(0, 6); + + const setScope = (ns: Namespace, value: ScopeBits) => { + onUpdate(actor.id, { + scope: { ...(actor.scope as Record), [ns]: value }, + }); + }; + + const setPaymentCap = (key: 'perTx' | 'daily', value: number) => { + onUpdate(actor.id, { + paymentCap: { ...(actor.paymentCap as { perTx: number; daily: number; currency: string }), [key]: value }, + }); + }; + + return ( + <> + + + actors + {' '} + / {actor.derivation} + + } + title={ + <> + / {actor.label} + + } + desc={`Bound at ${actor.omni}. All scope, payment-cap, and time-window settings are master-mutations — each save triggers K11 + chain commit.`} + actions={ + <> + + + + } + /> + +
+ warn + + {actor.label} attempted a payment outside its time-window 38m ago. Payments still gated; review the audit row → + +
+ + +
+
actor_omni
+
+ {actor.omni} ({actor.omniHex}) +
+
derivation
+
+ {actor.derivation} (hard / HDKD) +
+
device pubkey
+
+ {actor.devicePubkey} · K10 secp256k1 +
+
vendor
+
{actor.vendor}
+
device
+
{actor.device}
+
K11 user-presence
+
+ {actor.k11 ? 'enrolled (master device)' : none · agents cannot hold K11} +
+
last active
+
{actor.lastActive}
+
workers in scope
+
+ {(actor.services ?? []).map((s) => ( + + {s} + + ))} +
+
+
+ + +
+ Maps to ScopeContract[O_master][{actor.omni}] → {'{namespaces, ops}'}. Changes commit to chain via master K11. +
+ {NAMESPACES.map((ns) => ( +
+
+
{ns}
+
+ {ns === 'personal' && 'private to you — diaries, photos, individual preferences'} + {ns === 'family' && 'shared with family — schedules, lists, household state'} + {ns === 'work' && 'work artifacts — credentials, repos, calendars'} + {ns === 'travel' && 'travel context — locations, bookings, itineraries'} +
+
+ setScope(ns, v)} + /> +
+ ))} +
+ + +
+ One-shot CAS-burn cap per arch §19. Above per-tx threshold, broker requires K11 assertion at mint time. +
+
+
+
per-transaction limit
+
single payment cannot exceed this amount
+
+
+ setPaymentCap('perTx', Number(e.target.value))} + style={{ + width: 70, + padding: '4px 8px', + fontFamily: 'inherit', + fontSize: 13, + border: '1px solid var(--rule)', + background: 'var(--bg)', + color: 'var(--ink)', + textAlign: 'right', + }} + /> + {actor.paymentCap?.currency ?? 'USDC'} +
+
+
+
+
daily ceiling
+
rolling 24h cumulative limit
+
+
+ setPaymentCap('daily', Number(e.target.value))} + style={{ + width: 70, + padding: '4px 8px', + fontFamily: 'inherit', + fontSize: 13, + border: '1px solid var(--rule)', + background: 'var(--bg)', + color: 'var(--ink)', + textAlign: 'right', + }} + /> + {actor.paymentCap?.currency ?? 'USDC'} +
+
+ {actor.timeWindow && ( +
+
+
time window
+
payments outside this window are rejected at broker
+
+
+ {actor.timeWindow.start} {actor.timeWindow.end} +
+
+ )} +
+ + + {capTokens.length === 0 ? ( +
+ no caps minted in this window. Daemon endpoint GET /v1/actors/{actor.id}/caps{' '} + populates this table. +
+ ) : ( + + + + + + + + + + + + {capTokens.map((c) => ( + + + + + + + + ))} + +
capscopettlminted
+ {c.cap} + {c.scope}{c.ttl}{c.minted} + +
+ )} +
+ + + {events.length === 0 ? ( +
+ no activity in this window. +
+ ) : ( + events.map((e) => ( +
+ {e.ts} + {e.actor} + + {e.kind} + · {e.detail} + + {e.chip} +
+ )) + )} +
+ + ); +} + +function MasterDetail({ actor, onBack }: { actor: Actor; onBack: () => void }) { + return ( + <> + + + actors + {' '} + / / + + } + title={ + <> + / {actor.label} + + } + desc="Root of your HDKD actor tree. K11 user-presence credential lives on this device; all master mutations sign with it." + actions={ + + } + /> + + +
+
actor_omni
+
+ {actor.omni} ({actor.omniHex}) +
+
device pubkey
+
+ {actor.devicePubkey} · K10 secp256k1 · SE +
+
K11 (WebAuthn)
+
+ {actor.k11 ? 'enrolled · platform authenticator' : 'not enrolled · run onboarding to enroll K11'} +
+
device
+
{actor.device}
+
last active
+
{actor.lastActive}
+
+
+ + ); +} + +// ─── Page: Audit feed ──────────────────────────────────────────── +export function AuditPage({ + events, + status, + onPick, + paused, + onPause, +}: { + events: AuditEvent[]; + status: ConnectionStatus; + onPick: (e: AuditEvent) => void; + paused: boolean; + onPause: () => void; +}) { + const [filter, setFilter] = useState('all'); + const filtered = filter === 'all' ? events : events.filter((e) => e.chip === filter); + const filters: (ChipKind | 'all')[] = ['all', 'memory', 'creds', 'payment', 'audit', 'chain']; + const isEmpty = events.length === 0; + + return ( + <> + + / audit feed + + } + desc="Real-time stream from the audit-service worker. Tier-1 is off-chain SSE (sub-200ms); tier-2 anchors a Merkle root on chain every 2 min." + actions={ + + } + /> + +
+ + + {status.kind === 'connected' ? (paused ? 'paused' : 'live') : 'offline'} + + + {status.kind === 'connected' + ? paused + ? 'feed paused — incoming events queue at the broker SSE buffer.' + : 'streaming from /v1/audit/stream · 1 connection · auto-reconnect on drop.' + : 'daemon offline — no events to display.'} + +
+ + + {filters.map((f) => ( + + ))} + + } + flush + > + {isEmpty ? ( +
+ + Once an agent runs memory.read,{' '} + cred.fetch, or audit.append, events stream + in here within ~200 ms. + + } + /> +
+ ) : ( +
+ {filtered.map((e) => ( +
onPick(e)} + > + {e.ts} + {e.actor} + + {e.kind} + · {e.detail} + + {e.chip} +
+ ))} + {filtered.length === 0 && ( +
+ no events match this filter. +
+ )} +
+ )} +
+ + ); +} + +// ─── Page: Anchor status ───────────────────────────────────────── +export function AnchorPage() { + const [now, setNow] = useState(null); + useEffect(() => { + setNow(Date.now()); + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + let elapsed = 0; + let next = 120; + let pct = 0; + if (now !== null) { + elapsed = Math.floor((now / 1000) % 120); + next = 120 - elapsed; + pct = (elapsed / 120) * 100; + } + + return ( + <> + + / anchor status + + } + desc="Every 2 minutes, the audit-service worker Merkleizes the tier-1 batch and submits a single extrinsic to the Litentry parachain." + /> + + +
+
+
+ next anchor in +
+
+ {String(Math.floor(next / 60)).padStart(2, '0')}:{String(next % 60).padStart(2, '0')} +
+
+
+
+ events in batch +
+
+ — +
+
+
+
+
+
+
+ countdown is local · live data lands in PR-C (GET /v1/anchor/status) + tier-1 ↦ tier-2 commit +
+
+ + +
+ recent anchors will populate once the daemon exposes GET /v1/anchor/status{' '} + (tracked for PR-C). +
+
+ + ); +} diff --git a/apps/parent-control/app/_components/shared.tsx b/apps/parent-control/app/_components/shared.tsx new file mode 100644 index 0000000..943af5c --- /dev/null +++ b/apps/parent-control/app/_components/shared.tsx @@ -0,0 +1,323 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { CHIP_STYLES } from '@/lib/constants'; +import type { ConnectionStatus } from '@/lib/client/types'; +import type { Actor, ChipKind, ScopeBits, StatusKind } from './types'; + +export function Chip({ children, kind = 'default' }: { children: ReactNode; kind?: ChipKind }) { + const cls = CHIP_STYLES[kind] || 'chip'; + return {children}; +} + +export function Dot({ status = 'ok', pulse = false }: { status?: StatusKind; pulse?: boolean }) { + const cls = `dot ${status === 'ok' ? '' : status} ${pulse ? 'pulse' : ''}`.trim(); + return ; +} + +export function AsciiRule({ glyph = '─' }: { glyph?: string }) { + return
{glyph.repeat(220)}
; +} + +export function PageHead({ + crumb, + title, + desc, + actions, +}: { + crumb?: ReactNode; + title: ReactNode; + desc?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+ {crumb &&
{crumb}
} +

{title}

+ {desc &&
{desc}
} +
+ {actions &&
{actions}
} +
+ ); +} + +export function Panel({ + title, + right, + flush, + children, +}: { + title?: ReactNode; + right?: ReactNode; + flush?: boolean; + children: ReactNode; +}) { + return ( +
+ {title && ( +
+ {title} + {right} +
+ )} +
{children}
+
+ ); +} + +export function TripleToggle({ + value, + onChange, +}: { + value: ScopeBits; + onChange: (v: ScopeBits) => void; +}) { + const state = value.write ? 'rw' : value.read ? 'r' : 'off'; + const set = (s: 'off' | 'r' | 'rw') => { + if (s === 'off') onChange({ read: false, write: false }); + else if (s === 'r') onChange({ read: true, write: false }); + else onChange({ read: true, write: true }); + }; + return ( +
+ + + +
+ ); +} + +export function Modal({ + title, + onClose, + children, + footer, +}: { + title: ReactNode; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [onClose]); + return ( +
+
e.stopPropagation()}> +
+ {title} + +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} + +function hashCode(s: string) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (((h << 5) - h) + s.charCodeAt(i)) | 0; + return h; +} + +export function WebAuthnModal({ + intent, + onConfirm, + onCancel, +}: { + intent: { text: string; fields: [string, string][] }; + onConfirm: () => void; + onCancel: () => void; +}) { + const [phase, setPhase] = useState<'idle' | 'scanning' | 'ok'>('idle'); + const startScan = () => { + setPhase('scanning'); + setTimeout(() => { + setPhase('ok'); + setTimeout(onConfirm, 350); + }, 1100); + }; + + return ( +
+
e.stopPropagation()}> +
+ K11 · WebAuthn confirmation + {phase === 'idle' && ( + + )} +
+
+

{intent.text}

+
+ agentkeys-cli @ localhost:9091 · this device only +
+ +
+ {intent.fields.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ +
+
+ {phase === 'ok' ? '✓' : 'fp'} +
+
+ {phase === 'idle' && 'Touch the sensor to authorize this mutation.'} + {phase === 'scanning' && 'Verifying biometric…'} + {phase === 'ok' && 'Authorized · publishing to chain.'} +
+
+ +
+ challenge = sha256(intent · binding_nonce · D_pub) +
+ + 0x{Math.abs(hashCode(intent.text)).toString(16).padStart(8, '0')}… + {Math.abs(hashCode(JSON.stringify(intent.fields))).toString(16).padStart(8, '0')} + +
+
+
+ {phase === 'idle' && ( + <> + + + + )} + {phase === 'scanning' && ( + + )} + {phase === 'ok' && ( + + )} +
+
+
+ ); +} + +export function EmptyState({ + status, + title = 'backend not connected', + hint, +}: { + status: ConnectionStatus; + title?: string; + hint?: ReactNode; +}) { + if (status.kind === 'connected') return null; + const reasonText = + status.reason === 'no-backend-configured' + ? 'No daemon backend configured.' + : status.reason === 'unauthorized' + ? 'Daemon rejected the session JWT (expired or revoked).' + : 'Daemon unreachable. Is it running?'; + return ( +
+
+ {title} +
+
+ {reasonText} +
+ {status.detail && ( +
+ {status.detail} +
+ )} + {hint && ( +
{hint}
+ )} +
+ ); +} + +export function ActorTree({ + actors, + onPick, + currentId, +}: { + actors: Actor[]; + onPick: (id: string) => void; + currentId?: string; +}) { + const master = actors.find((a) => a.role === 'master')!; + const agents = actors.filter((a) => a.role === 'agent'); + return ( +
+
onPick(master.id)} + > + + + {master.label} + + master · {master.omniHex} +
+ {agents.map((a, i) => { + const last = i === agents.length - 1; + return ( +
onPick(a.id)} + > + {last ? '└── ' : '├── '} + + {a.label} + + {a.derivation} · {a.lastActive} + +
+ ); + })} +
+ ); +} diff --git a/apps/parent-control/app/_components/types.ts b/apps/parent-control/app/_components/types.ts new file mode 100644 index 0000000..7b6dbbf --- /dev/null +++ b/apps/parent-control/app/_components/types.ts @@ -0,0 +1,98 @@ +export type Namespace = 'personal' | 'family' | 'work' | 'travel'; + +export type ScopeBits = { read: boolean; write: boolean }; + +export type ActorRole = 'master' | 'agent'; +export type StatusKind = 'ok' | 'warn' | 'bad' | 'muted'; + +export interface Actor { + id: string; + omni: string; + omniHex: string; + label: string; + role: ActorRole; + parent: string | null; + derivation: string; + device: string; + devicePubkey: string; + lastActive: string; + status: StatusKind; + vendor: string; + k11: boolean; + children?: string[]; + scope?: Record; + paymentCap?: { perTx: number; daily: number; currency: string }; + timeWindow?: { start: string; end: string; tz: string }; + services?: string[]; +} + +export type ChipKind = + | 'default' + | 'ok' + | 'warn' + | 'bad' + | 'memory' + | 'creds' + | 'audit' + | 'broker' + | 'chain' + | 'payment' + | 'revoke'; + +export interface AuditEvent { + id: string; + ts: string; + actorId: string; + actor: string; + kind: string; + detail: string; + chip: ChipKind; + sev: StatusKind; + _isNew?: boolean; +} + +export interface SimEvent { + actorId: string; + actor: string; + kind: string; + detail: string; + chip: ChipKind; + sev: StatusKind; +} + +export interface Worker { + id: 'memory' | 'credentials' | 'audit' | 'email' | 'payment'; + title: string; + host: string; + desc: string; + callsToday: number; + callsHour: number; + p50: number; + p95: number; + cap: string; + byActor: { actor: string; count: number; share: number }[]; +} + +export type PendingAction = + | { + kind: 'revoke-device'; + actor: Actor; + intent: { text: string; fields: [string, string][] }; + } + | { + kind: 'revoke-scope'; + actor: Actor; + capName: string; + intent: { text: string; fields: [string, string][] }; + }; + +export type Route = + | { page: 'actors'; actorId: null } + | { page: 'detail'; actorId: string } + | { page: 'audit'; actorId: null } + | { page: 'anchor'; actorId: null } + | { page: 'workers'; actorId: null } + | { page: 'onboarding'; actorId: null } + | { page: 'onboarding-mobile'; actorId: null } + | { page: 'harness'; actorId: null } + | { page: 'logo'; actorId: null }; diff --git a/apps/parent-control/app/_components/workers.tsx b/apps/parent-control/app/_components/workers.tsx new file mode 100644 index 0000000..f976853 --- /dev/null +++ b/apps/parent-control/app/_components/workers.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useClient } from '@/lib/ClientProvider'; +import type { ConnectionStatus } from '@/lib/client/types'; +import { Chip, EmptyState, Panel, PageHead } from './shared'; +import type { Worker } from './types'; + +const HUE_BY_WORKER: Record = { + memory: 180, + credentials: 295, + audit: 145, + email: 220, + payment: 50, +}; + +export function WorkersPage({ + status, + onPickActor, +}: { + status: ConnectionStatus; + onPickActor: (id: string) => void; +}) { + const client = useClient(); + const [workers, setWorkers] = useState([]); + const [selected, setSelected] = useState(null); + const worker = selected ? workers.find((w) => w.id === selected) ?? null : null; + + useEffect(() => { + let cancelled = false; + (async () => { + const r = await client.listWorkers(); + if (!cancelled && r.ok) setWorkers(r.data); + })(); + return () => { + cancelled = true; + }; + }, [client]); + + if (worker) { + return ( + setSelected(null)} + onPickActor={onPickActor} + /> + ); + } + + const isEmpty = workers.length === 0; + + return ( + <> + + / workers + + } + desc="Each worker holds no secrets at rest — per-invocation STS creds, mTLS to the signer enclave, independent chain re-verification on every cap. Tap any worker for per-actor usage." + /> + + {isEmpty ? ( + + Workers report in via daemon endpoint GET /v1/workers (lands in PR-C). The + five canonical workers per arch.md §15 are memory,{' '} + credentials, audit,{' '} + email, payment. + + } + /> + ) : ( + <> +
+ {workers.map((w) => ( +
setSelected(w.id)} + style={{ cursor: 'pointer' }} + > +
+
+
{w.title}
+
+ {w.host} +
+
+ {w.cap} +
+
+
{w.desc}
+
+
+
{w.callsToday.toLocaleString()}
+
calls · today
+
+
+
{w.callsHour}
+
last hour
+
+
+
+ {w.p50} + ms +
+
p50 latency
+
+
+
+ {w.p95} + ms +
+
p95 latency
+
+
+
+ share by actor +
+ {w.byActor.slice(0, 4).map((a) => ( +
+ {a.actor} + {(a.share * 100).toFixed(0)}% + {a.count.toLocaleString()} +
+ ))} +
inspect →
+
+
+ ))} +
+ +
+ why split + + Compromise of any one worker yields bounded damage — no shared IAM, no shared S3 prefix, no shared cap-token + authority. See arch.md §3 (blast-radius table). + +
+ + )} + + ); +} + +function WorkerDetail({ + worker, + onBack, + onPickActor, +}: { + worker: Worker; + onBack: () => void; + onPickActor: (id: string) => void; +}) { + const hue = HUE_BY_WORKER[worker.id]; + return ( + <> + + + workers + {' '} + / {worker.id} + + } + title={ + <> + / {worker.title} + + } + desc={worker.desc} + actions={ + + } + /> + +
+
+
+
{worker.title}
+
+ {worker.host} · mTLS to signer · STS minted per call +
+
+ {worker.cap} +
+
+
+
+
{worker.callsToday.toLocaleString()}
+
calls today
+
+
+
{worker.callsHour}
+
last hour
+
+
+
+ {worker.p50} + ms +
+
p50
+
+
+
+ {worker.p95} + ms +
+
p95
+
+
+
+
+ + + + + + + + + + + + + {worker.byActor.map((line) => ( + + + + + + + ))} + +
actorsharecalls (24h)
{line.actor} +
+
+
+
+ + {(line.share * 100).toFixed(0)}% + +
+
{line.count.toLocaleString()} + +
+
+ + +
+
secrets at rest
+
none
+
iam principal
+
{`arn:aws:sts:::*:assumed-role/agentkeys-${worker.id}-v1`}
+
session ttl
+
3600s · refreshed per-call
+
chain re-verify
+
every cap-token · ScopeContract + SidecarRegistry + K3EpochCounter
+
storage
+
+ {`s3://${ + worker.id === 'payment' ? 'PAYMENT_AUDIT_BUCKET' : `${worker.id.toUpperCase()}_BUCKET` + }/bots//`} +
+
compromise blast
+
+ {worker.id === 'memory' && + 'this worker only · cannot decrypt creds, cannot pay, cannot mint caps'} + {worker.id === 'credentials' && + 'this worker only · decrypt for valid caps · cannot mint caps · cannot reach other classes'} + {worker.id === 'audit' && + 'append spam possible (rejected on chain mismatch) · cannot read other workers'} + {worker.id === 'email' && 'mail send/receive within DKIM domain only · K9 isolated'} + {worker.id === 'payment' && + 'cannot exceed per-tx cap · K11 gate above threshold · CAS-burn prevents replay'} +
+
+
+ + ); +} diff --git a/apps/parent-control/app/globals.css b/apps/parent-control/app/globals.css new file mode 100644 index 0000000..ab8efbb --- /dev/null +++ b/apps/parent-control/app/globals.css @@ -0,0 +1,651 @@ +/* AgentKeys parent-control UI — iii.dev-inspired aesthetic */ + +:root { + --bg: #f6f3ec; + --bg-elev: #eeeae0; + --bg-deep: #e6e1d3; + --ink: #1a1815; + --ink-dim: #5a5448; + --ink-faint: #8a8273; + --rule: #2a261f; + --rule-soft: #c4bda9; + --rule-hair: #d8d1bd; + --accent: oklch(0.55 0.13 50); /* warm amber */ + --accent-soft: oklch(0.92 0.04 50); + --ok: oklch(0.5 0.08 165); + --ok-soft: oklch(0.92 0.03 165); + --danger: oklch(0.5 0.16 25); + --danger-soft: oklch(0.94 0.05 25); + --info: oklch(0.5 0.07 240); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { background: var(--ink); color: var(--bg); } + +a { + color: var(--ink); + text-decoration: underline; + text-decoration-color: var(--rule-soft); + text-underline-offset: 3px; + text-decoration-thickness: 1px; +} +a:hover { text-decoration-color: var(--ink); } + +.serif { + font-family: 'IBM Plex Serif', 'Iowan Old Style', Georgia, serif; + font-feature-settings: "ss01"; +} + +/* ─── Section accents (master/actors stay neutral) ───────────── */ + +.app-main[data-section="audit"] { --hue: 145; --section-name: 'audit'; } +.app-main[data-section="anchor"] { --hue: 240; --section-name: 'anchor'; } +.app-main[data-section="workers"] { --hue: 300; --section-name: 'workers'; } +.app-main[data-section="logo"] { --hue: 25; --section-name: 'logo'; } + +.app-main[data-section] { + --section: oklch(0.5 0.12 var(--hue)); + --section-soft: oklch(0.94 0.04 var(--hue)); + --section-faint: oklch(0.97 0.02 var(--hue)); +} + +.app-main[data-section] .page-head { + border-bottom-color: var(--section); +} +.app-main[data-section] .page-head h1 { + color: var(--section); +} +.app-main[data-section] .page-head .crumb { + color: var(--section); +} +.app-main[data-section] .panel-head { + background: var(--section-faint); + border-bottom-color: var(--section-soft); + color: var(--section); +} +.app-main[data-section] .stat .v { + color: var(--section); +} +.app-main[data-section] .feed-row.new { + background: var(--section-soft); +} +.app-main[data-section] .banner:not(.warn) { + background: var(--section-faint); + border-color: var(--section-soft); +} +.app-main[data-section] .banner:not(.warn) .lbl { + color: var(--section); + border-right-color: var(--section-soft); +} + +/* worker-specific tints inside the workers page */ +.worker-card { border: 1px solid var(--rule); padding: 0; } +.worker-card[data-worker] { + --w-hue: 0; + --w: oklch(0.5 0.12 var(--w-hue)); + --w-soft: oklch(0.94 0.04 var(--w-hue)); + --w-faint: oklch(0.97 0.02 var(--w-hue)); +} +.worker-card[data-worker="memory"] { --w-hue: 180; } +.worker-card[data-worker="credentials"] { --w-hue: 295; } +.worker-card[data-worker="audit"] { --w-hue: 145; } +.worker-card[data-worker="email"] { --w-hue: 220; } +.worker-card[data-worker="payment"] { --w-hue: 50; } +.worker-card .w-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 14px 18px; + background: var(--w-faint); + border-bottom: 1px solid var(--w-soft); + gap: 12px; +} +.worker-card .w-head .name { + font-family: 'IBM Plex Serif', serif; + font-style: italic; + font-size: 22px; + color: var(--w); + letter-spacing: -0.01em; +} +.worker-card .w-head .who { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--w); +} +.worker-card .w-body { padding: 16px 18px; font-size: 12px; } +.worker-card .w-body .desc { color: var(--ink-dim); margin-bottom: 12px; } +.worker-card .w-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; } +.worker-card .w-stat { border: 1px solid var(--w-soft); padding: 10px 12px; background: var(--w-faint); } +.worker-card .w-stat .v { font-family: 'IBM Plex Serif', serif; font-style: italic; font-size: 22px; color: var(--w); line-height: 1; letter-spacing: -0.01em; } +.worker-card .w-stat .k { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); margin-top: 4px; } +.worker-card .w-bar { height: 4px; background: var(--w-soft); position: relative; margin: 6px 0 10px; } +.worker-card .w-bar > div { height: 100%; background: var(--w); } +.worker-card .actor-line { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + padding: 6px 0; + border-bottom: 1px dashed var(--rule-hair); + font-size: 11.5px; + align-items: center; +} +.worker-card .actor-line:last-child { border-bottom: 0; } +.worker-card .actor-line .cnt { font-variant-numeric: tabular-nums; color: var(--w); font-weight: 500; } + +.workers-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 16px; +} + +/* ─── Layout ──────────────────────────────────────────────────── */ + +.app { + min-height: 100vh; + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: auto 1fr; + grid-template-areas: + "head head" + "side main"; +} + +.app-head { + grid-area: head; + border-bottom: 1px solid var(--rule); + padding: 14px 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + background: var(--bg); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + display: flex; + align-items: baseline; + gap: 10px; +} +.brand .mark { + font-family: 'IBM Plex Serif', serif; + font-size: 18px; + font-style: italic; + letter-spacing: -0.01em; +} +.brand .sub { + font-size: 11px; + color: var(--ink-dim); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.head-right { + display: flex; + gap: 14px; + align-items: center; + font-size: 11px; + color: var(--ink-dim); +} +.head-right .who { + display: flex; gap: 6px; align-items: center; +} +.head-right .who::before { + content: ""; + width: 6px; height: 6px; + background: var(--ok); + display: inline-block; +} + +.app-side { + grid-area: side; + border-right: 1px solid var(--rule); + padding: 20px 0; + position: sticky; + top: 53px; + height: calc(100vh - 53px); + overflow-y: auto; +} +.nav-section { padding: 0 22px 6px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); margin-top: 16px; } +.nav-section:first-child { margin-top: 0; } +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 22px; + cursor: pointer; + font-size: 13px; + border: 0; + background: none; + width: 100%; + text-align: left; + font-family: inherit; + color: var(--ink); +} +.nav-item:hover { background: var(--bg-elev); } +.nav-item.active { + background: var(--ink); + color: var(--bg); +} +.nav-item .marker { + font-family: inherit; + width: 14px; + display: inline-block; + color: var(--ink-faint); +} +.nav-item.active .marker { color: var(--bg); } +.nav-item .count { + margin-left: auto; + font-size: 11px; + color: var(--ink-faint); +} +.nav-item.active .count { color: var(--bg-deep); } + +.app-main { + grid-area: main; + padding: 28px 36px 80px; + max-width: 1180px; +} + +/* ─── Mobile ──────────────────────────────────────────────────── */ + +.hamb { display: none; background: none; border: 1px solid var(--rule); padding: 6px 10px; font-family: inherit; font-size: 12px; cursor: pointer; color: var(--ink); } + +@media (max-width: 820px) { + .app { + grid-template-columns: 1fr; + grid-template-areas: "head" "main"; + } + .app-side { + position: fixed; + inset: 53px 0 0 0; + height: auto; + width: 100%; + background: var(--bg); + z-index: 9; + border-right: 0; + transform: translateX(-100%); + transition: transform 0.18s ease; + } + .app-side.open { transform: translateX(0); } + .app-main { padding: 20px 18px 80px; } + .hamb { display: inline-block; } + .head-right .who-text { display: none; } +} + +/* ─── Page header ─────────────────────────────────────────────── */ + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--rule); + padding-bottom: 14px; + margin-bottom: 22px; + flex-wrap: wrap; +} +.page-head h1 { + font-family: 'IBM Plex Serif', serif; + font-weight: 400; + font-size: 28px; + margin: 0 0 2px; + letter-spacing: -0.015em; + font-style: italic; +} +.page-head .crumb { + font-size: 11px; + color: var(--ink-faint); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 4px; +} +.page-head .desc { + font-size: 12px; + color: var(--ink-dim); + max-width: 580px; + margin-top: 2px; +} + +/* ─── Cards / panels ──────────────────────────────────────────── */ + +.panel { + border: 1px solid var(--rule); + background: var(--bg); + margin-bottom: 22px; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid var(--rule-soft); + background: var(--bg-elev); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-dim); +} +.panel-body { padding: 16px; } +.panel-body.flush { padding: 0; } + +/* ─── Tables ──────────────────────────────────────────────────── */ + +.tab { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} +.tab th, .tab td { + padding: 10px 16px; + text-align: left; + border-bottom: 1px solid var(--rule-hair); + vertical-align: middle; +} +.tab th { + font-weight: 500; + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-faint); + border-bottom: 1px solid var(--rule-soft); + background: var(--bg); +} +.tab tr:last-child td { border-bottom: 0; } +.tab tr.clickable { cursor: pointer; } +.tab tr.clickable:hover td { background: var(--bg-elev); } +.tab td.right, .tab th.right { text-align: right; } +.tab td.mono { font-variant-numeric: tabular-nums; } +.tab td .secondary { color: var(--ink-faint); font-size: 11px; } + +/* ─── Status indicators ───────────────────────────────────────── */ + +.dot { + display: inline-block; + width: 7px; height: 7px; + background: var(--ok); + margin-right: 8px; + vertical-align: 1px; +} +.dot.warn { background: var(--accent); } +.dot.bad { background: var(--danger); } +.dot.muted { background: var(--ink-faint); } +.dot.pulse { animation: pulse 1.4s ease-in-out infinite; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid var(--rule-soft); + font-size: 10.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-dim); + background: var(--bg); + white-space: nowrap; +} +.chip.ok { color: var(--ok); border-color: var(--ok); background: var(--ok-soft); } +.chip.warn { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.chip.bad { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); } +.chip.solid { background: var(--ink); color: var(--bg); border-color: var(--ink); } + +/* ─── Buttons ─────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid var(--rule); + background: var(--bg); + font-family: inherit; + font-size: 12px; + color: var(--ink); + cursor: pointer; + letter-spacing: 0.02em; +} +.btn:hover { background: var(--bg-elev); } +.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); } +.btn.primary:hover { background: #000; } +.btn.danger { background: var(--bg); color: var(--danger); border-color: var(--danger); } +.btn.danger:hover { background: var(--danger); color: var(--bg); } +.btn.danger.solid { background: var(--danger); color: var(--bg); } +.btn.ghost { border-color: transparent; padding: 6px 10px; } +.btn.ghost:hover { border-color: var(--rule-soft); } +.btn.sm { padding: 4px 10px; font-size: 11px; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ─── Toggle ──────────────────────────────────────────────────── */ + +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px dashed var(--rule-hair); + gap: 12px; +} +.toggle-row:last-child { border-bottom: 0; } +.toggle-row .lbl { font-size: 12.5px; } +.toggle-row .desc { font-size: 11px; color: var(--ink-faint); margin-top: 2px; } + +.tswitch { + display: inline-flex; + border: 1px solid var(--rule); + background: var(--bg); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.tswitch button { + border: 0; + background: none; + padding: 5px 12px; + font-family: inherit; + font-size: inherit; + letter-spacing: inherit; + cursor: pointer; + color: var(--ink-dim); +} +.tswitch button.on { background: var(--ink); color: var(--bg); } +.tswitch button.deny.on { background: var(--danger); } + +/* ─── Tree ────────────────────────────────────────────────────── */ + +.tree { + font-family: inherit; + font-size: 12px; + line-height: 1.9; +} +.tree .branch { color: var(--ink-faint); } +.tree .node { color: var(--ink); } +.tree .meta { color: var(--ink-faint); margin-left: 8px; font-size: 11px; } + +/* ─── Audit feed ──────────────────────────────────────────────── */ + +.feed { font-size: 12.5px; } +.feed-row { + display: grid; + grid-template-columns: 96px 110px 1fr auto; + gap: 14px; + padding: 9px 16px; + border-bottom: 1px solid var(--rule-hair); + align-items: center; + cursor: pointer; +} +.feed-row:hover { background: var(--bg-elev); } +.feed-row .ts { color: var(--ink-faint); font-size: 11px; font-variant-numeric: tabular-nums; } +.feed-row .actor { font-size: 11.5px; } +.feed-row .msg { color: var(--ink); } +.feed-row .msg .arg { color: var(--ink-faint); } +.feed-row.new { animation: slideIn 0.4s ease; background: var(--accent-soft); } +@keyframes slideIn { + from { opacity: 0; transform: translateY(-4px); background: var(--accent); } + to { opacity: 1; transform: translateY(0); background: var(--accent-soft); } +} + +@media (max-width: 640px) { + .feed-row { grid-template-columns: 70px 1fr; gap: 8px; } + .feed-row .actor { grid-column: 2; font-size: 11px; color: var(--ink-faint); } + .feed-row .msg { grid-column: 1 / -1; padding-left: 78px; margin-top: -4px; } + .feed-row .chip { grid-column: 2; justify-self: end; } +} + +/* ─── Stats ───────────────────────────────────────────────────── */ + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + border: 1px solid var(--rule); + margin-bottom: 22px; +} +.stat { + padding: 16px 18px; + border-right: 1px solid var(--rule-hair); +} +.stat:last-child { border-right: 0; } +.stat .v { + font-family: 'IBM Plex Serif', serif; + font-size: 26px; + font-weight: 400; + line-height: 1; + letter-spacing: -0.02em; +} +.stat .k { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-faint); + margin-top: 6px; +} +.stat .delta { font-size: 11px; color: var(--ink-dim); margin-top: 4px; } + +@media (max-width: 640px) { + .stat { border-right: 0; border-bottom: 1px solid var(--rule-hair); } + .stat:last-child { border-bottom: 0; } +} + +/* ─── Modal ───────────────────────────────────────────────────── */ + +.modal-bg { + position: fixed; inset: 0; + background: rgba(20, 17, 13, 0.55); + z-index: 100; + display: flex; align-items: center; justify-content: center; + padding: 20px; + animation: fade 0.18s ease; +} +@keyframes fade { from { opacity: 0; } } +.modal { + background: var(--bg); + border: 1px solid var(--rule); + max-width: 480px; + width: 100%; + animation: pop 0.22s cubic-bezier(.2,.8,.2,1); +} +@keyframes pop { from { transform: translateY(8px) scale(0.98); opacity: 0.5; } } +.modal-head { + padding: 14px 20px; + border-bottom: 1px solid var(--rule); + display: flex; justify-content: space-between; align-items: center; +} +.modal-head .ttl { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-dim); } +.modal-head .x { background: none; border: 0; cursor: pointer; font-family: inherit; font-size: 16px; color: var(--ink-faint); } +.modal-body { padding: 20px; } +.modal-foot { padding: 14px 20px; border-top: 1px solid var(--rule-soft); display: flex; gap: 10px; justify-content: flex-end; background: var(--bg-elev); } + +/* ─── WebAuthn dialog ─────────────────────────────────────────── */ + +.wa-dialog .ttl-big { + font-family: 'IBM Plex Serif', serif; + font-size: 22px; + font-style: italic; + margin: 0 0 4px; + letter-spacing: -0.01em; +} +.wa-intent { + border: 1px solid var(--rule); + background: var(--bg-elev); + padding: 14px 16px; + margin: 16px 0; + font-size: 12px; +} +.wa-intent .key { color: var(--ink-faint); display: inline-block; min-width: 90px; } +.wa-intent .val { color: var(--ink); } +.wa-fingerprint { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 18px 0 6px; +} +.fp-ring { + width: 64px; height: 64px; + border: 2px solid var(--rule); + border-radius: 50%; + display: grid; place-items: center; + position: relative; +} +.fp-ring.scanning { + border-color: var(--accent); + animation: spin 1.2s linear infinite; + border-top-color: transparent; +} +@keyframes spin { to { transform: rotate(360deg); } } +.fp-ring .glyph { font-family: 'IBM Plex Serif', serif; font-size: 28px; font-style: italic; } +.fp-msg { font-size: 11px; color: var(--ink-dim); letter-spacing: 0.04em; } + +/* ─── Misc ────────────────────────────────────────────────────── */ + +.kvs { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; font-size: 12px; } +.kvs dt { color: var(--ink-faint); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; } +.kvs dd { margin: 0; word-break: break-all; } + +.hr-ascii { + font-family: inherit; + color: var(--rule-soft); + font-size: 12px; + margin: 18px 0; + overflow: hidden; + white-space: nowrap; + user-select: none; +} + +.banner { + border: 1px solid var(--rule); + background: var(--bg-elev); + padding: 12px 16px; + font-size: 12px; + display: flex; align-items: center; gap: 12px; + margin-bottom: 22px; +} +.banner.warn { background: var(--accent-soft); border-color: var(--accent); } +.banner .lbl { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-dim); border-right: 1px solid var(--rule-soft); padding-right: 12px; } + +.muted { color: var(--ink-faint); } +.tight { letter-spacing: -0.01em; } + +/* Tap targets on mobile */ +@media (max-width: 820px) { + .btn { padding: 10px 16px; font-size: 12.5px; } + .nav-item { padding: 12px 22px; } + .tab td, .tab th { padding: 12px 14px; } +} diff --git a/apps/parent-control/app/layout.tsx b/apps/parent-control/app/layout.tsx new file mode 100644 index 0000000..b716cfb --- /dev/null +++ b/apps/parent-control/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata, Viewport } from 'next'; +import { ClientProvider } from '@/lib/ClientProvider'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'agentKeys · parent control', + description: 'Phase 1 parent-control UI for AgentKeys — HDKD actor tree, per-namespace scope, live audit feed, on-chain anchor status.', +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', + themeColor: '#f6f3ec', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + {children} + + + ); +} diff --git a/apps/parent-control/app/page.tsx b/apps/parent-control/app/page.tsx new file mode 100644 index 0000000..6e037d0 --- /dev/null +++ b/apps/parent-control/app/page.tsx @@ -0,0 +1,5 @@ +import { App } from './_components/App'; + +export default function Page() { + return ; +} diff --git a/apps/parent-control/lib/ClientProvider.tsx b/apps/parent-control/lib/ClientProvider.tsx new file mode 100644 index 0000000..ae787a1 --- /dev/null +++ b/apps/parent-control/lib/ClientProvider.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { selectBackend } from './client'; +import type { AgentKeysClient, ConnectionStatus } from './client/types'; + +const INITIAL_STATUS: ConnectionStatus = { + kind: 'disconnected', + reason: 'no-backend-configured', + detail: + 'Set NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon and AGENTKEYS_DAEMON_URL to a running agentkeys-daemon to populate this view.', +}; + +const ClientContext = createContext(null); +const StatusContext = createContext(INITIAL_STATUS); + +export function ClientProvider({ children }: { children: ReactNode }) { + const client = useMemo(() => selectBackend(), []); + const [status, setStatus] = useState(INITIAL_STATUS); + + useEffect(() => { + let cancelled = false; + client.status().then((s) => { + if (!cancelled) setStatus(s); + }); + return () => { + cancelled = true; + }; + }, [client]); + + return ( + + {children} + + ); +} + +export function useClient(): AgentKeysClient { + const c = useContext(ClientContext); + if (!c) throw new Error('useClient must be used inside '); + return c; +} + +export function useConnectionStatus(): ConnectionStatus { + return useContext(StatusContext); +} diff --git a/apps/parent-control/lib/client/daemon.ts b/apps/parent-control/lib/client/daemon.ts new file mode 100644 index 0000000..6e19f57 --- /dev/null +++ b/apps/parent-control/lib/client/daemon.ts @@ -0,0 +1,410 @@ +import type { + AgentKeysClient, + AnchorStatus, + CapToken, + ConnectionStatus, + DisconnectedStatus, + K11EnrollBegin, + K11EnrollFinishInput, + K11EnrollResult, + Result, + RevokeIntent, +} from './types'; +import type { + Actor, + AuditEvent, + ChipKind, + Namespace, + ScopeBits, + StatusKind, + Worker, +} from '@/app/_components/types'; + +/** + * DaemonBackend — talks to a running agentkeys-daemon over HTTP. + * + * Every method here maps 1:1 to a daemon HTTP endpoint: + * + * GET /healthz → status() + * GET /v1/actors → listActors + * GET /v1/actors/:id → getActor + * GET /v1/actors/:id/caps → listCapTokens + * GET /v1/audit/recent → listRecentAuditEvents + * GET /v1/audit/stream (SSE) → streamAudit + * GET /v1/anchor/status → getAnchorStatus + * GET /v1/workers → listWorkers + * GET /v1/workers/:id → getWorker + * POST /v1/actors/:id/scope → updateScope + * POST /v1/actors/:id/payment-cap → updatePaymentCap + * POST /v1/actors/:id/revoke → revokeDevice + * POST /v1/actors/:id/caps/revoke → revokeCap + * POST /v1/k11/enroll/begin → enrollK11Begin + * POST /v1/k11/enroll/finish → enrollK11Finish + */ + +const DEFAULT_BASE_URL = 'http://localhost:3114'; + +function unreachable(detail: string): DisconnectedStatus { + return { kind: 'disconnected', reason: 'unreachable', detail }; +} + +export class DaemonBackend implements AgentKeysClient { + private baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = (baseUrl ?? process.env.NEXT_PUBLIC_AGENTKEYS_DAEMON_URL ?? DEFAULT_BASE_URL).replace(/\/$/, ''); + } + + private async getJson(path: string): Promise> { + try { + const resp = await fetch(`${this.baseUrl}${path}`, { method: 'GET', cache: 'no-store' }); + if (!resp.ok) { + const text = await resp.text(); + return { ok: false, status: unreachable(`GET ${path} → ${resp.status}: ${text}`) }; + } + return { ok: true, data: (await resp.json()) as T }; + } catch (e) { + return { ok: false, status: unreachable(`GET ${path}: ${(e as Error).message}`) }; + } + } + + private async postJson(path: string, body: unknown): Promise> { + try { + const resp = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const text = await resp.text(); + return { ok: false, status: unreachable(`POST ${path} → ${resp.status}: ${text}`) }; + } + return { ok: true, data: (await resp.json()) as T }; + } catch (e) { + return { ok: false, status: unreachable(`POST ${path}: ${(e as Error).message}`) }; + } + } + + async status(): Promise { + try { + const resp = await fetch(`${this.baseUrl}/healthz`, { method: 'GET', cache: 'no-store' }); + if (!resp.ok) return unreachable(`/healthz returned ${resp.status}`); + return { kind: 'connected', via: 'daemon', endpoint: this.baseUrl }; + } catch (e) { + return unreachable(`fetch ${this.baseUrl}/healthz failed: ${(e as Error).message}`); + } + } + + async listActors(): Promise> { + const r = await this.getJson<{ actors: ApiActor[] }>('/v1/actors'); + if (!r.ok) return r; + return { ok: true, data: r.data.actors.map(apiToActor) }; + } + + async getActor(id: string): Promise> { + const r = await this.getJson(`/v1/actors/${encodeURIComponent(id)}`); + if (!r.ok) { + if (r.status.detail?.includes('→ 404')) return { ok: true, data: null }; + return r; + } + return { ok: true, data: apiToActor(r.data) }; + } + + async listCapTokens(actorId: string): Promise> { + const r = await this.getJson<{ caps: CapToken[] }>( + `/v1/actors/${encodeURIComponent(actorId)}/caps`, + ); + if (!r.ok) return r; + return { ok: true, data: r.data.caps }; + } + + async listRecentAuditEvents(opts?: { actorId?: string; limit?: number }): Promise> { + const params = new URLSearchParams(); + if (opts?.actorId) params.set('actor_id', opts.actorId); + if (opts?.limit) params.set('limit', String(opts.limit)); + const qs = params.toString(); + const r = await this.getJson<{ events: ApiAuditEvent[] }>( + `/v1/audit/recent${qs ? `?${qs}` : ''}`, + ); + if (!r.ok) return r; + return { ok: true, data: r.data.events.map(apiToAuditEvent) }; + } + + streamAudit( + onEvent: (e: AuditEvent) => void, + onStatusChange: (s: ConnectionStatus) => void, + ): () => void { + if (typeof window === 'undefined' || typeof EventSource === 'undefined') { + onStatusChange(unreachable('EventSource not available in this environment')); + return () => {}; + } + const es = new EventSource(`${this.baseUrl}/v1/audit/stream`); + es.addEventListener('audit', (msg) => { + try { + const apiEvent: ApiAuditEvent = JSON.parse((msg as MessageEvent).data); + onEvent(apiToAuditEvent(apiEvent)); + } catch { + // ignore malformed event + } + }); + es.onopen = () => onStatusChange({ kind: 'connected', via: 'daemon', endpoint: this.baseUrl }); + es.onerror = () => onStatusChange(unreachable('/v1/audit/stream errored')); + return () => es.close(); + } + + async listWorkers(): Promise> { + const r = await this.getJson<{ workers: ApiWorker[] }>('/v1/workers'); + if (!r.ok) return r; + return { ok: true, data: r.data.workers.map(apiToWorker) }; + } + + async getWorker(id: Worker['id']): Promise> { + const r = await this.getJson(`/v1/workers/${encodeURIComponent(id)}`); + if (!r.ok) { + if (r.status.detail?.includes('→ 404')) return { ok: true, data: null }; + return r; + } + return { ok: true, data: apiToWorker(r.data) }; + } + + async getAnchorStatus(): Promise> { + const r = await this.getJson<{ + last_anchor_at: number; + next_anchor_in: number; + recent: { ts: string; root: string; count: number; txn: string; conf: number }[]; + }>('/v1/anchor/status'); + if (!r.ok) return r; + return { + ok: true, + data: { + lastAnchorAt: r.data.last_anchor_at, + nextAnchorIn: r.data.next_anchor_in, + recent: r.data.recent, + }, + }; + } + + async updateScope(actorId: string, ns: Namespace, value: ScopeBits): Promise> { + const r = await this.postJson(`/v1/actors/${encodeURIComponent(actorId)}/scope`, { + namespace: ns, + read: value.read, + write: value.write, + }); + return r.ok ? { ok: true, data: undefined as unknown as void } : r; + } + + async updatePaymentCap(actorId: string, perTx: number, daily: number): Promise> { + const r = await this.postJson(`/v1/actors/${encodeURIComponent(actorId)}/payment-cap`, { + per_tx: perTx, + daily, + }); + return r.ok ? { ok: true, data: undefined as unknown as void } : r; + } + + async revokeDevice(actorId: string, intent: RevokeIntent): Promise> { + const r = await this.postJson(`/v1/actors/${encodeURIComponent(actorId)}/revoke`, { + intent_text: intent.text, + intent_fields: intent.fields, + }); + return r.ok ? { ok: true, data: undefined as unknown as void } : r; + } + + async revokeCap(actorId: string, capName: string, intent: RevokeIntent): Promise> { + const r = await this.postJson( + `/v1/actors/${encodeURIComponent(actorId)}/caps/revoke`, + { cap: capName, intent_text: intent.text }, + ); + return r.ok ? { ok: true, data: undefined as unknown as void } : r; + } + + async enrollK11Begin(input: { userName: string; userDisplayName: string }): Promise> { + try { + const resp = await fetch(`${this.baseUrl}/v1/k11/enroll/begin`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: input.userName, display_name: input.userDisplayName }), + }); + if (!resp.ok) { + const text = await resp.text(); + return { ok: false, status: unreachable(`enroll/begin returned ${resp.status}: ${text}`) }; + } + const body = await resp.json(); + const opts = body.creation_options?.publicKey ?? body.creation_options ?? {}; + return { + ok: true, + data: { + challenge: opts.challenge ?? '', + rpId: opts.rp?.id ?? 'localhost', + rpName: opts.rp?.name ?? 'AgentKeys', + userId: body.user_id ?? '', + userName: opts.user?.name ?? input.userName, + userDisplayName: opts.user?.displayName ?? input.userDisplayName, + bindingNonce: '', + pubKeyCredParams: opts.pubKeyCredParams ?? [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + timeout: opts.timeout ?? 60_000, + }, + }; + } catch (e) { + return { ok: false, status: unreachable(`enroll/begin fetch failed: ${(e as Error).message}`) }; + } + } + + async enrollK11Finish(input: K11EnrollFinishInput): Promise> { + try { + const resp = await fetch(`${this.baseUrl}/v1/k11/enroll/finish`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + user_id: input.bindingNonce, + credential: { + id: input.credentialId, + rawId: input.credentialId, + response: { + attestationObject: input.attestationObject, + clientDataJSON: input.clientDataJSON, + }, + type: 'public-key', + }, + }), + }); + if (!resp.ok) { + const text = await resp.text(); + return { ok: false, status: unreachable(`enroll/finish returned ${resp.status}: ${text}`) }; + } + const body = await resp.json(); + return { + ok: true, + data: { + credentialId: body.credential_id, + registeredAt: body.registered_at_unix, + chainTxHash: body.chain_tx_hash ?? undefined, + }, + }; + } catch (e) { + return { ok: false, status: unreachable(`enroll/finish fetch failed: ${(e as Error).message}`) }; + } + } +} + +// ─── API wire types (snake_case, mirror ui_bridge.rs ApiActor etc.) ──── + +interface ApiActor { + id: string; + omni: string; + omni_hex: string; + label: string; + role: string; + parent: string | null; + derivation: string; + device: string; + device_pubkey: string; + last_active: string; + status: string; + vendor: string; + k11: boolean; + scope?: Record; + payment_cap?: { per_tx: number; daily: number; currency: string }; + time_window?: { start: string; end: string; tz: string }; + services?: string[]; +} + +interface ApiAuditEvent { + id: string; + ts: string; + actor_id: string; + actor: string; + kind: string; + detail: string; + chip: string; + sev: string; +} + +interface ApiWorker { + id: string; + title: string; + host: string; + desc: string; + calls_today: number; + calls_hour: number; + p50: number; + p95: number; + cap: string; + by_actor: { actor: string; count: number; share: number }[]; +} + +function apiToActor(a: ApiActor): Actor { + return { + id: a.id, + omni: a.omni, + omniHex: a.omni_hex, + label: a.label, + role: a.role === 'master' ? 'master' : 'agent', + parent: a.parent, + derivation: a.derivation, + device: a.device, + devicePubkey: a.device_pubkey, + lastActive: a.last_active, + status: normalizeStatus(a.status), + vendor: a.vendor, + k11: a.k11, + scope: a.scope as Actor['scope'], + paymentCap: a.payment_cap + ? { perTx: a.payment_cap.per_tx, daily: a.payment_cap.daily, currency: a.payment_cap.currency } + : undefined, + timeWindow: a.time_window, + services: a.services, + }; +} + +function apiToAuditEvent(e: ApiAuditEvent): AuditEvent { + return { + id: e.id, + ts: e.ts, + actorId: e.actor_id, + actor: e.actor, + kind: e.kind, + detail: e.detail, + chip: normalizeChip(e.chip), + sev: normalizeStatus(e.sev), + }; +} + +function apiToWorker(w: ApiWorker): Worker { + return { + id: w.id as Worker['id'], + title: w.title, + host: w.host, + desc: w.desc, + callsToday: w.calls_today, + callsHour: w.calls_hour, + p50: w.p50, + p95: w.p95, + cap: w.cap, + byActor: w.by_actor, + }; +} + +function normalizeStatus(s: string): StatusKind { + if (s === 'ok' || s === 'warn' || s === 'bad' || s === 'muted') return s; + return 'muted'; +} + +function normalizeChip(c: string): ChipKind { + const allowed: ChipKind[] = [ + 'default', + 'ok', + 'warn', + 'bad', + 'memory', + 'creds', + 'audit', + 'broker', + 'chain', + 'payment', + 'revoke', + ]; + return (allowed as string[]).includes(c) ? (c as ChipKind) : 'default'; +} diff --git a/apps/parent-control/lib/client/empty.ts b/apps/parent-control/lib/client/empty.ts new file mode 100644 index 0000000..a7f84ac --- /dev/null +++ b/apps/parent-control/lib/client/empty.ts @@ -0,0 +1,90 @@ +import type { + AgentKeysClient, + AnchorStatus, + CapToken, + ConnectionStatus, + DisconnectedStatus, + K11EnrollBegin, + K11EnrollFinishInput, + K11EnrollResult, + Result, + RevokeIntent, +} from './types'; +import type { Actor, AuditEvent, Namespace, ScopeBits, Worker } from '@/app/_components/types'; + +const DISCONNECTED: DisconnectedStatus = { + kind: 'disconnected', + reason: 'no-backend-configured', + detail: + 'Set NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon and AGENTKEYS_DAEMON_URL to a running agentkeys-daemon to populate this view.', +}; + +function disconnected(): Result { + return { ok: false, status: DISCONNECTED }; +} + +export class EmptyBackend implements AgentKeysClient { + async status(): Promise { + return DISCONNECTED; + } + + async listActors(): Promise> { + return disconnected(); + } + + async getActor(): Promise> { + return disconnected(); + } + + async listCapTokens(_actorId: string): Promise> { + return disconnected(); + } + + async listRecentAuditEvents(): Promise> { + return disconnected(); + } + + streamAudit( + _onEvent: (e: AuditEvent) => void, + onStatusChange: (s: ConnectionStatus) => void, + ): () => void { + onStatusChange(DISCONNECTED); + return () => {}; + } + + async listWorkers(): Promise> { + return disconnected(); + } + + async getWorker(): Promise> { + return disconnected(); + } + + async getAnchorStatus(): Promise> { + return disconnected(); + } + + async updateScope(_actorId: string, _ns: Namespace, _value: ScopeBits): Promise> { + return disconnected(); + } + + async updatePaymentCap(_actorId: string, _perTx: number, _daily: number): Promise> { + return disconnected(); + } + + async revokeDevice(_actorId: string, _intent: RevokeIntent): Promise> { + return disconnected(); + } + + async revokeCap(_actorId: string, _capName: string, _intent: RevokeIntent): Promise> { + return disconnected(); + } + + async enrollK11Begin(): Promise> { + return disconnected(); + } + + async enrollK11Finish(_input: K11EnrollFinishInput): Promise> { + return disconnected(); + } +} diff --git a/apps/parent-control/lib/client/index.ts b/apps/parent-control/lib/client/index.ts new file mode 100644 index 0000000..d269141 --- /dev/null +++ b/apps/parent-control/lib/client/index.ts @@ -0,0 +1,17 @@ +import { DaemonBackend } from './daemon'; +import { EmptyBackend } from './empty'; +import type { AgentKeysClient } from './types'; + +export type BackendKind = 'empty' | 'daemon'; + +export function selectBackend(): AgentKeysClient { + const kind = (process.env.NEXT_PUBLIC_AGENTKEYS_BACKEND ?? 'empty') as BackendKind; + if (kind === 'daemon') { + return new DaemonBackend(process.env.NEXT_PUBLIC_AGENTKEYS_DAEMON_URL); + } + return new EmptyBackend(); +} + +export * from './types'; +export { EmptyBackend } from './empty'; +export { DaemonBackend } from './daemon'; diff --git a/apps/parent-control/lib/client/types.ts b/apps/parent-control/lib/client/types.ts new file mode 100644 index 0000000..18c4ced --- /dev/null +++ b/apps/parent-control/lib/client/types.ts @@ -0,0 +1,86 @@ +import type { Actor, AuditEvent, Namespace, ScopeBits, Worker } from '@/app/_components/types'; + +export type ConnectionStatus = + | { kind: 'disconnected'; reason: 'no-backend-configured' | 'unreachable' | 'unauthorized'; detail?: string } + | { kind: 'connected'; via: 'daemon' | 'broker' | 'mock'; endpoint: string }; + +export type DisconnectedStatus = Extract; + +export type Result = + | { ok: true; data: T } + | { ok: false; status: DisconnectedStatus }; + +export interface AnchorBatch { + ts: string; + root: string; + count: number; + txn: string; + conf: number; +} + +export interface AnchorStatus { + lastAnchorAt: number; + nextAnchorIn: number; + recent: AnchorBatch[]; +} + +export interface CapToken { + id: string; + cap: string; + scope: string; + ttl: string; + minted: string; + danger?: boolean; +} + +export interface K11EnrollBegin { + challenge: string; + rpId: string; + rpName: string; + userId: string; + userName: string; + userDisplayName: string; + bindingNonce: string; + pubKeyCredParams: { type: 'public-key'; alg: number }[]; + timeout: number; +} + +export interface K11EnrollFinishInput { + credentialId: string; + attestationObject: string; + clientDataJSON: string; + bindingNonce: string; +} + +export interface K11EnrollResult { + credentialId: string; + registeredAt: number; + chainTxHash?: string; +} + +export interface RevokeIntent { + text: string; + fields: [string, string][]; +} + +export interface AgentKeysClient { + status(): Promise; + + listActors(): Promise>; + getActor(id: string): Promise>; + listCapTokens(actorId: string): Promise>; + listRecentAuditEvents(opts?: { actorId?: string; limit?: number }): Promise>; + streamAudit(onEvent: (e: AuditEvent) => void, onStatusChange: (s: ConnectionStatus) => void): () => void; + + listWorkers(): Promise>; + getWorker(id: Worker['id']): Promise>; + getAnchorStatus(): Promise>; + + updateScope(actorId: string, ns: Namespace, value: ScopeBits): Promise>; + updatePaymentCap(actorId: string, perTx: number, daily: number): Promise>; + revokeDevice(actorId: string, intent: RevokeIntent): Promise>; + revokeCap(actorId: string, capName: string, intent: RevokeIntent): Promise>; + + enrollK11Begin(input: { userName: string; userDisplayName: string }): Promise>; + enrollK11Finish(input: K11EnrollFinishInput): Promise>; +} diff --git a/apps/parent-control/lib/constants.ts b/apps/parent-control/lib/constants.ts new file mode 100644 index 0000000..49e9173 --- /dev/null +++ b/apps/parent-control/lib/constants.ts @@ -0,0 +1,17 @@ +import type { ChipKind, Namespace } from '@/app/_components/types'; + +export const NAMESPACES: Namespace[] = ['personal', 'family', 'work', 'travel']; + +export const CHIP_STYLES: Record = { + default: 'chip', + ok: 'chip ok', + warn: 'chip warn', + bad: 'chip bad', + memory: 'chip', + creds: 'chip', + audit: 'chip', + broker: 'chip', + chain: 'chip ok', + payment: 'chip warn', + revoke: 'chip bad', +}; diff --git a/apps/parent-control/lib/webauthn.ts b/apps/parent-control/lib/webauthn.ts new file mode 100644 index 0000000..03d6839 --- /dev/null +++ b/apps/parent-control/lib/webauthn.ts @@ -0,0 +1,90 @@ +/** + * Browser-side WebAuthn helpers for K11 enrollment. + * + * Maps daemon /v1/k11/enroll/begin JSON → navigator.credentials.create() args, + * and the resulting PublicKeyCredential → daemon /v1/k11/enroll/finish payload. + * + * arch.md §10.2 stage 2 ("master binding ceremony — WebAuthn") is what + * this drives. The challenge bytes themselves are constructed by the + * daemon (sha256(binding_nonce || D_pub)); the browser is just the + * relying-party transport. + */ + +export function base64UrlDecode(s: string): Uint8Array { + const padded = s.padEnd(s.length + ((4 - (s.length % 4)) % 4), '=').replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export function base64UrlEncode(buf: ArrayBuffer | Uint8Array): string { + const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/=+$/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export interface CreationOptionsJson { + rp: { id?: string; name: string }; + user: { id: string; name: string; displayName: string }; + challenge: string; + pubKeyCredParams: { type: 'public-key'; alg: number }[]; + timeout?: number; + attestation?: AttestationConveyancePreference; + authenticatorSelection?: AuthenticatorSelectionCriteria; + excludeCredentials?: { type: 'public-key'; id: string; transports?: AuthenticatorTransport[] }[]; +} + +export function jsonToCreationOptions(json: CreationOptionsJson): PublicKeyCredentialCreationOptions { + return { + rp: { id: json.rp.id, name: json.rp.name }, + user: { + id: base64UrlDecode(json.user.id), + name: json.user.name, + displayName: json.user.displayName, + }, + challenge: base64UrlDecode(json.challenge), + pubKeyCredParams: json.pubKeyCredParams, + timeout: json.timeout, + attestation: json.attestation, + authenticatorSelection: json.authenticatorSelection, + excludeCredentials: json.excludeCredentials?.map((c) => ({ + type: 'public-key', + id: base64UrlDecode(c.id), + transports: c.transports, + })), + }; +} + +export interface FinishPayload { + credentialId: string; + attestationObject: string; + clientDataJSON: string; +} + +export function credentialToFinishPayload(cred: PublicKeyCredential): FinishPayload { + const att = cred.response as AuthenticatorAttestationResponse; + return { + credentialId: base64UrlEncode(cred.rawId), + attestationObject: base64UrlEncode(att.attestationObject), + clientDataJSON: base64UrlEncode(att.clientDataJSON), + }; +} + +export function webauthnAvailable(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.PublicKeyCredential !== 'undefined' && + typeof navigator.credentials?.create === 'function' + ); +} + +export async function platformAuthenticatorAvailable(): Promise { + if (!webauthnAvailable()) return false; + try { + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } catch { + return false; + } +} diff --git a/apps/parent-control/next-env.d.ts b/apps/parent-control/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/apps/parent-control/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/parent-control/next.config.mjs b/apps/parent-control/next.config.mjs new file mode 100644 index 0000000..d5456a1 --- /dev/null +++ b/apps/parent-control/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/apps/parent-control/package-lock.json b/apps/parent-control/package-lock.json new file mode 100644 index 0000000..7f23522 --- /dev/null +++ b/apps/parent-control/package-lock.json @@ -0,0 +1,499 @@ +{ + "name": "@agentkeys/parent-control", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agentkeys/parent-control", + "version": "0.1.0", + "dependencies": { + "next": "^14.2.34", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.10", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } + }, + "node_modules/@next/env": { + "version": "14.2.34", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.34.tgz", + "integrity": "sha512-iuGW/UM+EZbn2dm+aLx+avo1rVap+ASoFr7oLpTBVW2G2DqhD5l8Fme9IsLZ6TTsp0ozVSFswidiHK1NGNO+pg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "20.14.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", + "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.34", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.34.tgz", + "integrity": "sha512-s7mRraWlkEVRLjHHdu5khn0bSnmUh+U+YtigBc+t2Ge7jJHFIVBZna+W9Jcx7b04HhM7eJWrNJ2A+sQs9gJ3eg==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.34", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/parent-control/package.json b/apps/parent-control/package.json new file mode 100644 index 0000000..555d598 --- /dev/null +++ b/apps/parent-control/package.json @@ -0,0 +1,25 @@ +{ + "name": "@agentkeys/parent-control", + "version": "0.1.0", + "private": true, + "description": "AgentKeys parent-control UI — Phase 1 mobile-responsive web app for the M1 demo (issue #110)", + "scripts": { + "dev": "next dev -p 3113", + "dev:stack": "bash ../../dev.sh", + "build": "next build", + "start": "next start -p 3113", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "^14.2.34", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.10", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/apps/parent-control/tsconfig.json b/apps/parent-control/tsconfig.json new file mode 100644 index 0000000..25a72b4 --- /dev/null +++ b/apps/parent-control/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/crates/agentkeys-daemon/Cargo.toml b/crates/agentkeys-daemon/Cargo.toml index dedf67f..bc9c7a4 100644 --- a/crates/agentkeys-daemon/Cargo.toml +++ b/crates/agentkeys-daemon/Cargo.toml @@ -30,9 +30,21 @@ reqwest = { version = "0.12", features = ["json"] } # AGENTKEYS_DAEMON_TCP=1) and serves cap-token mint + cache requests. axum = { version = "0.7", features = ["json"] } tower = { version = "0.4", features = ["util"] } +tower-http = { version = "0.5", features = ["cors"] } hyper = { version = "1", features = ["server", "http1"] } hyper-util = { version = "0.1", features = ["server", "tokio"] } tower-service = "0.3" +# v2 stage-1 K11 WebAuthn enrollment surface for the parent-control web +# UI. webauthn-rs is the standard Rust server-side WebAuthn library; the +# daemon's ui-bridge mode uses it to construct registration challenges + +# verify attestations from the browser's navigator.credentials.create(). +# See src/ui_bridge.rs. +webauthn-rs = "0.5" +url = "2" +# SSE audit-feed broadcast (PR-C) — futures-util drives the axum +# Sse stream; tokio-stream wraps the broadcast::Receiver as a Stream. +futures-util = { version = "0.3", default-features = false } +tokio-stream = { version = "0.1", features = ["sync"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index e7187dd..01d3e59 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -15,6 +15,7 @@ mod hardening; mod pairing; mod proxy; mod session; +mod ui_bridge; #[derive(Parser)] #[command(name = "agentkeys-daemon", about = "AgentKeys sandbox sidecar daemon")] @@ -27,6 +28,34 @@ struct Args { #[arg(long)] proxy: bool, + /// v2 stage-1 ui-bridge mode (arch.md §22c.1 web-UI surface). When + /// set, the daemon serves the parent-control web UI's HTTP surface + /// on `--ui-bridge-bind` (default 127.0.0.1:3114), CORS-allowing + /// `--ui-bridge-origin` (default http://localhost:3113). Exposes + /// /v1/k11/enroll/{begin,finish} for browser-driven WebAuthn + /// enrollment. Independent of `--proxy` and `--master-companion`. + #[arg(long)] + ui_bridge: bool, + + /// Bind address for ui-bridge mode. Default 127.0.0.1:3114. + #[arg(long, env = "AGENTKEYS_UI_BRIDGE_BIND", default_value = "127.0.0.1:3114")] + ui_bridge_bind: String, + + /// Origin the web UI is served from (used for CORS + WebAuthn rpOrigin). + /// Default http://localhost:3113. + #[arg(long, env = "AGENTKEYS_UI_BRIDGE_ORIGIN", default_value = "http://localhost:3113")] + ui_bridge_origin: String, + + /// WebAuthn Relying Party ID. Defaults to "localhost" for dev. + /// In production, set to the operator's domain (e.g. "agentkeys.io"). + #[arg(long, env = "AGENTKEYS_UI_BRIDGE_RP_ID", default_value = "localhost")] + ui_bridge_rp_id: String, + + /// WebAuthn Relying Party display name. Shown to user in the + /// platform-authenticator UI ("agentKeys would like to register…"). + #[arg(long, env = "AGENTKEYS_UI_BRIDGE_RP_NAME", default_value = "AgentKeys")] + ui_bridge_rp_name: String, + /// v2 stage-2 master-companion mode (arch.md §10.3.1 + #90). Spins up /// a SECOND daemon instance that holds a distinct K10 + K11 credential /// on RP ID `companion.localhost` and serves an HTTP approval API on @@ -191,6 +220,10 @@ async fn main() -> anyhow::Result<()> { return run_proxy_mode(args).await; } + if args.ui_bridge { + return run_ui_bridge_mode(args).await; + } + // 1. Apply kernel hardening let _hardening_report = hardening::apply_hardening()?; @@ -523,6 +556,35 @@ async fn run_companion_mode(args: Args) -> anyhow::Result<()> { /// Binds a Unix socket (always) and optionally a TCP listener; serves /// the axum router from `proxy::build_router`. The router caches caps /// for 5 min and fails closed after 60s of broker silence. +async fn run_ui_bridge_mode(args: Args) -> anyhow::Result<()> { + let state = ui_bridge::build_state( + &args.ui_bridge_rp_id, + &args.ui_bridge_origin, + &args.ui_bridge_rp_name, + ) + .with_context(|| { + format!( + "ui-bridge: webauthn build failed (rp_id={}, origin={})", + args.ui_bridge_rp_id, args.ui_bridge_origin + ) + })?; + let app = ui_bridge::build_router(state, &args.ui_bridge_origin); + + let listener = tokio::net::TcpListener::bind(&args.ui_bridge_bind) + .await + .with_context(|| format!("ui-bridge: bind TCP {}", args.ui_bridge_bind))?; + + info!( + bind = %args.ui_bridge_bind, + origin = %args.ui_bridge_origin, + rp_id = %args.ui_bridge_rp_id, + "ui-bridge serving" + ); + + axum::serve(listener, app).await?; + Ok(()) +} + async fn run_proxy_mode(args: Args) -> anyhow::Result<()> { let broker_url = args.proxy_broker_url.clone().ok_or_else(|| { anyhow::anyhow!( diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs new file mode 100644 index 0000000..34e1673 --- /dev/null +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -0,0 +1,1287 @@ +//! UI bridge — HTTP surface the parent-control web UI talks to. +//! +//! Distinct from `proxy.rs` (agent-facing cap-mint) and `companion.rs` +//! (second-master M-of-N approval). The ui-bridge listens on +//! `127.0.0.1:3114` by default, accepts CORS from `http://localhost:3113` +//! (the Next.js dev server / bundled web UI), and exposes operator-side +//! ceremonies the browser drives — initially K11 enrollment. +//! +//! Per arch.md §10.2, K11 enrollment is the master-binding ceremony: +//! +//! 1. browser POST /v1/k11/enroll/begin → daemon returns +//! PublicKeyCredentialCreationOptions (challenge + rp + user + +//! pubKeyCredParams + authenticatorSelection) +//! 2. browser calls navigator.credentials.create(options) +//! 3. browser POST /v1/k11/enroll/finish → daemon verifies +//! attestation via webauthn-rs, returns credentialId +//! +//! For M1 the on-chain SidecarRegistry.register_master_device() call +//! is stubbed (returns chainTxHash=null). Real chain submission lands +//! in PR-C alongside the audit-service SSE feed. + +use std::collections::{HashMap, VecDeque}; +use std::convert::Infallible; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::{ + extract::{Path, State}, + http::{HeaderValue, Method, StatusCode}, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, + }, + routing::{get, post}, + Json, Router, +}; +use futures_util::stream::Stream; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, RwLock}; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::StreamExt; +use tower_http::cors::{Any, CorsLayer}; +use url::Url; +use webauthn_rs::prelude::*; + +/// In-flight registration state. Keyed by `user_id` (the random opaque +/// handle the browser echoes back). Cleared once a finish call consumes +/// the entry, or on next start (in-memory only). +#[derive(Default)] +pub struct EnrollState { + pending: HashMap, + registered: HashMap, +} + +#[derive(Clone)] +#[allow(dead_code)] // fields are read once chain submission lands in PR-C +pub struct RegisteredCredential { + pub credential_id_b64: String, + pub registered_at_unix: u64, +} + +pub struct UiBridgeState { + pub webauthn: Webauthn, + pub enroll: RwLock, + pub actors: RwLock>, + pub caps: RwLock>>, + pub audit: RwLock>, + pub audit_tx: broadcast::Sender, + pub workers: RwLock>, + pub anchor: RwLock, +} + +pub type SharedUiBridgeState = Arc; + +const AUDIT_BUFFER_CAP: usize = 200; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiScopeBits { + pub read: bool, + pub write: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiPaymentCap { + pub per_tx: f64, + pub daily: f64, + pub currency: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiTimeWindow { + pub start: String, + pub end: String, + pub tz: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiActor { + pub id: String, + pub omni: String, + pub omni_hex: String, + pub label: String, + pub role: String, + pub parent: Option, + pub derivation: String, + pub device: String, + pub device_pubkey: String, + pub last_active: String, + pub status: String, + pub vendor: String, + pub k11: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment_cap: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub services: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiCapToken { + pub id: String, + pub cap: String, + pub scope: String, + pub ttl: String, + pub minted: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub danger: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiAuditEvent { + pub id: String, + pub ts: String, + pub actor_id: String, + pub actor: String, + pub kind: String, + pub detail: String, + pub chip: String, + pub sev: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiWorkerActorShare { + pub actor: String, + pub count: u64, + pub share: f64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiWorker { + pub id: String, + pub title: String, + pub host: String, + pub desc: String, + pub calls_today: u64, + pub calls_hour: u64, + pub p50: u64, + pub p95: u64, + pub cap: String, + pub by_actor: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ApiAnchorBatch { + pub ts: String, + pub root: String, + pub count: u64, + pub txn: String, + pub conf: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ApiAnchorStatus { + pub last_anchor_at: u64, + pub next_anchor_in: u64, + pub recent: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct EnrollBeginRequest { + pub username: String, + pub display_name: String, +} + +#[derive(Debug, Serialize)] +pub struct EnrollBeginResponse { + pub user_id: String, + pub creation_options: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +pub struct EnrollFinishRequest { + pub user_id: String, + pub credential: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct EnrollFinishResponse { + pub credential_id: String, + pub registered_at_unix: u64, + pub chain_tx_hash: Option, +} + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: String, + reason: &'static str, +} + +fn err(status: StatusCode, error: impl Into, reason: &'static str) -> (StatusCode, Json) { + (status, Json(ErrorBody { error: error.into(), reason })) +} + +/// Build the ui-bridge router with CORS open to the configured web-UI origin. +pub fn build_router(state: SharedUiBridgeState, allowed_origin: &str) -> Router { + let cors = CorsLayer::new() + .allow_origin( + allowed_origin + .parse::() + .unwrap_or(HeaderValue::from_static("http://localhost:3113")), + ) + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers(Any) + .max_age(std::time::Duration::from_secs(600)); + + Router::new() + .route("/healthz", get(healthz)) + .route("/v1/k11/enroll/begin", post(enroll_begin)) + .route("/v1/k11/enroll/finish", post(enroll_finish)) + .route("/v1/actors", get(list_actors)) + .route("/v1/actors/:id", get(get_actor)) + .route("/v1/actors/:id/caps", get(list_caps)) + .route("/v1/actors/:id/scope", post(update_scope)) + .route("/v1/actors/:id/payment-cap", post(update_payment_cap)) + .route("/v1/actors/:id/revoke", post(revoke_device)) + .route("/v1/actors/:id/caps/revoke", post(revoke_cap)) + .route("/v1/audit/recent", get(list_recent_audit)) + .route("/v1/audit/stream", get(audit_stream)) + .route("/v1/anchor/status", get(anchor_status)) + .route("/v1/workers", get(list_workers)) + .route("/v1/workers/:id", get(get_worker)) + .route("/v1/dev/seed", post(dev_seed)) + .route("/v1/dev/event", post(dev_emit_event)) + .layer(cors) + .with_state(state) +} + +/// Build the bridge state. `rp_id` is the WebAuthn relying-party id — +/// always "localhost" for dev, "agentkeys.io" (or operator domain) in +/// production. `rp_origin` is the browser's window.location.origin. +pub fn build_state(rp_id: &str, rp_origin: &str, rp_name: &str) -> anyhow::Result { + let origin = Url::parse(rp_origin)?; + let builder = WebauthnBuilder::new(rp_id, &origin)?.rp_name(rp_name); + let webauthn = builder.build()?; + let (audit_tx, _audit_rx) = broadcast::channel::(256); + Ok(Arc::new(UiBridgeState { + webauthn, + enroll: RwLock::new(EnrollState::default()), + actors: RwLock::new(HashMap::new()), + caps: RwLock::new(HashMap::new()), + audit: RwLock::new(VecDeque::with_capacity(AUDIT_BUFFER_CAP)), + audit_tx, + workers: RwLock::new(HashMap::new()), + anchor: RwLock::new(ApiAnchorStatus::default()), + })) +} + +async fn healthz() -> impl IntoResponse { + Json(serde_json::json!({ "ok": true, "surface": "ui-bridge" })) +} + +async fn enroll_begin( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if req.username.trim().is_empty() { + return Err(err(StatusCode::BAD_REQUEST, "username required", "missing-username")); + } + let user_id = Uuid::new_v4(); + let user_id_str = user_id.to_string(); + let (ccr, reg_state) = state + .webauthn + .start_passkey_registration(user_id, &req.username, &req.display_name, None) + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, format!("webauthn start failed: {e}"), "webauthn-start-failed"))?; + + let mut guard = state.enroll.write().await; + guard.pending.insert(user_id_str.clone(), reg_state); + + Ok(Json(EnrollBeginResponse { + user_id: user_id_str, + creation_options: serde_json::to_value(&ccr).map_err(|e| { + err( + StatusCode::INTERNAL_SERVER_ERROR, + format!("encode failed: {e}"), + "encode-failed", + ) + })?, + })) +} + +async fn enroll_finish( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let reg = serde_json::from_value::(req.credential) + .map_err(|e| err(StatusCode::BAD_REQUEST, format!("malformed credential: {e}"), "credential-malformed"))?; + + let reg_state = { + let mut guard = state.enroll.write().await; + guard + .pending + .remove(&req.user_id) + .ok_or_else(|| err(StatusCode::BAD_REQUEST, "no pending enrollment for this user_id", "no-pending"))? + }; + + let passkey = state + .webauthn + .finish_passkey_registration(®, ®_state) + .map_err(|e| err(StatusCode::BAD_REQUEST, format!("attestation rejected: {e}"), "attestation-rejected"))?; + + let credential_id_b64 = base64url_encode(passkey.cred_id().as_ref()); + let registered_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut guard = state.enroll.write().await; + guard.registered.insert( + req.user_id.clone(), + RegisteredCredential { + credential_id_b64: credential_id_b64.clone(), + registered_at_unix, + }, + ); + + // TODO(PR-C): submit credentialId to SidecarRegistry.register_master_device() + // via the broker. Currently stubbed — chain_tx_hash returns null. + let chain_tx_hash: Option = None; + + Ok(Json(EnrollFinishResponse { + credential_id: credential_id_b64, + registered_at_unix, + chain_tx_hash, + })) +} + +fn base64url_encode(bytes: &[u8]) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + URL_SAFE_NO_PAD.encode(bytes) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn now_ts_hms() -> String { + // HH:MM:SS in UTC for audit event timestamps. Operator-facing only — + // chain timestamps are independent. + let now = now_unix(); + let h = (now / 3600) % 24; + let m = (now / 60) % 60; + let s = now % 60; + format!("{:02}:{:02}:{:02}", h, m, s) +} + +// ─── Read endpoints ──────────────────────────────────────────────────── + +async fn list_actors(State(state): State) -> impl IntoResponse { + let guard = state.actors.read().await; + let mut actors: Vec = guard.values().cloned().collect(); + // Stable order: master first, then by id. + actors.sort_by(|a, b| { + let a_master = if a.role == "master" { 0 } else { 1 }; + let b_master = if b.role == "master" { 0 } else { 1 }; + a_master.cmp(&b_master).then_with(|| a.id.cmp(&b.id)) + }); + Json(serde_json::json!({ "actors": actors })) +} + +async fn get_actor( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let guard = state.actors.read().await; + guard + .get(&id) + .cloned() + .map(Json) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found")) +} + +async fn list_caps( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let guard = state.caps.read().await; + let caps = guard.get(&id).cloned().unwrap_or_default(); + Json(serde_json::json!({ "caps": caps })) +} + +#[derive(Debug, Deserialize)] +pub struct UpdateScopeRequest { + pub namespace: String, + pub read: bool, + pub write: bool, +} + +async fn update_scope( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let mut guard = state.actors.write().await; + let actor = guard + .get_mut(&id) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; + let scope = actor.scope.get_or_insert_with(HashMap::new); + scope.insert(req.namespace.clone(), ApiScopeBits { read: req.read, write: req.write }); + let snapshot = actor.clone(); + drop(guard); + + let evt = ApiAuditEvent { + id: format!("e-scope-{}", now_unix()), + ts: now_ts_hms(), + actor_id: "master".into(), + actor: "master".into(), + kind: "scope.updated".into(), + detail: format!("{} · {} · read={} write={}", id, req.namespace, req.read, req.write), + chip: "broker".into(), + sev: "ok".into(), + }; + push_audit(&state, evt).await; + Ok(Json(snapshot)) +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePaymentCapRequest { + pub per_tx: f64, + pub daily: f64, +} + +async fn update_payment_cap( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let mut guard = state.actors.write().await; + let actor = guard + .get_mut(&id) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; + let cap = actor.payment_cap.get_or_insert(ApiPaymentCap { + per_tx: 0.0, + daily: 0.0, + currency: "USDC".into(), + }); + cap.per_tx = req.per_tx; + cap.daily = req.daily; + let snapshot = actor.clone(); + drop(guard); + + let evt = ApiAuditEvent { + id: format!("e-paycap-{}", now_unix()), + ts: now_ts_hms(), + actor_id: "master".into(), + actor: "master".into(), + kind: "payment-cap.updated".into(), + detail: format!("{} · per_tx={} daily={}", id, req.per_tx, req.daily), + chip: "broker".into(), + sev: "ok".into(), + }; + push_audit(&state, evt).await; + Ok(Json(snapshot)) +} + +#[derive(Debug, Deserialize)] +pub struct RevokeDeviceRequest { + pub intent_text: String, + pub intent_fields: Vec<(String, String)>, +} + +async fn revoke_device( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let mut guard = state.actors.write().await; + let actor = guard + .get_mut(&id) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; + actor.status = "bad".into(); + actor.last_active = "revoked".into(); + if !actor.label.ends_with(" (revoked)") { + actor.label.push_str(" (revoked)"); + } + let snapshot = actor.clone(); + drop(guard); + + // Invalidate every cap minted for this actor (TTL → 0). + state.caps.write().await.remove(&id); + + let evt = ApiAuditEvent { + id: format!("e-revoke-{}", now_unix()), + ts: now_ts_hms(), + actor_id: "master".into(), + actor: "master".into(), + kind: "device.revoked".into(), + detail: format!("{} · intent='{}' · fields={}", id, req.intent_text, req.intent_fields.len()), + chip: "revoke".into(), + sev: "bad".into(), + }; + push_audit(&state, evt).await; + Ok(Json(snapshot)) +} + +#[derive(Debug, Deserialize)] +pub struct RevokeCapRequest { + pub cap: String, + pub intent_text: String, +} + +async fn revoke_cap( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + { + let actors = state.actors.read().await; + if !actors.contains_key(&id) { + return Err(err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found")); + } + } + let mut caps_guard = state.caps.write().await; + if let Some(caps) = caps_guard.get_mut(&id) { + caps.retain(|c| c.cap != req.cap); + } + drop(caps_guard); + + let evt = ApiAuditEvent { + id: format!("e-cap-revoke-{}", now_unix()), + ts: now_ts_hms(), + actor_id: "master".into(), + actor: "master".into(), + kind: "cap.revoked".into(), + detail: format!("{} · cap={} · intent='{}'", id, req.cap, req.intent_text), + chip: "revoke".into(), + sev: "bad".into(), + }; + push_audit(&state, evt).await; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[derive(Debug, Deserialize)] +pub struct ListRecentAuditQuery { + #[serde(default)] + pub actor_id: Option, + #[serde(default)] + pub limit: Option, +} + +async fn list_recent_audit( + State(state): State, + axum::extract::Query(q): axum::extract::Query, +) -> impl IntoResponse { + let limit = q.limit.unwrap_or(50).min(AUDIT_BUFFER_CAP); + let guard = state.audit.read().await; + let mut events: Vec = guard + .iter() + .rev() + .filter(|e| q.actor_id.as_deref().is_none_or(|a| e.actor_id == a)) + .take(limit) + .cloned() + .collect(); + // Reverse-rev: newest first, which is the natural iteration order + // when we push_back + iter().rev(). Already in that order; ensure stable. + // (Re-sort by ts descending as a safety belt for ties.) + events.sort_by(|a, b| b.ts.cmp(&a.ts)); + Json(serde_json::json!({ "events": events })) +} + +async fn audit_stream( + State(state): State, +) -> Sse>> { + let rx = state.audit_tx.subscribe(); + let stream = BroadcastStream::new(rx).filter_map(|msg| match msg { + Ok(evt) => match serde_json::to_string(&evt) { + Ok(json) => Some(Ok(Event::default().event("audit").data(json))), + Err(_) => None, + }, + Err(_) => None, + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +async fn anchor_status(State(state): State) -> impl IntoResponse { + let mut snapshot = state.anchor.read().await.clone(); + // Compute next_anchor_in dynamically (2-min cadence per arch.md §11). + let now = now_unix(); + if snapshot.last_anchor_at > 0 { + let elapsed = now.saturating_sub(snapshot.last_anchor_at); + snapshot.next_anchor_in = 120u64.saturating_sub(elapsed % 120); + } else { + snapshot.next_anchor_in = 120u64.saturating_sub(now % 120); + } + Json(snapshot) +} + +async fn list_workers(State(state): State) -> impl IntoResponse { + let guard = state.workers.read().await; + let mut workers: Vec = guard.values().cloned().collect(); + workers.sort_by(|a, b| a.id.cmp(&b.id)); + Json(serde_json::json!({ "workers": workers })) +} + +async fn get_worker( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let guard = state.workers.read().await; + guard + .get(&id) + .cloned() + .map(Json) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such worker", "worker-not-found")) +} + +// ─── Dev seed (operator-only, debug data injection) ──────────────────── + +#[derive(Debug, Deserialize)] +pub struct DevSeedRequest { + #[serde(default)] + pub actors: Vec, + #[serde(default)] + pub caps: HashMap>, + #[serde(default)] + pub workers: Vec, + #[serde(default)] + pub anchor: Option, + #[serde(default)] + pub audit: Vec, +} + +async fn dev_seed( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + { + let mut actors = state.actors.write().await; + for a in req.actors { + actors.insert(a.id.clone(), a); + } + } + { + let mut caps = state.caps.write().await; + for (k, v) in req.caps { + caps.insert(k, v); + } + } + { + let mut workers = state.workers.write().await; + for w in req.workers { + workers.insert(w.id.clone(), w); + } + } + if let Some(a) = req.anchor { + *state.anchor.write().await = a; + } + for evt in req.audit { + push_audit(&state, evt).await; + } + Json(serde_json::json!({ "ok": true })) +} + +async fn dev_emit_event( + State(state): State, + Json(evt): Json, +) -> impl IntoResponse { + push_audit(&state, evt).await; + Json(serde_json::json!({ "ok": true })) +} + +async fn push_audit(state: &SharedUiBridgeState, evt: ApiAuditEvent) { + let mut buf = state.audit.write().await; + if buf.len() == AUDIT_BUFFER_CAP { + buf.pop_front(); + } + buf.push_back(evt.clone()); + drop(buf); + // Ignore send errors — broadcast Sender returns Err when there + // are no subscribers, which is the normal case until the UI connects. + let _ = state.audit_tx.send(evt); +} + +// ─── Tests ───────────────────────────────────────────────────────────── +// +// These tests exercise the begin/finish state machine without a real +// browser. They use webauthn-rs's `SoftPasskey` test helper so the +// attestation chain is real (not stubbed), but everything happens +// in-process — no network, no platform authenticator, no Touch ID. +// +// Coverage focus per PR-A's cargo-llvm-cov gate: +// - happy-path begin → finish round-trip +// - finish with a stale / never-issued user_id → "no-pending" error +// - finish with a malformed credential JSON → "credential-malformed" error +// - finish that tries to replay a consumed user_id → "no-pending" (consumed at finish) +// - begin with empty username → "missing-username" error +// +// Run: `cargo test -p agentkeys-daemon --lib ui_bridge` +// `cargo llvm-cov -p agentkeys-daemon --lib ui_bridge` + +#[cfg(test)] +mod tests { + use super::*; + + fn make_state() -> SharedUiBridgeState { + build_state("localhost", "http://localhost:3113", "AgentKeys Test").unwrap() + } + + #[tokio::test] + async fn begin_returns_user_id_and_creation_options() { + let state = make_state(); + let resp = enroll_begin( + State(state.clone()), + Json(EnrollBeginRequest { + username: "sara@example.com".into(), + display_name: "Sara".into(), + }), + ) + .await + .expect("begin should succeed"); + assert!(!resp.0.user_id.is_empty(), "user_id must be set"); + assert!( + resp.0.creation_options.get("publicKey").is_some(), + "creation_options must contain publicKey field per WebAuthn spec, got: {}", + resp.0.creation_options + ); + + let guard = state.enroll.read().await; + assert!(guard.pending.contains_key(&resp.0.user_id), "pending registration must be stored"); + } + + #[tokio::test] + async fn begin_rejects_empty_username() { + let state = make_state(); + let err = enroll_begin( + State(state), + Json(EnrollBeginRequest { + username: " ".into(), + display_name: "Sara".into(), + }), + ) + .await + .expect_err("empty username must be rejected"); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert_eq!(err.1.0.reason, "missing-username"); + } + + #[tokio::test] + async fn finish_with_unknown_user_id_returns_no_pending() { + let state = make_state(); + let err = enroll_finish( + State(state), + Json(EnrollFinishRequest { + user_id: "00000000-0000-0000-0000-000000000000".into(), + credential: serde_json::json!({ + "id": "test", + "rawId": "dGVzdA", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjGSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAALraVWanqkAfvZZFYZpVEg0AQg", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0" + }, + "type": "public-key" + }), + }), + ) + .await + .expect_err("unknown user_id must be rejected"); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert_eq!(err.1.0.reason, "no-pending"); + } + + #[tokio::test] + async fn finish_with_malformed_credential_returns_malformed() { + let state = make_state(); + let err = enroll_finish( + State(state), + Json(EnrollFinishRequest { + user_id: "doesn-t-matter".into(), + credential: serde_json::json!({ "totally": "not a credential" }), + }), + ) + .await + .expect_err("malformed credential must be rejected"); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert_eq!(err.1.0.reason, "credential-malformed"); + } + + #[tokio::test] + async fn replay_after_consume_returns_no_pending() { + // First begin to get a real user_id, then finish twice with the + // SAME user_id and the same (malformed-but-parseable-only-the-second-time) + // credential — we don't need a real attestation for this assertion, + // we just need to confirm the pending entry is consumed on first + // attempt regardless of finish outcome. + let state = make_state(); + let begin_resp = enroll_begin( + State(state.clone()), + Json(EnrollBeginRequest { + username: "replay@example.com".into(), + display_name: "Replay Test".into(), + }), + ) + .await + .unwrap(); + let user_id = begin_resp.0.user_id; + + // Confirm pending exists. + assert!(state.enroll.read().await.pending.contains_key(&user_id)); + + // First finish (with malformed credential — fails before pending consume). + let _ = enroll_finish( + State(state.clone()), + Json(EnrollFinishRequest { + user_id: user_id.clone(), + credential: serde_json::json!({ "not": "valid" }), + }), + ) + .await + .expect_err("first finish should fail at parse"); + + // Pending should STILL exist because parse failed before consume. + assert!( + state.enroll.read().await.pending.contains_key(&user_id), + "pending must survive a parse-stage failure so the user can retry" + ); + + // Now simulate a valid-shaped-but-bad-attestation credential. Pending + // gets consumed on .remove() call, and webauthn-rs rejects the + // attestation. + let _ = enroll_finish( + State(state.clone()), + Json(EnrollFinishRequest { + user_id: user_id.clone(), + credential: serde_json::json!({ + "id": "test", + "rawId": "dGVzdA", + "response": { + "attestationObject": "o2NmbXRkbm9uZQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0" + }, + "type": "public-key" + }), + }), + ) + .await + .expect_err("second finish must fail attestation"); + + // Pending must NOT exist anymore — consume happened at .remove(). + assert!( + !state.enroll.read().await.pending.contains_key(&user_id), + "pending must be consumed after a finish attempt that parsed the credential" + ); + + // Third finish should fail with no-pending. + let err = enroll_finish( + State(state.clone()), + Json(EnrollFinishRequest { + user_id: user_id.clone(), + credential: serde_json::json!({ + "id": "test", + "rawId": "dGVzdA", + "response": { + "attestationObject": "o2NmbXRkbm9uZQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0" + }, + "type": "public-key" + }), + }), + ) + .await + .expect_err("third finish must fail no-pending after consume"); + assert_eq!(err.1.0.reason, "no-pending"); + } + + #[tokio::test] + async fn healthz_returns_ok() { + let resp = healthz().await.into_response(); + assert_eq!(resp.status(), StatusCode::OK); + } + + fn seed_actor(state: &SharedUiBridgeState) -> ApiActor { + let actor = ApiActor { + id: "agent-folotoy".into(), + omni: "O_master//folotoy".into(), + omni_hex: "0x7c2d…41a9".into(), + label: "FoloToy bear".into(), + role: "agent".into(), + parent: Some("master".into()), + derivation: "//folotoy".into(), + device: "FoloToy hardware".into(), + device_pubkey: "D_pub_folotoy".into(), + last_active: "now".into(), + status: "ok".into(), + vendor: "FoloToy Inc.".into(), + k11: false, + scope: None, + payment_cap: None, + time_window: None, + services: None, + }; + let cloned = actor.clone(); + let st = state.clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { st.actors.write().await.insert(cloned.id.clone(), cloned) }) + }); + actor + } + + async fn seed_actor_async(state: &SharedUiBridgeState) -> ApiActor { + let actor = ApiActor { + id: "agent-folotoy".into(), + omni: "O_master//folotoy".into(), + omni_hex: "0x7c2d…41a9".into(), + label: "FoloToy bear".into(), + role: "agent".into(), + parent: Some("master".into()), + derivation: "//folotoy".into(), + device: "FoloToy hardware".into(), + device_pubkey: "D_pub_folotoy".into(), + last_active: "now".into(), + status: "ok".into(), + vendor: "FoloToy Inc.".into(), + k11: false, + scope: None, + payment_cap: None, + time_window: None, + services: None, + }; + state.actors.write().await.insert(actor.id.clone(), actor.clone()); + actor + } + + #[tokio::test] + async fn list_actors_returns_empty_when_nothing_registered() { + let state = make_state(); + let resp = list_actors(State(state)).await.into_response(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn list_actors_returns_master_first() { + let state = make_state(); + let mut actors = state.actors.write().await; + actors.insert( + "agent-1".into(), + ApiActor { + id: "agent-1".into(), + role: "agent".into(), + omni: "x".into(), + omni_hex: "x".into(), + label: "agent-1".into(), + parent: Some("master".into()), + derivation: "//agent1".into(), + device: "".into(), + device_pubkey: "".into(), + last_active: "now".into(), + status: "ok".into(), + vendor: "".into(), + k11: false, + scope: None, + payment_cap: None, + time_window: None, + services: None, + }, + ); + actors.insert( + "master".into(), + ApiActor { + id: "master".into(), + role: "master".into(), + omni: "O_master".into(), + omni_hex: "x".into(), + label: "Sara".into(), + parent: None, + derivation: "/".into(), + device: "".into(), + device_pubkey: "".into(), + last_active: "now".into(), + status: "ok".into(), + vendor: "self".into(), + k11: true, + scope: None, + payment_cap: None, + time_window: None, + services: None, + }, + ); + drop(actors); + + // Decode the JSON to check ordering invariant. + let resp = list_actors(State(state)).await.into_response(); + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let actors_arr = json["actors"].as_array().unwrap(); + assert_eq!(actors_arr[0]["role"], "master", "master must come first"); + } + + #[tokio::test] + async fn get_actor_unknown_returns_404() { + let state = make_state(); + let err = get_actor(State(state), Path("does-not-exist".into())) + .await + .expect_err("must 404"); + assert_eq!(err.0, StatusCode::NOT_FOUND); + assert_eq!(err.1.0.reason, "actor-not-found"); + } + + #[tokio::test] + async fn get_actor_known_returns_payload() { + let state = make_state(); + seed_actor_async(&state).await; + let resp = get_actor(State(state), Path("agent-folotoy".into())).await.unwrap(); + assert_eq!(resp.0.label, "FoloToy bear"); + } + + #[tokio::test] + async fn update_scope_writes_and_emits_audit() { + let state = make_state(); + seed_actor_async(&state).await; + let resp = update_scope( + State(state.clone()), + Path("agent-folotoy".into()), + Json(UpdateScopeRequest { + namespace: "family".into(), + read: true, + write: false, + }), + ) + .await + .unwrap(); + assert_eq!( + resp.0.scope.as_ref().unwrap().get("family").unwrap().read, + true + ); + // Audit event landed. + let audit = state.audit.read().await; + assert!(audit.iter().any(|e| e.kind == "scope.updated")); + } + + #[tokio::test] + async fn update_scope_unknown_actor_404() { + let state = make_state(); + let err = update_scope( + State(state), + Path("nope".into()), + Json(UpdateScopeRequest { + namespace: "family".into(), + read: true, + write: false, + }), + ) + .await + .expect_err("must 404"); + assert_eq!(err.0, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn update_payment_cap_writes_and_emits_audit() { + let state = make_state(); + seed_actor_async(&state).await; + let resp = update_payment_cap( + State(state.clone()), + Path("agent-folotoy".into()), + Json(UpdatePaymentCapRequest { per_tx: 5.0, daily: 25.0 }), + ) + .await + .unwrap(); + assert_eq!(resp.0.payment_cap.as_ref().unwrap().per_tx, 5.0); + let audit = state.audit.read().await; + assert!(audit.iter().any(|e| e.kind == "payment-cap.updated")); + } + + #[tokio::test] + async fn revoke_device_flips_status_and_clears_caps() { + let state = make_state(); + seed_actor_async(&state).await; + // Pre-seed some caps so we can verify they're cleared. + state.caps.write().await.insert( + "agent-folotoy".into(), + vec![ApiCapToken { + id: "cap-1".into(), + cap: "memory:read".into(), + scope: "family".into(), + ttl: "900s".into(), + minted: "now".into(), + danger: None, + }], + ); + + let resp = revoke_device( + State(state.clone()), + Path("agent-folotoy".into()), + Json(RevokeDeviceRequest { + intent_text: "Revoke FoloToy".into(), + intent_fields: vec![("actor".into(), "agent-folotoy".into())], + }), + ) + .await + .unwrap(); + assert_eq!(resp.0.status, "bad"); + assert!(resp.0.label.ends_with("(revoked)")); + assert!(state.caps.read().await.get("agent-folotoy").is_none()); + let audit = state.audit.read().await; + assert!(audit.iter().any(|e| e.kind == "device.revoked")); + } + + #[tokio::test] + async fn revoke_cap_removes_only_matching_cap_and_emits_audit() { + let state = make_state(); + seed_actor_async(&state).await; + state.caps.write().await.insert( + "agent-folotoy".into(), + vec![ + ApiCapToken { + id: "cap-1".into(), + cap: "memory:read".into(), + scope: "family".into(), + ttl: "900s".into(), + minted: "now".into(), + danger: None, + }, + ApiCapToken { + id: "cap-2".into(), + cap: "payment:execute".into(), + scope: "p≤5".into(), + ttl: "60s".into(), + minted: "now".into(), + danger: Some(true), + }, + ], + ); + + let _ = revoke_cap( + State(state.clone()), + Path("agent-folotoy".into()), + Json(RevokeCapRequest { + cap: "memory:read".into(), + intent_text: "Revoke memory:read".into(), + }), + ) + .await + .unwrap(); + + let caps = state.caps.read().await; + let remaining = caps.get("agent-folotoy").unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].cap, "payment:execute"); + let audit = state.audit.read().await; + assert!(audit.iter().any(|e| e.kind == "cap.revoked")); + } + + #[tokio::test] + async fn dev_seed_populates_all_collections() { + let state = make_state(); + let resp = dev_seed( + State(state.clone()), + Json(DevSeedRequest { + actors: vec![ApiActor { + id: "seed-1".into(), + omni: "x".into(), + omni_hex: "x".into(), + label: "seed".into(), + role: "agent".into(), + parent: Some("master".into()), + derivation: "//seed".into(), + device: "".into(), + device_pubkey: "".into(), + last_active: "now".into(), + status: "ok".into(), + vendor: "".into(), + k11: false, + scope: None, + payment_cap: None, + time_window: None, + services: None, + }], + caps: HashMap::new(), + workers: vec![ApiWorker { + id: "memory".into(), + title: "memory-service".into(), + host: "memory.litentry.org".into(), + desc: "".into(), + calls_today: 100, + calls_hour: 10, + p50: 30, + p95: 100, + cap: "mem:r".into(), + by_actor: vec![], + }], + anchor: Some(ApiAnchorStatus { + last_anchor_at: 100, + next_anchor_in: 0, + recent: vec![], + }), + audit: vec![], + }), + ) + .await + .into_response(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(state.actors.read().await.len(), 1); + assert_eq!(state.workers.read().await.len(), 1); + assert_eq!(state.anchor.read().await.last_anchor_at, 100); + } + + #[tokio::test] + async fn list_workers_empty_by_default() { + let state = make_state(); + let resp = list_workers(State(state)).await.into_response(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn get_worker_unknown_returns_404() { + let state = make_state(); + let err = get_worker(State(state), Path("memory".into())) + .await + .expect_err("must 404"); + assert_eq!(err.0, StatusCode::NOT_FOUND); + assert_eq!(err.1.0.reason, "worker-not-found"); + } + + #[tokio::test] + async fn audit_buffer_caps_at_buffer_cap() { + let state = make_state(); + for i in 0..(AUDIT_BUFFER_CAP + 25) { + let evt = ApiAuditEvent { + id: format!("e-{i}"), + ts: format!("00:00:{:02}", i % 60), + actor_id: "x".into(), + actor: "x".into(), + kind: "test.event".into(), + detail: format!("event {i}"), + chip: "audit".into(), + sev: "ok".into(), + }; + push_audit(&state, evt).await; + } + let buf = state.audit.read().await; + assert_eq!(buf.len(), AUDIT_BUFFER_CAP, "ring buffer must cap at AUDIT_BUFFER_CAP"); + } + + #[tokio::test] + async fn audit_stream_subscribes_before_emit_and_receives() { + let state = make_state(); + let mut rx = state.audit_tx.subscribe(); + let evt = ApiAuditEvent { + id: "e-stream-1".into(), + ts: "00:00:00".into(), + actor_id: "x".into(), + actor: "x".into(), + kind: "stream.test".into(), + detail: "broadcast".into(), + chip: "audit".into(), + sev: "ok".into(), + }; + push_audit(&state, evt.clone()).await; + let received = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv()) + .await + .expect("must receive within 200ms") + .expect("must not error"); + assert_eq!(received.id, "e-stream-1"); + } + + // Convince clippy the sync helper isn't dead code. + #[allow(dead_code)] + fn _keep_seed_actor_alive(state: &SharedUiBridgeState) -> ApiActor { + seed_actor(state) + } +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..436cc7d --- /dev/null +++ b/dev.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# dev.sh — single-terminal dev stack for the parent-control web UI. +# +# Lives at the agentkeys repo root so the entry point is one path away +# from the operator on a fresh clone: +# +# bash dev.sh # from the repo root +# ./dev.sh # same +# cd apps/parent-control && npm run dev:stack # equivalent via npm +# +# Starts THREE processes and multiplexes their stdouts into this +# terminal with colored per-process line prefixes: +# +# [daemon] magenta — agentkeys-daemon --ui-bridge (port 3114) +# [mcp] green — agentkeys-mcp-server (port 8088) +# [ui] cyan — npx next dev (port 3113) +# [dev] yellow — this script's own status lines +# +# Ctrl-C cleans up all children. Stale processes holding any of the +# three ports are SIGTERM'd, given 3 s to exit, SIGKILL'd if still +# alive, then re-checked before binding. +# +# Environment overrides: +# UI_PORT default 3113 +# DAEMON_PORT default 3114 +# MCP_PORT default 8088 +# DAEMON_ORIGIN default http://localhost:${UI_PORT} +# DAEMON_RP_ID default localhost +# DAEMON_RP_NAME default AgentKeys +# MCP_BACKEND default in-memory (zero external deps; auto-seeds demo fixtures) +# +# Requirements: cargo, npx (node), lsof, curl. Bash 3.2+ (works with +# macOS default /bin/bash). + +set -euo pipefail +# Disable job-control monitor mode so bash doesn't print "Terminated: 15" +# notifications for the background children we SIGTERM during cleanup. +set +m + +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +APP_DIR="$REPO_ROOT/apps/parent-control" + +if [ ! -d "$APP_DIR" ]; then + echo "[dev] expected $APP_DIR — is dev.sh at the agentkeys repo root?" >&2 + exit 1 +fi + +# ─── Colors ──────────────────────────────────────────────────────── +if [ -t 1 ]; then + C_DAEMON='\033[0;35m' # magenta + C_MCP='\033[0;32m' # green + C_UI='\033[0;36m' # cyan + C_INFO='\033[1;33m' # bold yellow + C_ERR='\033[1;31m' # bold red + C_DIM='\033[2m' + C_RESET='\033[0m' +else + C_DAEMON='' C_MCP='' C_UI='' C_INFO='' C_ERR='' C_DIM='' C_RESET='' +fi + +UI_PORT="${UI_PORT:-3113}" +DAEMON_PORT="${DAEMON_PORT:-3114}" +MCP_PORT="${MCP_PORT:-8088}" +DAEMON_BIND="127.0.0.1:${DAEMON_PORT}" +MCP_BIND="127.0.0.1:${MCP_PORT}" +DAEMON_ORIGIN="${DAEMON_ORIGIN:-http://localhost:${UI_PORT}}" +DAEMON_RP_ID="${DAEMON_RP_ID:-localhost}" +DAEMON_RP_NAME="${DAEMON_RP_NAME:-AgentKeys}" +MCP_BACKEND="${MCP_BACKEND:-in-memory}" + +DAEMON_BIN="$REPO_ROOT/target/debug/agentkeys-daemon" +MCP_BIN="$REPO_ROOT/target/debug/agentkeys-mcp-server" + +say() { printf "%b[dev]%b %s\n" "$C_INFO" "$C_RESET" "$*"; } +warn() { printf "%b[dev]%b %s\n" "$C_INFO" "$C_RESET" "$*" >&2; } +err() { printf "%b[dev]%b %s\n" "$C_ERR" "$C_RESET" "$*" >&2; } + +# Prefix every line of a stream with a coloured tag, written to stdout. +prefix() { + local color="$1" + local tag="$2" + while IFS= read -r line; do + printf "%b[%s]%b %s\n" "$color" "$tag" "$C_RESET" "$line" + done +} + +# Kill any leftover process holding a port. Graceful first (SIGTERM, +# 3 s wait), forceful if needed (SIGKILL), then verify the port is +# actually free before returning. +# +# `lsof -ti` can return MULTIPLE pids on separate lines for a single +# port — e.g. when a process listens on both IPv4 and IPv6, or when a +# parent has a child sharing the socket. The body iterates over each +# pid individually; a single bare `kill "$pid"` with a multiline +# variable would fail silently and leave the port occupied (exactly +# the bug the operator hit). +# +# Idempotent: re-running dev.sh after a hard kill / lost terminal +# cleans up the previous run's stragglers and starts fresh. +free_port() { + local port="$1" + local pass + for pass in 1 2; do + local pids + pids=$(lsof -ti tcp:"$port" 2>/dev/null || true) + if [ -z "$pids" ]; then return 0; fi + + local pid + for pid in $pids; do + warn "port :$port held by pid $pid — sending SIGTERM (pass $pass)" + kill "$pid" 2>/dev/null || true + done + + # Wait up to 3 s for all of them to exit. + local waited=0 + while [ "$waited" -lt 6 ]; do + sleep 0.5 + waited=$((waited + 1)) + local still=0 + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then still=1; break; fi + done + [ "$still" = "0" ] && break + done + + # SIGKILL anything still alive. + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then + warn "pid $pid still alive after 3 s — sending SIGKILL" + kill -9 "$pid" 2>/dev/null || true + fi + done + sleep 0.5 + + # Loop will re-check on next pass. Stops once lsof returns nothing + # at the top of the loop. + done + + if lsof -ti tcp:"$port" >/dev/null 2>&1; then + err "port :$port is still occupied after SIGKILL — investigate manually" + err " lsof -i tcp:$port" + return 1 + fi +} + +# Build a Rust binary iff missing or older than any .rs source under the +# listed crates. $1 = bin path, remaining args = crate dirs to watch. +build_if_needed() { + local bin="$1"; shift + local label="$1"; shift + local cargo_pkg="$1"; shift + local need_build=0 + if [ ! -x "$bin" ]; then + need_build=1 + else + local d + for d in "$@"; do + if [ -n "$(find "$d" -name '*.rs' -newer "$bin" -print -quit 2>/dev/null)" ]; then + need_build=1 + break + fi + done + fi + if [ "$need_build" = "1" ]; then + say "building $label (debug)…" + ( cd "$REPO_ROOT" && cargo build -p "$cargo_pkg" ) \ + || { err "cargo build -p $cargo_pkg failed"; exit 1; } + else + # NB: $C_DIM contains escape sequences in single-quoted form, so it + # MUST go through %b (not %s) to be interpreted. The literal label + # string after it goes through %s. + printf "%b[dev]%b %b%s binary is current — skipping build%b\n" \ + "$C_INFO" "$C_RESET" "$C_DIM" "$label" "$C_RESET" + fi +} + +DAEMON_PID="" +MCP_PID="" +UI_PID="" + +# Per-run temp dir for the FIFOs that carry each process's stdout into +# its prefix reader. Using FIFOs (not bash process substitution) so +# that the script itself never holds an fd to the writer end — killing +# the binary cleanly closes the FIFO, the prefix reader sees EOF, and +# `wait` returns. Process substitution leaves the fd open in the +# parent shell, which made Ctrl-C hang indefinitely. +RUN_TMPDIR="${TMPDIR:-/tmp}/agentkeys-dev-stack-$$" +mkdir -p "$RUN_TMPDIR" +FIFO_DAEMON="$RUN_TMPDIR/daemon.fifo" +FIFO_MCP="$RUN_TMPDIR/mcp.fifo" +FIFO_UI="$RUN_TMPDIR/ui.fifo" +mkfifo "$FIFO_DAEMON" "$FIFO_MCP" "$FIFO_UI" + +PREFIX_DAEMON_PID="" +PREFIX_MCP_PID="" +PREFIX_UI_PID="" + +cleanup() { + trap - INT TERM EXIT + printf "\n" + say "shutting down…" + # SIGTERM the actual binaries first — their FIFO writes will close + # and the prefix readers see EOF naturally. + local p + for p in "$UI_PID" "$MCP_PID" "$DAEMON_PID"; do + [ -z "$p" ] && continue + if kill -0 "$p" 2>/dev/null; then + kill -TERM "$p" 2>/dev/null || true + fi + done + # Poll for all of them (including prefix readers) to actually exit. + # We use polling instead of `wait` so bash doesn't print "Terminated: + # 15" job-control notifications during shutdown — combined with the + # disowns after each spawn, the shutdown is now silent except for + # our own [dev] lines. + local waited=0 + while [ "$waited" -lt 16 ]; do + sleep 0.25 + waited=$((waited + 1)) + local still=0 + for p in "$UI_PID" "$MCP_PID" "$DAEMON_PID" "$PREFIX_UI_PID" "$PREFIX_MCP_PID" "$PREFIX_DAEMON_PID"; do + [ -z "$p" ] && continue + kill -0 "$p" 2>/dev/null && { still=1; break; } + done + [ "$still" = "0" ] && break + done + # SIGKILL anything still alive. + for p in "$UI_PID" "$MCP_PID" "$DAEMON_PID" "$PREFIX_UI_PID" "$PREFIX_MCP_PID" "$PREFIX_DAEMON_PID"; do + [ -z "$p" ] && continue + kill -0 "$p" 2>/dev/null && kill -9 "$p" 2>/dev/null || true + done + rm -rf "$RUN_TMPDIR" + say "stopped." + # Exit immediately so we don't fall through to the polling loop's + # post-loop "one of the children exited" warning, which would be + # misleading after a clean operator-initiated shutdown. + exit 0 +} +trap cleanup INT TERM EXIT + +# ─── Preflight ───────────────────────────────────────────────────── +free_port "$UI_PORT" +free_port "$DAEMON_PORT" +free_port "$MCP_PORT" +build_if_needed "$DAEMON_BIN" "agentkeys-daemon" "agentkeys-daemon" \ + "$REPO_ROOT/crates/agentkeys-daemon" +build_if_needed "$MCP_BIN" "agentkeys-mcp-server" "agentkeys-mcp-server" \ + "$REPO_ROOT/crates/agentkeys-mcp" "$REPO_ROOT/crates/agentkeys-mcp-server" + +# ─── Start daemon ────────────────────────────────────────────────── +# +# Pattern for all three processes: spawn the prefix reader FIRST on +# the FIFO (so it's blocking on read when the writer opens), then +# spawn the binary with stdout/stderr redirected to the FIFO. $! is +# now the real binary's pid — clean Ctrl-C kill semantics. +say "starting daemon on http://${DAEMON_BIND} (rp_id=${DAEMON_RP_ID})" +prefix "$C_DAEMON" "daemon" < "$FIFO_DAEMON" & +PREFIX_DAEMON_PID=$! +disown "$PREFIX_DAEMON_PID" 2>/dev/null || true +"$DAEMON_BIN" --ui-bridge \ + --ui-bridge-bind "$DAEMON_BIND" \ + --ui-bridge-origin "$DAEMON_ORIGIN" \ + --ui-bridge-rp-id "$DAEMON_RP_ID" \ + --ui-bridge-rp-name "$DAEMON_RP_NAME" \ + > "$FIFO_DAEMON" 2>&1 & +DAEMON_PID=$! +disown "$DAEMON_PID" 2>/dev/null || true + +say "waiting for daemon /healthz…" +ready=0 +for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -sSf "http://${DAEMON_BIND}/healthz" >/dev/null 2>&1; then + ready=1; break + fi + sleep 0.5 + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + err "daemon exited before becoming ready — see [daemon] log above" + exit 1 + fi +done +[ "$ready" = "0" ] && { err "daemon did not respond on /healthz within 5 s"; exit 1; } +say "daemon ready." + +# ─── Start MCP server ────────────────────────────────────────────── +say "starting mcp-server on http://${MCP_BIND} (backend=${MCP_BACKEND})" +prefix "$C_MCP" "mcp" < "$FIFO_MCP" & +PREFIX_MCP_PID=$! +disown "$PREFIX_MCP_PID" 2>/dev/null || true +"$MCP_BIN" --backend "$MCP_BACKEND" --listen "$MCP_BIND" \ + > "$FIFO_MCP" 2>&1 & +MCP_PID=$! +disown "$MCP_PID" 2>/dev/null || true + +# Wait for the MCP server's listener (no /healthz today — probe TCP). +say "waiting for mcp-server tcp…" +ready=0 +for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -sS -o /dev/null -w "%{http_code}" "http://${MCP_BIND}/" 2>/dev/null | grep -qE "^(2..|3..|4..)"; then + ready=1; break + fi + sleep 0.5 + if ! kill -0 "$MCP_PID" 2>/dev/null; then + err "mcp-server exited before becoming ready — see [mcp] log above" + exit 1 + fi +done +[ "$ready" = "0" ] && { err "mcp-server did not respond on / within 5 s"; exit 1; } +say "mcp-server ready." + +# ─── Start Next.js dev server ────────────────────────────────────── +# +# The subshell `exec`s into npx so $! points at the npx process itself +# (not the subshell). Output flows through the FIFO into the prefix +# reader spawned just above. +say "starting Next.js dev server on http://localhost:${UI_PORT}" +say " NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon" +say " NEXT_PUBLIC_AGENTKEYS_DAEMON_URL=http://${DAEMON_BIND}" +say " NEXT_PUBLIC_AGENTKEYS_MCP_URL=http://${MCP_BIND}" +prefix "$C_UI" "ui" < "$FIFO_UI" & +PREFIX_UI_PID=$! +disown "$PREFIX_UI_PID" 2>/dev/null || true +( + cd "$APP_DIR" && \ + NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon \ + NEXT_PUBLIC_AGENTKEYS_DAEMON_URL="http://${DAEMON_BIND}" \ + NEXT_PUBLIC_AGENTKEYS_MCP_URL="http://${MCP_BIND}" \ + exec npx next dev -p "$UI_PORT" +) > "$FIFO_UI" 2>&1 & +UI_PID=$! +disown "$UI_PID" 2>/dev/null || true + +say "all three processes running. Ctrl-C to stop." +say " UI: http://localhost:${UI_PORT}" +say " daemon: http://${DAEMON_BIND}" +say " mcp: http://${MCP_BIND}" + +# Wait until any child exits, then cleanup() trap handles the rest. +# `wait -n` is bash 4.3+; macOS default /bin/bash is 3.2. Poll instead. +while \ + kill -0 "$DAEMON_PID" 2>/dev/null && \ + kill -0 "$MCP_PID" 2>/dev/null && \ + kill -0 "$UI_PID" 2>/dev/null +do + sleep 1 +done +warn "one of the children exited — shutting down the others" diff --git a/docs/plan/README.md b/docs/plan/README.md index 571aa67..8500cfb 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -12,3 +12,8 @@ Agent-authored implementation plans (Claude, codex, ralph) drafted **before** th Plain markdown. No YAML frontmatter. Link to repo files with `../../` and to other docs with `../.md` or `../spec/.md`. See the `agentkeys-docs` skill for the full layout policy. + +## Active plans + +- [`agentkeys-memory-design.md`](agentkeys-memory-design.md) — memory worker design (single file). +- [`web-flow/`](web-flow/) — parent-control web UI operator user flow. Multi-file. Binds harness v2-stage {1,2,3} flows to UI screens with real inputs (no mock data). Start at [`web-flow/README.md`](web-flow/README.md). diff --git a/docs/plan/web-flow/README.md b/docs/plan/web-flow/README.md new file mode 100644 index 0000000..e361649 --- /dev/null +++ b/docs/plan/web-flow/README.md @@ -0,0 +1,44 @@ +# docs/plan/web-flow — parent-control web UI · operator user flow plan + +**Status:** plan (not implementation). Pending review. +**Source of truth this plan defers to:** [`docs/arch.md`](../../arch.md), [`docs/v2-stage1-migration-and-demo.md`](../../v2-stage1-migration-and-demo.md), [`harness/v2-stage1-demo.sh`](../../../harness/v2-stage1-demo.sh), [`harness/v2-stage2-demo.sh`](../../../harness/v2-stage2-demo.sh), [`harness/v2-stage3-demo.sh`](../../../harness/v2-stage3-demo.sh). + +## Why this plan exists + +The harness scripts (`harness/v2-stage{1,2,3}-demo.sh`, 43 numbered steps in total) are the real flows operators run today — but as shell commands an engineer fires from a terminal. The parent-control web UI must surface every one of those steps as a **natural operator user flow** — meaning: + +- the operator types the same inputs (real email, real device password, real seed import) the harness expects; +- the order is the same as the script (because the dependencies are real: K11 can't enroll before identity, scope can't grant before chain bring-up); +- the UI never invents data the harness wouldn't (no mock actors, no synthetic email aliases used as if they were the operator's actual email); +- pre-existing daemon / CLI behaviour is reused — the UI is a *thin transport over the same engine*, not a parallel re-implementation. + +This directory contains the design. Implementation lands as separate PRs that reference these docs. + +## File map + +| File | Scope | +|---|---| +| [`overview.md`](overview.md) | End-to-end narrative the first-time operator walks through · state-machine sketch · resumability invariants | +| [`stage1-first-run.md`](stage1-first-run.md) | Harness `v2-stage1-demo.sh` 16 steps → UI screens. Identity, K10, K11 WebAuthn, AWS infra, chain bring-up, first master register, first agent. | +| [`stage2-second-master.md`](stage2-second-master.md) | Harness `v2-stage2-demo.sh` 11 steps → UI screens. Companion-device pairing, recoveryThreshold=2, M-of-N quorum revoke ceremony. | +| [`stage3-agent-usage.md`](stage3-agent-usage.md) | Harness `v2-stage3-demo.sh` 16 steps → UI demonstrations. Per-actor + per-data-class isolation, worker round-trips, agent-driven credential use. | +| [`input-discipline.md`](input-discipline.md) | Which inputs the operator types vs the system derives vs the system auto-generates. Resolves the operator-login-email vs agent-inbox-address distinction explicitly. | +| [`data-model.md`](data-model.md) | The HTTP surface the daemon must expose for the UI to drive these flows. Concrete request/response shapes, persistence boundaries, what's local vs chain-anchored. | +| [`deferred-and-followups.md`](deferred-and-followups.md) | What stays shell-only forever (operator power-user paths). Open questions for review. Implementation sequencing if approved. | + +## How to read + +Start with [`overview.md`](overview.md) for the narrative. Then read [`input-discipline.md`](input-discipline.md) — it locks down terminology that the other three stage docs lean on. After that, the three stage docs can be read independently in any order. + +`data-model.md` is the contract between the UI and the daemon; it's the one engineering will iterate on most. `deferred-and-followups.md` collects the questions that need an answer before any of this can land. + +## Cross-references + +- arch.md is the canonical reference for K1–K11 (the key inventory), HDKD actor tree (§6.2), ceremony shapes (§10), worker isolation invariants (§17.2, §15), and the AgentKeys app surface (§22c). +- The wiki page [`docs/wiki/agent-role-and-usage-hdkd-per-agent-omni.md`](../../wiki/agent-role-and-usage-hdkd-per-agent-omni.md) is the operator-facing summary of the agent role; this plan refers to it instead of re-stating. + +## What this plan does NOT cover + +- **Mobile-native iOS/Android.** Per [issue #110](https://github.com/litentry/agentKeys/issues/110), mobile-native lands in M5 after vendor pilot. The "mobile companion as second master" page in stage 2 is a real *cross-device WebAuthn hybrid-transport* flow inside a browser on the phone — not a native app. +- **K3 epoch rotation runbook.** That's in [`docs/runbook-k3-rotation.md`](../../runbook-k3-rotation.md), an operator-only flow today. A web-flow promotion is tracked in [`deferred-and-followups.md`](deferred-and-followups.md) §3. +- **Vendor branding / white-label.** M2 vendor pilot work. diff --git a/docs/plan/web-flow/data-model.md b/docs/plan/web-flow/data-model.md new file mode 100644 index 0000000..f930069 --- /dev/null +++ b/docs/plan/web-flow/data-model.md @@ -0,0 +1,350 @@ +# data-model · daemon HTTP surface the UI needs + +This document is the contract between the parent-control UI and `agentkeys-daemon`. Every endpoint is tagged: + +- **shipped** — already in `crates/agentkeys-daemon/src/ui_bridge.rs` after PR-B / PR-C. Used by Phase 1 without changes. +- **Phase 1** — new endpoint required for Phase 1 (overview.md Act 1 steps 1–7). Build before Phase 1 ships. +- **deferred** — required for Phase 2 / Phase 3 (everything in overview.md's TODO list). Not in Phase 1's contract. + +The daemon is the only thing the UI talks to. Direct calls to the broker, signer, chain RPC, or AWS from the browser are forbidden — the daemon is the trust core (per arch.md §22c.5 "what the daemon does NOT become" + arch.md §6). + +## Phase 1 endpoint count + +**Twelve new endpoints** ([`overview.md` § Phase 1 endpoint inventory](overview.md#phase-1-endpoint-inventory-the-only-new-endpoints-to-build)) plus three shipped ones (`/healthz`, `/v1/k11/enroll/begin`, `/v1/k11/enroll/finish`). Everything else listed below is deferred — explicit so the reviewer can see which lines are not on the Phase 1 critical path. + +## Surfaces + +The daemon runs three independent HTTP surfaces (already established in [`crates/agentkeys-daemon/src/`](../../../crates/agentkeys-daemon/src/)): + +| Mode | Bind | Audience | Auth | New in this plan? | +|---|---|---|---|---| +| `--proxy` | unix socket + optional TCP `127.0.0.1:9090` | local agents (cap-mint) | bearer JWT | no | +| `--master-companion` | TCP `127.0.0.1:9091` | second-master daemon (M-of-N approval) | localhost-only | no | +| **`--ui-bridge`** | TCP `127.0.0.1:3114` | parent-control web UI | bearer JWT + CORS | shipped (PR-B/C); extends in this plan | + +The ui-bridge is where every UI endpoint lives. The expansions below extend the existing `ui_bridge.rs` module. + +## Endpoint inventory + +### Onboarding state machine (**Phase 1**) + +The single endpoint that the UI hits on every navigation to decide which screen to render. Stateless aggregate over local + broker + chain state. + +``` +GET /v1/onboarding/state +``` + +**Phase 1 response shape:** + +```json +{ + "identity": "verified" | "pending" | "missing", + "k10": "present" | "missing", + "k11": "enrolled" | "missing", + "cloud": "provisioned" | "partial" | "missing", + "cloud_detail": { + "vault_bucket": "ok" | "missing" | "policy-mismatch", + "memory_bucket": "ok" | "missing" | "policy-mismatch", + "audit_bucket": "ok" | "missing", + "email_bucket": "ok" | "missing", + "vault_role": "ok" | "missing", + "memory_role": "ok" | "missing", + "smoke_test": "passed" | "failed" | "not-run" + }, + "chain": "master-registered" | "contracts-deployed" | "missing", + "chain_detail": { + "sidecar_registry": "0x..." | null, + "agentkeys_scope": "0x..." | null, + "k3_epoch_counter": "0x..." | null, + "credential_audit": "0x..." | null, + "master_device_hash": "0x..." | null + } +} +``` + +**Deferred fields** (Phase 2+, additive only — clients ignore unknown): + +```json +{ + "first_agent": "created" | "missing", + "second_master": "active" | "pending" | "missing", + "recovery_threshold": 1 | 2 | null +} +``` + +The UI computes its routing decision from this object alone. Each field's transitions correspond to a stage doc's screen. + +### Identity (**Phase 1**) + +Screen A. Wraps the broker's `/v1/auth/email/*` so the UI doesn't deal with broker auth directly. + +``` +POST /v1/auth/email/start + body: { "email": "sara@example.com" } + → 200 { "request_id": "...", "verify_polling_after_seconds": 5 } + → 400 { "error": "email-domain-not-allowed", "allowed_domains": ["bots.litentry.org", ...] } + → 502 { "error": "broker-unreachable", "broker_url": "..." } + +POST /v1/auth/email/verify + body: { "request_id": "...", "magic_token": "..." } + → 200 { "session_jwt": "...", "wallet_address": "0xf3a8...", "actor_omni": "0x...", "binding_nonce": "..." } + → 401 { "error": "magic-token-invalid-or-expired" } + +GET /v1/auth/email/status?request_id=... + → 200 { "status": "pending" | "verified" | "expired" } +``` + +The `binding_nonce` from `/verify` is what powers screen B's challenge construction. + +### K11 enrollment (**shipped** — PR-B) + +``` +POST /v1/k11/enroll/begin — shipped +POST /v1/k11/enroll/finish — shipped +``` + +Already mapped to the harness's K11 enroll flow + arch.md §10.2. **K10 derivation is folded into `enroll/begin`'s handler** so the operator sees one Touch ID prompt, not two. + +### K11 assertion for master mutations (**Phase 1**) + +Phase 1 uses this pattern exactly once — for screen D's `register_master_device` call. Phase 2+ reuses it for every other master mutation (scope grant, payment cap update, device revoke, threshold change, K3 rotation). + +``` +POST /v1/k11/assert/begin + body: { "intent": { "op": "register_master" | "set_scope" | ..., "fields": [["k","v"], ...] } } + → 200 { "challenge": "...", "binding": "...", "assertion_id": "..." } +``` + +Browser calls `navigator.credentials.get({ publicKey: { challenge, allowCredentials, userVerification: "required" } })`. Then: + +``` +POST /v1/k11/assert/finish + body: { "assertion_id": "...", "authenticatorData": "...", "clientDataJSON": "...", "signature": "..." } + → 200 { "intent_commitment": "0x..." } +``` + +For most master mutations the daemon submits the on-chain extrinsic itself (so the browser doesn't handle chain calldata). For Phase 1, screen D calls `/v1/onboarding/chain/register-master` after `/assert/finish` succeeds, passing the `assertion_id`. + +### Cloud provisioning (**Phase 1**) + +Screen C parts A + C. + +``` +POST /v1/onboarding/cloud/provision + body: {} — uses the operator's existing session + → 200 { "job_id": "..." } + + SSE on GET /v1/onboarding/cloud/stream emits per-step progress + +POST /v1/onboarding/cloud/smoke + body: {} + → 200 { "passed": true, "envelope_url": "s3://vault/bots//credentials/.healthcheck/smoke.test" } + → 200 { "passed": false, "error": "AccessDenied: ..." } +``` + +The provision endpoint orchestrates the existing scripts (`scripts/provision-vault-bucket.sh`, etc.) — the daemon runs them server-side, streams progress as SSE. + +### Master vault + memory listings (**Phase 1, new per user feedback**) + +Screen C part B. Lets the operator see what their *master* actor holds in vault + memory, immediately after cloud provisioning completes. Empty for new operators; populated for re-onboarding. + +``` +GET /v1/master/credentials + → 200 { "entries": [ + { "service": "openrouter", "last_write_at": 1779812900, "size_bytes": 384, "encryption_alg": "aes-256-gcm" }, + ... + ] } + → 502 { "error": "vault-bucket-unreachable", "bucket": "agentkeys-vault-..." } + +GET /v1/master/memory + → 200 { "entries": [ + { "key": "family/grocery-list", "last_write_at": 1779812900, "size_bytes": 2048, "writer_actor_omni": "0x..." }, + ... + ] } + → 502 { "error": "memory-bucket-unreachable", "bucket": "agentkeys-memory-..." } +``` + +**Metadata only.** Plaintext is never returned. Plaintext fetch is per-cap-token and Phase 2+ (it's how an agent reads, not how the operator browses). + +`writer_actor_omni` on memory entries distinguishes things the master wrote themselves vs things an agent wrote on their behalf (per arch.md §15.2). On screen C part B both lists are typically empty until Phase 2 puts agents in scope. + +These endpoints scope by IAM PrincipalTag — the daemon uses the operator's existing STS creds against `s3:///bots//credentials/*` and `s3:///bots//memory/*`. Cross-actor leakage is impossible by construction (arch.md §17.2 layer 3). + +### Chain bring-up + master registration (**Phase 1**) + +Screen D. + +``` +POST /v1/onboarding/chain/deploy + body: { "chain": "heima-paseo" | "heima" | "anvil", "confirm_mainnet": false } + → 200 { "contracts": { "sidecar_registry": "0x...", ... }, "deployed_or_detected": ["new", "detected", "new", "new"] } + → 400 { "error": "mainnet-deploy-requires-confirm" } + +POST /v1/onboarding/chain/register-master + body: { "k11_assertion_id": "..." } — uses an assert/finish'd K11 + → 200 { "tx_hash": "0x...", "block": 1234567, "device_key_hash": "0x..." } +``` + +The daemon delegates to `harness/scripts/heima-bring-up.sh` and `heima-register-first-master.sh` underneath. + +### Agent lifecycle (**deferred** — Phase 2) + +Phase-2 work. Stage-1 screen E, stage-3 §1. + +``` +POST /v1/agents/bootstrap/this-device +POST /v1/agents/bootstrap/remote — returns pair code + URL +POST /v1/agents/bootstrap/vendor — returns pair code +GET /v1/agents/pair/status?code=... — operator polls during pairing +POST /v1/agents/create — finalize: chain registerAgentDevice + body: { "label": "...", "vendor": "...", "kind": "this-device|remote|vendor" } + → 200 { "agent_id": "agent-folotoy", "agent_omni": "0x...", "derivation": "//folotoy" } + +POST /v1/actors/:id/scope — shipped (PR-C) +POST /v1/actors/:id/payment-cap — shipped (PR-C) +POST /v1/actors/:id/revoke — shipped (PR-C) +POST /v1/actors/:id/caps/revoke — shipped (PR-C) +GET /v1/actors — shipped (PR-C) +GET /v1/actors/:id — shipped (PR-C) +GET /v1/actors/:id/caps — shipped (PR-C) +``` + +The shipped POSTs from PR-C take an `intent_text`/`intent_fields` pair today; under the new plan they extend to take `k11_assertion_id` so the K11 ceremony is decoupled from the actual mutation. + +### Second-master pairing (**deferred** — Phase 3) + +Phase-3 work. Stage-2 screens G–L. + +``` +POST /v1/onboarding/pair/start + → 200 { "pair_token": "...", "qr_url": "https://...#tok=...", "expires_in_seconds": 600 } + +POST /v1/onboarding/pair/exchange — called from the COMPANION's daemon + body: { "token": "..." } + → 200 { "exchange_jwt": "...", "primary_endpoint": "..." } + +POST /v1/onboarding/pair/companion-ready — called from the COMPANION's UI after K11 enroll + body: { "exchange_jwt": "...", "device_key_hash": "...", "k11_cred_id_hash": "..." } + → 200 { "ok": true } + +GET /v1/onboarding/pair/status?token=... — primary polls + → 200 { "status": "waiting" | "companion-active", "companion": { "device_key_hash": "...", "k11_cred_id_hash": "..." } } + +POST /v1/onboarding/pair/finalize/begin + body: { "device_key_hash": "...", "k11_cred_id_hash": "...", "roles": "cap-mint|recovery" } + → 200 { "challenge": "...", "assertion_id": "..." } + +POST /v1/onboarding/pair/finalize/submit + body: { "assertion_id": "...", "authenticatorData": "...", "clientDataJSON": "...", "signature": "..." } + → 200 { "tx_hash": "...", "block": 1234567 } +``` + +### Recovery quorum + drill (**deferred** — Phase 3) + +``` +POST /v1/onboarding/recovery/threshold — set threshold; requires K11 from primary +POST /v1/onboarding/drill/register-spare — synthetic 3rd master; primary K11 +POST /v1/onboarding/drill/revoke-spare/begin — returns challenge for primary +POST /v1/onboarding/drill/revoke-spare/companion-assert — companion provides its K11 assertion +POST /v1/onboarding/drill/revoke-spare/submit — daemon bundles both assertions, calls revokeMasterDevice +``` + +The two-assertion bundle is the **only** UI flow that requires assertions from two different devices in a single chain call. The companion's POST is authenticated by the companion's pair-derived JWT; the primary's by its session JWT. + +### Read endpoints — actors / audit / anchor / workers (**shipped** — PR-C; live-data wiring deferred to Phase 2+) + +The endpoints exist; Phase 1 does not exercise them because there are no agents, no audit events, no workers active until Phase 2. The UI's `DaemonBackend` calls them today and renders empty states. + +``` +GET /v1/actors — shipped +GET /v1/actors/:id — shipped +GET /v1/actors/:id/caps — shipped +GET /v1/audit/recent?actor_id=&limit= — shipped +GET /v1/audit/stream (SSE) — shipped +GET /v1/anchor/status — shipped +GET /v1/workers — shipped +GET /v1/workers/:id — shipped +``` + +### Isolation health check (**deferred** — Phase 3) + +Phase-3 work. Stage-3 §3. + +``` +POST /v1/isolation/run — kicks off the 16-step check + body: { "include_cleanup": true } + → 200 { "run_id": "..." } + +GET /v1/isolation/run/:id/stream (SSE) — per-step status: { step: 4, status: "ok" | "fail", detail: "...", expected: "deny", got: "AccessDenied" } +GET /v1/isolation/run/:id — final summary report after stream closes +``` + +The run uses synthetic actor_omni + isolated test prefixes (`.healthcheck/...`). Cleanup happens automatically as step 16. + +### Email worker integration (**deferred** — Phase 2) + +Phase-2 work. Stage-3 §1 + agent inbox visibility. + +``` +GET /v1/agents/:id/email + → 200 { "inbox_address": "agent-folotoy@bots.litentry.org", "recent_messages": [...] } +GET /v1/agents/:id/email/:msg_id + → 200 { "from": "...", "subject": "...", "body": "...", "received_at": ... } +``` + +These wrap the email-service worker's `list-inbox(cap)` + `read-message(cap, msg_id)` calls per arch.md §15.4. The cap-token mint is the daemon's responsibility — the UI never holds a worker cap directly. + +### Dev seed (**shipped** — PR-C; Phase 1 does NOT use it) + +``` +POST /v1/dev/seed — operator-only data injection for demos +POST /v1/dev/event — manually push one audit event into the SSE feed +``` + +Kept for demo purposes only. Phase 1 has no need for it because there's no mock data in Phase 1's flows — every value the UI shows is real. Feature-flag off in production deployments per [`deferred-and-followups.md`](deferred-and-followups.md) §1. + +## Persistence boundaries + +What lives where (the table that lets a reviewer answer "is this data lost when the UI restarts?"): + +| Data | Where it's stored | Lifetime | +|---|---|---| +| session JWT | OS keychain (via daemon) | TTL from broker (~5 h) | +| K10 keypair | OS keychain (per device) | until rotation | +| K11 credential id + COSE pubkey | `~/.agentkeys/k11/.json` (daemon-managed) | until revoked | +| operator's `actor_omni`, `wallet_address`, `email` | broker DB + local session record | account lifetime | +| chain contract addresses | `scripts/operator-workstation.env` + chain | deployment lifetime | +| actors, scope, payment caps, time-windows | chain (SidecarRegistry + AgentKeysScope) + daemon's in-memory cache (TTL'd) | chain lifetime | +| cap-tokens | chain mint events + worker-side validation (no daemon persistence) | per-cap TTL | +| audit events (tier 1) | audit-service worker's S3 bucket + daemon's 200-event in-memory ring | retention per worker config | +| audit anchors (tier 2) | chain extrinsics every 2 min | chain lifetime | +| worker stats (calls/hour, p50/p95) | aggregated by daemon from audit feed | in-memory, recomputed on restart | +| onboarding state machine | NOT persisted — re-derived from local + broker + chain on `GET /v1/onboarding/state` | per query | + +The discipline: **the daemon never stores anything it can re-derive from broker + chain + local files**. The audit-feed ring buffer is the one exception — chain has tier-2 roots but tier-1 events live only at the audit-service worker; the daemon caches enough to populate the UI on restart without re-querying. + +## What is local-only vs chain-anchored + +| Claim | Where it's verified | +|---|---| +| "this user owns this email" | broker (email magic-link record) | +| "this device holds K10 for this actor" | local OS keychain + chain (`SidecarRegistry.device(D_pub_hash).device_pubkey_hash` matches) | +| "this device holds K11 for this master" | platform authenticator (sealed) + chain (`SidecarRegistry.device(D_pub_hash).k11_cred_id_hash` matches) | +| "this agent has memory:read on family" | chain (`AgentKeysScope[O_master][agent_omni][family]`) | +| "this agent did X at time T" | tier-1 SSE + tier-2 chain anchor (Merkle root) | + +The UI must never claim "X is true" without resolving X's claim back to its authority. The audit row "FoloToy bear · memory.read · family/bedtime-story" is claimable because the row carries `cap_token_id` and a `tier-2 status` indicator; clicking through shows the full chain. + +## Request/response style + +- **Bodies are JSON.** snake_case on the wire (matches existing PR-C handlers); the UI's `daemon.ts` translates to camelCase at the boundary. +- **Errors are `{ error, reason, detail? }`.** `reason` is a stable `kebab-case` token the UI can switch on; `error` is operator-readable copy. +- **Long-running operations stream.** Anything that takes >1s emits SSE on a dedicated stream endpoint (`.../stream`) rather than blocking the request. +- **K11 assertions are decoupled.** Every mutation that needs a K11 goes through the two-step `/v1/k11/assert/{begin,finish}` pattern so the browser can sequence the WebAuthn prompt cleanly. + +## Open contract questions for review + +1. **Should `/v1/onboarding/state` be cached or always live-query?** Live query against chain on every page load is expensive (~2× block time per master / agent / scope lookup). Proposal: daemon polls chain on a 5 s tick + listens to its own audit feed for invalidations. +2. **Pair-flow JWT lifetime.** 10 min is the harness's window; the web flow could be tighter (3 min?). What's right depends on UX testing — leaving 10 min as the default until we see operator drop-off. +3. **CORS for `--ui-bridge` mode.** Currently allows `http://localhost:3113` only. Production deployment with `https://parent.{operator}.litentry.org` needs the daemon's CORS layer to accept the operator-specific origin per env. Should the daemon take this as a CLI flag (current shape) or pull it from the broker's deployment-config endpoint at startup? + +These are tracked in [`deferred-and-followups.md`](deferred-and-followups.md). diff --git a/docs/plan/web-flow/deferred-and-followups.md b/docs/plan/web-flow/deferred-and-followups.md new file mode 100644 index 0000000..15cddc2 --- /dev/null +++ b/docs/plan/web-flow/deferred-and-followups.md @@ -0,0 +1,163 @@ +# deferred-and-followups · what stays shell · open questions · sequencing + +## §1 — Stays shell-only (intentionally not in the web UI) + +Some harness flows are operator-cluster admin or SRE concerns, not parent-facing. They will never get a screen. + +| Flow | Source | Why not in UI | +|---|---|---| +| `scripts/heima-bring-up.sh` (chain genesis) | one-off per operator deployment | run by SRE who controls the deployer wallet + sudo authority; the parent operator inherits the running chain | +| `scripts/setup-broker-host.sh --upgrade` (EC2 / nginx / certbot) | per-deployment infra mgmt | infrastructure layer; lives behind the broker URL the parent operator uses | +| `forge test` (28 stage-2 contract tests) | CI gate | engineering quality gate; runs in `.github/workflows/harness-ci.yml` | +| `cargo test --workspace` | CI gate | engineering quality gate | +| `harness/v2-stage3-demo.sh` in CI | required check on every PR | retained as the *gate* that proves isolation can't regress unnoticed; the web UI's isolation health check is a complement (operator-visible verification), NOT a replacement | +| K3 epoch rotation (`docs/runbook-k3-rotation.md`) | rare operator ceremony | today shell-only; web promotion is M5+ ("rotate keys" button → 2-of-2 quorum) | +| `awsp` profile switching | OS-level shell helper | not a parent concern | + +These flows are referenced from the web UI when relevant (e.g. the cloud-provision screen says "if this fails, run `scripts/setup-broker-host.sh --upgrade` on the broker host"), but the UI never invokes them. + +## §2 — Operator-power-user escape hatches + +For the engineer / SRE who wants to bypass the wizard: + +| Escape hatch | Where | When to use | +|---|---|---| +| `POST /v1/onboarding/skip { steps: [...] }` | daemon endpoint, only enabled when `AGENTKEYS_OPERATOR_ROLE=admin` in daemon env | when the operator ran the shell scripts manually and wants the UI to acknowledge that state | +| `AGENTKEYS_CLOUD_PROVISIONED=1` | daemon startup env | operator-cluster admin pre-provisioned the buckets/roles; the UI skips screen C | +| `AGENTKEYS_CHAIN_BOOTSTRAPPED=1` | daemon startup env | contracts already deployed (`scripts/operator-workstation.env` has the addresses); the UI skips the contract-deploy half of screen D | +| `agentkeys` CLI | every flow | every endpoint the UI uses is also reachable via the existing CLI; an SRE can complete onboarding from terminal and the UI picks it up on next visit | + +**Discipline:** these flags are *opt-in privileges*, not defaults. A new-to-AgentKeys operator running the deployed web UI sees the full wizard and provisions their own resources. The flags exist so a power user isn't forced to click through screens for state they already established. + +## §3 — Open questions for review + +These are the decisions that need an answer before implementation begins. + +### Q1 — Onboarding screen merging + +Stage-1 has 6 screens (A through F). Some could be merged for fewer clicks: + +- A (identity) + B (passkey): the operator types email + immediately enrolls passkey on the same screen, since the daemon needs `binding_nonce` from A to drive B anyway. +- C (cloud) + D (chain): both are "the system stands up infrastructure for you". Operator might see one combined "provisioning" screen. + +**Tradeoff:** fewer screens = less context-switch, but each screen has distinct failure modes that benefit from being separated (cloud failure ≠ chain failure ≠ identity failure). The plan currently keeps them separate; review may collapse. + +### Q2 — Pair flow JWT lifetime + +`docs/plan/web-flow/data-model.md` §"Second-master pairing" sets 10 minutes. The harness uses 10 minutes (per `v2-stage2-demo.sh`). UX-testing might find that's too long (operator wanders off) or too short (operator gets blocked by an OS update on the companion device). Defer until first usability test. + +### Q3 — Cross-browser passkey behavior + +WebAuthn credentials are RP-bound and origin-bound. A passkey enrolled in Safari is not visible to Chrome on the same Mac (unless both surface iCloud Keychain). The wizard must: + +- Detect when the operator is in a browser without their existing passkey and prompt them to switch. +- Fall back gracefully: option to "use a security key" or "use your phone via cross-device hybrid transport". + +This is a substantial sub-design that isn't in the current plan. Tracked here for the implementation phase. + +### Q4 — What happens if the operator changes their email later + +The plan treats the operator's login email as *Real, account-lifetime*. Changing it is a master-mutation that ripples through: + +- broker DB rebinding (email → actor_omni) +- chain `SidecarRegistry.master_devices[O_master]` does NOT change (actor_omni is derived from email but the chain stores the hash, not the email) + +A "change my email" flow exists in arch.md §10's identity-rebinding ceremony but is out of scope for v0. Defer. + +### Q5 — Multi-operator handoff + +One operator's UI session showing another operator's data is forbidden (per arch.md §17 isolation). But what about a shared-team workflow where two operators collaborate on a single AgentKeys deployment (e.g. parent + co-parent each manage the same FoloToy bear)? + +Not addressed in the harness. Tracked here for a M5+ feature: multi-operator-per-deployment. + +### Q6 — Anchor verification flow + +stage-3 §2.3 mentions the operator can verify any tier-1 event against its tier-2 Merkle root. The UI currently links out to an explorer. A future "verify on-chain" button in the audit-row modal would: + +1. Take the event's `cap_token_id`. +2. Look up the 2-min batch that contains it. +3. Recompute the Merkle path locally in the browser. +4. Show the operator: "this event's path is `[h1, h2, h3]`, root is `0x7e3f…`, chain root at block X is `0x7e3f…`, match ✓". + +This is a real product value (the operator can prove integrity without trusting the daemon). Tracked for post-v0. + +## §4 — Implementation sequencing if approved + +The plan above is broken into tasks the implementation work can pick up in order. The sequencing isn't a guarantee — open questions may force re-ordering — but it's the proposal. + +### Phase D — onboarding state machine + cloud provision (1.5 days) + +- new daemon endpoint `GET /v1/onboarding/state` +- new daemon endpoint `POST /v1/onboarding/cloud/provision` + SSE stream — orchestrates the four existing `scripts/provision-*.sh` +- new daemon endpoint `POST /v1/onboarding/cloud/smoke` +- UI: screen C (cloud) lands as a real wizard step replacing the PR-B stub +- Rust unit tests for the new endpoints (per the discipline established in PR-B/C) + +### Phase E — identity + chain bring-up (2 days) + +- new daemon endpoints `POST /v1/auth/email/{start,verify,status}` proxying the broker +- new daemon endpoints `POST /v1/onboarding/chain/{deploy,register-master}` +- new daemon endpoints `POST /v1/k11/assert/{begin,finish}` (decoupled K11 assertion path) +- UI: screens A (identity), B (passkey, refactored from PR-B's existing flow), D (chain) become live +- Rust unit tests + integration tests against a local anvil chain + +### Phase F — agent lifecycle (1 day) + +- new daemon endpoints `POST /v1/agents/bootstrap/{this-device,remote,vendor}` + `GET /v1/agents/pair/status` + `POST /v1/agents/create` +- shipped endpoints (PR-C `/v1/actors/:id/scope`, etc.) get extended to take `k11_assertion_id` +- UI: screens E (first agent) becomes live; "add agent" in steady state works + +### Phase G — second-master pairing + recovery drill (2 days) + +- new daemon endpoints for `/v1/onboarding/pair/*` and `/v1/onboarding/drill/*` +- two-assertion-bundle support in the daemon +- UI: stage-2 screens G, H, I, J, K, L (Act 2) + +### Phase H — isolation health check (1 day) + +- new daemon endpoints `/v1/isolation/*` +- background runner that drives the 16 stage-3 steps against the operator's real cloud +- UI: stage-3 §3 `/isolation-demo` screen +- Rust unit tests verifying the runner's expectations match the harness's + +### Phase I — email worker integration + actor-detail polish (0.5 day) + +- new daemon endpoints `/v1/agents/:id/email{,/:msg_id}` +- UI: agent inbox visibility on actor-detail page + +### Phase J — coverage gate strict + docs sync (0.5 day) + +- bump `--fail-under-lines` from 60% to whatever the new daemon code's coverage is +- update arch.md §22c.1 with the new ui-bridge endpoints +- archive obsolete sections of the prototype + initial implementation comments + +**Total: ~9 days of focused work.** This assumes Q3 (cross-browser passkey) gets a separate carve-out spike if it surfaces issues. + +## §5 — Risks + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Broker `/v1/auth/email/*` API drifts during implementation | medium | freeze the broker interface in Phase E before UI work; daemon proxies are tied to it | +| Cross-device WebAuthn (Q3) reveals iOS-Safari quirks | medium | spike Phase G early; if blocked, ship Act 2 without the recovery drill (Screen K) first | +| Operator's `operator-workstation.env` shape changes between this plan and implementation | low | the daemon reads this file today; treat it as a stable contract until M5 | +| Stage-3 isolation check fails for legitimate operators when their AWS region differs | low | the existing harness handles per-region setup; surface region in `/v1/onboarding/state.cloud_detail` | +| Onboarding state machine's chain queries are too slow | medium | the 5-second poll proposal in `data-model.md` §"Open contract questions" Q1 mitigates; benchmark before shipping | + +## §6 — What this plan does not commit to + +- **Specific UI copy.** All operator-facing text in this plan is illustrative. Real copy gets a design pass. +- **Visual treatment.** The prototype's iii.dev aesthetic is assumed but not prescribed by this plan. Screens A-L could be redesigned wholesale; the flow + endpoint contracts are what's locked in. +- **Mobile-native iOS / Android.** Per issue #110, that's M5 after vendor pilot. +- **Workflow automation.** No "if X then Y" rules, no scheduled scope changes, no auto-revoke-after-N-failures. v0 is manual every time. + +## §7 — Review checkpoint + +Before any implementation work begins under this plan, the reviewer should confirm: + +1. **Stage docs accurately reflect the harness.** Spot-check a step in `harness/v2-stage1-demo.sh` against `stage1-first-run.md`'s mapping table. Same for stages 2 and 3. +2. **The email distinction in `input-discipline.md` §1 is correct.** Operator-login-email vs agent-inbox-sub-address — confirm the email worker (arch.md §15.4) routes the way the doc claims. +3. **The daemon contract in `data-model.md` is implementable without rewriting PR-C's existing endpoints.** Most additions are net-new endpoints; the shipped POST mutations get a small extension (taking `k11_assertion_id`) but no breaking change. +4. **The sequencing in §4 above is achievable.** ~9 days is the estimate; multiply by 1.5x if reviewer expects scope creep. +5. **Q1–Q6 open questions are tracked.** None of them block the plan from being approved; they block specific phases from starting. + +If all five check out, implementation can begin at Phase D. diff --git a/docs/plan/web-flow/input-discipline.md b/docs/plan/web-flow/input-discipline.md new file mode 100644 index 0000000..e68bae8 --- /dev/null +++ b/docs/plan/web-flow/input-discipline.md @@ -0,0 +1,128 @@ +# input-discipline · real vs derived vs auto-generated inputs + +This document fixes terminology that the three stage docs depend on. Every value the web UI handles falls into one of three categories. Mistakes happen when the categories blur — most commonly when a synthetic test value (from the harness or the prototype) gets confused for an operator-typed input. + +## The three categories + +| Category | Definition | Examples | Source of truth | +|---|---|---|---| +| **Real** | The operator types this value. Reflects their actual identity, intent, or property of the real world. | login email, agent label, payment cap amount, time-window hours, scope toggle (deny/read/write) | the operator | +| **Derived** | Computed by the system from real inputs (and possibly other derived values). Always reproducible from its inputs. | `actor_omni`, `wallet_address`, `D_pub_hash`, agent's `child_omni = HDKD(master_omni, label)`, `binding_nonce`, `cap_token_id`, agent inbox address | a deterministic function | +| **Auto-generated** | Created by the system fresh, with entropy, when needed. Not reproducible. | K10 keypair, K11 credential id, pairing token, isolation-check synthetic `actor_omni`, deployer wallet (during chain bring-up), session JWT signing key (broker-side) | the system's CSPRNG | + +The discipline: any field that takes operator input must be Real. Any value displayed to the operator (so they recognise it later) must be either Real or Derived from Real. Auto-generated values are internal — the operator sees them only when they're explicitly secrets (a recovery code, a passkey id) and even then sees them once and then they're on disk / in a keychain. + +## §1 — The operator's login email vs. the agent's inbox address + +This is the source of the user's review note. It's worth resolving once, clearly. + +### §1.1 The operator's login email — **Real** + +The operator types their real email when they first open the UI. It's the email *they* read, on a phone or laptop they own. + +- Used for: broker `/v1/auth/email/start` → magic link → SIWE → session JWT. +- Stored at: broker (associated with the operator's wallet + actor_omni) + locally in OS keychain as part of the session record. +- Lifetime: as long as the operator's account exists. Changing it is a master-mutation (not in scope for v0; would be a deferred follow-up). +- Visibility: the operator sees this in the header strip (`Sara · O_master · iPhone 17 Pro`) and on the master-detail page. + +**The harness uses `demo-N@bots.litentry.org` because the demo runs in a domain SES has verified.** A real operator running the deployed web UI types their own email. The broker's allowed-domain check (configured per deployment via env var) gates which domains are accepted. See [`docs/v2-stage1-migration-and-demo.md`](../../v2-stage1-migration-and-demo.md) §0.0 for why `@example.com` placeholders are rejected (RFC 2606 reserved → magic link goes into the void). + +### §1.2 The agent's inbox address — **Derived**, NOT operator-typed + +When an agent needs to receive emails — to verify a signup, get an OTP, claim a token — the agent does NOT use the operator's email. Doing so would: + +- conflate identities (the agent acting on behalf of the operator vs. the operator-as-themselves); +- give the agent access to the operator's other mail; +- violate arch.md §15.4 ("per-actor inbox" is keyed on `actor_omni`). + +Instead, the email-service worker per arch.md §15.4 routes a *derived* sub-address to that agent's actor-scoped S3 prefix: + +``` +agent label → derived sub-address +───────────────────────────────────────────────────────────────── +FoloToy bear → agent-folotoy@bots.litentry.org +ChatGPT (cloud) → agent-chatgpt@bots.litentry.org +Pluto (home robot) → agent-pluto@bots.litentry.org +``` + +These sub-addresses are **derived from** the operator's chosen agent label + the operator's deployment's email domain (`bots.litentry.org` in the canonical deployment, configurable per-operator). They are NOT typed by the operator. The operator chose the label; the system derived the sub-address. + +The SES routing layer (Lambda extension per arch.md §15.4) maps each sub-address to a per-actor S3 prefix: + +``` +agent-folotoy@bots.litentry.org → s3://email-bucket/bots//inbound/* +``` + +The agent presents a cap-token to read from its own inbox prefix; cross-actor reads are blocked by the same `PrincipalTag/agentkeys_actor_omni` chain as every other worker (arch.md §17.2 layer 3). + +### §1.3 The UI's responsibility + +The UI must: + +1. **Show the operator's email** prominently — in the header, on the master-detail page. It's their identity. +2. **NEVER use the operator's email as an agent inbox.** When an agent needs to receive verification, the UI displays the derived sub-address (`agent-folotoy@bots.litentry.org`) and copies it to the clipboard. The operator does NOT pick the sub-address; they pick the agent label, and the sub-address is shown to them so they can verify what was derived. +3. **Display agent inboxes on the actor-detail page** as a row in the "workers in scope" section when the agent has `email-service` in scope: e.g. *"FoloToy bear · receives mail at agent-folotoy@bots.litentry.org"*. +4. **Surface the inbox list** when the operator wants to debug: a small modal showing the recent inbound messages to the agent's sub-address. Agent reads happen via cap-tokens; operator reads via master authority. + +### §1.4 The two emails in the audit feed + +Both addresses are visible in the audit feed but tagged distinctly: + +| Event | Address shown | Tag | +|---|---|---| +| operator's login (manual / not common after onboarding) | `sara@example.com` | `K6 · session JWT` | +| agent inbox receive | `agent-folotoy@bots.litentry.org` | `email worker` | +| agent inbox read | `agent-folotoy@bots.litentry.org` | `email worker · cap=mail:inbox` | + +A reviewer who searches the audit feed for the operator's real email should find only login events (and zero agent-driven traffic). A reviewer searching for an agent's sub-address should find only that agent's mail events. Cross-contamination is the smell. + +## §2 — The agent label vs. the agent's on-chain derivation + +The operator types **the label** ("FoloToy bear"). The label is Real. + +The agent's on-chain identity is **derived**: + +``` +agent_omni = HDKD(master_omni, label) // per arch.md §6.2 +device_pubkey_hash = keccak256(D_pub_agent) // K10 from the agent's own hardware +``` + +The UI shows: +- The label everywhere user-facing. +- The hex `agent_omni` (truncated, `0x7c2d…41a9`) on the actor-detail page for operators who want to verify on chain. +- The full hex `device_pubkey_hash` only inside the "binding" panel (advanced detail). + +The agent's *derivation path* (`//folotoy` from `O_master`) is shown explicitly in the actor list — that's a Real-looking detail that's actually Derived from the label. + +## §3 — Payment caps, time-windows, scope toggles + +All **Real**. Operator-typed. No defaults pre-filled with non-trivial values (defaults are `deny` everywhere on first scope grant, `0` USDC on payment caps, `00:00–24:00` on time-window). + +The UI never invents a payment cap as a "reasonable default" — the operator's chosen number is the only number stored. (A "suggested starting point" copy line in the empty state, e.g. *"most operators start with 5 USDC per transaction for class-C agents"*, is fine; pre-filling the field is not.) + +## §4 — The dead `SIM_EVENTS` problem + +The prototype the design started from (`apps/parent-control/.../data.ts`, deleted in PR-A) shipped with a `SIM_EVENTS` array — synthetic audit events looped on a 4.2 s tick so the feed visibly moved during the demo. They were Auto-generated values pretending to be Real events. + +**The plan's posture:** there is no `SIM_EVENTS` in the implementation. The audit feed shows only real events from the audit-service worker. When the operator first opens the UI after stage-1 completes, the feed is empty until an agent actually does something. The `POST /v1/onboarding/agent/audit-ping` from stage-1 screen F is the single deliberate exception — *one* event with `kind: 'onboarding.complete'` and a comment so an operator inspecting the audit log later sees that yes, this was a system-generated welcome ping. + +If a demo path *needs* a populated feed (e.g. operator's first pitch to a vendor partner with no real agent activity yet), the demo path is the **isolation health check** (stage 3 §3) — which produces real events from a real synthetic actor. + +## §5 — Chain values: deployer, master, agent + +The operator's primary identity on chain is **`master_omni = SHA256("agentkeys" ‖ "email" ‖ email)`** — Derived from the Real login email. (Per arch.md `identity_omni` discussion.) The operator never types this; the UI shows the first/last 12 hex chars on the master-detail page. + +The deployer wallet (the wallet that pays gas for chain bring-up) is per-operator-deployment. On `heima-paseo` it's funded by sudo; on `heima` mainnet the operator-cluster admin pre-funds it from their treasury. **The deployer wallet is invisible to the parent operator.** The parent's master wallet (`session_wallet`) is what they see — derived from their email + signer-bound, distinct from the deployer. + +Confusing the deployer wallet with the master wallet is a real bug we've hit during stage-1 dev. The UI surfaces `master_wallet` everywhere, never `deployer_wallet`. + +## §6 — Quick checklist for new screens + +Before any UI screen ships, the reviewer should ask, for every editable field and every displayed value: + +- Real, Derived, or Auto-generated? +- If Real: does the underlying daemon endpoint persist it? Is there a validation gate? +- If Derived: is the derivation function in arch.md or another spec doc? Does the UI re-derive on display rather than storing the derived value locally? +- If Auto-generated: where is the entropy from? Where does the value end up at rest? When is it shown to the operator and when is it not? + +A field that can't answer those three questions cleanly should not ship. diff --git a/docs/plan/web-flow/overview.md b/docs/plan/web-flow/overview.md new file mode 100644 index 0000000..2037da7 --- /dev/null +++ b/docs/plan/web-flow/overview.md @@ -0,0 +1,205 @@ +# overview · operator user flow end-to-end + +## Phase 1 scope (this review) + +**Phase 1 covers Act 1, steps 1–7 only.** This is the *become a master* slice: the operator opens the web app, types an email, enrolls Touch ID, gets their cloud provisioned, and lands on the chain as a registered master. After step 7 the operator's master identity exists end-to-end and the parent-control UI can render the master-detail page with the master's vault + memory listings. + +Everything after step 7 — first agent creation, scope grant, audit ping, second-master pairing, recovery drill, isolation health check — is on the [TODO list](#todo-list--out-of-phase-1-scope) at the bottom of this document. Those become Phase 2 / Phase 3 reviews. + +The narrative below describes the full three-act arc for context, but the *contract that backs Phase 1 implementation* is the first 7 steps + the endpoint inventory at the end. + +--- + +A first-time operator opens the parent-control web UI. They have nothing yet — no keys, no chain identity, no AWS infra. By the end of Phase 1 they have all of those, plus the ability to see (an empty) credentials vault and memory store under their own master actor. Acts 2 and 3 (currently TODO) layer on agents, second masters, and live workloads. + +## Act 1 — first run · become a master *(Phase 1: steps 1–7)* + +*Source: [`harness/v2-stage1-demo.sh`](../../../harness/v2-stage1-demo.sh) steps 1–11.* + +The operator visits the parent-control URL. The app detects there is no local session and routes them straight to onboarding. There is no log-in form on the landing page because there is nothing to log in to yet — the first action is **becoming an operator**, not authenticating an existing one. + +### Step 1 — tell me who you are + +A single screen asks for the operator's real email address. The UI POSTs to `POST /v1/auth/email/start` and tells the operator to check their inbox. The email is **operator-typed and real** — see [`input-discipline.md` §1](input-discipline.md). *(Harness step 6.)* + +### Step 2 — click the magic link + +The link opens in any browser tab; the daemon proxies the verification to the broker and posts a `binding_nonce` plus the operator's deterministic Ethereum wallet back to the UI. The original tab (which has been polling `GET /v1/auth/email/status`) advances. The UI displays "your master wallet is `0xf3a8…`" — a wallet the operator never had to manage a seed phrase for. *(Harness step 6 continued.)* + +### Step 3 — register a device key + +The UI tells the daemon to derive K10 — the local-machine secp256k1 keypair — and store it in the platform keychain. Touch ID / Windows Hello unlocks the keychain; the operator sees a single OS-native dialog. K10 derivation is folded into the next step's request (`POST /v1/k11/enroll/begin` triggers it server-side so the operator doesn't see a separate click). *(Implicit in harness step 6; explicit in arch.md §10.)* + +### Step 4 — enroll a passkey for biometric approval + +Real `navigator.credentials.create()` runs. The platform authenticator generates K11. The challenge bytes — `sha256(binding_nonce ‖ D_pub)` — come from the daemon. The browser shows the OS Touch ID prompt; the operator authenticates. The credential never leaves the device. *(Harness step 11.)* + +### Step 5 — provision the operator's cloud, then show what's in it + +This step combines two concerns the user explicitly asked to bind together: + +**Part A — bucket + role + policy provisioning.** The UI shows a one-screen progress strip: "creating vault bucket… memory bucket… IAM roles… policies…". The daemon delegates to the existing `scripts/provision-vault-bucket.sh`, `scripts/provision-vault-role.sh`, `scripts/apply-vault-bucket-policy.sh`, `scripts/provision-memory-bucket.sh`, `scripts/provision-memory-role.sh`, `scripts/apply-memory-bucket-policy.sh`. The UI renders the operator-readable status of each as SSE events. *(Harness step 7.)* + +**Part B — show the master's current vault + memory contents.** As soon as provisioning completes the UI lists, side-by-side: + +> ``` +> ── your credentials (vault) 0 entries +> ── your memories (memory) 0 entries +> ``` + +For a brand-new operator both are empty. For an operator who's re-running onboarding (e.g. on a new device) they may have entries already, populated by past agent activity — the listings appear immediately and confirm "yes, this is your data; you're not staring at a stranger's cloud." + +These listings come from two new daemon endpoints scoped to the master actor: + +- `GET /v1/master/credentials` — returns metadata only (service, last write, size), never plaintext. +- `GET /v1/master/memory` — returns memory entries' keys + metadata. + +The master actor's omni is the operator's `actor_omni` (derived from email per arch.md §10). The S3 prefixes the daemon lists from are `s3:///bots//credentials/*` and `s3:///bots//memory/*` — the operator's own prefix, scoped by IAM PrincipalTag per arch.md §17.2 layer 3. + +**Why "master credentials" and "master memory" at all.** Per arch.md §6.2 (HDKD actor tree) the master is *also* an actor. Agents are HDKD children of it. Credentials the master stores about themselves (e.g. their personal OpenRouter key, used when they invoke an agent interactively) live under `master_omni`'s prefix — not under any agent's prefix. The UI surfacing this from step 5 onward is the operator's first window into their own cloud. + +### Step 6 — smoke-test cloud isolation + +With the STS creds the operator's wallet can mint, the UI writes one envelope to `s3://vault/bots//credentials/.healthcheck/smoke.test` and reads it back. If the round-trip fails, the UI pauses with the actual error from AWS. If it succeeds, the operator sees a single green check — and the listing from step 5's Part B updates to show the smoke-test entry (so they see their own data appear in their own UI). The `.healthcheck/` prefix is the marker; the operator can leave it or delete it once they trust the round-trip. *(Harness step 8.)* + +### Step 7 — anchor your identity on chain + +The UI deploys (or detects already-deployed) the four contracts — SidecarRegistry, AgentKeysScope, K3EpochCounter, CredentialAudit — and then calls `register_master_device(D_pub_hash, K11_cred_id_hash, roles=CAP_MINT|RECOVERY|SCOPE_MGMT)`. The K11 assertion for the register call runs through the new `POST /v1/k11/assert/{begin,finish}` pattern. This is the moment the operator becomes a real on-chain identity. *(Harness steps 9 + 10.)* + +After step 7 the operator's master is fully wired: + +- on chain: contracts deployed, master device registered, K11 cred_id committed +- on AWS: vault + memory buckets exist with policies scoped to `master_omni_hex` +- locally: K10 in keychain, K11 cred id on disk, session JWT alive +- in the UI: master-detail page renders, vault + memory listings work, audit feed has 1 entry (the DeviceRegistered event) + +**Phase 1 ends here.** The operator is in a steady state where they can re-open the UI on this device and land on the master-detail page; the onboarding wizard never reappears for this operator on this device. + +--- + +## Phase 1 endpoint inventory (the only new endpoints to build) + +Every endpoint Phase 1 needs. Anything not on this list is out of scope until Phase 2. + +| Step | New endpoint | Method | Purpose | +|---|---|---|---| +| umbrella | `/v1/onboarding/state` | GET | single endpoint the UI reads on every navigation to decide which screen to render | +| 1 | `/v1/auth/email/start` | POST | proxies broker's email magic-link issue; takes `{ email }` | +| 1→2 | `/v1/auth/email/status` | GET (poll) | original tab polls; returns `pending` / `verified` | +| 2 | `/v1/auth/email/verify` | POST | called by the tab that opened the magic link; returns `{ session_jwt, wallet_address, actor_omni, binding_nonce }` | +| 5 (Part A) | `/v1/onboarding/cloud/provision` | POST | dispatches the 6 existing provision-*.sh scripts | +| 5 (Part A) | `/v1/onboarding/cloud/stream` | GET (SSE) | per-script progress events | +| 5 (Part B) | `/v1/master/credentials` | GET | metadata-only listing of master's vault prefix | +| 5 (Part B) | `/v1/master/memory` | GET | metadata-only listing of master's memory prefix | +| 6 | `/v1/onboarding/cloud/smoke` | POST | one-shot envelope round-trip + result | +| 7 | `/v1/onboarding/chain/deploy` | POST | deploys (or detects) the 4 contracts | +| 7 | `/v1/onboarding/chain/register-master` | POST | calls `register_master_device(...)` on chain after a K11 assertion completes | +| 7 | `/v1/k11/assert/begin` | POST | two-step K11 assertion: build challenge, return `assertion_id` | +| 7 | `/v1/k11/assert/finish` | POST | submit the WebAuthn assertion; daemon submits the on-chain extrinsic | + +**Shipped already** (PR-B / PR-C) and reused without changes by Phase 1: + +| Endpoint | Source | +|---|---| +| `GET /healthz` | PR-B | +| `POST /v1/k11/enroll/begin` | PR-B | +| `POST /v1/k11/enroll/finish` | PR-B | + +That's the complete contract Phase 1 implementation works against. Twelve new endpoints. No others. + +--- + +## State machine sketch *(Phase 1 fragment)* + +``` + ┌─────────────────────────┐ + visit URL ──▶│ /onboarding/identity │ no local session + └────────────┬────────────┘ + │ email submitted + ▼ + ┌─────────────────────────┐ + │ await magic link │ broker pending + └────────────┬────────────┘ + │ link clicked (any tab) + ▼ + ┌─────────────────────────┐ + │ /onboarding/keys │ K10 + K11 + Touch ID + └────────────┬────────────┘ + │ K11 enrolled + ▼ + ┌─────────────────────────┐ + │ /onboarding/cloud │ bucket + role + STS + smoke + vault/memory listings + └────────────┬────────────┘ + │ provision green + ▼ + ┌─────────────────────────┐ + │ /onboarding/chain │ contracts + register_master_device + └────────────┬────────────┘ + │ master device on-chain + ▼ + ┌─────────────────────────┐ + │ /master │ master-detail home screen (Phase 1 terminus) + └─────────────────────────┘ +``` + +Subsequent sessions land on `/master` directly — `GET /v1/onboarding/state` returns `chain: 'master-registered'` and the UI skips the wizard. + +## Resumability invariants *(Phase 1)* + +The harness scripts run as a single shell process — if the operator's terminal closes mid-step, they re-run with `--from-step N` to pick up. The web flow needs the same property: + +1. **Every onboarding step writes the same on-disk + on-chain artifacts the harness writes.** If the UI crashes between step 4 (K11 enrolled) and step 5 (cloud provisioned), the next time the operator opens the URL, the UI inspects what's already on disk + chain and routes them directly to step 5. They never re-enroll K11 unless K11 is gone. +2. **The daemon owns the resume logic.** `GET /v1/onboarding/state` returns the aggregated state; the UI reads it on every navigation and renders the right screen. +3. **Re-onboarding a device that's already a master is allowed.** When the operator opens the UI on a different browser / new install, `GET /v1/onboarding/state` confirms `chain: 'master-registered'` and the UI lands at `/master`. The vault + memory listings populate from chain + S3, reproducing the same view the operator saw on the first device. + +--- + +## TODO list — out of Phase 1 scope + +Everything below is *deferred* until Phase 1 is reviewed + shipped. Each item links to where it's currently planned in the other docs. + +### Out-of-Phase-1 Act 1 steps + +These were drafted in [`stage1-first-run.md`](stage1-first-run.md) but defer past step 7: + +- **Step 8 — create your first agent.** Operator picks label + vendor → `POST /v1/agents/create` → chain `registerAgentDevice(...)`. *(Harness step 12.)* +- **Step 9 — decide what the agent is allowed to do.** Per-namespace scope toggles + payment cap inputs + time-window → K11 assertion → `setScopeWithWebauthn(...)`. *(Harness step 13.)* +- **Step 10 — watch the agent use a credential.** One demo `CredentialAudit.append` from the operator's own session → visible in audit feed within 200 ms. *(Harness step 14.)* + +### Act 2 — defense in depth (entire act, currently in [`stage2-second-master.md`](stage2-second-master.md)) + +- Pair a companion master device (QR pairing, companion K11 enroll, primary signs the addition) +- Raise `recoveryThreshold` to 2 (2-of-2 quorum on chain) +- Recovery drill: register a synthetic spare, revoke it via 2-of-2 quorum (proves the gate works) + +### Act 3 — normal operation (entire act, currently in [`stage3-agent-usage.md`](stage3-agent-usage.md)) + +- Steady-state actor list / audit feed / anchor status / workers dashboards (UI exists; ties to live data) +- Per-actor cap-token listing + per-cap revoke (UI mostly shipped in PR-C; needs daemon mutation endpoints to drive) +- Agent bootstrap paths: this-device / remote-sandbox / vendor-hardware +- On-demand isolation health check (the 16-step v2-stage3 proof against the operator's real cloud) +- Email worker integration (agent inbox sub-address visibility) + +### Open questions still pending review + +These were collected in [`deferred-and-followups.md`](deferred-and-followups.md). The ones that block Phase 1 are flagged here: + +- Q1 (onboarding screen merging) — defer; Phase 1 keeps 4 screens (identity / keys / cloud / chain). +- Q2 (pair-flow JWT lifetime) — N/A in Phase 1 (no pairing yet). +- Q3 (cross-browser passkey behavior) — **blocks Phase 1** for operators who switch browsers between magic-link click and the rest of the flow. Needs a spike during Phase 1 implementation. +- Q4 (email change) — defer to post-v0. +- Q5 (multi-operator handoff) — defer to M5+. +- Q6 (anchor verification flow) — N/A in Phase 1 (no audit feed displays yet at end of step 7 beyond the single DeviceRegistered event). + +--- + +## Where the harness still has the operator's terminal *(unchanged from previous draft)* + +These remain shell-only forever: + +| Harness step / runbook | Stays shell? Why | +|---|---| +| `scripts/heima-bring-up.sh` (one-shot chain genesis) | Run once per operator deployment, by the SRE who controls the deployer wallet. Not parent-facing. | +| `scripts/setup-broker-host.sh --upgrade` (EC2 / nginx / certbot tweaks) | Operator-cluster infrastructure, not consumer-facing. | +| K3 epoch rotation (`docs/runbook-k3-rotation.md`) | Today shell-only; web UI promotion deferred to M5+. | +| `harness/v2-stage3-demo.sh` itself in CI | Stays in CI as the gate that proves the production isolation invariants. The UI runs equivalent live checks (Phase 2+) but does NOT replace the CI gate. | diff --git a/docs/plan/web-flow/stage1-first-run.md b/docs/plan/web-flow/stage1-first-run.md new file mode 100644 index 0000000..1d28a7f --- /dev/null +++ b/docs/plan/web-flow/stage1-first-run.md @@ -0,0 +1,389 @@ +# stage1-first-run · operator first run · become a master + +**Phase 1 scope:** harness steps 6–11 only (identity, cloud provision, smoke test, chain bring-up, master register, K11 enroll). Steps 12–14 (first agent + scope + audit-ping) are Phase 2 — see [`overview.md` § TODO list](overview.md#todo-list--out-of-phase-1-scope). + +**Source script:** [`harness/v2-stage1-demo.sh`](../../../harness/v2-stage1-demo.sh) — 16 numbered steps, idempotent, resumable with `--from-step N`. +**Source runbook:** [`docs/v2-stage1-migration-and-demo.md`](../../v2-stage1-migration-and-demo.md) §0 + §1 + §2 + §4. +**Canonical reference:** [`docs/arch.md`](../../arch.md) §10 (ceremonies), §6.2 (HDKD actor tree), §17.2 (per-data-class isolation). +**Companion docs:** [`input-discipline.md`](input-discipline.md), [`data-model.md`](data-model.md). + +## What we're mapping (Phase 1) + +Each row says where the harness step surfaces in the UI, what input the operator types (vs. what the system computes), and what daemon endpoint backs it. Harness preflight steps 1–5 are not exposed in the wizard — they're internal checks the daemon runs before responding to the screens below. + +| # | Harness step | UI screen | Operator input | Daemon endpoint | +|--:|---|---|---|---| +| 1–5 | preflight (tools, env, AWS profile, CLI, chain reachability) | — (background) | none | folded into `GET /v1/onboarding/state` | +| 6 | init session via email magic-link | **screen A — identity** | operator's real email | `POST /v1/auth/email/start`, `POST /v1/auth/email/verify`, `GET /v1/auth/email/status` | +| 11 | K11 enrollment (real WebAuthn) | **screen B — passkey** | Touch ID / Hello / passkey | `POST /v1/k11/enroll/{begin,finish}` (shipped) | +| 7 | provision vault infrastructure | **screen C — cloud** (part A) | none (uses creds from screen A) | `POST /v1/onboarding/cloud/provision` + SSE `/v1/onboarding/cloud/stream` | +| 7 (new) | list master's vault + memory contents | **screen C — cloud** (part B) | none | `GET /v1/master/credentials`, `GET /v1/master/memory` | +| 8 | smoke-test S3 envelope | **screen C — cloud** (part C) | none | `POST /v1/onboarding/cloud/smoke` | +| 9 | chain bring-up (deploy 4 contracts) | **screen D — chain** (part A) | confirm on mainnet | `POST /v1/onboarding/chain/deploy` | +| 10 | register operator master device on chain | **screen D — chain** (part B) | Touch ID | `POST /v1/k11/assert/{begin,finish}` then `POST /v1/onboarding/chain/register-master` | + +The Phase 1 wizard is **4 screens (A–D)**. Order matches the harness; the dependencies (K11 ⇒ register-master) are real. Screens E (first agent) and F (done) from the previous draft are deferred to Phase 2. + +Note: harness step 11 (K11 enroll) maps to UI screen B, which the operator sees *before* the cloud + chain screens. This mirrors the harness's actual dependency graph — K11 must exist before the chain step's master-register K11 assertion runs — even though the harness script numbers step 11 later for ordering reasons (steps 12-14 don't depend on K11 in the script). The web flow puts K11 enroll right after identity, where it belongs in the operator's mental model. + +--- + +## Screen A — identity + +**Purpose:** establish who the operator is. After this screen they have a session JWT, a deterministic Ethereum wallet, and a `binding_nonce` from the broker. Nothing else changes. + +**What the operator sees first:** + +> *agentKeys · parent control* +> +> *Type the email you'll use to manage your agents. We send a one-time link there to prove it's yours.* +> +> `[ email input ]` +> `[ Continue → ]` + +The email field is **a real email the operator owns and reads**. See [`input-discipline.md` §1](input-discipline.md) for why this is non-negotiable. + +**What happens on submit:** + +1. UI calls `POST /v1/auth/email/start { email }` (daemon proxies to broker `/v1/auth/email/start`). +2. UI advances to "check your inbox" screen with a polling indicator. +3. Operator opens their mail client, clicks the link. +4. The link's target (`https://parent.litentry.org/verify?token=…`) hits the daemon, which proxies to broker `/v1/auth/email/verify`. Broker returns `{ session_jwt, wallet_address, actor_omni, binding_nonce }`. +5. The original tab — still polling — sees `verified=true` and advances. + +**Where the harness path differs and why:** + +- Harness step 6 falls back to `wallet_sig` (SIWE with a deployer key file) when `--skip-email` is passed. That is CI-only. The web flow has no CI in the loop — humans always click links, so the wallet_sig path is not exposed. (For ops-power-user dev mode, see [`deferred-and-followups.md` §2](deferred-and-followups.md).) +- Harness step 6 uses `demo-N@bots.litentry.org` SES-verified aliases. That is the **demo's** real email, used because the harness operator doesn't have a real SES-verified domain. A real operator using the deployed web UI types their actual email — which must resolve through the broker's allowed domain list (see [`input-discipline.md` §1.1](input-discipline.md) for the domain policy). + +**Validation gates:** + +- Empty / malformed email → inline error. +- Domain outside the broker's allow-list → "this email domain isn't supported in your deployment. Contact your operator-cluster admin." with the configured allow-list shown. +- Magic link clicked but the originating tab is closed → daemon stores `verified=true` on the broker side; next time the operator opens the UI, `GET /v1/onboarding/state` returns `identity: 'verified'` and they pick up at screen B. + +**Resume:** if `GET /v1/onboarding/state` returns `identity: 'verified'`, this screen is skipped and the UI lands on screen B. If `identity: 'pending'` (broker has a pending verify), they get the "check your inbox" view. If `identity: 'missing'`, the email form. + +**State after this screen:** + +- Local: session JWT in OS keychain (via the daemon). +- Broker: identity record bound to email + wallet + actor_omni. +- Chain: nothing yet. + +--- + +## Screen B — passkey + +**Purpose:** enroll K11. The operator's *biometric proof of intent* for every future master mutation gets created here. + +**What the operator sees:** + +> *Set up a passkey on this device* +> +> *You'll use Touch ID, Hello, or your phone's passkey to approve every change to your agents — granting them access, revoking devices, raising payment caps. The passkey lives on this device only.* +> +> *Why this matters: even if someone steals your session, they can't do anything serious without your face / fingerprint.* +> +> `[ Enroll passkey → ]` + +**What happens on submit:** + +This screen is **already implemented** in PR-B. It's the existing `/onboarding` page step 3, simply lifted into its own dedicated screen instead of buried in a list. See [`apps/parent-control/app/_components/onboarding.tsx`](../../../apps/parent-control/app/_components/onboarding.tsx) and [`crates/agentkeys-daemon/src/ui_bridge.rs`](../../../crates/agentkeys-daemon/src/ui_bridge.rs) `enroll_begin` / `enroll_finish`. + +The daemon-side challenge construction is what arch.md §10.2 specifies: `sha256(binding_nonce ‖ D_pub)`. `binding_nonce` came from screen A; `D_pub` is computed when K10 is generated (next paragraph). + +**What about K10?** + +K10 — the per-device secp256k1 key — needs to exist before the K11 challenge can be computed (because the challenge binds D_pub atomically inside it). Two options: + +- **Option 1 (separate screen):** explicit "creating device key" mini-screen between A and B. Pro: explicit. Con: the operator doesn't care; one more click. +- **Option 2 (folded into B):** the daemon generates K10 when `POST /v1/k11/enroll/begin` is hit, in the same handler call. The UI shows a single "Enroll passkey" button that does both. + +**Recommendation: Option 2.** The operator's mental model is "set up the passkey"; the K10 detail is invisible. Per arch.md §10 the two are part of one ceremony ("master binding ceremony"). The harness step 11 already runs K10 derivation inline with K11 enrollment. The UI follows. + +**Validation gates:** + +- WebAuthn not available in the browser (e.g. desktop Firefox without a platform authenticator) → screen renders with the enroll button disabled and an explanation: "your browser doesn't expose a platform authenticator. Try Safari, Chrome, or Edge on this device, or use a phone." +- `navigator.credentials.create()` returns null (user cancelled the OS dialog) → "you cancelled. Try again." +- Daemon rejects attestation (`attestation-rejected`) → "your passkey couldn't be verified. Try again, or contact support if this keeps happening." Operator can retry; the begin call issues a fresh challenge. + +**Resume:** `k11: 'enrolled'` → skip to screen C. + +**State after this screen:** + +- Local: K10 in keychain, K11 credential id on disk (`~/.agentkeys/k11/.json`). +- Broker: K11 cred_id is NOT yet known to the broker — it lands when screen D registers the master device on chain. +- Chain: nothing yet. + +--- + +## Screen C — cloud + +**Purpose:** stand up the operator's per-data-class AWS infrastructure (or detect it already exists), prove it's reachable + isolated, and surface what the master currently holds in vault + memory. + +This screen has three parts that flow together in one continuous view; the operator doesn't tap between them. + +### Part A — provisioning progress + +> *Setting up your cloud · this happens once* +> +> ``` +> [✓] AWS account · 928... (from your operator-workstation.env) +> [⟳] vault bucket · creating agentkeys-vault-... +> [ ] memory bucket +> [ ] audit bucket +> [ ] email bucket +> [ ] vault IAM role · agentkeys-vault-role +> [ ] memory IAM role · agentkeys-memory-role +> [ ] bucket policies · scoped to your actor_omni +> ``` +> +> *takes ~90 seconds the first time. Subsequent runs detect existing infra and skip.* + +**What happens:** + +1. UI calls `POST /v1/onboarding/cloud/provision`. +2. Daemon dispatches to the existing `scripts/provision-vault-bucket.sh`, `scripts/provision-vault-role.sh`, `scripts/apply-vault-bucket-policy.sh`, `scripts/provision-memory-bucket.sh`, `scripts/provision-memory-role.sh`, `scripts/apply-memory-bucket-policy.sh`. +3. Each sub-script's progress streams back via SSE on `GET /v1/onboarding/cloud/stream` as `provision.step` events. The UI updates each row as events land. + +### Part B — your credentials + your memories (master scope) + +As soon as provisioning succeeds, the UI swaps in a side-by-side listing of what the master holds: + +> ``` +> ── your credentials (vault) 0 entries +> (none yet — agents will add credentials they store on your behalf; +> you can also add personal credentials here that only your master +> session uses, see arch.md §15.1) +> +> ── your memories (memory) 0 entries +> (none yet — agents writing to family/personal namespaces will +> populate this; the master is the recipient of agent writes per +> arch.md §15.2) +> ``` + +For a brand-new operator both panels are empty. For an operator re-running onboarding on a new device, real entries appear immediately — confirming "yes, this is your data; you're not staring at a stranger's cloud." + +**Daemon endpoints (new in Phase 1):** + +- `GET /v1/master/credentials` — metadata-only listing of the master's vault prefix (`s3://vault/bots//credentials/*`). Returns `[{ service, last_write_at, size_bytes, encryption_alg }]`. Never plaintext. +- `GET /v1/master/memory` — metadata-only listing of the master's memory prefix (`s3://memory/bots//memory/*`). Returns `[{ key, last_write_at, size_bytes, writer_actor_omni }]`. Per arch.md §15.2 the `writer_actor_omni` distinguishes things the master wrote themselves vs things an agent wrote on their behalf. + +**Why these listings appear here, not on a later "master detail" page only:** the operator's mental model just shifted from "abstract cloud" to "my AWS account has buckets with my data" — surfacing what's there immediately closes the loop. It also tests the listing endpoints with zero entries, which is a useful smoke test on its own. + +**Why master is also an actor:** per arch.md §6.2, the master is the root of the HDKD actor tree. Agents are HDKD children of it. Credentials the master stores directly (their own OpenRouter key, used when invoking an agent interactively) live under the master's `actor_omni` prefix — distinct from any agent's prefix. The master-detail page surfaces this from step 5 onward; the operator sees their own slice of the cloud separately from anything an agent does later. + +### Part C — smoke test + +After Part B renders, the UI fires `POST /v1/onboarding/cloud/smoke`. The daemon writes one envelope to `s3://vault/bots//credentials/.healthcheck/smoke.test` (using `service="onboarding-smoke"`, `secret=` — see "harness path differs" below), reads it back, and reports `{ passed: true | false, envelope_url, error? }`. + +On success the Part B vault listing updates live to include the `.healthcheck/smoke.test` entry — the operator sees their own data appear in their own UI. They can leave it or delete it; the `.healthcheck/` prefix is the marker for the operator-cluster admin's cleanup policy. *(Harness step 8.)* + +### Validation gates + +- AWS caller identity wrong → "agentkeys-admin profile expected; got `default`. Run `awsp agentkeys-admin` and retry." +- A bucket name already taken → daemon retries with `-2` suffix, surfaces the change. +- IAM trust policy / bucket policy apply fails → "your AWS account is missing these permissions: ..." prompt. +- Smoke test fails → screen pauses, error from AWS is surfaced raw, retry button. + +### Where the harness path differs + +- Harness has `--skip-provision` for CI. The web flow does NOT expose `--skip-provision` — every real operator provisions their own buckets. (Operator-cluster admin overrides via env var `AGENTKEYS_CLOUD_PROVISIONED=1`; see [`deferred-and-followups.md` §2](deferred-and-followups.md).) +- Harness step 8's smoke uses `SMOKE_TEST_SERVICE=openrouter` + `SMOKE_TEST_SECRET=sk-or-v1-DEMO-FAKE…`. The web UI uses a hard-coded `service="onboarding-smoke"` + random `secret` — no real-looking credential lands in the operator's vault. + +### Resume + +- `cloud: 'provisioned'` → skip Part A, render Parts B + C only. +- `cloud: 'partial'` → the UI lists what's still missing and offers a "resume provisioning" button. +- Master credentials + memory always re-listed on screen entry (cheap call against the operator's own prefix). + +### State after this screen + +- AWS: vault + memory + audit + email buckets exist, scoped to the operator's `master_omni_hex`. +- AWS contents: `s3://vault/bots//credentials/.healthcheck/smoke.test` exists with a random secret. +- Local: STS creds for vault + memory roles cached for the duration of the session. +- Chain: nothing yet — chain step is screen D. + +--- + +## Screen D — chain + +**Purpose:** anchor the operator's identity on the chain by registering the master device on `SidecarRegistry`. This is the single moment after which the operator is "a real on-chain identity." + +**What the operator sees:** + +> *Anchoring you on chain · this is the moment you become a master* +> +> ``` +> [✓] chain reachable · heima-paseo / heima +> [ ] SidecarRegistry · deploying (or 'detected at 0xa3f1…') +> [ ] AgentKeysScope · deploying (or 'detected at 0xb1e9…') +> [ ] K3EpochCounter · deploying (or 'detected at 0xc4d8…') +> [ ] CredentialAudit · deploying (or 'detected at 0xd7c0…') +> [ ] registering this device as your master ← needs Touch ID +> ``` +> +> *Your master wallet: `0xf3a8…b1d2` · gas estimate: 0.012 HEI* +> +> `[ Approve with Touch ID → ]` + +**What happens:** + +1. UI calls `POST /v1/onboarding/chain/deploy` for the contract bring-up. Daemon dispatches to existing `harness/scripts/heima-deploy-stage2.sh` + `heima-init-epoch-counter.sh` paths. +2. If the contracts already exist (their addresses are in `scripts/operator-workstation.env`), the daemon detects + reports them as `detected at 0x...`, no re-deploy. +3. After contracts are live, the UI runs the two-step K11 assertion pattern: + - `POST /v1/k11/assert/begin { intent: { op: "register_master", fields: [["device_pubkey_hash", "0x..."], ["roles", "CAP_MINT|RECOVERY|SCOPE_MGMT"]] } }` → returns `{ challenge, assertion_id }`. + - Browser calls `navigator.credentials.get({ publicKey: { challenge, allowCredentials: [], userVerification: "required" } })`. + - `POST /v1/k11/assert/finish { assertion_id, authenticatorData, clientDataJSON, signature }` — daemon verifies the assertion + holds it ready for the chain call. +4. UI calls `POST /v1/onboarding/chain/register-master { k11_assertion_id }`. Daemon submits the extrinsic. +5. Tx hash + block number stream back. UI shows "confirmed at block #1,234,567 in 4.2 s" with a link to the chain explorer. + +**Mainnet confirmation step:** + +Per the harness's `--confirm` flag and arch.md §8 ("chain bring-up policy"), when the operator's deployment targets `AGENTKEYS_CHAIN=heima` (mainnet), the UI inserts an extra "you're about to deploy contracts on Heima mainnet. Type `deploy` to confirm" pause. heima-paseo / anvil skip this gate. + +**Validation gates:** + +- Insufficient gas on the deployer wallet → daemon surfaces "wallet `0xf3a8…` needs at least 0.05 HEI; current balance 0.012". On heima-paseo this links to the sudo-fund helper; on heima mainnet it just stops. +- K11 assertion fails (Touch ID cancelled / wrong device) → "we couldn't verify your passkey. Try again." +- Chain rejects the extrinsic (e.g. address already registered) → daemon catches the on-chain `DeviceAlreadyRegistered` event, reports "this device is already on chain — moving you forward." + +**Where the harness path differs:** + +- Harness step 9 deploys to whatever `AGENTKEYS_CHAIN` is set to. The web UI defaults to the operator's configured chain (single value in `operator-workstation.env`); switching chains mid-flow is not exposed. (Per-chain operator deployments are separate URLs.) +- Harness step 10 is a single `register_master_device` call. The web UI inserts the gas-estimate + confirmation step to surface the on-chain action explicitly. + +**Resume:** `chain: 'master-registered'` → onboarding wizard is complete; UI lands on the master-detail page. `chain: 'contracts-deployed'` → operator lands on the "register this device" sub-step. `chain: 'missing'` → full deploy flow. + +**State after this screen (Phase 1 terminus):** + +- Chain: contracts exist; operator's `D_pub_hash` is registered with `roles = CAP_MINT | RECOVERY | SCOPE_MGMT`, `k11_cred_id_hash` matches what was enrolled on screen B. +- Local: contract addresses cached. +- Audit: a `DeviceRegistered` event is in the chain's event log. + +**This is the end of the Phase 1 onboarding wizard.** The operator is now a fully-registered master. Subsequent UI sessions land directly on the master-detail page; the wizard never reappears for this operator on this device. + +--- + +## What comes after Phase 1 (deferred) + +The previous draft of this doc contained two more screens: + +- **Screen E — first agent.** Agent label + vendor → `POST /v1/agents/create` → chain `registerAgentDevice(...)`. Then per-namespace scope toggles + payment cap inputs → K11 assertion → `setScopeWithWebauthn(...)`. *(Harness steps 12 + 13.)* +- **Screen F — done.** Single demo `CredentialAudit.append` from the operator's session → visible in audit feed within 200 ms. *(Harness step 14.)* + +Both are **deferred to Phase 2**. See [`overview.md` § TODO list](overview.md#todo-list--out-of-phase-1-scope) for the full deferred-work index. + +Reason for the cut: Phase 1 already delivers a complete, useful slice — the operator can claim their identity, see their own cloud, and exist on chain as a registered master. Agent creation depends on the master being live; nothing in Phase 1 is blocked by deferring it. Shipping Phase 1 alone unlocks both vendor pilot demos ("look, I'm a master on the Heima chain") and the eventual Phase 2 implementation. + +--- + +## Removed screens (formerly drafted, now deferred) + +The original sections for screens E and F that lived here have been moved to the deferred work index. They will return in `docs/plan/web-flow/` when Phase 2 begins, with the lessons from Phase 1 implementation folded in (cross-browser passkey quirks, real broker URL handling, etc.). + +**Purpose:** the operator creates an agent device and grants it scope. By the end of this screen the operator has done the *complete* AgentKeys flow at least once. + +**What the operator sees, part 1 (agent creation):** + +> *Add your first agent* +> +> *An agent is any device or sandbox that needs to act on your behalf with bounded permissions. Your home robot, a chatbot you trust, a coding assistant.* +> +> `[ Name your agent ] e.g. "FoloToy bear" / "ChatGPT" / "Pluto"` +> `[ Vendor (optional) ] e.g. "FoloToy Inc." / "OpenAI" / "Anthropic"` +> `[ Continue → ]` + +**Name** is operator-typed, free-text. **Vendor** is operator-typed, free-text (used as a display string only — the trust chain doesn't depend on the vendor name). + +**What happens on submit:** + +1. UI calls `POST /v1/onboarding/agent/create { label, vendor }`. +2. Daemon dispatches to existing `harness/scripts/heima-agent-create.sh --label