diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..cb44d49 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,120 @@ +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 + # Collect coverage once (single test pass), then emit each report from + # the stored profile data. `cargo llvm-cov report` does NOT accept + # --workspace or a trailing `-- ` — newer cargo-llvm-cov + # rejects them ("--workspace ... not supported for subcommand 'report'"). + # Only the collecting run takes --workspace + test args. + cargo llvm-cov --no-report --workspace -- --test-threads=1 + cargo llvm-cov report --lcov --output-path lcov.info + cargo llvm-cov report --html + cargo llvm-cov report --summary-only | 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 --fail-under-lines 60 + + - 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 0c07228..b627016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ dependencies = [ "aws-sdk-sesv2", "aws-sdk-sts", "axum", - "base64", + "base64 0.22.1", "clap", "futures-util", "getrandom 0.2.17", @@ -64,7 +64,7 @@ dependencies = [ "k256", "p256 0.13.2", "pkcs8 0.10.2", - "rand_core", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -94,7 +94,7 @@ dependencies = [ "async-trait", "aws-credential-types", "axum", - "base64", + "base64 0.22.1", "ciborium", "clap", "hex", @@ -103,7 +103,7 @@ dependencies = [ "k256", "p256 0.13.2", "predicates", - "rand_core", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -129,15 +129,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", @@ -160,24 +160,30 @@ 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", + "sha2 0.10.9", "tokio", + "tokio-stream", "tower 0.4.13", + "tower-http 0.5.2", "tower-service", "tracing", "tracing-subscriber", + "url", + "webauthn-rs", ] [[package]] @@ -205,7 +211,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "clap", "futures-util", "hex", @@ -232,7 +238,7 @@ dependencies = [ "agentkeys-types", "async-trait", "axum", - "base64", + "base64 0.22.1", "ciborium", "clap", "ed25519-dalek", @@ -244,8 +250,8 @@ dependencies = [ "jsonwebtoken", "k256", "p256 0.13.2", - "rand", - "rand_core", + "rand 0.8.5", + "rand_core 0.6.4", "reqwest", "rusqlite", "serde", @@ -322,12 +328,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", @@ -367,7 +373,7 @@ dependencies = [ "aws-config", "aws-sdk-s3", "axum", - "base64", + "base64 0.22.1", "clap", "hex", "reqwest", @@ -462,6 +468,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" @@ -1203,6 +1248,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" @@ -1225,6 +1276,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" @@ -1561,7 +1623,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", ] @@ -1573,7 +1635,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", ] @@ -1585,7 +1647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -1670,6 +1732,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" @@ -1781,7 +1857,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -1808,7 +1884,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", @@ -1828,7 +1904,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", @@ -1951,7 +2027,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", ] @@ -1961,7 +2037,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", ] @@ -2189,7 +2265,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", ] @@ -2200,7 +2276,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", ] @@ -2524,7 +2600,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", @@ -2750,7 +2826,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", @@ -2938,6 +3014,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" @@ -2978,6 +3060,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" @@ -3072,6 +3164,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" @@ -3202,13 +3303,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", ] @@ -3458,8 +3565,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]] @@ -3469,7 +3586,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]] @@ -3481,6 +3608,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" @@ -3531,7 +3667,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", @@ -3625,6 +3761,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" @@ -3813,7 +3958,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand", + "rand 0.8.5", "serde", "sha2 0.10.9", "zbus", @@ -3871,6 +4016,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" @@ -4024,7 +4179,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]] @@ -4034,7 +4189,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]] @@ -4361,6 +4516,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" @@ -4577,7 +4744,7 @@ dependencies = [ "http 1.4.0", "httparse", "log", - "rand", + "rand 0.8.5", "rustls 0.23.37", "rustls-pki-types", "sha1 0.10.6", @@ -4640,6 +4807,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4674,6 +4842,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4848,6 +5017,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" @@ -5183,6 +5420,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" @@ -5249,7 +5503,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..4e46629 --- /dev/null +++ b/apps/parent-control/app/_components/App.tsx @@ -0,0 +1,496 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + CHAIN_PROFILE, + ONCHAIN_KINDS, + PAIRING_STEPS, + contractFor, + decodeCalldata, + txHash, +} from '@/lib/demoData'; +import { NAMESPACES } from '@/lib/constants'; +import { CeremonyRunner, OnboardingScreen } from './ceremony'; +import { ActorDetail, ActorsList, AuditFeed } from './dashboard'; +import { LogoPage } from './logos'; +import { MemoryPage } from './memory'; +import { PairingPage } from './pairing'; +import { EmptyState, Modal, WebAuthnModal } from './shared'; +import { useClient, useConnectionStatus } from '@/lib/ClientProvider'; +import { PREPARED_MEMORY } from '@/lib/preparedMemory'; +import type { MasterMemoryEntry } from '@/lib/client/types'; +import type { Actor, AuditEvent, Namespace, PairingRequest, PreservedMemory } from './types'; + +type Page = 'actors' | 'detail' | 'memory' | 'pairing' | 'audit' | 'chain' | 'logo'; + +type PendingAction = + | { kind: 'revoke-device'; actor: Actor; intent: Intent } + | { kind: 'pair-accept'; req: PairingRequest; intent: Intent }; +interface Intent { text: string; fields: [string, string][] } + +// MasterMemoryEntry (client wire) → PreservedMemory (UI). Daemon ns is a free +// string; clamp to a known namespace for display grouping. +const KNOWN_NS = new Set(NAMESPACES); +function toPreserved(e: MasterMemoryEntry): PreservedMemory { + const ns = (KNOWN_NS.has(e.ns) ? e.ns : 'personal') as Namespace; + return { ns, key: e.key, title: e.title, bytes: e.bytes, version: e.version, updated: e.updated, preview: e.preview, body: e.body }; +} + +export function App() { + const client = useClient(); + const status = useConnectionStatus(); + const [actors, setActors] = useState([]); + const [events, setEvents] = useState([]); + const [page, setPage] = useState('actors'); + const [actorId, setActorId] = useState(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); + + const [onboarded, setOnboarded] = useState(false); + const [memories, setMemories] = useState([]); + const [planting, setPlanting] = useState(false); + const [pairingRequests, setPairingRequests] = useState([]); + const [pairingCeremony, setPairingCeremony] = useState(null); + const [justPaired, setJustPaired] = useState(null); + const [memoryView, setMemoryView] = useState(null); + + useEffect(() => { + try { setOnboarded(localStorage.getItem('ak_onboarded') === '1'); } catch {} + }, []); + + // §2: list the master's real memory once onboarded. EmptyBackend returns + // disconnected → stays empty → the memory page renders its empty state. + useEffect(() => { + if (!onboarded) return; + let cancelled = false; + (async () => { + const r = await client.listMasterMemory(); + if (!cancelled && r.ok) setMemories(r.data.map(toPreserved)); + })(); + return () => { cancelled = true; }; + }, [onboarded, client]); + + // Actor tree + recent audit history from the client seam. Real daemon data; + // empty with EmptyBackend → the pages render their empty states. + useEffect(() => { + if (!onboarded) return; + let cancelled = false; + (async () => { + const [a, e] = await Promise.all([ + client.listActors(), + client.listRecentAuditEvents({ limit: 80 }), + ]); + if (cancelled) return; + if (a.ok) setActors(a.data); + if (e.ok) setEvents(e.data.map((x) => ({ ...x }))); + })(); + return () => { cancelled = true; }; + }, [onboarded, client]); + + // Live audit stream (tier-1 SSE) — real events only, no synthetic feed. + useEffect(() => { + if (!onboarded || paused) return; + const stop = client.streamAudit( + (e) => { + setEvents((prev) => [{ ...e, _isNew: true }, ...prev].slice(0, 90)); + setTimeout( + () => setEvents((prev) => prev.map((x) => (x.id === e.id ? { ...x, _isNew: false } : x))), + 1500, + ); + }, + () => {}, + ); + return stop; + }, [onboarded, paused, client]); + + const showToast = (msg: string) => { + setToast(msg); + setTimeout(() => setToast(null), 2600); + }; + + const go = (p: Page, id: string | null = null) => { + setPage(p); + setActorId(id); + setSideOpen(false); + if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'instant' }); + }; + + // Log out: clear the local session flag and reset all in-memory view state so + // the next login starts clean. Returns to the §9 onboarding (email) screen. + const logout = () => { + try { localStorage.removeItem('ak_onboarded'); } catch {} + setOnboarded(false); + setActors([]); + setEvents([]); + setMemories([]); + setPlanting(false); + setPairingRequests([]); + setPairingCeremony(null); + setJustPaired(null); + setMemoryView(null); + setPendingAction(null); + setEventDetail(null); + setActorId(null); + setPage('actors'); + setSideOpen(false); + }; + + const updateActor = (id: string, patch: Partial) => { + setActors((prev) => prev.map((a) => (a.id === id ? { ...a, ...patch } : a))); + showToast('scope updated · K11 assertion queued for next save'); + }; + + // §2 plant: import the PREPARED archive through the real client seam + // (daemon content-hash dedup). Gated in the UI to connected + empty. + const plantMemory = () => { + if (memories.length > 0) return; // dedup guard — already planted + setPlanting(true); + }; + const plantDone = async () => { + setPlanting(false); + const r = await client.plantMemory(PREPARED_MEMORY); + if (r.ok) { + const listed = await client.listMasterMemory(); + if (listed.ok) setMemories(listed.data.map(toPreserved)); + showToast(`Prepared memory planted · ${r.data.planted} new, ${r.data.skipped} deduped.`); + } else { + showToast('Connect a daemon to plant prepared memory.'); + } + }; + + // ─── Pairing: accept → K11 → ceremony → bind ─────────────────── + const acceptPairing = (req: PairingRequest) => { + setPendingAction({ + kind: 'pair-accept', + req, + intent: { + text: `Pair agent · ${req.agent}`, + fields: [ + ['new actor', `O_master${req.derivation}`], + ['device pubkey', req.dpub], + ['pair-code', req.pairCode], + ['grant', req.requested.map((p) => p.cap).join(' · ')], + ['mutation', 'SidecarRegistry.registerDevice + setScope'], + ], + }, + }); + }; + const declinePairing = (id: string) => { + setPairingRequests((prev) => prev.filter((r) => r.id !== id)); + showToast('Pairing request declined.'); + }; + const refreshPairing = () => { + showToast( + status.kind === 'connected' + ? 'Polled rendezvous · no pending pairing codes.' + : 'Connect a daemon to poll for agent pairing codes.', + ); + }; + + 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 confirmAction = () => { + const action = pendingAction; + setPendingAction(null); + if (!action) return; + if (action.kind === 'pair-accept') { + setPairingRequests((prev) => prev.filter((r) => r.id !== action.req.id)); + setPairingCeremony(action.req); + } + if (action.kind === 'revoke-device') { + const actor = action.actor; + void client.revokeDevice(actor.id, action.intent); + setActors((prev) => prev.map((a) => (a.id === actor.id ? { ...a, status: 'bad', lastActive: 'revoked', label: a.label + ' (revoked)' } : a))); + showToast(`${actor.label} revoked. SSE drop event broadcast.`); + go('audit'); + } + }; + + // Workflow 7-8: pairing ceremony completes → re-fetch the actor tree so a + // newly-bound agent (if the daemon bound one) appears. No fabricated actor. + const finishPairingCeremony = async () => { + const req = pairingCeremony; + setPairingCeremony(null); + if (!req) return; + setJustPaired(req.agent); + const a = await client.listActors(); + if (a.ok) setActors(a.data); + showToast(`${req.agent} paired · cap-tokens minted · session key handed off.`); + go('pairing'); + }; + + const currentActor = actorId ? actors.find((a) => a.id === actorId) : null; + const master = actors.find((a) => a.role === 'master'); + const sectionAttr = (['audit', 'memory', 'pairing', 'chain', 'logo'] as string[]).includes(page) ? page : undefined; + + // ─── Onboarding gate (workflow 1) ────────────────────────────── + if (!onboarded) { + return ( + { + try { localStorage.setItem('ak_onboarded', '1'); } catch {} + setOnboarded(true); + go('actors'); + }} + /> + ); + } + + return ( +
+
+
+ +
+ agentKeys + parent control · m1 +
+
+
+ {CHAIN_PROFILE.name} · {status.kind === 'connected' ? `daemon ${status.via}` : 'daemon offline'} + + {master ? `${master.label} · ${master.omni}` : 'O_master'} + +
+
+ + + +
+ {page === 'actors' && go('detail', id)} />} + {page === 'detail' && currentActor && ( + go('actors')} onUpdate={updateActor} onRevoke={handleRevokeDevice} recentEvents={events} /> + )} + {page === 'memory' && ( + + )} + {page === 'pairing' && ( + go('detail', id)} /> + )} + {page === 'audit' && setPaused((p) => !p)} />} + {page === 'chain' && } + {page === 'logo' && } +
+ + {pendingAction && ( + setPendingAction(null)} /> + )} + + {eventDetail && setEventDetail(null)} />} + + {memoryView && ( + setMemoryView(null)} + footer={} + > +
+
path
s3://…/bots/<omni>/{memoryView.ns}/{memoryView.key}.enc
+
envelope
{memoryView.version} · AES-256-GCM · k3 v1
+
bytes
{memoryView.bytes}
+
updated
{memoryView.updated}
+
+
decrypted plaintext
+
{memoryView.body}
+
+ )} + + {pairingCeremony && ( +
+
e.stopPropagation()}> +
pairing ceremony · {pairingCeremony.agent}
+
+
+ Binding O_master{pairingCeremony.derivation} under your master identity. Each on-chain step is a real Heima transaction. +
+ +
+
+
+ )} + + {toast && ( +
+ {toast} +
+ )} +
+ ); +} + +// ─── Step 9: decode the Heima transaction for an audit event ────── +function EventDecodeModal({ event, onClose }: { event: AuditEvent; onClose: () => void }) { + const dec = decodeCalldata(event); + const onchain = ONCHAIN_KINDS.has(event.kind); + const tx = txHash(event.id + event.kind); + const signer = event.actor === 'Sara (master)' ? 'D_pub_master_iphone' : 'D_pub_' + event.actor.toLowerCase().replace(/[^a-z]/g, ''); + const toContract = contractFor(event.kind); + return ( + + + view on heima ↗ + + } + > +
+
timestamp
{event.ts}
+
actor
{event.actor}
+
kind
{event.kind}
+
detail
{event.detail}
+
worker
{event.chip}-service
+
tier
{onchain ? 'tier-2 · committed on-chain' : 'tier-1 (sse) · folds into next 2-min anchor'}
+
K10 signer
{signer}…
+
+ +
{'─'.repeat(220)}
+ +
decoded heima transaction
+
+
tx_hash{tx}
+
status{onchain ? '✓ success · finalized' : 'tier-1 · not yet anchored'}
+
to{toContract} · {CHAIN_PROFILE.contracts[0].addr.slice(0, 14)}…
+
selector{dec.sel}
+
function{dec.fn}
+
gas{onchain ? '0.0009 HEI' : '— (off-chain)'}
+
+
+ calldata decoded against verified ABI · {CHAIN_PROFILE.display} · mock — real decode: GH #153 +
+
+ ); +} + +// ─── Lightweight chain page (deployed contracts + anchor countdown) ── +function ChainPage() { + const p = CHAIN_PROFILE; + const [picked, setPicked] = useState<(typeof p.contracts)[number] | null>(null); + return ( + <> +
+
+
chain · {p.name} · chain_id {p.chainId}
+

/ chain

+
Four stage-1 contracts deployed via Foundry. Tier-2 audit anchors a Merkle root here every 2 minutes.
+
+
+
+
{p.name}
AGENTKEYS_CHAIN
+
{p.chainId}
chain id
+
{p.block}
latest block
+
{p.contracts.length}
contracts deployed
+
+
+
── deployed contracts · stage-1
+
+ + + + {p.contracts.map((c) => ( + setPicked(c)}> + + + + + + ))} + +
contractaddressdeployed
{c.name}
{c.purpose}
{c.addr}{c.deployedAt}explorer ↗
+
+
+ {picked && ( + setPicked(null)} + footer={view on {p.name} explorer ↗} + > +
+
name
{picked.name}
+
address
{picked.addr}
+
deployed at
{picked.deployedAt}
+
purpose
{picked.purpose}
+
verify
cast code {picked.addr} --rpc-url {p.rpc}
+
+
+ )} + + ); +} diff --git a/apps/parent-control/app/_components/ceremony.tsx b/apps/parent-control/app/_components/ceremony.tsx new file mode 100644 index 0000000..07f3b14 --- /dev/null +++ b/apps/parent-control/app/_components/ceremony.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { txHash } from '@/lib/demoData'; +import { useClient } from '@/lib/ClientProvider'; +import type { AgentKeysClient } from '@/lib/client/types'; +import { credentialToFinishPayload, jsonToCreationOptions, webauthnAvailable } from '@/lib/webauthn'; +import type { CeremonyStep } from './types'; + +// Real K11 enroll via the daemon ui-bridge (PR-B) — used by onboarding when a +// daemon is configured. Returns 'real' on a completed browser ceremony, +// 'fallback' when no daemon / no authenticator / the user dismissed it (the +// onboarding then runs the narrated ceremony so the offline demo still flows). +async function tryRealEnroll(client: AgentKeysClient): Promise<'real' | 'fallback'> { + if (!webauthnAvailable()) return 'fallback'; + const begin = await client.enrollK11Begin({ userName: 'sara@local', userDisplayName: 'Sara (master)' }); + if (!begin.ok) return 'fallback'; // EmptyBackend → disconnected → narrated fallback + try { + const opts = jsonToCreationOptions({ + rp: { id: begin.data.rpId, name: begin.data.rpName }, + user: { id: begin.data.userId, name: begin.data.userName, displayName: begin.data.userDisplayName }, + challenge: begin.data.challenge, + pubKeyCredParams: begin.data.pubKeyCredParams, + timeout: begin.data.timeout, + authenticatorSelection: { userVerification: 'required', residentKey: 'preferred' }, + }); + const cred = (await navigator.credentials.create({ publicKey: opts })) as PublicKeyCredential | null; + if (!cred) return 'fallback'; + const payload = credentialToFinishPayload(cred); + const fin = await client.enrollK11Finish({ + credentialId: payload.credentialId, + attestationObject: payload.attestationObject, + clientDataJSON: payload.clientDataJSON, + bindingNonce: begin.data.userId, + }); + return fin.ok ? 'real' : 'fallback'; + } catch { + return 'fallback'; + } +} + +// Shared progress-bar ceremony with a live step log + per-step tx hashes. +export function CeremonyRunner({ + steps, + onDone, + accent = '#1a1815', + stepMs = 750, +}: { + steps: CeremonyStep[]; + onDone: () => void; + accent?: string; + stepMs?: number; +}) { + const [done, setDone] = useState(0); + const [txs, setTxs] = useState>({}); + + useEffect(() => { + if (done >= steps.length) { + const t = setTimeout(onDone, 700); + return () => clearTimeout(t); + } + let cancelled = false; + const t = setTimeout(async () => { + const step = steps[done]; + // Real async work for this step (e.g. the §9 Stage-2 WebAuthn Touch ID) + // runs WHILE the row shows "running"; the bar advances when it resolves. + if (step.action) { + try { await step.action(); } catch { /* fall through — narrated */ } + } + if (cancelled) return; + if (step.onchain) { + setTxs((prev) => ({ ...prev, [done]: txHash(step.label + done) })); + } + setDone((d) => d + 1); + }, stepMs); + return () => { cancelled = true; clearTimeout(t); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [done]); + + const pct = Math.round((done / steps.length) * 100); + + return ( +
+
+
+
+
+
+ {done >= steps.length ? 'complete' : 'working…'} + + {Math.min(done, steps.length)}/{steps.length} · {pct}% + +
+
+ +
+ {steps.map((s, i) => { + const status = i < done ? 'done' : i === done ? 'running' : 'pending'; + return ( +
+ {status === 'done' ? '✓' : status === 'running' ? '▸' : '·'} +
+
+ {s.label} + {s.onchain && on-chain} +
+
{s.sub}
+ {txs[i] &&
tx {txs[i].slice(0, 22)}… · heima · confirmed
} +
+
+ ); + })} +
+
+ ); +} + +// Full-screen WebAuthn login → onboarding ceremony (workflow 1). +export function OnboardingScreen({ onComplete }: { onComplete: () => void }) { + const client = useClient(); + const [phase, setPhase] = useState<'email' | 'ceremony'>('email'); + const [enrollMode, setEnrollMode] = useState<'real' | 'demo' | 'pending'>('pending'); + const [email, setEmail] = useState(''); + const emailValid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim()); + + // First-run is the arch.md §9 master-bootstrap ceremony. Identity (the real + // email) comes FIRST; the WebAuthn Touch ID is Stage 2 (master binding), + // fired automatically MID-ceremony. There is no separate "register" step — + // the passkey binding is one stage of the running ceremony. + const submitEmail = () => { + if (emailValid) setPhase('ceremony'); + }; + + // §9 Stages 0–4. The Stage-2 binding step carries the real WebAuthn action; + // the runner awaits it (real Touch ID via the daemon ui-bridge, narrated + // fallback offline). + const stages: CeremonyStep[] = [ + { label: 'Generate device key (K10)', sub: 'secp256k1 keypair · generated locally · no network · sealed in the OS keychain' }, + { label: 'Verify your email', sub: `magic link → ${email} · broker returns binding_nonce (single-use, TTL-bound)` }, + { + label: 'Bind passkey (K11) · Touch ID', + sub: 'WebAuthn create · challenge = SHA256(binding_nonce ‖ D_pub) · commits the device atomically', + action: async () => { + const outcome = await tryRealEnroll(client); + setEnrollMode(outcome === 'real' ? 'real' : 'demo'); + }, + }, + { label: 'Derive wallet + SIWE → session', sub: 'signer derives initial_master_wallet · SIWE round-trip → J1 · actor_omni freezes here' }, + { label: 'Register master device on chain', sub: 'SidecarRegistry.register_master_device · roles = CAP_MINT | RECOVERY | SCOPE_MGMT', onchain: true, fn: 'register_master_device(bytes32,bytes32,bytes32,bytes32,bytes,uint8,bytes)' }, + ]; + + return ( +
+
+
+
+ ◐ +
+
+
+ agentKeys +
+
+ sovereign keys · for agents +
+
+
+ +
{'─'.repeat(220)}
+ + {phase === 'email' && ( +
+

Set up your master identity.

+

+ Enter the email you'll use as your account. We send a one-time magic link there to verify it's + yours — your master identity is anchored to it. No password, no seed phrase. +

+ + setEmail(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitEmail(); }} + placeholder="you@example.com" + style={{ + width: '100%', padding: '11px 12px', fontFamily: 'inherit', fontSize: 14, + border: '1px solid var(--rule)', background: 'var(--bg)', color: 'var(--ink)', marginBottom: 14, + }} + /> + +
+ first login creates O_master · HDKD root at / +
+
+ )} + + {phase === 'ceremony' && ( +
+
+ Bringing up your trust core · {email} + {enrollMode === 'real' && K11 bound · real WebAuthn} + {enrollMode === 'demo' && demo · no daemon} +
+ +
+ )} +
+
+ ); +} diff --git a/apps/parent-control/app/_components/dashboard.tsx b/apps/parent-control/app/_components/dashboard.tsx new file mode 100644 index 0000000..b612cf4 --- /dev/null +++ b/apps/parent-control/app/_components/dashboard.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useState } from 'react'; +import { CHIP_STYLES, NAMESPACES } from '@/lib/constants'; +import type { ConnectionStatus } from '@/lib/client/types'; +import { PermissionList } from './permissions'; +import { ActorTree, Chip, Dot, EmptyState, PageHead, Panel } from './shared'; +import type { Actor, AuditEvent, ChipKind, Namespace, ScopeBits } from './types'; + +// ─── Actors list ───────────────────────────────────────────────── +export function ActorsList({ 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; + + if (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." + /> + + + ); + } + + 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." + /> +
+
{agents.length}
agents bound
+
{active}
active now
+
+ + +
+ {master && } +
+
+ + + + + + + + {master && ( + onPick(master.id)}> + + + + + + + + + )} + {agents.map((a) => ( + onPick(a.id)}> + + + + + + + + + ))} + +
actorderivationvendordevicelast active
+ {master.label} +
{master.omni} · {master.omniHex}
+
/ (root)self{master.device}nowmaster
+ {a.label} +
{a.omni}
+
{a.derivation}{a.vendor}{a.device}{a.lastActive}
+
+ + ); +} + +// ─── Actor detail — uses the mobile PermissionList (no tables) ──── +export function ActorDetail({ + actor, + onBack, + onUpdate, + onRevoke, + recentEvents, +}: { + actor: Actor; + onBack: () => void; + onUpdate: (id: string, patch: Partial) => void; + onRevoke: (a: Actor) => void; + recentEvents: AuditEvent[]; +}) { + const events = recentEvents.filter((e) => e.actorId === actor.id).slice(0, 6); + const isMaster = actor.role === 'master'; + + const setScope = (ns: Namespace | '__email', v: ScopeBits | boolean) => { + if (ns === '__email') { + const services = new Set(actor.services ?? []); + if (v) services.add('email'); else services.delete('email'); + onUpdate(actor.id, { services: [...services] }); + return; + } + onUpdate(actor.id, { scope: { ...(actor.scope as Record), [ns]: v as ScopeBits } }); + }; + + return ( + <> + actors / {actor.derivation}} + title={<>/ {actor.label}} + desc={`Bound at ${actor.omni}. All scope + payment-cap settings are master-mutations — each save triggers K11 + chain commit.`} + actions={ + <> + + {!isMaster && } + + } + /> + + +
+
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}
+
+
+ + {!isMaster && ( + +
+ Maps to ScopeContract[O_master][{actor.omni}]. Changes commit to chain via master K11. +
+ +
+ )} + + + {events.length === 0 ? ( +
no activity in this window.
+ ) : ( + events.map((e) => ( +
+ {e.ts} + {e.actor} + {e.kind} · {e.detail} + {e.chip} +
+ )) + )} +
+ + ); +} + +// ─── Audit feed — click any row → tx-decode modal (step 9) ──────── +export function AuditFeed({ + 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', 'broker']; + + if (events.length === 0) { + return ( + <> + / audit feed} + desc="Real-time stream from the audit-service worker. Tier-1 is off-chain SSE; tier-2 anchors a Merkle root on chain every 2 min. Click any row to decode its Heima transaction." + /> + + + ); + } + + return ( + <> + / audit feed} + desc="Real-time stream from the audit-service worker. Tier-1 is off-chain SSE; tier-2 anchors a Merkle root on chain every 2 min. Click any row to decode its Heima transaction." + actions={} + /> + + {filters.map((f) => ( + + ))} +
+ } + flush + > +
+ {filtered.map((e) => ( +
onPick(e)}> + {e.ts} + {e.actor} + {e.kind} · {e.detail} + {e.chip} +
+ ))} + {filtered.length === 0 &&
no events match this filter.
} +
+ + + ); +} 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/memory.tsx b/apps/parent-control/app/_components/memory.tsx new file mode 100644 index 0000000..8d84408 --- /dev/null +++ b/apps/parent-control/app/_components/memory.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { NAMESPACES } from '@/lib/constants'; +import type { ConnectionStatus } from '@/lib/client/types'; +import { PREPARED_MEMORY } from '@/lib/preparedMemory'; +import { CeremonyRunner } from './ceremony'; +import { EmptyState, PageHead, Panel } from './shared'; +import type { CeremonyStep, PreservedMemory } from './types'; + +const PLANT_STEPS: CeremonyStep[] = [ + { label: 'Read prepared archive', sub: `${PREPARED_MEMORY.length} entries · travel / personal / family`, onchain: false }, + { label: 'Dedupe against existing', sub: 'content-hash compare · server-side (re-plant is a no-op)', onchain: false }, + { label: 'Encrypt envelopes', sub: 'AES-256-GCM under K3 epoch v1 KEK · per (actor, key)', onchain: false }, + { label: 'Write to memory store', sub: 'POST /v1/master/memory/plant → master memory store', onchain: false }, + { label: 'Index + audit', sub: 'CredentialAudit.append(op=memory.plant) · tier-1 + anchor', onchain: true, fn: 'append(bytes32,bytes32,bytes32)' }, +]; + +// Workflow 2: see the master's real memory. Entries come from the client seam +// (`listMasterMemory`). When connected + empty, the operator can plant the +// PREPARED archive (real data, through `plantMemory` → daemon, content-hash +// dedup). Disconnected → empty state (no daemon to plant into). +export function MemoryPage({ + memories, + status, + planting, + onPlant, + onPlantDone, + onView, +}: { + memories: PreservedMemory[]; + status: ConnectionStatus; + planting: boolean; + onPlant: () => void; + onPlantDone: () => void; + onView: (m: PreservedMemory) => void; +}) { + const hasMemory = memories.length > 0; + const connected = status.kind === 'connected'; + const byNs = NAMESPACES.map((ns) => ({ ns, items: memories.filter((m) => m.ns === ns) })).filter((g) => g.items.length > 0); + const totalBytes = memories.reduce((a, m) => a + m.bytes, 0); + + return ( + <> + / memory} + desc="Your portable memory namespace — the spine agents read from and write to. It follows you across every vendor device. Stored encrypted; agents see only what their scope grants." + /> + + {!hasMemory && !planting && ( + connected ? ( +
+
+

No memory planted yet.

+

+ Plant your prepared memory archive to give every paired agent the same context — your trip, your + profile, your routines. This is a one-time import through the real memory store; duplicates are detected + by content-hash and skipped automatically. +

+ +
+ prepared archive · {PREPARED_MEMORY.length} entries · idempotent (content-hash dedup) +
+
+ ) : ( + + ) + )} + + {planting && ( + + + + )} + + {hasMemory && ( + <> +
+
{memories.length}
memory entries
+
{byNs.length}
namespaces
+
{(totalBytes / 1024).toFixed(1)}KB
total size
+
k3 v1
epoch (kek)
+
+ +
+ ✓ planted + + Prepared memory is live. The plant action is hidden — re-planting is a server-side no-op + (content-hash match). Agents read this per their granted scope. + +
+ + {byNs.map((g) => ( + + + + + + + + + + + + + {g.items.map((m) => ( + onView(m)}> + + + + + + + ))} + +
entrypreviewbytesupdated
+ {m.title} +
{m.ns}/{m.key}
+
{m.preview}{m.bytes}{m.updated}
+
+ ))} + + )} + + ); +} diff --git a/apps/parent-control/app/_components/pairing.tsx b/apps/parent-control/app/_components/pairing.tsx new file mode 100644 index 0000000..b3eea5a --- /dev/null +++ b/apps/parent-control/app/_components/pairing.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState } from 'react'; +import { Dot, PageHead } from './shared'; +import { PermissionView } from './permissions'; +import type { Actor, PairingRequest } from './types'; + +// Workflows 3–8: incoming pairing requests + device view + permission view. +export function PairingPage({ + requests, + actors, + onAccept, + onDecline, + onRefresh, + justPaired, + onManage, +}: { + requests: PairingRequest[]; + actors: Actor[]; + onAccept: (req: PairingRequest) => void; + onDecline: (id: string) => void; + onRefresh: () => void; + justPaired: string | null; + onManage?: (id: string) => void; +}) { + const [view, setView] = useState<'devices' | 'permissions'>('devices'); + const pairedAgents = actors.filter((a) => a.role === 'agent'); + + return ( + <> + / pairing} + desc="An agent on another machine shows a one-time pairing code; you claim it here (J1_master-gated), review the device + requested scope, then approve with one Touch ID — which submits registerAgentDevice + the scope grant. Granted scope becomes on-chain cap-tokens." + actions={} + /> + + {requests.length > 0 ? ( + requests.map((req) => ( +
+
+
+ +
+
+ Pairing request · {req.agent} +
+
{req.vendor} · {req.requestedAt}
+
+
+ action required +
+ +
+
+
device
+
{req.device}
+
machine
+
{req.machine}
+
runtime
+
{req.runtime}
+
+
+
pair-code
+
{req.pairCode}
+
derivation
+
O_master{req.derivation}
+
D_pub
+
{req.dpub}
+
+
+ +
+
requested permissions
+ {req.requested.map((p) => ( +
+ {p.cap} + {p.ns.join(', ')} + {p.reason} +
+ ))} +
+ +
+
{req.attestation}
+
+ + +
+
+
+ )) + ) : ( +
+ idle + + No pending pairing codes.{' '} + {justPaired ? <>{justPaired} was just paired and now appears below. : 'When an agent shows a pairing code, claim it here — hit "check for codes" to poll.'} + +
+ )} + +
+ + +
+ + {view === 'devices' && ( +
+ {pairedAgents.map((a) => ( +
+
+ + {a.label.replace(' (revoked)', '')} + {a.justPaired && new} +
+
+
actor
{a.omni}
+
vendor
{a.vendor}
+
device
{a.device}
+
scope
+
+ {Object.entries(a.scope ?? {}) + .filter(([, v]) => v.read || v.write) + .map(([ns, v]) => `${ns}:${v.write ? 'rw' : 'r'}`) + .join(' · ') || 'none'} +
+
active
{a.lastActive}
+
+
+ ))} +
+ )} + + {view === 'permissions' && } + + ); +} diff --git a/apps/parent-control/app/_components/permissions.tsx b/apps/parent-control/app/_components/permissions.tsx new file mode 100644 index 0000000..55cc2ef --- /dev/null +++ b/apps/parent-control/app/_components/permissions.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; +import { NAMESPACES } from '@/lib/constants'; +import { Dot } from './shared'; +import type { Actor, Namespace, ScopeBits, VaultItem } from './types'; + +// Segmented control: deny | read | read+write +export function PermSeg({ + value, + onChange, + disabled, +}: { + value: ScopeBits; + onChange: (v: ScopeBits) => void; + disabled?: boolean; +}) { + const state = value.write ? 'rw' : value.read ? 'r' : 'off'; + const set = (s: 'off' | 'r' | 'rw') => { + if (disabled) return; + 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 PermSwitch({ on, onToggle, locked }: { on: boolean; onToggle?: (v: boolean) => void; locked?: boolean }) { + return ( + + ))} + +
+
{actor.omni} · granted scope as on-chain cap-tokens
+ {onManage && } +
+ + + ); +} diff --git a/apps/parent-control/app/_components/shared.tsx b/apps/parent-control/app/_components/shared.tsx new file mode 100644 index 0000000..193fbd3 --- /dev/null +++ b/apps/parent-control/app/_components/shared.tsx @@ -0,0 +1,291 @@ +'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, 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 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 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..5b36747 --- /dev/null +++ b/apps/parent-control/app/_components/types.ts @@ -0,0 +1,160 @@ +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[]; + justPaired?: boolean; +} + +export type ChipKind = + | 'default' + | 'ok' + | 'warn' + | 'bad' + | 'memory' + | 'creds' + | 'audit' + | 'broker' + | 'chain' + | 'payment' + | 'revoke' + | 'scope' + | 'device' + | 'k11'; + +// ─── 9-step flow types ─────────────────────────────────────────── +export interface CeremonyStep { + label: string; + sub: string; + onchain?: boolean; + fn?: string; + /** Optional real async work the runner awaits while this step is "running" + * (e.g. the WebAuthn Touch ID at the §9 Stage-2 binding step). */ + action?: () => Promise; +} + +export interface PreservedMemory { + ns: Namespace; + key: string; + title: string; + bytes: number; + version: string; + updated: string; + preview: string; + body: string; +} + +// A vaulted credential envelope for an actor (Class-B bearer token). Populated +// from the client seam (real daemon) — no seed fixture; defaults to empty. +export interface VaultItem { + service: string; + actor: string; + version: string; + bytes: number; + readCount: number; + status: 'ok' | 'stale'; +} + +export interface RequestedPerm { + cap: string; + ns: string[]; + reason: string; +} + +export interface PairingRequest { + id: string; + agent: string; + vendor: string; + device: string; + machine: string; + runtime: string; + dpub: string; + dpubFull: string; + pairCode: string; + derivation: string; + requested: RequestedPerm[]; + requestedAt: string; + attestation: string; +} + +export interface ContractInfo { + name: string; + addr: string; + deployedAt: string; + purpose: string; +} + +export interface ChainProfile { + name: string; + display: string; + chainId: number; + kind: string; + rpc: string; + wss: string; + substrateWss: string; + explorer: string; + tokenSymbol: string; + tokenDecimals: number; + finality: string; + block: string; + contracts: ContractInfo[]; +} + +export interface AuditEvent { + id: string; + ts: string; + actorId: string; + actor: string; + kind: string; + detail: string; + chip: ChipKind; + sev: StatusKind; + _isNew?: boolean; +} + +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][] }; + }; diff --git a/apps/parent-control/app/globals.css b/apps/parent-control/app/globals.css new file mode 100644 index 0000000..241f14b --- /dev/null +++ b/apps/parent-control/app/globals.css @@ -0,0 +1,839 @@ +/* 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; } +} + +/* ─── 9-step flow: ceremony / onboarding / memory / pairing / permissions / tx-decode ─── */ + +/* Ceremony (onboarding / pairing / plant) */ +.ceremony-bar-wrap { margin-bottom: 18px; } +.ceremony-bar-track { + height: 6px; + background: var(--rule-hair); + border: 1px solid var(--rule-soft); + overflow: hidden; +} +.ceremony-bar-fill { height: 100%; transition: width 0.5s cubic-bezier(.4,.8,.3,1); } +.ceremony-bar-meta { + display: flex; justify-content: space-between; + font-size: 11px; color: var(--ink-dim); margin-top: 6px; +} +.clog-row { + display: grid; grid-template-columns: 20px 1fr; gap: 10px; + padding: 8px 0; border-bottom: 1px dashed var(--rule-hair); align-items: flex-start; +} +.clog-row:last-child { border-bottom: 0; } +.clog-row.pending { opacity: 0.4; } +.clog-mark { font-family: inherit; font-size: 13px; width: 20px; text-align: center; color: var(--ink-faint); } +.clog-row.done .clog-mark { color: var(--ok); } +.clog-row.running .clog-mark { color: var(--accent); animation: pulse 1s ease-in-out infinite; } +.clog-label { font-size: 12.5px; font-weight: 500; display: flex; align-items: center; gap: 8px; } +.clog-chain { + font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--info); border: 1px solid var(--info); padding: 0 5px; +} +.clog-sub { font-size: 11px; color: var(--ink-dim); margin-top: 1px; } +.clog-tx { font-size: 10.5px; color: var(--ok); margin-top: 3px; } + +/* Onboarding screen */ +.onboard { + position: fixed; inset: 0; background: var(--bg); z-index: 300; + display: flex; align-items: center; justify-content: center; + padding: 24px; overflow-y: auto; +} +.onboard-card { width: 100%; max-width: 480px; border: 1px solid var(--rule); background: var(--bg); padding: 32px; } +.onboard-brand { display: flex; align-items: center; gap: 18px; } +@media (max-width: 540px) { + .onboard-card { padding: 22px; } + .onboard-brand { gap: 12px; } +} + +/* Memory */ +.empty-memory { + border: 1px dashed var(--rule-soft); padding: 48px 24px; text-align: center; + background: var(--bg-elev); margin-bottom: 22px; +} +.mem-body { + font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 11.5px; line-height: 1.65; + background: var(--ink); color: var(--bg); padding: 16px; margin: 0; + white-space: pre-wrap; word-break: break-word; border: 1px solid var(--rule); +} + +/* Pairing request */ +.pair-req { border: 1px solid var(--accent); background: var(--accent-soft); margin-bottom: 22px; } +.pair-req-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; border-bottom: 1px solid var(--rule-soft); gap: 12px; +} +.pair-req-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 24px; + padding: 16px 18px; border-bottom: 1px dashed var(--rule-soft); +} +.pair-k { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); margin-top: 10px; } +.pair-k:first-child { margin-top: 0; } +.pair-v { font-size: 12.5px; margin-top: 2px; } +.pair-perms { padding: 16px 18px; border-bottom: 1px dashed var(--rule-soft); } +.pair-perm-row { display: grid; grid-template-columns: 130px 120px 1fr; gap: 12px; align-items: center; padding: 5px 0; } +.pair-req-foot { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; gap: 12px; background: var(--bg); +} +@media (max-width: 640px) { + .pair-req-grid { grid-template-columns: 1fr; gap: 4px; } + .pair-perm-row { grid-template-columns: 1fr; gap: 2px; } + .pair-req-foot { flex-direction: column; align-items: stretch; } + .pair-req-foot > div:last-child { display: grid; grid-template-columns: 1fr 1fr; } +} + +/* Device / permission views */ +.view-toggle { display: inline-flex; border: 1px solid var(--rule); margin-bottom: 18px; } +.view-toggle button { + border: 0; background: none; padding: 8px 16px; font-family: inherit; + font-size: 12px; cursor: pointer; color: var(--ink-dim); +} +.view-toggle button.on { background: var(--ink); color: var(--bg); } +.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } +.device-card { border: 1px solid var(--rule); background: var(--bg); } +.device-card.revoked { opacity: 0.55; } +.device-card-head { + display: flex; align-items: center; gap: 8px; padding: 12px 14px; + border-bottom: 1px solid var(--rule-soft); background: var(--bg-elev); +} +.device-kvs { display: grid; grid-template-columns: 64px 1fr; gap: 6px 12px; padding: 14px; margin: 0; font-size: 12px; } +.device-kvs dt { color: var(--ink-faint); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; } +.device-kvs dd { margin: 0; word-break: break-all; } + +/* Notification bell */ +.bell { + position: relative; background: none; border: 1px solid var(--rule); + padding: 6px 10px; cursor: pointer; font-family: inherit; font-size: 13px; color: var(--ink); +} +.bell:hover { background: var(--bg-elev); } +.bell .badge { + position: absolute; top: -7px; right: -7px; background: var(--accent); color: var(--bg); + font-size: 9px; min-width: 15px; height: 15px; display: grid; place-items: center; + border-radius: 8px; padding: 0 3px; +} +.bell.has-req { animation: pulse 1.4s ease-in-out infinite; } + +/* TX decode */ +.tx-decode { border: 1px solid var(--rule-soft); background: var(--bg-elev); } +.tx-row { + display: grid; grid-template-columns: 90px 1fr; gap: 12px; padding: 7px 12px; + border-bottom: 1px dashed var(--rule-hair); font-size: 11.5px; +} +.tx-row:last-child { border-bottom: 0; } +.tx-k { color: var(--ink-faint); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; } +.tx-v { word-break: break-all; } + +/* Mobile-style permission list */ +.perm-list { display: flex; flex-direction: column; gap: 22px; } +.perm-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 0 2px 8px; } +.perm-section-head .ttl { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-faint); } +.perm-section-head .summary { font-size: 11px; color: var(--ink-dim); font-variant-numeric: tabular-nums; } +.perm-rows { border: 1px solid var(--rule); background: var(--bg); } +.perm-row { + display: grid; grid-template-columns: 34px 1fr auto; gap: 14px; align-items: center; + padding: 13px 16px; border-bottom: 1px solid var(--rule-hair); +} +.perm-rows .perm-row:last-child { border-bottom: 0; } +.perm-row.tappable { cursor: pointer; } +.perm-row.tappable:hover { background: var(--bg-elev); } +.perm-row.denied { opacity: 0.62; } +.perm-icon { + width: 34px; height: 34px; border: 1px solid var(--rule-soft); display: grid; place-items: center; + font-family: 'IBM Plex Serif', serif; font-style: italic; font-size: 16px; color: var(--ink); background: var(--bg-elev); +} +.perm-row.granted .perm-icon { background: var(--ink); color: var(--bg); border-color: var(--ink); } +.perm-row.denied .perm-icon { color: var(--ink-faint); } +.perm-body { min-width: 0; } +.perm-title { font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 8px; } +.perm-why { font-size: 11px; color: var(--ink-dim); margin-top: 2px; line-height: 1.45; } +.perm-state { font-size: 10.5px; color: var(--ink-faint); margin-top: 3px; font-variant-numeric: tabular-nums; } +.perm-risk { font-size: 8.5px; letter-spacing: 0.08em; text-transform: uppercase; padding: 1px 5px; border: 1px solid var(--rule-soft); color: var(--ink-faint); } +.perm-risk.high { color: var(--danger); border-color: var(--danger); } +.perm-risk.medium { color: var(--accent); border-color: var(--accent); } +.perm-seg { display: inline-flex; border: 1px solid var(--rule); background: var(--bg); white-space: nowrap; } +.perm-seg button { + border: 0; background: none; padding: 5px 11px; font-family: inherit; font-size: 10.5px; + letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer; color: var(--ink-dim); border-left: 1px solid var(--rule-hair); +} +.perm-seg button:first-child { border-left: 0; } +.perm-seg button.on { background: var(--ink); color: var(--bg); } +.perm-seg button.deny.on { background: var(--danger); } +.perm-seg button:disabled { cursor: default; } +.perm-switch { + width: 42px; height: 24px; border: 1px solid var(--rule); background: var(--bg-elev); + position: relative; cursor: pointer; padding: 0; transition: background 0.15s ease; flex-shrink: 0; +} +.perm-switch::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; + background: var(--ink-faint); transition: transform 0.15s ease, background 0.15s ease; +} +.perm-switch.on { background: var(--ink); border-color: var(--ink); } +.perm-switch.on::after { transform: translateX(18px); background: var(--bg); } +.perm-switch.locked { opacity: 0.55; cursor: not-allowed; } +.perm-readonly { + font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-dim); + border: 1px solid var(--rule-soft); padding: 4px 9px; white-space: nowrap; +} +.perm-readonly.on { background: var(--ink); color: var(--bg); border-color: var(--ink); } +.perm-readonly.off { color: var(--ink-faint); } +.perm-agent-pick { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; } +.perm-agent-pick button { + border: 1px solid var(--rule-soft); background: var(--bg); padding: 8px 14px; font-family: inherit; + font-size: 12px; cursor: pointer; color: var(--ink); display: flex; align-items: center; gap: 8px; +} +.perm-agent-pick button.on { background: var(--ink); color: var(--bg); border-color: var(--ink); } +@media (max-width: 640px) { + .perm-row { grid-template-columns: 30px 1fr; gap: 12px; } + .perm-row > .perm-seg, .perm-row > .perm-switch, .perm-row > .perm-readonly { grid-column: 2; justify-self: start; margin-top: 6px; } + .perm-icon { width: 30px; height: 30px; font-size: 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..19ab9c5 --- /dev/null +++ b/apps/parent-control/lib/client/daemon.ts @@ -0,0 +1,453 @@ +import type { + AgentKeysClient, + AnchorStatus, + CapToken, + ConnectionStatus, + DisconnectedStatus, + K11EnrollBegin, + K11EnrollFinishInput, + K11EnrollResult, + MasterMemoryEntry, + PlantResult, + 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}`) }; + } + } + + async listMasterMemory(): Promise> { + const r = await this.getJson<{ entries: ApiMemoryEntry[] }>('/v1/master/memory'); + if (!r.ok) return r; + return { ok: true, data: r.data.entries.map(apiToMemoryEntry) }; + } + + async plantMemory(entries: MasterMemoryEntry[]): Promise> { + const r = await this.postJson<{ planted: number; skipped: number; total: number }>( + '/v1/master/memory/plant', + { + entries: entries.map((m) => ({ + ns: m.ns, key: m.key, title: m.title, bytes: m.bytes, + version: m.version, updated: m.updated, preview: m.preview, body: m.body, + content_hash: m.contentHash ?? '', + })), + }, + ); + if (!r.ok) return r; + return { ok: true, data: { planted: r.data.planted, skipped: r.data.skipped, total: r.data.total } }; + } +} + +// ─── 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'; +} + +interface ApiMemoryEntry { + ns: string; + key: string; + title: string; + bytes: number; + version: string; + updated: string; + preview: string; + body: string; + content_hash?: string; +} + +function apiToMemoryEntry(m: ApiMemoryEntry): MasterMemoryEntry { + return { + ns: m.ns, key: m.key, title: m.title, bytes: m.bytes, + version: m.version, updated: m.updated, preview: m.preview, body: m.body, + contentHash: m.content_hash, + }; +} diff --git a/apps/parent-control/lib/client/empty.ts b/apps/parent-control/lib/client/empty.ts new file mode 100644 index 0000000..6d1e212 --- /dev/null +++ b/apps/parent-control/lib/client/empty.ts @@ -0,0 +1,100 @@ +import type { + AgentKeysClient, + AnchorStatus, + CapToken, + ConnectionStatus, + DisconnectedStatus, + K11EnrollBegin, + K11EnrollFinishInput, + K11EnrollResult, + MasterMemoryEntry, + PlantResult, + 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(); + } + + async listMasterMemory(): Promise> { + return disconnected(); + } + + async plantMemory(_entries: MasterMemoryEntry[]): 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..7fbdde3 --- /dev/null +++ b/apps/parent-control/lib/client/types.ts @@ -0,0 +1,108 @@ +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 MasterMemoryEntry { + ns: string; + key: string; + title: string; + bytes: number; + version: string; + updated: string; + preview: string; + body: string; + contentHash?: string; +} + +export interface PlantResult { + planted: number; + skipped: number; + total: number; +} + +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>; + + // §2 — master memory (real list + idempotent plant; server dedups by content-hash) + listMasterMemory(): Promise>; + plantMemory(entries: MasterMemoryEntry[]): Promise>; +} diff --git a/apps/parent-control/lib/constants.ts b/apps/parent-control/lib/constants.ts new file mode 100644 index 0000000..4c16ab9 --- /dev/null +++ b/apps/parent-control/lib/constants.ts @@ -0,0 +1,20 @@ +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', + scope: 'chip', + device: 'chip', + k11: 'chip', +}; diff --git a/apps/parent-control/lib/demoData.ts b/apps/parent-control/lib/demoData.ts new file mode 100644 index 0000000..4b8f253 --- /dev/null +++ b/apps/parent-control/lib/demoData.ts @@ -0,0 +1,84 @@ +// Config + the one allowed mock (audit tx-decode, tracked in GH #153). +// ALL fabricated user data (actors, audit events, memory, pairing requests, +// vault) was removed — the app is now driven entirely by the lib/client seam +// (real daemon data, empty states otherwise). See docs/plan/web-flow/issue-9step-flow.md. + +import type { CeremonyStep, ChainProfile } from '@/app/_components/types'; + +// Pairing ceremony narration (the §10.2 master-claim → bind → grant steps). +// Process text only — shown while the real on-chain bind/grant runs. +export const PAIRING_STEPS: CeremonyStep[] = [ + { label: 'Verify pairing code', sub: 'broker matches the agent-shown code → unbound request (method A)', onchain: false }, + { label: 'Attest agent device', sub: 'fetch D_pub_agent · verify pop_sig (proof-of-possession)', onchain: false }, + { label: 'Derive child actor', sub: 'HDKD //label → O_master//label · public + recomputable', onchain: false }, + { label: 'Register agent device', sub: 'SidecarRegistry.registerAgentDevice(tier=AGENT, roles=CAP_MINT)', onchain: true, fn: 'registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)' }, + { label: 'Grant scope (Touch ID)', sub: 'AgentKeysScope.setScopeWithWebauthn(... requested scope ...)', onchain: true, fn: 'setScopeWithWebauthn(bytes32,bytes32,bytes,bytes)' }, + { label: 'Mint initial cap-tokens', sub: 'scoped cap-token · ttl 900s', onchain: false }, + { label: 'Ack binding', sub: 'POST /v1/agent/pending-bindings/ack → clears the rendezvous', onchain: false }, +]; + +// Chain deployment config (real Heima params; contract addresses are filled by +// the operator's deployment — informational, used for explorer links in the +// audit tx-decode modal below). +export const CHAIN_PROFILE: ChainProfile = { + name: 'heima', + display: 'Heima Network · Litentry parachain mainnet', + chainId: 212013, + kind: 'substrate-frontier', + rpc: 'https://rpc.heima.network', + wss: 'wss://rpc.heima.network', + substrateWss: 'wss://rpc.heima.network', + explorer: 'https://heima.statescan.io', + tokenSymbol: 'HEI', + tokenDecimals: 18, + finality: 'latest (instant)', + block: '—', + contracts: [ + { name: 'AgentKeysScope', addr: '—', deployedAt: '—', purpose: 'per-actor scope grants — services, namespaces, time-windows' }, + { name: 'SidecarRegistry', addr: '—', deployedAt: '—', purpose: 'D_pub ↔ (operator_omni, actor_omni, roles) bindings + K11 cred storage' }, + { name: 'K3EpochCounter', addr: '—', deployedAt: '—', purpose: 'current K3 epoch; bumps trigger KEK derivation rotation' }, + { name: 'CredentialAudit', addr: '—', deployedAt: '—', purpose: 'per-actor audit log + tier-2 Merkle root anchor every 2 min' }, + ], +}; + +// ─── Audit tx-decode — the ONE retained mock (real decode = GH #153) ── +// Deterministic placeholder tx hash + a kind→selector/signature map, used by +// the audit page's decode modal until the real CBOR + ABI decoder lands (#153). +export function txHash(seed: string): string { + let h = 0; + const s = String(seed); + for (let i = 0; i < s.length; i++) h = (((h << 5) - h + s.charCodeAt(i)) | 0); + const hex = (n: number) => Math.abs(n).toString(16).padStart(8, '0'); + return '0x' + hex(h) + hex(h * 7 + 13) + hex(h * 31 + 5) + hex(h * 131 + 9); +} + +const CALLDATA_MAP: Record = { + 'memory.read': { sel: '0x6c1a9f33', fn: 'memoryRead(bytes32,bytes32)' }, + 'memory.write': { sel: '0x9d2bce10', fn: 'memoryWrite(bytes32,bytes32,bytes32)' }, + 'cred.fetch': { sel: '0x3a7f10cd', fn: 'credentialFetch(bytes32,bytes32)' }, + 'cap.mint': { sel: '0x1f4c0a92', fn: 'capMint(bytes32,uint8,uint64)' }, + 'cap.revoked': { sel: '0xa98bbce0', fn: 'capRevoke(bytes32,bytes32)' }, + 'device.revoked': { sel: '0xd34c7e11', fn: 'revokeDevice(bytes32,bytes[])' }, + 'audit.append': { sel: '0x0c44b209', fn: 'append(bytes32,bytes32,bytes32)' }, + 'anchor.batch': { sel: '0x77ae5d8c', fn: 'appendRoot(bytes32,uint32)' }, + 'cap.pair': { sel: '0x2bd1f409', fn: 'registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)' }, + 'device.paired': { sel: '0x2bd1f409', fn: 'registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)' }, + 'scope.grant': { sel: '0x8e21c4aa', fn: 'setScopeWithWebauthn(bytes32,bytes32,bytes,bytes)' }, + 'payment.attempt': { sel: '0x4f0ab219', fn: 'paymentExecute(bytes32,uint256,bytes32)' }, +}; + +// MOCK calldata decode (event-kind → selector + signature). Real decode = GH #153. +export function decodeCalldata(ev: { kind: string }): { sel: string; fn: string } { + return CALLDATA_MAP[ev.kind] || { sel: '0x00000000', fn: (ev.kind || 'event') + '(bytes)' }; +} + +export const ONCHAIN_KINDS = new Set([ + 'anchor.batch', 'cap.mint', 'cap.revoked', 'device.revoked', 'cap.pair', 'scope.grant', 'audit.append', +]); + +export function contractFor(kind: string): string { + if (kind === 'anchor.batch' || kind === 'audit.append') return 'CredentialAudit'; + if (kind === 'scope.grant') return 'AgentKeysScope'; + if (kind === 'cap.pair' || kind === 'device.revoked') return 'SidecarRegistry'; + return 'Broker'; +} diff --git a/apps/parent-control/lib/preparedMemory.ts b/apps/parent-control/lib/preparedMemory.ts new file mode 100644 index 0000000..6e0fe13 --- /dev/null +++ b/apps/parent-control/lib/preparedMemory.ts @@ -0,0 +1,31 @@ +import type { MasterMemoryEntry } from '@/lib/client/types'; + +// The canonical PREPARED memory archive the operator imports on first run. +// +// This is NOT display mock data (the kind stripped from demoData.ts). It is the +// real, documented demo dataset that the rest of the system already uses — the +// "Chengdu trip" the agent-side wire demo seeds (harness/phase1-wire-demo.sh +// SEED_MEMORY_CONTENT) plus the per-namespace composition from the IAM strategy +// (docs/agent-iam-strategy.md §3.5). The plant button POSTs these entries through +// the real client seam → daemon `POST /v1/master/memory/plant` (content-hash +// dedup, idempotent) → master memory store. Re-planting is a server-side no-op. +const RAW: { ns: MasterMemoryEntry['ns']; key: string; title: string; body: string; updated: string }[] = [ + { ns: 'travel', key: 'chengdu-trip', title: 'Chengdu trip', body: 'Chengdu trip — Apr 12 to 16, hotpot at Yulin.', updated: '2026-04-02' }, + { ns: 'travel', key: 'chengdu-customs', title: 'Chengdu customs clearance', body: 'Asked about Chengdu customs clearance for the trip.', updated: '2026-04-02' }, + { ns: 'personal', key: 'profile', title: 'Profile', body: 'Lives in Shanghai, allergic to peanuts.', updated: '2026-04-01' }, + { ns: 'family', key: 'anniversary', title: 'Anniversary dinner', body: 'Anniversary dinner reservation 2026-06-15.', updated: '2026-04-01' }, +]; + +const byteLen = (s: string): number => + typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(s).length : s.length; + +export const PREPARED_MEMORY: MasterMemoryEntry[] = RAW.map((r) => ({ + ns: r.ns, + key: r.key, + title: r.title, + body: r.body, + preview: r.body, + version: 'v1', + updated: r.updated, + bytes: byteLen(r.body), +})); 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..554322a 100644 --- a/crates/agentkeys-daemon/Cargo.toml +++ b/crates/agentkeys-daemon/Cargo.toml @@ -13,6 +13,7 @@ agentkeys-core = { workspace = true } agentkeys-cli = { path = "../agentkeys-cli" } # K11 webauthn helpers (companion mode) agentkeys-mcp = { path = "../agentkeys-mcp" } hex = "0.4" +sha2 = "0.10" # ui-bridge master-memory content-hash dedup (§2 plant) tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -30,9 +31,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 9ab22f7..3000001 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,42 @@ 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 @@ -224,6 +261,10 @@ async fn main() -> anyhow::Result<()> { return run_proxy_mode(args).await; } + if args.ui_bridge { + return run_ui_bridge_mode(args).await; + } + // Issue #144 §10.2 (method A) one-shot pairing. Two synchronous steps mirror // the two broker endpoints: --request-pairing opens the request + prints the // code; --retrieve-pairing polls until the master claims, then persists @@ -852,6 +893,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..eaaed8a --- /dev/null +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -0,0 +1,1562 @@ +//! 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, + /// Master-actor memory entries, keyed by content_hash for idempotent + /// plant (re-planting the same entry is a no-op). Maps the §2 "plant + /// preserved memory" flow + GH plan issue-9step-flow.md. + pub master_memory: RwLock>, +} + +/// A master-actor memory entry. `content_hash` is the dedup key — +/// keccak-free sha256 over (ns || key || body) so a re-plant of the same +/// content is detected and skipped (the "prevent duplicate plant" gate). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiMemoryEntry { + pub ns: String, + pub key: String, + pub title: String, + pub bytes: u64, + pub version: String, + pub updated: String, + pub preview: String, + pub body: String, + #[serde(default)] + pub content_hash: String, +} + +impl ApiMemoryEntry { + fn compute_hash(&self) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(self.ns.as_bytes()); + h.update(b"\x1f"); + h.update(self.key.as_bytes()); + h.update(b"\x1f"); + h.update(self.body.as_bytes()); + hex::encode(h.finalize()) + } +} + +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/master/memory", get(list_master_memory)) + .route("/v1/master/memory/plant", post(plant_master_memory)) + .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()), + master_memory: RwLock::new(HashMap::new()), + })) +} + +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, + #[serde(default)] + pub master_memory: 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; + } + if !req.master_memory.is_empty() { + let mut mem = state.master_memory.write().await; + for mut e in req.master_memory { + let hash = if e.content_hash.is_empty() { + e.compute_hash() + } else { + e.content_hash.clone() + }; + e.content_hash = hash.clone(); + mem.insert(hash, e); + } + } + 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 })) +} + +// ─── Master memory — list + idempotent plant (§2 "plant preserved memory") ── + +async fn list_master_memory(State(state): State) -> impl IntoResponse { + let guard = state.master_memory.read().await; + let mut entries: Vec = guard.values().cloned().collect(); + entries.sort_by(|a, b| a.ns.cmp(&b.ns).then_with(|| a.key.cmp(&b.key))); + Json(serde_json::json!({ "entries": entries })) +} + +#[derive(Debug, Deserialize)] +pub struct PlantRequest { + pub entries: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PlantResponse { + pub planted: usize, + pub skipped: usize, + pub total: usize, +} + +/// Idempotent plant: each entry's content_hash is the dedup key. Re-planting +/// the same content is a no-op (skipped++), so "prevent duplicate plant" is +/// enforced server-side, not just in the UI. Returns planted/skipped counts + +/// the resulting total. An audit row records the plant. +async fn plant_master_memory( + State(state): State, + Json(req): Json, +) -> Json { + let mut planted = 0usize; + let mut skipped = 0usize; + { + let mut mem = state.master_memory.write().await; + for mut e in req.entries { + let hash = if e.content_hash.is_empty() { + e.compute_hash() + } else { + e.content_hash.clone() + }; + e.content_hash = hash.clone(); + if let std::collections::hash_map::Entry::Vacant(slot) = mem.entry(hash) { + slot.insert(e); + planted += 1; + } else { + skipped += 1; + } + } + } + let total = state.master_memory.read().await.len(); + if planted > 0 { + let evt = ApiAuditEvent { + id: format!("e-mem-plant-{}", now_unix()), + ts: now_ts_hms(), + actor_id: "master".into(), + actor: "master".into(), + kind: "memory.write".into(), + detail: format!("planted preserved memory · {planted} entries · {skipped} duplicates"), + chip: "memory".into(), + sev: "ok".into(), + }; + push_audit(&state, evt).await; + } + Json(PlantResponse { + planted, + skipped, + total, + }) +} + +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!(resp.0.scope.as_ref().unwrap().get("family").unwrap().read); + // 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![], + master_memory: 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); + } + + fn mem_entry(ns: &str, key: &str, body: &str) -> ApiMemoryEntry { + ApiMemoryEntry { + ns: ns.into(), + key: key.into(), + title: format!("{key}.md"), + bytes: body.len() as u64, + version: "v2".into(), + updated: "just now".into(), + preview: body.chars().take(40).collect(), + body: body.into(), + content_hash: String::new(), + } + } + + #[tokio::test] + async fn master_memory_empty_by_default() { + let state = make_state(); + let resp = list_master_memory(State(state)).await.into_response(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn plant_then_replant_is_idempotent_dedup() { + let state = make_state(); + let entries = vec![ + mem_entry("personal", "profile", "name: Kevin"), + mem_entry("travel", "chengdu", "trip May 25-29"), + ]; + + // First plant: both land. + let r1 = plant_master_memory( + State(state.clone()), + Json(PlantRequest { + entries: entries.clone(), + }), + ) + .await; + assert_eq!(r1.0.planted, 2); + assert_eq!(r1.0.skipped, 0); + assert_eq!(r1.0.total, 2); + assert_eq!(state.master_memory.read().await.len(), 2); + + // Re-plant the SAME content: 0 planted, 2 skipped (dedup by content_hash). + let r2 = plant_master_memory(State(state.clone()), Json(PlantRequest { entries })).await; + assert_eq!(r2.0.planted, 0); + assert_eq!(r2.0.skipped, 2); + assert_eq!(r2.0.total, 2); + assert_eq!( + state.master_memory.read().await.len(), + 2, + "re-plant must not duplicate" + ); + + // Plant emits a memory.write audit row (only when something was planted). + assert!(state + .audit + .read() + .await + .iter() + .any(|e| e.kind == "memory.write")); + } + + #[tokio::test] + async fn plant_changed_body_adds_a_new_entry() { + let state = make_state(); + let _ = plant_master_memory( + State(state.clone()), + Json(PlantRequest { + entries: vec![mem_entry("personal", "profile", "v1 body")], + }), + ) + .await; + // Same ns/key but DIFFERENT body → different content_hash → a new entry. + let r = plant_master_memory( + State(state.clone()), + Json(PlantRequest { + entries: vec![mem_entry("personal", "profile", "v2 body")], + }), + ) + .await; + assert_eq!(r.0.planted, 1); + assert_eq!(state.master_memory.read().await.len(), 2); + } + + #[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..60a7c58 --- /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 18088) +# [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 18088 (8088 collides with the sandbox gem-server, per #141) +# 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:-18088}" # 18088 per #141 — 8088 collides with the sandbox's built-in gem-server +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..1de3c0b --- /dev/null +++ b/docs/plan/web-flow/README.md @@ -0,0 +1,46 @@ +# 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) (§22c app surface, **§22d IAM-guarantee delivery**), [`docs/agent-iam-strategy.md`](../../agent-iam-strategy.md), [`docs/operator-runbook-wire.md`](../../operator-runbook-wire.md), [`docs/user-manual.md`](../../user-manual.md), [`docs/wiki/agent-iam-guarantee-glossary.md`](../../wiki/agent-iam-guarantee-glossary.md), and the harness scripts [`harness/v2-stage{1,2,3}-demo.sh`](../../../harness/) (master onboarding) + [`harness/phase1-wire-demo.sh`](../../../harness/phase1-wire-demo.sh) (the agent wire flow). + +> **Redesigned 2026-05-31 after PRs [#140](https://github.com/litentry/agentKeys/pull/140) + [#141](https://github.com/litentry/agentKeys/pull/141) merged.** The agent half of the flow ([`stage3-agent-usage.md`](stage3-agent-usage.md)) was rebuilt around the **Authority-Host / Task-Host** model: AgentKeys installs **IAM-guarantee hooks** (`agentkeys wire`) the LLM can't bypass, and the agent's key is **born in its own runtime** (`agentkeys agent device-session`), never on the master. The master-onboarding half ([`stage1`](stage1-first-run.md) / [`stage2`](stage2-second-master.md)) is unchanged. + +## 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) | **(redesigned for #141)** Add an agent: **pair** (`agentkeys agent device-session` — key born in the runtime) → **wire** (`agentkeys wire` installs IAM-guarantee hooks) → the **three acts** (permissioned memory / deterministic denial / audit) + the memory surprise. Plus the hook-aware live dashboard and the preserved 16-step isolation health check. Maps `harness/phase1-wire-demo.sh`. | +| [`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..3ffe5a1 --- /dev/null +++ b/docs/plan/web-flow/data-model.md @@ -0,0 +1,402 @@ +# 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 — pair + wire (**deferred** — Phase 2; redesigned for #141) + +> **Superseded.** The old `bootstrap/{this-device,remote,vendor}` + `agents/create` paste-a-pair-code shape is gone. The agent lifecycle is now pair (device-session) → wire (install hooks) → observe. These endpoints are the daemon surface the web UI needs to *drive and observe* the CLI flow that [`harness/phase1-wire-demo.sh`](../../../harness/phase1-wire-demo.sh) runs by hand. See [`stage3-agent-usage.md`](stage3-agent-usage.md). + +**Pairing (Phase P).** The device-session keygen runs *in the agent's runtime*, not the daemon — the key must never touch the master. So the daemon endpoints here are master-side: they accept the agent's *public* outputs and drive the on-chain bind + scope grant. + +``` +POST /v1/agents/pair/init + body: { "label": "travel-bot", "runtime": "hermes", "namespaces": ["travel"], "payment_scope": "payment.spend", "daily_cap_rmb": 500 } + → 200 { "pair_id": "...", "link_code": "...", "broker_url": "...", "instructions": "run `agentkeys agent device-session` in the runtime with this link-code" } + # The link_code is what the agent's `device-session --link-code` echoes back for binding. + +POST /v1/agents/pair/bind — P.2: master binds the sandbox-generated device on-chain + body: { "pair_id": "...", "agent_address": "0x...", "actor_omni": "0x...", "device_key_hash": "0x...", "pop_sig": "0x..." } + → 200 { "tx_hash": "0x...", "block": 1234567 } # heima-agent-create --from-pubkey → registerAgentDevice + → 4xx { "error": "pop-sig-invalid" } # the agent's proof-of-possession didn't verify + +POST /v1/agents/pair/approve-scope/begin — P.3: build the K11 challenge for the scope grant + body: { "pair_id": "...", "services": ["travel"] } + → 200 { "challenge": "...", "assertion_id": "..." } # reuses the /v1/k11/assert pattern +POST /v1/agents/pair/approve-scope/submit — P.3: submit Touch ID assertion → heima-scope-set --webauthn + body: { "assertion_id": "...", "authenticatorData": "...", "clientDataJSON": "...", "signature": "..." } + → 200 { "tx_hash": "0x...", "granted": ["travel"] } + +POST /v1/agents/:id/seed-memory — step 1.5: seed a fresh actor's empty namespace + body: { "namespace": "travel", "content": "..." } # operator-supplied; optional + → 200 { "ok": true, "s3_key": "bots//memory/..." } +``` + +**Wire (Phase 2).** The hook scripts install into the *runtime's* config, which for a remote runtime lives in the sandbox — so the daemon drives `agentkeys wire` over the runtime's exec channel and reports the per-step `ok/skip/fail`. + +``` +POST /v1/agents/:id/wire + body: { "runtime": "hermes", "mcp_url": "...", "vendor_token": "...", "session_bearer": "..." } + → 200 { "steps": [ {"step":"scripts","status":"ok"}, {"step":"config","status":"ok"}, {"step":"doctor","status":"ok"} ], + "managed_block": "# >>> agentkeys wire …" } # the exact YAML written, for the "preview" affordance + +GET /v1/agents/:id/wire/status — drift detection (agentkeys wire --check-only) + → 200 { "state": "wired" | "drifted" | "not-wired", "hooks": ["check","audit","memory-inject"], "detail": "..." } + +POST /v1/agents/:id/unwire — remove the managed hooks block from the runtime config + → 200 { "ok": true } +``` + +**Verify + observe (Phase 3/4).** The deterministic Act-1 check + the live hook-event feed. + +``` +POST /v1/agents/:id/verify/memory-inject — runs `hermes hooks test pre_llm_call` via the runtime's dispatcher + → 200 { "injected": true, "context": "## Memory: travel\nChengdu trip — …" } # the authoritative Act-1 signal + → 200 { "injected": false, "reason": "mcp-unreachable" | "scope-missing" | "session-bad" } + +GET /v1/agents/:id/guarantee-health — the §2.2 health panel + → 200 { "wired": "hermes 3/3", "mcp_reachable": true, "fail_closed_armed": true, + "last_check": {...}, "last_block": {...}, "last_memory_inject": {...}, "scope_on_chain": ["travel"] } + +GET /v1/audit/stream?hook=check|audit|memory-inject — the existing SSE feed (PR-C), now hook-tagged + # each event carries { hook: "pre_tool_call|post_tool_call|pre_llm_call", action: "check|audit|memory-inject", + # decision?: "block|allow", reason?, namespace?, actor_omni, ts } +``` + +**Reused, unchanged (shipped PR-C; extend to take `k11_assertion_id`):** + +``` +POST /v1/actors/:id/scope — tighten/loosen scope (master mutation, K11) +POST /v1/actors/:id/payment-cap — change spend cap (master mutation, K11) +POST /v1/actors/:id/revoke — revoke the agent device on-chain (Act 3) +POST /v1/actors/:id/caps/revoke — revoke a single cap +GET /v1/actors — actor list +GET /v1/actors/:id — actor detail (now includes wire state + scope) +GET /v1/actors/:id/caps — live cap-tokens +``` + +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 mutation (same pattern the pairing scope-grant uses). + +**MCP server config the daemon must thread through** (per #141 — these flow into the wired hook scripts + the MCP server the hooks call): `--vendor-token`, `--session-bearer` / `--agent-session-bearer`, `--memory-role-arn`, `--vault-role-arn`, `--aws-region` (the per-actor STS relay, issue #90), and `--default-daily-spend-cap-rmb` (the deterministic-denial cap). **MCP port is `18088`** by convention (8088 collides with the sandbox's built-in `gem-server`). + +### 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..aef10a9 --- /dev/null +++ b/docs/plan/web-flow/input-discipline.md @@ -0,0 +1,145 @@ +# 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. + +> **#141 note — fresh-pairing changes where `device_pubkey` comes from.** Under the wire flow ([`stage3-agent-usage.md`](stage3-agent-usage.md) §1.2), the agent's K10 device key is generated **in the agent's own runtime** by `agentkeys agent device-session`, not on the master. So `actor_omni` + `device_key_hash` arrive at the master as **Real outputs of the agent's keygen** — the master receives them, doesn't derive them, and binds them on-chain. They're still Derived *from the agent's side* (deterministic from the agent's key), but from the **master/UI's** vantage they're inbound values to be verified (via `pop_sig`), not computed. The UI must never claim to have generated the agent's key — the whole guarantee is that it didn't. + +## §2.5 — The agent's runtime + wire inputs + +PR #141 adds new inputs on the agent-onboarding path. Their categories: + +| Input | Category | Notes | +|---|---|---| +| **runtime** (Hermes / Claude Code / Codex / OpenClaw) | **Real** | Operator-selected. Gated on what `agentkeys wire` supports — only options with a shipped `RuntimeAdapter` are selectable (Hermes today; the rest are disabled with their #133 tracking link, **never faked**). | +| **memory namespaces** (`travel`, `family`, …) | **Real** | Operator-selected checkboxes → `agentkeys wire --namespaces`. The `pre_llm_call` hook injects *only* these. | +| **payment scope + daily cap** | **Real** | Operator-typed → `--payment-scope` + the MCP server's `--default-daily-spend-cap-rmb`. The `pre_tool_call` `check` hook enforces the cap deterministically. | +| **link-code** (pairing) | **Auto-generated** | Minted by `/v1/agents/pair/init`; single-use; the agent's `device-session --link-code` echoes it back for binding. Shown to the operator only as a transient pairing token. | +| **device key / `pop_sig`** | **Auto-generated, in the agent's runtime** | Born in the sandbox, `0600`, never leaves. The master sees only the public address + proof-of-possession. | +| **the managed `hooks:` block** | **Auto-generated** | Written by `agentkeys wire` into the runtime config, sentinel-delimited. The "preview what gets written" affordance shows it verbatim; the operator does not hand-author it. | + +The discipline that matters most here: **the runtime list reflects real adapter support, and the agent key is never operator- or master-supplied.** Both are honesty guarantees — don't show a runtime the wire CLI can't drive, and don't imply the master holds the agent's private key. + +## §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/issue-9step-flow.md b/docs/plan/web-flow/issue-9step-flow.md new file mode 100644 index 0000000..7e16e9a --- /dev/null +++ b/docs/plan/web-flow/issue-9step-flow.md @@ -0,0 +1,65 @@ +# 9-step operator flow — plan, verification, and pushback + +**Status:** plan + implementation (design port + pushback #2 now wired). Source design: Claude Design handoff `agentkeyweb` (onboarding / memory / pairing / permissions / tx-decode). +**Backend merged into this branch:** #159 (§10.2 **agent-initiated** pairing, method A), #149 (HDKD agent bootstrap — superseded front-half by #159), #146 (memory build-vs-gate Position C), #141 (wire/hook), #137 (AuditEnvelope v1 CBOR vectors), #138 (CI hardening). +**Defers to:** [`overview.md`](overview.md) (Authority/Task-Host model), [`stage3-agent-usage.md`](stage3-agent-usage.md), [`data-model.md`](data-model.md), [`docs/arch.md`](../../arch.md) §10.2 / §22c / §22d. + +> **Update (this PR, after #159 merge):** +> - **Pushback #1 (pairing direction) — RESOLVED upstream by #159.** §10.2 is now agent-initiated (method A): the agent *shows* a one-time pairing code; the master *claims* it (`POST /v1/agent/pairing/claim`, J1_master-gated) → reviews the device → one Touch ID submits `registerAgentDevice` + `setScopeWithWebauthn`. This matches the design's "agent broadcasts a code" intuition. The pairing-page copy is aligned to this; full daemon-proxy wiring of claim/pending/bind is the next step (broker reachable required). +> - **Pushback #2 — IMPLEMENTED in this PR (was: narrated).** +> - *Onboarding WebAuthn is real.* `OnboardingScreen` runs a genuine `navigator.credentials.create()` via the daemon `POST /v1/k11/enroll/{begin,finish}` (PR-B) through the `lib/client` seam when a daemon is configured; it shows a "K11 enrolled · real WebAuthn" chip. Offline (EmptyBackend) it falls back to the narrated scan so the demo still runs. +> - *Memory plant is real + idempotent.* New daemon ui-bridge endpoints `GET /v1/master/memory` + `POST /v1/master/memory/plant` with **server-side content-hash dedup** (re-planting the same content is a no-op; changed body → new entry) + 3 Rust unit tests. The UI auto-detects existing memory on load (`listMasterMemory` → hides the plant button) and plants via `plantMemory` (server dedups), with a seed fallback offline. +> - **Pushback #3 (audit decode) — still a mock**, tracked in [#153](https://github.com/litentry/agentKeys/issues/153) per the user's scope ("just 2"). + +## The 9 steps the user specified + +1. Login with WebAuthn → onboarding ceremony with a progress bar + live text log. +2. Memory panel: see memories; if none, a **plant preserved memory** button; auto-detect existing memory (hide the button); programmatically prevent duplicate plants. +3. On another machine, the user creates a new **Hermes** agent and tries to connect to the master. +4. Master side: a notification (or refresh-triggered) shows a pairing request. +5. Click the request → agent info + requested permissions (e.g. memory). +6. Accept pairing → Touch ID authorizes. +7. Pairing ceremony with a progress bar + process text. +8. On complete: paired device visible in the dashboard, with a **device view** and a **permission view**. +9. Audit messages + their Heima TXs, decodable. + +## Verification — each step against the merged backend + +| # | Step | Backend reality after merge | Shippable now? | +|---|---|---|---| +| 1 | WebAuthn onboarding ceremony | Real K11 enroll exists (`/v1/k11/enroll/{begin,finish}`, daemon `ui_bridge.rs`, PR-B). The *rest* of the ceremony (createOmniAccount, registerMasterDevice, vault provision, contract verify) are harness shell steps, **not** web endpoints. | **UI now** (real WebAuthn + narrated backend steps); real wiring = Phase 2 (`data-model.md` onboarding endpoints). | +| 2 | Memory plant + dedup | Real memory worker exists (MCP `memory.put`/`memory.get`, S3). #146 settled the build-vs-gate question. Dedup = content-hash. Needs daemon `GET /v1/master/memory` + a plant endpoint. | **UI now** (seed + idempotent dedup guard); real wiring = Phase 2. | +| 3 | Agent creates + connects | **Real, shipped by #149**: `agentkeys agent create` (master mints a one-time link-code bound to the HDKD child omni) → agent `agentkeys-daemon --init-link-code ` generates its own K10 in the sandbox + redeems → broker records a **pending binding**. | **Real backend exists.** UI demo seeds the request. | +| 4 | Master notification of request | **Real, shipped by #149**: `GET /v1/agent/pending-bindings` (master polls). This is the notification source. | **Real backend exists.** UI = bell + poll. | +| 5 | Request detail (agent + perms) | Pending-binding record carries child omni + device pubkey + requested services. | **Real backend exists.** | +| 6 | Accept + Touch ID | Master binds (`heima-agent-create --from-pubkey` → `registerAgentDevice`) + grants (`heima-scope-set --webauthn` → `setScopeWithWebauthn`, one Touch ID). | **Real backend exists** (shell + cast today; CLI `agent create`/`pending` added). | +| 7 | Pairing ceremony progress | The two on-chain txs (bind + grant) + cap-mint. | **UI now** (CeremonyRunner over the real step list). | +| 8 | Device view + permission view | Dashboard from the actor tree / SidecarRegistry + AgentKeysScope. | **UI now** (seed); real read = `/v1/actors` (PR-C). | +| 9 | Audit + decodable Heima TXs | #137 shipped the **AuditEnvelope v1 CBOR** cross-language vector exporter; calldata→function decode needs an ABI decoder. No web decode endpoint yet. | **UI now** ships a mock `decodeCalldata` (kind→selector+signature); real decode = the new GH issue below. | + +## Pushback — three things the user should know before we call this "real" + +1. **Pairing direction is inverted between the design and #149.** The design narrates *"the agent broadcasts a pair-code; the master discovers it."* #149's real ceremony is the opposite: **the master creates the link-code first** (`agent create`), hands it to the agent, the agent redeems it, and *then* a pending binding appears for the master to approve. The polling + accept + Touch ID half is identical; only the code's origin differs. **Recommendation:** align the UI to #149 — the "pairing request" the master sees is a *pending binding* (agent redeemed a code), and "create agent" mints the code the operator gives the new machine. The implemented demo keeps the design's request-card UX but the plan + data-model treat the request as a pending binding. Confirm you're OK with this reconciliation. + +2. **Onboarding + memory-plant backends are not web endpoints yet.** Real WebAuthn enroll is live, but createOmniAccount / registerMasterDevice / vault-provision / memory-plant are harness shell steps. For M1 the UI runs the *real* WebAuthn assertion and **narrates** the remaining steps (honest ceremony, not faked success). Real wiring is the Phase-2 daemon endpoints already specced in [`data-model.md`](data-model.md). This matches the existing Phase-1/Phase-2 split — no new deferral, just naming it. + +3. **Audit decode needs a real library, not the mock.** The UI ships a deterministic mock (`decodeCalldata`: event-kind → 4-byte selector + function signature; `txHash`: deterministic hash). Real decoding has two halves — (a) **CBOR `AuditEnvelope`** decode (vectors shipped in #137; needs a TS/Rust decoder surfaced to the UI), (b) **EVM calldata → typed args** against the four contract ABIs. Tracked as a **separate GH issue — [#153](https://github.com/litentry/agentKeys/issues/153)**. The UI's decode panel is wired so swapping the mock for the real endpoint is a one-function change. + +## What this PR implements (the design port) + +Faithful port of the Claude-design 9-step flow into `apps/parent-control` (Next.js), as the **primary, demoable operator experience** driven by seed data + local ceremony state (exactly the prototype's model). Real-daemon wiring stays behind the existing `lib/client` seam and is Phase 2. + +- `globals.css` — new blocks: ceremony/clog, onboard, empty-memory, pair-req, view-toggle, device-grid/card, bell+badge, tx-decode, mem-body, perm-* (mobile-style scoped permission list, **no tables**). +- `lib/demoData.ts` — `ONBOARDING_STEPS`, `PAIRING_STEPS`, `PRESERVED_MEMORY`, `INCOMING_PAIRING`, `CHAIN_PROFILE`, `MASTER_DEVICES`, `txHash`, `decodeCalldata`, seed actors/events + types. +- `_components/ceremony.tsx` — `CeremonyRunner` (progress bar + live step log + per-step tx hashes) + `OnboardingScreen` (WebAuthn login → ceremony). +- `_components/memory.tsx` — `MemoryPage` (empty state + plant button, plant ceremony, dedup guard, per-namespace listing). +- `_components/pairing.tsx` — `PairingPage` (incoming request card → accept → Touch ID → ceremony; device view + permission view toggle). +- `_components/permissions.tsx` — `PermissionList` / `PermissionView` / `PermSeg` / `PermSwitch` (mobile scoped permissions — the "tables won't scale" ask). +- `App.tsx` — onboarding gate (localStorage), header bell with pending-request badge, memory/pairing routes, tx-decode modal in the event detail (step 9). + +## Sequencing (after this port lands) + +- **P2.1** Daemon endpoints for steps 1–2 + 8 reads (onboarding state, master memory list + plant, actor tree) — `data-model.md`. +- **P2.2** Wire pairing to #149: `agent create` (mint code), `GET /v1/agent/pending-bindings` (bell poll), bind + grant on accept. Reconcile direction per pushback #1. +- **P2.3** Real audit decode ([#153](https://github.com/litentry/agentKeys/issues/153)) — swap the mock `decodeCalldata`. +- **P2.4** Remove seed data behind the client seam once endpoints exist (mirrors the PR-A empty-state discipline). diff --git a/docs/plan/web-flow/overview.md b/docs/plan/web-flow/overview.md new file mode 100644 index 0000000..5fb52b4 --- /dev/null +++ b/docs/plan/web-flow/overview.md @@ -0,0 +1,227 @@ +# overview · operator user flow end-to-end + +## The two-host model (read this first — it frames everything) + +> **Updated 2026-05-31 after PR [#140](https://github.com/litentry/agentKeys/pull/140) + [#141](https://github.com/litentry/agentKeys/pull/141) merged.** + +AgentKeys is the **Authority Host**: the operator's master, the daemon, this web UI. It owns identity, keys, scope, audit — the *policy decision*. The LLM runtime the operator's agents run on (Hermes today; Claude Code / Codex / OpenClaw next) is the **Task Host**: it owns the agent loop and the *work*. AgentKeys never becomes a Task Host (strategy §2.4 zero-orchestration line). + +The product's load-bearing claim is the difference between an **IAM tool** and an **IAM guarantee** ([`agent-iam-guarantee-glossary.md`](../../wiki/agent-iam-guarantee-glossary.md)): + +- An **IAM tool** is a permission function in the LLM's registry — the LLM decides whether to call it. A jailbreak skips it. +- An **IAM guarantee** is a non-LLM gate the *runtime* fires deterministically before the action runs. It **fails closed**. The LLM's intent is irrelevant. + +`agentkeys wire ` turns AgentKeys' MCP tools into guarantees by installing runtime **hooks the LLM cannot bypass**. Everything the web UI does on the agent side exists to deliver + visualize that: the operator isn't handing the agent a permission tool it might ignore; they're wiring a gate it physically can't get around. See [`stage3-agent-usage.md`](stage3-agent-usage.md) for the full agent flow (pair → wire → the three acts). + +## 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) + +### Phase 2 — add an agent · the wire flow (redesigned for #141, now in [`stage3-agent-usage.md`](stage3-agent-usage.md)) + +This is the agent half of the product, fully reframed around the Authority/Task-Host model. It is the next implementation phase after the master onboarding (Phase 1) ships. + +- **Choose runtime + scope** — Hermes now; Claude Code / Codex / OpenClaw gated on #133 adapters. Namespaces + payment scope are Real operator inputs. +- **Pair (Phase P)** — the agent's device key is *born in its own runtime* (`agentkeys agent device-session`) and never touches the master; the master binds it on-chain (`registerAgentDevice`) and approves its scope via Touch ID (`heima-scope-set --webauthn`). +- **Wire (Phase 2)** — `agentkeys wire ` installs the three IAM-guarantee hooks (`pre_tool_call`→check, `post_tool_call`→audit, `pre_llm_call`→memory-inject) into the runtime config; the LLM cannot bypass them. Idempotent; drift-detectable via `--check-only`. +- **The three acts** — Permissioned Memory, Deterministic Denial (fails closed), Auto-audit; plus the memory-aware "surprise" (deterministically backed by `hermes hooks test pre_llm_call`, not a chat reply). +- **Live dashboard** — audit feed tagged by hook; guarantee-health panel (wired? fail-closed armed? last block?); scope/revoke/**unwire** (Act 3 online revocation, live here). +- **On-demand isolation health check** (preserved) — the 16-step v2-stage3 proof against the operator's real cloud. + +> The prior "agent bootstrap: this-device / remote-sandbox / vendor-hardware (paste-a-pair-code)" design is **superseded** by the wire flow above. The proxy fallback for hooks-less hosts (xiaozhi-server, mobile SDKs) is arch.md §22d.3 / Phase 3b. + +### Act 2 — second master · still applies (in [`stage2-second-master.md`](stage2-second-master.md)) + +Unchanged by #141 — the companion-master + recovery-quorum flow is orthogonal to the agent wire flow. + +### 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