Compare commits
No commits in common. "774019119483a8b7f4c77f22805a02b64d272128" and "44deacb3f4499844091da975703ac16c97141db4" have entirely different histories.
7740191194
...
44deacb3f4
254
Cargo.lock
generated
254
Cargo.lock
generated
|
|
@ -132,45 +132,6 @@ dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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 7.1.3",
|
|
||||||
"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]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
|
|
@ -242,7 +203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -290,29 +251,12 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.21.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -866,12 +810,6 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crunchy"
|
|
||||||
version = "0.2.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -971,20 +909,6 @@ version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||||
|
|
||||||
[[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 7.1.3",
|
|
||||||
"num-bigint",
|
|
||||||
"num-traits",
|
|
||||||
"rusticata-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -1536,17 +1460,6 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "half"
|
|
||||||
version = "2.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"crunchy",
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
|
|
@ -1733,7 +1646,7 @@ version = "0.1.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -2183,7 +2096,7 @@ name = "llm-engine"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
|
@ -2566,16 +2479,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-bigint"
|
|
||||||
version = "0.4.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
|
||||||
dependencies = [
|
|
||||||
"num-integer",
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -2593,15 +2496,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-integer"
|
|
||||||
version = "0.1.46"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|
@ -2648,15 +2542,6 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "oid-registry"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
|
||||||
dependencies = [
|
|
||||||
"asn1-rs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|
@ -2745,12 +2630,6 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pastey"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdf-extract"
|
name = "pdf-extract"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
|
@ -3379,7 +3258,7 @@ version = "0.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|
@ -3467,15 +3346,6 @@ dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rusticata-macros"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
|
||||||
dependencies = [
|
|
||||||
"nom 7.1.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
|
|
@ -3692,16 +3562,6 @@ dependencies = [
|
||||||
"serde_derive",
|
"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]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -4201,7 +4061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"fancy-regex",
|
"fancy-regex",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
|
|
@ -4311,14 +4171,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
|
||||||
"libc",
|
"libc",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
"num_threads",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
"time-macros",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4327,16 +4185,6 @@ version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time-macros"
|
|
||||||
version = "0.2.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
|
||||||
dependencies = [
|
|
||||||
"num-conv",
|
|
||||||
"time-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
@ -4676,7 +4524,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||||
name = "tui"
|
name = "tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"client",
|
"client",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"fs4",
|
"fs4",
|
||||||
|
|
@ -4838,7 +4686,6 @@ dependencies = [
|
||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5364,74 +5211,6 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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 7.1.3",
|
|
||||||
"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]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|
@ -5798,7 +5577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert-json-diff",
|
"assert-json-diff",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"deadpool",
|
"deadpool",
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -5998,23 +5777,6 @@ version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
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 7.1.3",
|
|
||||||
"oid-registry",
|
|
||||||
"rusticata-macros",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xattr"
|
name = "xattr"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
|
|
@ -6106,9 +5868,7 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"webauthn-rs",
|
|
||||||
"worker",
|
"worker",
|
||||||
"worker-runtime",
|
"worker-runtime",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,4 @@ tokio-tungstenite = "0.29"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
url = "2.5"
|
|
||||||
uuid = "1.23"
|
uuid = "1.23"
|
||||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BackendAuthTarget {
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendAuthTarget {
|
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: base_url.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}{}",
|
|
||||||
self.base_url.trim_end_matches('/'),
|
|
||||||
path.strip_prefix('/')
|
|
||||||
.map(|path| format!("/{path}"))
|
|
||||||
.unwrap_or_else(|| path.to_string())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct DeviceLoginStartResponse {
|
|
||||||
pub device_code: String,
|
|
||||||
pub user_code: String,
|
|
||||||
pub verification_uri: String,
|
|
||||||
pub verification_uri_complete: String,
|
|
||||||
pub expires_in: u64,
|
|
||||||
pub interval: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct DeviceLoginPollResponse {
|
|
||||||
pub status: String,
|
|
||||||
pub access_token: Option<String>,
|
|
||||||
pub token_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum BackendAuthClientError {
|
|
||||||
Http(reqwest::Error),
|
|
||||||
BackendStatus { status: u16, body: String },
|
|
||||||
MissingAccessToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for BackendAuthClientError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Http(error) => write!(f, "Backend auth request failed: {error}"),
|
|
||||||
Self::BackendStatus { status, body } => {
|
|
||||||
write!(f, "Backend auth returned HTTP {status}: {body}")
|
|
||||||
}
|
|
||||||
Self::MissingAccessToken => {
|
|
||||||
f.write_str("Backend approved device login without an access token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for BackendAuthClientError {}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendAuthClientError {
|
|
||||||
fn from(value: reqwest::Error) -> Self {
|
|
||||||
Self::Http(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct DeviceLoginStartRequest<'a> {
|
|
||||||
client_name: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct DeviceLoginPollRequest<'a> {
|
|
||||||
device_code: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_device_login(
|
|
||||||
target: &BackendAuthTarget,
|
|
||||||
client_name: Option<&str>,
|
|
||||||
) -> Result<DeviceLoginStartResponse, BackendAuthClientError> {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let response = client
|
|
||||||
.post(target.api_url("/api/auth/device-login/start"))
|
|
||||||
.json(&DeviceLoginStartRequest { client_name })
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
parse_json_response(response).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn poll_device_login(
|
|
||||||
target: &BackendAuthTarget,
|
|
||||||
device_code: &str,
|
|
||||||
) -> Result<DeviceLoginPollResponse, BackendAuthClientError> {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let response = client
|
|
||||||
.post(target.api_url("/api/auth/device-login/poll"))
|
|
||||||
.json(&DeviceLoginPollRequest { device_code })
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
parse_json_response(response).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wait_for_device_login(
|
|
||||||
target: &BackendAuthTarget,
|
|
||||||
device_code: &str,
|
|
||||||
interval: Duration,
|
|
||||||
expires_in: Duration,
|
|
||||||
) -> Result<String, BackendAuthClientError> {
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
loop {
|
|
||||||
let response = poll_device_login(target, device_code).await?;
|
|
||||||
match response.status.as_str() {
|
|
||||||
"approved" => {
|
|
||||||
return response
|
|
||||||
.access_token
|
|
||||||
.ok_or(BackendAuthClientError::MissingAccessToken);
|
|
||||||
}
|
|
||||||
"expired" => {
|
|
||||||
return Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: 410,
|
|
||||||
body: "device login expired".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"consumed" => {
|
|
||||||
return Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: 409,
|
|
||||||
body: "device login was already consumed".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
if started.elapsed() >= expires_in {
|
|
||||||
return Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: 408,
|
|
||||||
body: "timed out waiting for device login approval".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
tokio::time::sleep(interval).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
|
||||||
response: reqwest::Response,
|
|
||||||
) -> Result<T, BackendAuthClientError> {
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
return Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: status.as_u16(),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(response.json::<T>().await?)
|
|
||||||
}
|
|
||||||
|
|
@ -8,17 +8,12 @@
|
||||||
//!
|
//!
|
||||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
pub mod backend_auth;
|
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod ticket_role;
|
pub mod ticket_role;
|
||||||
mod worker_client;
|
mod worker_client;
|
||||||
|
|
||||||
pub use backend_auth::{
|
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
|
||||||
poll_device_login, start_device_login, wait_for_device_login,
|
|
||||||
};
|
|
||||||
pub use backend_runtime::{
|
pub use backend_runtime::{
|
||||||
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
||||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,7 @@ worker.workspace = true
|
||||||
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
|
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
url.workspace = true
|
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
webauthn-rs = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
use axum::http::{HeaderMap, header};
|
|
||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{Error, Result, store::ControlPlaneStore};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct AuthPublicConfig {
|
|
||||||
pub rp_id: String,
|
|
||||||
pub origin: String,
|
|
||||||
pub public_base_url: String,
|
|
||||||
pub cookie_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct RequestActor {
|
|
||||||
pub user_id: String,
|
|
||||||
pub account_id: String,
|
|
||||||
pub handle: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub auth_method: ActorAuthMethod,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ActorAuthMethod {
|
|
||||||
BrowserSession,
|
|
||||||
ApiToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct AuthenticatedUser {
|
|
||||||
pub user_id: String,
|
|
||||||
pub account_id: String,
|
|
||||||
pub handle: String,
|
|
||||||
pub display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestActor {
|
|
||||||
pub fn user(&self) -> AuthenticatedUser {
|
|
||||||
AuthenticatedUser {
|
|
||||||
user_id: self.user_id.clone(),
|
|
||||||
account_id: self.account_id.clone(),
|
|
||||||
handle: self.handle.clone(),
|
|
||||||
display_name: self.display_name.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_handle(handle: &str) -> Result<String> {
|
|
||||||
let normalized = handle.trim().to_ascii_lowercase();
|
|
||||||
let valid = !normalized.is_empty()
|
|
||||||
&& normalized.len() <= 64
|
|
||||||
&& normalized
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
|
|
||||||
if !valid {
|
|
||||||
return Err(auth_error(
|
|
||||||
"invalid_handle",
|
|
||||||
"handle must be 1-64 chars of ascii lowercase letters, digits, '-' or '_'",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now_rfc3339() -> String {
|
|
||||||
Utc::now().to_rfc3339()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rfc3339_after(duration: Duration) -> String {
|
|
||||||
(Utc::now() + duration).to_rfc3339()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_expired(expires_at: &str) -> bool {
|
|
||||||
match DateTime::parse_from_rfc3339(expires_at) {
|
|
||||||
Ok(expires_at) => expires_at.with_timezone(&Utc) <= Utc::now(),
|
|
||||||
Err(_) => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_id(prefix: &str) -> String {
|
|
||||||
format!("{prefix}-{}", Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_challenge() -> String {
|
|
||||||
format!("chal-{}-{}", Uuid::now_v7(), Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mint_secret(prefix: &str) -> String {
|
|
||||||
format!("{prefix}_{}_{}", Uuid::now_v7(), Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn token_hash(token: &str) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(token.as_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
let mut encoded = String::with_capacity(digest.len() * 2 + "sha256:".len());
|
|
||||||
encoded.push_str("sha256:");
|
|
||||||
for byte in digest {
|
|
||||||
encoded.push_str(&format!("{byte:02x}"));
|
|
||||||
}
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_user_code() -> String {
|
|
||||||
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase();
|
|
||||||
format!("{}-{}", &hex[0..4], &hex[4..8])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
|
||||||
headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.strip_prefix("Bearer "))
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
|
||||||
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
|
|
||||||
cookie_header.split(';').find_map(|pair| {
|
|
||||||
let (name, value) = pair.trim().split_once('=')?;
|
|
||||||
(name == cookie_name && !value.is_empty()).then(|| value.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>(
|
|
||||||
store: &S,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
cookie_name: &str,
|
|
||||||
) -> Result<Option<RequestActor>> {
|
|
||||||
if let Some(token) = parse_bearer(headers) {
|
|
||||||
let token_hash = token_hash(&token);
|
|
||||||
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
|
||||||
if api_token
|
|
||||||
.expires_at
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|expires_at| is_expired(expires_at))
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
|
|
||||||
return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(session_token) = parse_cookie(headers, cookie_name) {
|
|
||||||
if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? {
|
|
||||||
if is_expired(&session.expires_at) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
|
||||||
store: &S,
|
|
||||||
user_id: &str,
|
|
||||||
auth_method: ActorAuthMethod,
|
|
||||||
) -> Result<Option<RequestActor>> {
|
|
||||||
let Some(user) = store.get_user(user_id)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
Ok(Some(RequestActor {
|
|
||||||
user_id: user.user_id,
|
|
||||||
account_id: user.account_id,
|
|
||||||
handle: user.handle,
|
|
||||||
display_name: user.display_name,
|
|
||||||
auth_method,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String {
|
|
||||||
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn auth_error(code: &str, message: &str) -> Error {
|
|
||||||
Error::RuntimeOperationFailed {
|
|
||||||
runtime_id: "workspace-auth".to_string(),
|
|
||||||
code: code.to_string(),
|
|
||||||
message: message.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,27 +15,8 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
|
||||||
include_str!("../../../resources/workspace-backend.default.toml");
|
include_str!("../../../resources/workspace-backend.default.toml");
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||||
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
|
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
|
||||||
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
|
|
||||||
const DEFAULT_AUTH_RP_ID: &str = "localhost";
|
|
||||||
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
|
||||||
const DEFAULT_MAX_RECORDS: usize = 200;
|
const DEFAULT_MAX_RECORDS: usize = 200;
|
||||||
|
|
||||||
fn default_auth_rp_id() -> String {
|
|
||||||
DEFAULT_AUTH_RP_ID.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_origin() -> String {
|
|
||||||
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_public_base_url() -> String {
|
|
||||||
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_cookie_name() -> String {
|
|
||||||
DEFAULT_AUTH_COOKIE_NAME.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendConfigFile {
|
pub struct WorkspaceBackendConfigFile {
|
||||||
|
|
@ -46,8 +27,6 @@ pub struct WorkspaceBackendConfigFile {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub limits: WorkspaceBackendLimitsConfig,
|
pub limits: WorkspaceBackendLimitsConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub auth: WorkspaceBackendAuthConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||||
|
|
@ -82,30 +61,6 @@ pub struct WorkspaceBackendLimitsConfig {
|
||||||
pub max_records: Option<usize>,
|
pub max_records: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendAuthConfig {
|
|
||||||
#[serde(default = "default_auth_rp_id")]
|
|
||||||
pub rp_id: String,
|
|
||||||
#[serde(default = "default_auth_origin")]
|
|
||||||
pub origin: String,
|
|
||||||
#[serde(default = "default_auth_public_base_url")]
|
|
||||||
pub public_base_url: String,
|
|
||||||
#[serde(default = "default_auth_cookie_name")]
|
|
||||||
pub cookie_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WorkspaceBackendAuthConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
rp_id: default_auth_rp_id(),
|
|
||||||
origin: default_auth_origin(),
|
|
||||||
public_base_url: default_auth_public_base_url(),
|
|
||||||
cookie_name: default_auth_cookie_name(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceRepositoryConfigFile {
|
pub struct WorkspaceRepositoryConfigFile {
|
||||||
|
|
@ -335,11 +290,8 @@ impl WorkspaceBackendConfigFile {
|
||||||
.iter()
|
.iter()
|
||||||
.map(resolve_remote_runtime)
|
.map(resolve_remote_runtime)
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
server.auth = AuthConfig::Passkey {
|
server.auth = AuthConfig::LocalDevToken {
|
||||||
rp_id: self.auth.rp_id.trim().to_string(),
|
token_configured: false,
|
||||||
origin: self.auth.origin.trim().to_string(),
|
|
||||||
public_base_url: self.auth.public_base_url.trim().to_string(),
|
|
||||||
cookie_name: self.auth.cookie_name.trim().to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(ResolvedWorkspaceBackendConfig {
|
Ok(ResolvedWorkspaceBackendConfig {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
||||||
//! remain the canonical project records and are read through bounded bridge APIs.
|
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
pub mod companion;
|
pub mod companion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, SET_COOKIE};
|
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION};
|
||||||
use axum::http::{HeaderMap, StatusCode, Uri};
|
use axum::http::{HeaderMap, StatusCode, Uri};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{delete, get, post, put};
|
use axum::routing::{delete, get, post, put};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::{Duration, SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use memory::backend::{
|
use memory::backend::{
|
||||||
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
|
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
|
||||||
|
|
@ -26,21 +26,9 @@ use tokio::net::TcpListener;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use url::Url;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use webauthn_rs::prelude::{
|
|
||||||
CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration,
|
|
||||||
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, Webauthn,
|
|
||||||
WebauthnBuilder,
|
|
||||||
};
|
|
||||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
|
|
||||||
use crate::auth::{
|
|
||||||
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id,
|
|
||||||
new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after,
|
|
||||||
session_set_cookie, token_hash,
|
|
||||||
};
|
|
||||||
use crate::companion::{
|
use crate::companion::{
|
||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
|
|
@ -76,9 +64,8 @@ use crate::repositories::{
|
||||||
use crate::resource_broker::BackendResourceBroker;
|
use crate::resource_broker::BackendResourceBroker;
|
||||||
use crate::skills;
|
use crate::skills;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
||||||
DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
|
WorkspaceRecord,
|
||||||
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
|
|
@ -104,14 +91,9 @@ fn embedded_runtime_id() -> String {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum AuthConfig {
|
pub enum AuthConfig {
|
||||||
/// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions;
|
/// Local/dev-only mode. If a token is configured by a future entrypoint, it
|
||||||
/// CLI/TUI auth uses API tokens obtained through the device login flow.
|
/// is a development guard only and not a production SaaS auth model.
|
||||||
Passkey {
|
LocalDevToken { token_configured: bool },
|
||||||
rp_id: String,
|
|
||||||
origin: String,
|
|
||||||
public_base_url: String,
|
|
||||||
cookie_name: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -144,11 +126,8 @@ impl ServerConfig {
|
||||||
frontend_url: "http://127.0.0.1:5173".to_string(),
|
frontend_url: "http://127.0.0.1:5173".to_string(),
|
||||||
embedded_runtime_store_root,
|
embedded_runtime_store_root,
|
||||||
static_assets_dir: None,
|
static_assets_dir: None,
|
||||||
auth: AuthConfig::Passkey {
|
auth: AuthConfig::LocalDevToken {
|
||||||
rp_id: "localhost".to_string(),
|
token_configured: false,
|
||||||
origin: "http://localhost:8787".to_string(),
|
|
||||||
public_base_url: "http://localhost:8787".to_string(),
|
|
||||||
cookie_name: "yoi_workspace_session".to_string(),
|
|
||||||
},
|
},
|
||||||
max_records: 200,
|
max_records: 200,
|
||||||
repositories: Vec::new(),
|
repositories: Vec::new(),
|
||||||
|
|
@ -254,7 +233,6 @@ impl WorkspaceApi {
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: config.workspace_id.clone(),
|
workspace_id: config.workspace_id.clone(),
|
||||||
owner_account_id: None,
|
|
||||||
display_name: config.workspace_display_name.clone(),
|
display_name: config.workspace_display_name.clone(),
|
||||||
state: "active".to_string(),
|
state: "active".to_string(),
|
||||||
created_at: config.workspace_created_at.clone(),
|
created_at: config.workspace_created_at.clone(),
|
||||||
|
|
@ -319,29 +297,6 @@ impl WorkspaceApi {
|
||||||
|
|
||||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/auth/config", get(get_auth_config))
|
|
||||||
.route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user))
|
|
||||||
.route(
|
|
||||||
"/api/auth/passkeys/registration/options",
|
|
||||||
post(post_passkey_registration_options),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/auth/passkeys/registration/complete",
|
|
||||||
post(post_passkey_registration_complete),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/auth/passkeys/login/options",
|
|
||||||
post(post_passkey_login_options),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/auth/passkeys/login/complete",
|
|
||||||
post(post_passkey_login_complete),
|
|
||||||
)
|
|
||||||
.route("/api/auth/logout", post(post_auth_logout))
|
|
||||||
.route("/api/auth/device-login/start", post(post_device_login_start))
|
|
||||||
.route("/api/auth/device-login/approve", post(post_device_login_approve))
|
|
||||||
.route("/api/auth/device-login/poll", post(post_device_login_poll))
|
|
||||||
.route("/api/auth/whoami", get(get_auth_whoami))
|
|
||||||
.route("/api/workspace", get(get_workspace))
|
.route("/api/workspace", get(get_workspace))
|
||||||
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -2488,690 +2443,6 @@ async fn scoped_list_host_workers(
|
||||||
list_host_workers(State(api), AxumPath(path.host_id)).await
|
list_host_workers(State(api), AxumPath(path.host_id)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct AuthBootstrapUserRequest {
|
|
||||||
handle: String,
|
|
||||||
#[serde(default)]
|
|
||||||
display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct AuthUserResponse {
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct PasskeyRegistrationOptionsRequest {
|
|
||||||
handle: String,
|
|
||||||
#[serde(default)]
|
|
||||||
display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct PasskeyRegistrationOptionsResponse {
|
|
||||||
challenge_id: String,
|
|
||||||
public_key: CreationChallengeResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct PasskeyRegistrationCompleteRequest {
|
|
||||||
challenge_id: String,
|
|
||||||
credential: RegisterPublicKeyCredential,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct PasskeyLoginOptionsRequest {
|
|
||||||
#[serde(default)]
|
|
||||||
handle: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct PasskeyLoginOptionsResponse {
|
|
||||||
challenge_id: String,
|
|
||||||
public_key: RequestChallengeResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct PasskeyLoginCompleteRequest {
|
|
||||||
challenge_id: String,
|
|
||||||
credential: PublicKeyCredential,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct PasskeyLoginCompleteResponse {
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct DeviceLoginStartRequest {
|
|
||||||
#[serde(default)]
|
|
||||||
client_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct DeviceLoginStartResponse {
|
|
||||||
device_code: String,
|
|
||||||
user_code: String,
|
|
||||||
verification_uri: String,
|
|
||||||
verification_uri_complete: String,
|
|
||||||
expires_in: u64,
|
|
||||||
interval: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct DeviceLoginApproveRequest {
|
|
||||||
user_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct DeviceLoginApproveResponse {
|
|
||||||
status: String,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct DeviceLoginPollRequest {
|
|
||||||
device_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct DeviceLoginPollResponse {
|
|
||||||
status: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
access_token: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
token_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct WhoamiResponse {
|
|
||||||
actor: Option<RequestActor>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct LogoutResponse {
|
|
||||||
status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_auth_config(State(api): State<WorkspaceApi>) -> ApiResult<Json<AuthPublicConfig>> {
|
|
||||||
Ok(Json(auth_public_config(&api.config)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_auth_bootstrap_user(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<AuthBootstrapUserRequest>,
|
|
||||||
) -> ApiResult<Json<AuthUserResponse>> {
|
|
||||||
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
|
|
||||||
Ok(Json(AuthUserResponse { user }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_passkey_registration_options(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<PasskeyRegistrationOptionsRequest>,
|
|
||||||
) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> {
|
|
||||||
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
|
|
||||||
let webauthn = webauthn(&api.config)?;
|
|
||||||
let exclude_credentials = passkeys_for_user(&api, &user.user_id)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|passkey| passkey.cred_id().clone())
|
|
||||||
.collect();
|
|
||||||
let user_unique_id = Uuid::now_v7();
|
|
||||||
let (public_key, state) = webauthn
|
|
||||||
.start_passkey_registration(
|
|
||||||
user_unique_id,
|
|
||||||
&user.handle,
|
|
||||||
&user.display_name,
|
|
||||||
Some(exclude_credentials),
|
|
||||||
)
|
|
||||||
.map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?;
|
|
||||||
let challenge_id = new_id("webauthn-registration");
|
|
||||||
let auth = auth_public_config(&api.config);
|
|
||||||
api.store.put_auth_challenge(&AuthChallengeRecord {
|
|
||||||
challenge_id: challenge_id.clone(),
|
|
||||||
ceremony: "passkey_registration".to_string(),
|
|
||||||
challenge: challenge_id.clone(),
|
|
||||||
user_id: Some(user.user_id),
|
|
||||||
rp_id: auth.rp_id,
|
|
||||||
origin: auth.origin,
|
|
||||||
state_json: Some(
|
|
||||||
serde_json::to_string(&state).map_err(|error| {
|
|
||||||
auth_error("webauthn_state_serialize_failed", &error.to_string())
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
expires_at: rfc3339_after(Duration::minutes(5)),
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
consumed_at: None,
|
|
||||||
})?;
|
|
||||||
Ok(Json(PasskeyRegistrationOptionsResponse {
|
|
||||||
challenge_id,
|
|
||||||
public_key,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_passkey_registration_complete(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<PasskeyRegistrationCompleteRequest>,
|
|
||||||
) -> ApiResult<Json<AuthUserResponse>> {
|
|
||||||
let challenge = api
|
|
||||||
.store
|
|
||||||
.consume_auth_challenge_by_id(
|
|
||||||
&request.challenge_id,
|
|
||||||
"passkey_registration",
|
|
||||||
&crate::auth::now_rfc3339(),
|
|
||||||
)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_passkey_challenge",
|
|
||||||
"passkey registration challenge is invalid or already consumed",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if is_expired(&challenge.expires_at) {
|
|
||||||
return Err(auth_error(
|
|
||||||
"expired_passkey_challenge",
|
|
||||||
"passkey registration challenge expired",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let user_id = challenge.user_id.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_passkey_challenge",
|
|
||||||
"passkey registration challenge is not bound to a user",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let user = api.store.get_user(&user_id)?.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"unknown_auth_user",
|
|
||||||
"passkey registration user does not exist",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let state_json = challenge.state_json.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"missing_webauthn_state",
|
|
||||||
"passkey registration state was not persisted",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let state: PasskeyRegistration = serde_json::from_str(&state_json)
|
|
||||||
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
|
|
||||||
let webauthn = webauthn(&api.config)?;
|
|
||||||
let passkey = webauthn
|
|
||||||
.finish_passkey_registration(&request.credential, &state)
|
|
||||||
.map_err(|error| {
|
|
||||||
auth_error(
|
|
||||||
"webauthn_registration_verification_failed",
|
|
||||||
&error.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let credential_id = passkey_credential_id(&passkey)?;
|
|
||||||
api.store
|
|
||||||
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
|
||||||
credential_id,
|
|
||||||
user_id: user.user_id.clone(),
|
|
||||||
public_key_cose: serde_json::to_string(&passkey).map_err(|error| {
|
|
||||||
auth_error("webauthn_passkey_serialize_failed", &error.to_string())
|
|
||||||
})?,
|
|
||||||
transports_json: None,
|
|
||||||
sign_count: 0,
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
last_used_at: None,
|
|
||||||
})?;
|
|
||||||
Ok(Json(AuthUserResponse {
|
|
||||||
user: user_response(user),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_passkey_login_options(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<PasskeyLoginOptionsRequest>,
|
|
||||||
) -> ApiResult<Json<PasskeyLoginOptionsResponse>> {
|
|
||||||
let user = match request.handle.as_deref() {
|
|
||||||
Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?,
|
|
||||||
None => api.store.any_user()?,
|
|
||||||
}
|
|
||||||
.ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?;
|
|
||||||
let passkeys = passkeys_for_user(&api, &user.user_id)?;
|
|
||||||
if passkeys.is_empty() {
|
|
||||||
return Err(auth_error(
|
|
||||||
"passkey_not_registered",
|
|
||||||
"user has no registered passkey credentials",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let webauthn = webauthn(&api.config)?;
|
|
||||||
let (public_key, state) = webauthn
|
|
||||||
.start_passkey_authentication(&passkeys)
|
|
||||||
.map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?;
|
|
||||||
let challenge_id = new_id("webauthn-login");
|
|
||||||
let auth = auth_public_config(&api.config);
|
|
||||||
api.store.put_auth_challenge(&AuthChallengeRecord {
|
|
||||||
challenge_id: challenge_id.clone(),
|
|
||||||
ceremony: "passkey_login".to_string(),
|
|
||||||
challenge: challenge_id.clone(),
|
|
||||||
user_id: Some(user.user_id),
|
|
||||||
rp_id: auth.rp_id,
|
|
||||||
origin: auth.origin,
|
|
||||||
state_json: Some(
|
|
||||||
serde_json::to_string(&state).map_err(|error| {
|
|
||||||
auth_error("webauthn_state_serialize_failed", &error.to_string())
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
expires_at: rfc3339_after(Duration::minutes(5)),
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
consumed_at: None,
|
|
||||||
})?;
|
|
||||||
Ok(Json(PasskeyLoginOptionsResponse {
|
|
||||||
challenge_id,
|
|
||||||
public_key,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_passkey_login_complete(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<PasskeyLoginCompleteRequest>,
|
|
||||||
) -> ApiResult<Response> {
|
|
||||||
let challenge = api
|
|
||||||
.store
|
|
||||||
.consume_auth_challenge_by_id(
|
|
||||||
&request.challenge_id,
|
|
||||||
"passkey_login",
|
|
||||||
&crate::auth::now_rfc3339(),
|
|
||||||
)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_passkey_challenge",
|
|
||||||
"passkey login challenge is invalid or already consumed",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if is_expired(&challenge.expires_at) {
|
|
||||||
return Err(auth_error(
|
|
||||||
"expired_passkey_challenge",
|
|
||||||
"passkey login challenge expired",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let user_id = challenge.user_id.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_passkey_challenge",
|
|
||||||
"passkey login challenge is not bound to a user",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let user = api
|
|
||||||
.store
|
|
||||||
.get_user(&user_id)?
|
|
||||||
.ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?;
|
|
||||||
let state_json = challenge.state_json.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"missing_webauthn_state",
|
|
||||||
"passkey login state was not persisted",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let state: PasskeyAuthentication = serde_json::from_str(&state_json)
|
|
||||||
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
|
|
||||||
let webauthn = webauthn(&api.config)?;
|
|
||||||
let auth_result = webauthn
|
|
||||||
.finish_passkey_authentication(&request.credential, &state)
|
|
||||||
.map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?;
|
|
||||||
let credential_id = serde_json::to_value(auth_result.cred_id())
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_webauthn_credential_id",
|
|
||||||
"verified credential id was not serializable",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let stored = api
|
|
||||||
.store
|
|
||||||
.get_passkey_credential(&credential_id)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"unknown_passkey_credential",
|
|
||||||
"verified passkey credential is not registered",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if stored.user_id != user.user_id {
|
|
||||||
return Err(auth_error(
|
|
||||||
"passkey_user_mismatch",
|
|
||||||
"verified passkey credential does not belong to the challenged user",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
api.store
|
|
||||||
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
|
||||||
credential_id,
|
|
||||||
user_id: user.user_id.clone(),
|
|
||||||
public_key_cose: stored.public_key_cose,
|
|
||||||
transports_json: stored.transports_json,
|
|
||||||
sign_count: u64::from(auth_result.counter()),
|
|
||||||
created_at: stored.created_at,
|
|
||||||
last_used_at: Some(crate::auth::now_rfc3339()),
|
|
||||||
})?;
|
|
||||||
let session_token = mint_secret("yoi_sess");
|
|
||||||
api.store.create_browser_session(&BrowserSessionRecord {
|
|
||||||
session_id: new_id("session"),
|
|
||||||
token_hash: token_hash(&session_token),
|
|
||||||
user_id: user.user_id.clone(),
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
expires_at: rfc3339_after(Duration::days(14)),
|
|
||||||
revoked_at: None,
|
|
||||||
})?;
|
|
||||||
let cookie_name = auth_public_config(&api.config).cookie_name;
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
SET_COOKIE,
|
|
||||||
session_set_cookie(&cookie_name, &session_token, 14 * 24 * 60 * 60)
|
|
||||||
.parse()
|
|
||||||
.map_err(|error| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_session_cookie",
|
|
||||||
&format!("failed to build session cookie: {error}"),
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
Ok((
|
|
||||||
headers,
|
|
||||||
Json(PasskeyLoginCompleteResponse {
|
|
||||||
user: user_response(user),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_device_login_start(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<DeviceLoginStartRequest>,
|
|
||||||
) -> ApiResult<Json<DeviceLoginStartResponse>> {
|
|
||||||
let auth = auth_public_config(&api.config);
|
|
||||||
let device_code = mint_secret("yoi_device");
|
|
||||||
let user_code = new_user_code();
|
|
||||||
let verification_uri = format!(
|
|
||||||
"{}/login/device",
|
|
||||||
auth.public_base_url.trim_end_matches('/')
|
|
||||||
);
|
|
||||||
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
|
|
||||||
api.store.create_device_login_flow(&DeviceLoginFlowRecord {
|
|
||||||
device_code: device_code.clone(),
|
|
||||||
user_code: user_code.clone(),
|
|
||||||
verification_uri: verification_uri.clone(),
|
|
||||||
client_name: request.client_name,
|
|
||||||
user_id: None,
|
|
||||||
api_token_id: None,
|
|
||||||
issued_access_token: None,
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
expires_at: rfc3339_after(Duration::minutes(10)),
|
|
||||||
approved_at: None,
|
|
||||||
consumed_at: None,
|
|
||||||
})?;
|
|
||||||
Ok(Json(DeviceLoginStartResponse {
|
|
||||||
device_code,
|
|
||||||
user_code,
|
|
||||||
verification_uri,
|
|
||||||
verification_uri_complete,
|
|
||||||
expires_in: 600,
|
|
||||||
interval: 5,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_device_login_approve(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(request): Json<DeviceLoginApproveRequest>,
|
|
||||||
) -> ApiResult<Json<DeviceLoginApproveResponse>> {
|
|
||||||
let actor = require_actor(&api, &headers).await?;
|
|
||||||
let flow = api
|
|
||||||
.store
|
|
||||||
.get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"unknown_device_login_code",
|
|
||||||
"device login code does not exist",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if is_expired(&flow.expires_at) {
|
|
||||||
return Err(auth_error("expired_device_login", "device login code expired").into());
|
|
||||||
}
|
|
||||||
if flow.approved_at.is_some() || flow.consumed_at.is_some() {
|
|
||||||
return Err(auth_error(
|
|
||||||
"device_login_already_used",
|
|
||||||
"device login code is already used",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let access_token = mint_secret("yoi_api");
|
|
||||||
let token_id = new_id("api-token");
|
|
||||||
api.store.create_api_token(&ApiTokenRecord {
|
|
||||||
token_id: token_id.clone(),
|
|
||||||
token_hash: token_hash(&access_token),
|
|
||||||
user_id: actor.user_id.clone(),
|
|
||||||
label: flow
|
|
||||||
.client_name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "yoi device login".to_string()),
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
expires_at: None,
|
|
||||||
revoked_at: None,
|
|
||||||
last_used_at: None,
|
|
||||||
})?;
|
|
||||||
let approved = api.store.approve_device_login_flow(
|
|
||||||
&flow.device_code,
|
|
||||||
&actor.user_id,
|
|
||||||
&token_id,
|
|
||||||
&access_token,
|
|
||||||
&crate::auth::now_rfc3339(),
|
|
||||||
)?;
|
|
||||||
if !approved {
|
|
||||||
return Err(auth_error(
|
|
||||||
"device_login_already_used",
|
|
||||||
"device login code is already used",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
Ok(Json(DeviceLoginApproveResponse {
|
|
||||||
status: "approved".to_string(),
|
|
||||||
user: actor.user(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_device_login_poll(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
Json(request): Json<DeviceLoginPollRequest>,
|
|
||||||
) -> ApiResult<Json<DeviceLoginPollResponse>> {
|
|
||||||
let Some(flow) = api
|
|
||||||
.store
|
|
||||||
.get_device_login_flow_by_device_code(&request.device_code)?
|
|
||||||
else {
|
|
||||||
return Err(auth_error("unknown_device_login", "device login flow does not exist").into());
|
|
||||||
};
|
|
||||||
if is_expired(&flow.expires_at) {
|
|
||||||
return Ok(Json(DeviceLoginPollResponse {
|
|
||||||
status: "expired".to_string(),
|
|
||||||
access_token: None,
|
|
||||||
token_type: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if flow.approved_at.is_none() {
|
|
||||||
return Ok(Json(DeviceLoginPollResponse {
|
|
||||||
status: "pending".to_string(),
|
|
||||||
access_token: None,
|
|
||||||
token_type: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let Some(consumed) = api
|
|
||||||
.store
|
|
||||||
.consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())?
|
|
||||||
else {
|
|
||||||
return Ok(Json(DeviceLoginPollResponse {
|
|
||||||
status: "consumed".to_string(),
|
|
||||||
access_token: None,
|
|
||||||
token_type: None,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
Ok(Json(DeviceLoginPollResponse {
|
|
||||||
status: "approved".to_string(),
|
|
||||||
access_token: consumed.issued_access_token,
|
|
||||||
token_type: Some("Bearer".to_string()),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_auth_whoami(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> ApiResult<Json<WhoamiResponse>> {
|
|
||||||
Ok(Json(WhoamiResponse {
|
|
||||||
actor: resolve_actor(&api, &headers).await?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_auth_logout(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> ApiResult<Response> {
|
|
||||||
let auth = auth_public_config(&api.config);
|
|
||||||
if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) {
|
|
||||||
let _ = api
|
|
||||||
.store
|
|
||||||
.revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?;
|
|
||||||
}
|
|
||||||
let mut response_headers = HeaderMap::new();
|
|
||||||
response_headers.insert(
|
|
||||||
SET_COOKIE,
|
|
||||||
session_set_cookie(&auth.cookie_name, "", 0)
|
|
||||||
.parse()
|
|
||||||
.map_err(|error| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_session_cookie",
|
|
||||||
&format!("failed to build logout cookie: {error}"),
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
Ok((
|
|
||||||
response_headers,
|
|
||||||
Json(LogoutResponse {
|
|
||||||
status: "logged_out".to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn webauthn(config: &ServerConfig) -> ApiResult<Webauthn> {
|
|
||||||
let auth = auth_public_config(config);
|
|
||||||
let origin = Url::parse(&auth.origin)
|
|
||||||
.map_err(|error| auth_error("invalid_webauthn_origin", &error.to_string()))?;
|
|
||||||
WebauthnBuilder::new(&auth.rp_id, &origin)
|
|
||||||
.map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()))?
|
|
||||||
.rp_name("Yoi Workspace")
|
|
||||||
.build()
|
|
||||||
.map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()).into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn passkeys_for_user(api: &WorkspaceApi, user_id: &str) -> ApiResult<Vec<Passkey>> {
|
|
||||||
api.store
|
|
||||||
.list_passkey_credentials_for_user(user_id)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|record| {
|
|
||||||
serde_json::from_str::<Passkey>(&record.public_key_cose).map_err(|error| {
|
|
||||||
auth_error("webauthn_passkey_deserialize_failed", &error.to_string()).into()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn passkey_credential_id(passkey: &Passkey) -> ApiResult<String> {
|
|
||||||
serde_json::to_value(passkey.cred_id())
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"invalid_webauthn_credential_id",
|
|
||||||
"verified passkey credential id was not serializable",
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig {
|
|
||||||
match &config.auth {
|
|
||||||
AuthConfig::Passkey {
|
|
||||||
rp_id,
|
|
||||||
origin,
|
|
||||||
public_base_url,
|
|
||||||
cookie_name,
|
|
||||||
} => AuthPublicConfig {
|
|
||||||
rp_id: rp_id.clone(),
|
|
||||||
origin: origin.clone(),
|
|
||||||
public_base_url: public_base_url.clone(),
|
|
||||||
cookie_name: cookie_name.clone(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_user_account(
|
|
||||||
api: &WorkspaceApi,
|
|
||||||
handle: &str,
|
|
||||||
display_name: Option<&str>,
|
|
||||||
) -> ApiResult<AuthenticatedUser> {
|
|
||||||
let handle = normalize_handle(handle)?;
|
|
||||||
if let Some(user) = api.store.get_user_by_handle(&handle)? {
|
|
||||||
return Ok(user_response(user));
|
|
||||||
}
|
|
||||||
let now = crate::auth::now_rfc3339();
|
|
||||||
let display_name = display_name
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or(handle.as_str())
|
|
||||||
.to_string();
|
|
||||||
let account = AccountRecord {
|
|
||||||
account_id: new_id("acct-user"),
|
|
||||||
kind: "user".to_string(),
|
|
||||||
handle: handle.clone(),
|
|
||||||
display_name: display_name.clone(),
|
|
||||||
created_at: now.clone(),
|
|
||||||
updated_at: now.clone(),
|
|
||||||
};
|
|
||||||
api.store.upsert_account(&account)?;
|
|
||||||
let user = UserRecord {
|
|
||||||
user_id: new_id("user"),
|
|
||||||
account_id: account.account_id,
|
|
||||||
handle,
|
|
||||||
display_name,
|
|
||||||
created_at: now.clone(),
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
api.store.upsert_user(&user)?;
|
|
||||||
Ok(user_response(user))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_response(user: UserRecord) -> AuthenticatedUser {
|
|
||||||
AuthenticatedUser {
|
|
||||||
user_id: user.user_id,
|
|
||||||
account_id: user.account_id,
|
|
||||||
handle: user.handle,
|
|
||||||
display_name: user.display_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult<Option<RequestActor>> {
|
|
||||||
let cookie_name = auth_public_config(&api.config).cookie_name;
|
|
||||||
Ok(resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn require_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult<RequestActor> {
|
|
||||||
resolve_actor(api, headers).await?.ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"auth_required",
|
|
||||||
"request requires a browser session or Bearer API token",
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
||||||
let schema_version = api.store.schema_version().await?;
|
let schema_version = api.store.schema_version().await?;
|
||||||
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
||||||
|
|
@ -8656,68 +7927,6 @@ mod tests {
|
||||||
dir,
|
dir,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn passkey_registration_rejects_unverified_credential_response() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let app = test_app(dir.path()).await;
|
|
||||||
|
|
||||||
let registration_options = post_json(
|
|
||||||
app.clone(),
|
|
||||||
"/api/auth/passkeys/registration/options",
|
|
||||||
json!({
|
|
||||||
"handle": "alice",
|
|
||||||
"display_name": "Alice"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(registration_options["public_key"].is_object());
|
|
||||||
let challenge_id = registration_options["challenge_id"].as_str().unwrap();
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/auth/passkeys/registration/complete")
|
|
||||||
.header(CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(
|
|
||||||
serde_json::to_vec(&json!({
|
|
||||||
"challenge_id": challenge_id,
|
|
||||||
"credential": {
|
|
||||||
"id": "credential-1",
|
|
||||||
"rawId": "Y3JlZGVudGlhbC0x",
|
|
||||||
"type": "public-key",
|
|
||||||
"response": {
|
|
||||||
"clientDataJSON": "e30",
|
|
||||||
"attestationObject": "e30"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.unwrap(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_ne!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let login_without_registered_passkey = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/auth/passkeys/login/options")
|
|
||||||
.header(CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(
|
|
||||||
serde_json::to_vec(&json!({ "handle": "alice" })).unwrap(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_ne!(login_without_registered_passkey.status(), StatusCode::OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_json(app: Router, uri: &str) -> Value {
|
async fn get_json(app: Router, uri: &str) -> Value {
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,14 +12,10 @@ use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use client::{
|
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||||
BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand,
|
|
||||||
start_device_login, wait_for_device_login,
|
|
||||||
};
|
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
|
||||||
|
|
@ -40,10 +36,6 @@ enum Mode {
|
||||||
subcommand: String,
|
subcommand: String,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
Login {
|
|
||||||
backend_url: String,
|
|
||||||
no_wait: bool,
|
|
||||||
},
|
|
||||||
WorkerRuntime(Vec<String>),
|
WorkerRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
SetupModel,
|
SetupModel,
|
||||||
|
|
@ -93,16 +85,6 @@ async fn main() -> ExitCode {
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
|
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
|
||||||
Mode::Login {
|
|
||||||
backend_url,
|
|
||||||
no_wait,
|
|
||||||
} => match run_login(&backend_url, no_wait).await {
|
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("yoi login: {e}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
||||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
||||||
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
||||||
|
|
@ -259,9 +241,6 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
"workspace" => {
|
"workspace" => {
|
||||||
return parse_workspace_args(&args[1..]);
|
return parse_workspace_args(&args[1..]);
|
||||||
}
|
}
|
||||||
"login" => {
|
|
||||||
return parse_login_args(&args[1..]);
|
|
||||||
}
|
|
||||||
"mcp" => {
|
"mcp" => {
|
||||||
let mcp_cli = parse_mcp_args(&args[1..])?;
|
let mcp_cli = parse_mcp_args(&args[1..])?;
|
||||||
return Ok(Mode::Mcp(mcp_cli));
|
return Ok(Mode::Mcp(mcp_cli));
|
||||||
|
|
@ -880,7 +859,15 @@ fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_path() -> Option<PathBuf> {
|
fn client_config_path() -> Option<PathBuf> {
|
||||||
yoi_config_dir().map(|dir| dir.join("client.toml"))
|
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
return Some(PathBuf::from(home).join("yoi").join("client.toml"));
|
||||||
|
}
|
||||||
|
std::env::var_os("HOME").map(|home| {
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("yoi")
|
||||||
|
.join("client.toml")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
||||||
|
|
@ -892,137 +879,6 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_login_args(args: &[String]) -> Result<Mode, ParseError> {
|
|
||||||
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
return Err(ParseError(
|
|
||||||
"yoi login usage: yoi login [--backend <URL>] [--no-wait]".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut backend_url = None;
|
|
||||||
let mut no_wait = false;
|
|
||||||
let mut i = 0;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--backend" => {
|
|
||||||
let value = args
|
|
||||||
.get(i + 1)
|
|
||||||
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
|
|
||||||
if value.starts_with('-') || value.is_empty() {
|
|
||||||
return Err(ParseError("--backend requires a URL".to_string()));
|
|
||||||
}
|
|
||||||
backend_url = Some(value.clone());
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
arg if arg.starts_with("--backend=") => {
|
|
||||||
let value = arg.trim_start_matches("--backend=");
|
|
||||||
if value.is_empty() {
|
|
||||||
return Err(ParseError("--backend requires a URL".to_string()));
|
|
||||||
}
|
|
||||||
backend_url = Some(value.to_string());
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--no-wait" => {
|
|
||||||
no_wait = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
return Err(ParseError(format!(
|
|
||||||
"unknown yoi login argument '{other}' (try 'yoi login --help')"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let backend_url = resolve_backend_url(backend_url, None)?;
|
|
||||||
Ok(Mode::Login {
|
|
||||||
backend_url,
|
|
||||||
no_wait,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
|
||||||
let target = BackendAuthTarget::new(backend_url.to_string());
|
|
||||||
let start = start_device_login(&target, Some("yoi cli"))
|
|
||||||
.await
|
|
||||||
.map_err(|error| ParseError(error.to_string()))?;
|
|
||||||
println!("Open this URL in your browser to approve Yoi CLI login:");
|
|
||||||
println!(" {}", start.verification_uri_complete);
|
|
||||||
println!();
|
|
||||||
println!("User code: {}", start.user_code);
|
|
||||||
println!("Expires in: {} seconds", start.expires_in);
|
|
||||||
if no_wait {
|
|
||||||
println!("Device code: {}", start.device_code);
|
|
||||||
println!(
|
|
||||||
"Run without --no-wait after approving, or poll the Backend device endpoint manually."
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let token = wait_for_device_login(
|
|
||||||
&target,
|
|
||||||
&start.device_code,
|
|
||||||
Duration::from_secs(start.interval.max(1)),
|
|
||||||
Duration::from_secs(start.expires_in.max(1)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ParseError(error.to_string()))?;
|
|
||||||
save_backend_token(backend_url, &token)?;
|
|
||||||
println!("Saved Backend API token for {backend_url}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
||||||
struct BackendTokenFile {
|
|
||||||
#[serde(default)]
|
|
||||||
tokens: BTreeMap<String, BackendTokenEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct BackendTokenEntry {
|
|
||||||
token_type: String,
|
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
|
|
||||||
let path = backend_token_path().ok_or_else(|| {
|
|
||||||
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
|
|
||||||
})?;
|
|
||||||
let mut file = if path.is_file() {
|
|
||||||
let contents = fs::read_to_string(&path)
|
|
||||||
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
|
|
||||||
serde_json::from_str::<BackendTokenFile>(&contents)
|
|
||||||
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
|
|
||||||
} else {
|
|
||||||
BackendTokenFile::default()
|
|
||||||
};
|
|
||||||
file.tokens.insert(
|
|
||||||
backend_url.trim_end_matches('/').to_string(),
|
|
||||||
BackendTokenEntry {
|
|
||||||
token_type: "Bearer".to_string(),
|
|
||||||
access_token: access_token.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|error| {
|
|
||||||
ParseError(format!("failed to create {}: {error}", parent.display()))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let serialized = serde_json::to_string_pretty(&file)
|
|
||||||
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
|
|
||||||
fs::write(&path, format!("{serialized}\n"))
|
|
||||||
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn backend_token_path() -> Option<PathBuf> {
|
|
||||||
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yoi_config_dir() -> Option<PathBuf> {
|
|
||||||
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
|
||||||
return Some(PathBuf::from(home).join("yoi"));
|
|
||||||
}
|
|
||||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,8 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
||||||
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
|
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
|
||||||
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
||||||
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
||||||
10. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
|
10. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
||||||
11. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
11. [`development/validation.md`](development/validation.md) — how to check changes.
|
||||||
12. [`development/validation.md`](development/validation.md) — how to check changes.
|
|
||||||
|
|
||||||
## What belongs here
|
## What belongs here
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# Backend account model and login flows
|
|
||||||
|
|
||||||
This document records the first Backend identity/auth layer introduced for the multi-workspace Backend direction.
|
|
||||||
|
|
||||||
## Account and owner model
|
|
||||||
|
|
||||||
The Backend store now separates a human user from the owner namespace used by workspace ownership:
|
|
||||||
|
|
||||||
- `accounts`: canonical namespace records. `kind = "user"` is implemented now; `kind = "organization"` is reserved in the schema for a later organization model.
|
|
||||||
- `users`: human user records. Each user owns exactly one `accounts` row.
|
|
||||||
- `workspaces.owner_account_id`: optional account owner reference. Existing/local workspaces may keep it unset until account bootstrap/ownership migration code assigns it.
|
|
||||||
|
|
||||||
This avoids baking `owner_user_id` into workspace authority while keeping the current implementation small.
|
|
||||||
|
|
||||||
## Browser login
|
|
||||||
|
|
||||||
Human browser auth is modeled as Passkey/WebAuthn ceremony endpoints:
|
|
||||||
|
|
||||||
- `POST /api/auth/bootstrap-user`
|
|
||||||
- `POST /api/auth/passkeys/registration/options`
|
|
||||||
- `POST /api/auth/passkeys/registration/complete`
|
|
||||||
- `POST /api/auth/passkeys/login/options`
|
|
||||||
- `POST /api/auth/passkeys/login/complete`
|
|
||||||
- `GET /api/auth/whoami`
|
|
||||||
|
|
||||||
The store persists registration/login challenges, serialized WebAuthn ceremony state, verified passkey credentials, browser sessions, and challenge consumption. Successful login mints an HttpOnly `SameSite=Lax` cookie session.
|
|
||||||
|
|
||||||
Completion endpoints require browser `PublicKeyCredential` responses and verify them with `webauthn-rs` before a passkey is stored or a browser session is issued. Fake or stale credential responses are rejected by the verifier, and consumed challenges cannot be reused.
|
|
||||||
|
|
||||||
## CLI/TUI device login
|
|
||||||
|
|
||||||
CLI/TUI clients use a device-login flow instead of browser cookies:
|
|
||||||
|
|
||||||
- `POST /api/auth/device-login/start` creates a `device_code` and human `user_code`.
|
|
||||||
- The CLI prints `verification_uri_complete` and polls `POST /api/auth/device-login/poll`.
|
|
||||||
- A browser-authenticated user approves with `POST /api/auth/device-login/approve`.
|
|
||||||
- Approval mints a Backend API token and returns it exactly once to the polling client.
|
|
||||||
|
|
||||||
`yoi login [--backend <URL>] [--no-wait]` starts this flow and stores the received token in `$XDG_CONFIG_HOME/yoi/backend-tokens.json` or `$HOME/.config/yoi/backend-tokens.json`.
|
|
||||||
|
|
||||||
## Request actor extraction
|
|
||||||
|
|
||||||
`workspace-server::auth::resolve_request_actor` resolves a typed request actor from either:
|
|
||||||
|
|
||||||
- `Authorization: Bearer <token>` for CLI/TUI API tokens, or
|
|
||||||
- the configured browser session cookie.
|
|
||||||
|
|
||||||
The resolved actor includes `user_id`, `account_id`, `handle`, `display_name`, and `auth_method`. This helper is intended to be used by mutating Backend APIs as authorization gets added route-by-route.
|
|
||||||
|
|
||||||
## Non-goals for this step
|
|
||||||
|
|
||||||
- Organization membership and RBAC.
|
|
||||||
- Password login, OAuth login, or other human auth methods.
|
|
||||||
- Token scoping beyond Backend API bearer authentication.
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
# Workspace / Runtime / Docker launch boundary
|
|
||||||
|
|
||||||
Yoi's browser-facing deployment is a three-process boundary: WebUI, Workspace Backend, and Worker Runtime. Docker Compose currently exercises that boundary with locally loaded Nix-built images; it is not a separate authority model.
|
|
||||||
|
|
||||||
This document records the current design state for the split launch path, Docker image layout, and working-directory ownership rules. It is intentionally about boundaries and invariants rather than a complete operations manual.
|
|
||||||
|
|
||||||
## Process boundary
|
|
||||||
|
|
||||||
The browser launch path is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser WebUI
|
|
||||||
-> Workspace Backend Server
|
|
||||||
-> Worker Runtime
|
|
||||||
-> Worker process
|
|
||||||
```
|
|
||||||
|
|
||||||
Responsibilities are split as follows:
|
|
||||||
|
|
||||||
- WebUI renders the workspace and submits browser-safe requests. It does not hold filesystem authority, raw runtime paths, raw socket paths, or local Worker names as operation authority.
|
|
||||||
- Workspace Backend Server is the workspace control plane. It owns workspace-scoped API identity, Runtime registry projection, Worker registry projection, Ticket/workspace records, memory backend access, and browser-safe launch options.
|
|
||||||
- Worker Runtime owns runtime-specific process spawning, Worker controller state, workdir materialization, runtime-local workdir cleanup, and Worker protocol connectivity.
|
|
||||||
- Worker process owns model turns, tool execution inside its granted scope, session history append, and Worker protocol handling.
|
|
||||||
|
|
||||||
The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records.
|
|
||||||
|
|
||||||
## Docker image layout
|
|
||||||
|
|
||||||
Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile.
|
|
||||||
|
|
||||||
Current image roles:
|
|
||||||
|
|
||||||
```text
|
|
||||||
yoi-runtime:latest
|
|
||||||
worker-runtime-rest-server
|
|
||||||
|
|
||||||
yoi-server:latest
|
|
||||||
yoi-workspace-server
|
|
||||||
|
|
||||||
yoi-webui:latest
|
|
||||||
nginx serving built WebUI assets and proxying API requests
|
|
||||||
```
|
|
||||||
|
|
||||||
The Compose setup assumes those images have already been loaded into the local Docker daemon. Compose is a local wiring layer, not the builder.
|
|
||||||
|
|
||||||
Typical development flow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
nix build .#docker-runtime
|
|
||||||
nix build .#docker-server
|
|
||||||
nix build .#docker-webui
|
|
||||||
docker load < result
|
|
||||||
# repeat load for each built image result
|
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
The Compose files live at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
compose.yaml
|
|
||||||
docker/workspace/.yoi/workspace.toml
|
|
||||||
docker/workspace/.yoi/workspace-backend.local.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `worker-runtime-rest-server` and owns Worker spawning/materialization for that runtime.
|
|
||||||
|
|
||||||
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
|
|
||||||
|
|
||||||
## Worker launch path
|
|
||||||
|
|
||||||
A browser-created Worker follows this control flow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
WebUI new-worker form
|
|
||||||
runtime_id / profile / initial input / optional workdir selection
|
|
||||||
|
|
||||||
Workspace Backend create-worker API
|
|
||||||
validate workspace scope
|
|
||||||
resolve Runtime by canonical runtime_id
|
|
||||||
resolve profile/manifest using Backend authority
|
|
||||||
convert browser workdir selection into a Runtime working-directory claim
|
|
||||||
forward a typed Worker creation request to the selected Runtime
|
|
||||||
|
|
||||||
Worker Runtime create_worker
|
|
||||||
reject a requested primary workdir if an existing live/registered Worker already owns it
|
|
||||||
spawn the Worker through the runtime execution backend
|
|
||||||
return Worker detail, run state, and workdir binding
|
|
||||||
|
|
||||||
Workspace Backend post-spawn projection
|
|
||||||
sync Worker registry
|
|
||||||
sync workdir registry from Runtime summary
|
|
||||||
sync Worker/workdir link
|
|
||||||
return browser-safe Worker detail
|
|
||||||
```
|
|
||||||
|
|
||||||
The Runtime guard against assigning the same primary workdir to two active Workers is required even when the Backend has a registry projection. It is the last authority that sees runtime-local Worker requests and execution bindings.
|
|
||||||
|
|
||||||
Worker interaction uses the unified Worker protocol WebSocket path. The older event-only stream is not an operation authority. Normal interaction should go through protocol transport and runtime/backend forwarding rather than raw local sockets.
|
|
||||||
|
|
||||||
## Working-directory model
|
|
||||||
|
|
||||||
A working directory is a materialized repository for a workspace on a concrete Runtime.
|
|
||||||
|
|
||||||
The normal invariant is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
workdir {
|
|
||||||
workspace_id # Backend workspace scope
|
|
||||||
runtime_id # concrete Runtime that owns the materialization
|
|
||||||
repository_id # repository being materialized
|
|
||||||
working_directory_id
|
|
||||||
selector / resolved commit
|
|
||||||
materialization status
|
|
||||||
cleanliness
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`repository_id` is part of the normal model. A workdir without a repository is not a normal reusable workspace workdir; it should be treated as diagnostic/orphan/corrupted state rather than as a first-class launch candidate.
|
|
||||||
|
|
||||||
The removed `management_kind` state must not be reintroduced. Whether a row was first authored by Backend creation or later observed from Runtime is provenance, not current workdir authority. Reuse eligibility must be derived from current state and ownership:
|
|
||||||
|
|
||||||
- Runtime reports the workdir as active/present;
|
|
||||||
- cleanliness is clean;
|
|
||||||
- no active Runtime Worker owns it as a primary workdir;
|
|
||||||
- Backend worker/workdir links do not show an existing owner;
|
|
||||||
- `primary_worker_id` is absent in the browser-safe summary.
|
|
||||||
|
|
||||||
Runtime-observed workdirs that satisfy those conditions are valid reusable candidates for the selected Runtime. Hiding them because Backend did not originally author the registry row creates a stale provenance bug.
|
|
||||||
|
|
||||||
## Workspace and Runtime sharing assumption
|
|
||||||
|
|
||||||
The current Docker and browser launch path treats a configured Runtime as serving the Workspace that registered it. Runtime workdir summaries do not carry independent workspace authority sufficient for arbitrary multi-workspace sharing.
|
|
||||||
|
|
||||||
If one Runtime is later shared by multiple Workspace Backends, the Runtime workdir materialization record/API must carry enough workspace identity to let a Backend decide whether a workdir belongs to its workspace. That should be modeled explicitly as workspace identity on materialization records, not through a vague managed/unmanaged kind.
|
|
||||||
|
|
||||||
Until that exists, do not infer cross-workspace ownership from display names, local paths, Runtime labels, or Backend registry provenance.
|
|
||||||
|
|
||||||
## Memory backend launch pitfall
|
|
||||||
|
|
||||||
Workspace memory backend HTTP operations run inside async Worker/runtime paths. They must use async HTTP clients. Blocking HTTP clients such as `reqwest::blocking` can panic when created/dropped inside a Tokio async context and can kill Worker controller tasks.
|
|
||||||
|
|
||||||
The current memory backend HTTP path uses async `reqwest::Client`. New Backend/Runtime launch code should preserve that boundary.
|
|
||||||
|
|
||||||
## Current validation surface
|
|
||||||
|
|
||||||
The local-image Compose path has been checked by building/loading the Nix Docker images, running `docker compose config`, starting the Compose stack, loading WebUI `/`, and confirming `/api/runtimes` sees the remote Runtime.
|
|
||||||
|
|
||||||
Code-level changes around Runtime/Backend/WebUI boundaries should generally be checked with the relevant Rust crates, WebUI check, and whitespace validation, for example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
nix develop -c cargo check -p yoi-workspace-server -p worker-runtime -p client
|
|
||||||
nix develop -c cargo test -p yoi-workspace-server --lib
|
|
||||||
cd web/workspace && deno task check
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
@ -41,14 +41,6 @@ frontend_url = "http://127.0.0.1:5173"
|
||||||
[limits]
|
[limits]
|
||||||
max_records = 200
|
max_records = 200
|
||||||
|
|
||||||
[auth]
|
|
||||||
# WebAuthn / Passkey relying-party settings. For local development keep rp_id
|
|
||||||
# aligned with the browser host in public_base_url/origin.
|
|
||||||
rp_id = "localhost"
|
|
||||||
origin = "http://localhost:8787"
|
|
||||||
public_base_url = "http://localhost:8787"
|
|
||||||
cookie_name = "yoi_workspace_session"
|
|
||||||
|
|
||||||
# Repository registry. Browser/API repository projection reads only configured
|
# Repository registry. Browser/API repository projection reads only configured
|
||||||
# entries and never falls back to the backend process cwd. Relative URI values
|
# entries and never falls back to the backend process cwd. Relative URI values
|
||||||
# are resolved from this workspace config root. Git is the v0 supported provider.
|
# are resolved from this workspace config root. Git is the v0 supported provider.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
|
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -445,14 +445,12 @@
|
||||||
.runtime-card {
|
.runtime-card {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.ticket-list,
|
|
||||||
.objective-list {
|
.objective-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
.ticket-row,
|
|
||||||
.objective-row {
|
.objective-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) max-content;
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
|
@ -465,34 +463,27 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 140ms ease;
|
transition: background-color 140ms ease;
|
||||||
}
|
}
|
||||||
.ticket-row:hover,
|
|
||||||
.ticket-row:focus-visible,
|
|
||||||
.objective-row:hover,
|
.objective-row:hover,
|
||||||
.objective-row:focus-visible {
|
.objective-row:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--interactive-hover);
|
||||||
}
|
}
|
||||||
.ticket-row.selected,
|
|
||||||
.objective-row.selected {
|
.objective-row.selected {
|
||||||
background: var(--interactive-selected);
|
background: var(--interactive-selected);
|
||||||
}
|
}
|
||||||
.ticket-row.selected .ticket-title,
|
|
||||||
.objective-row.selected .objective-title {
|
.objective-row.selected .objective-title {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.ticket-main,
|
|
||||||
.objective-main {
|
.objective-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.ticket-title-row,
|
|
||||||
.objective-title-row {
|
.objective-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.ticket-title,
|
|
||||||
.objective-title {
|
.objective-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -509,14 +500,12 @@
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.ticket-summary,
|
|
||||||
.objective-summary {
|
.objective-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.ticket-meta,
|
|
||||||
.objective-meta {
|
.objective-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -529,12 +518,10 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.ticket-row,
|
|
||||||
.objective-row {
|
.objective-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
.ticket-meta,
|
|
||||||
.objective-meta {
|
.objective-meta {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -555,9 +542,6 @@
|
||||||
.runtime-heading span.warn {
|
.runtime-heading span.warn {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
.detail-heading.compact {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.detail-heading h3 {
|
.detail-heading h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -565,36 +549,6 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.ticket-detail-grid {
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
.risk-flags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
.risk-flags span,
|
|
||||||
.warning-pill {
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.18rem 0.55rem;
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
color: var(--warning);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.ticket-body pre {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-panel);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
dl {
|
dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
|
@ -872,45 +826,6 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-details {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin: var(--space-3) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-details div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(8rem, 0.35fr) 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-details dt {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-details dd {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-details code {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.settings-hero,
|
.settings-hero,
|
||||||
.settings-notice,
|
.settings-notice,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
|
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
|
||||||
const layout = await Deno.readTextFile(
|
const layout = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
@ -47,17 +47,24 @@ Deno.test("root layout bootstraps only the scoped workspace entry", async () =>
|
||||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
layout.includes("throw redirect(307") &&
|
layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
|
||||||
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
|
"unscoped layout should redirect to the scoped workspace route",
|
||||||
!layout.includes("scopedCompatibilityRoute") &&
|
|
||||||
!layout.includes("workspaceRoute(workspaceId, pathname)"),
|
|
||||||
"root layout should redirect only to the scoped workspace entry",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!layout.includes("`/api${path}`") &&
|
!layout.includes("`/api${path}`") &&
|
||||||
!layout.includes('"/api/repositories"'),
|
!layout.includes('"/api/repositories"'),
|
||||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
"layout must not fall back to unscoped workspace-scoped API calls",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const unscopedSettings = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/settings/+page.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
unscopedSettings.includes("Redirecting to scoped settings") &&
|
||||||
|
!unscopedSettings.includes("fetch(") &&
|
||||||
|
!unscopedSettings.includes("settingsApiPath"),
|
||||||
|
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
|
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import {
|
|
||||||
authenticationCredentialToJson,
|
|
||||||
isPublicKeyCredential,
|
|
||||||
prepareLoginOptions,
|
|
||||||
prepareRegistrationOptions,
|
|
||||||
registrationCredentialToJson,
|
|
||||||
type DeviceApprovalResponse,
|
|
||||||
type PasskeyLoginOptionsResponse,
|
|
||||||
type PasskeyRegistrationOptionsResponse,
|
|
||||||
type PasskeyUserResponse,
|
|
||||||
type WhoamiResponse,
|
|
||||||
} from "./model";
|
|
||||||
|
|
||||||
async function jsonOrThrow<T>(response: Response): Promise<T> {
|
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`);
|
|
||||||
}
|
|
||||||
return text ? JSON.parse(text) as T : (null as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> {
|
|
||||||
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerPasskey(
|
|
||||||
handle: string,
|
|
||||||
displayName: string,
|
|
||||||
fetcher: typeof fetch = fetch,
|
|
||||||
): Promise<PasskeyUserResponse> {
|
|
||||||
const options = await fetcher("/api/auth/passkeys/registration/options", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({ handle, display_name: displayName }),
|
|
||||||
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
|
|
||||||
|
|
||||||
const credential = await navigator.credentials.create({
|
|
||||||
publicKey: prepareRegistrationOptions(options),
|
|
||||||
});
|
|
||||||
if (!isPublicKeyCredential(credential)) {
|
|
||||||
throw new Error("Passkey registration did not return a public-key credential.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fetcher("/api/auth/passkeys/registration/complete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({
|
|
||||||
challenge_id: options.challenge_id,
|
|
||||||
credential: registrationCredentialToJson(credential),
|
|
||||||
}),
|
|
||||||
}).then(jsonOrThrow<PasskeyUserResponse>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginWithPasskey(
|
|
||||||
handle: string,
|
|
||||||
fetcher: typeof fetch = fetch,
|
|
||||||
): Promise<PasskeyUserResponse> {
|
|
||||||
const options = await fetcher("/api/auth/passkeys/login/options", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({ handle }),
|
|
||||||
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
|
|
||||||
|
|
||||||
const credential = await navigator.credentials.get({
|
|
||||||
publicKey: prepareLoginOptions(options),
|
|
||||||
});
|
|
||||||
if (!isPublicKeyCredential(credential)) {
|
|
||||||
throw new Error("Passkey login did not return a public-key credential.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fetcher("/api/auth/passkeys/login/complete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({
|
|
||||||
challenge_id: options.challenge_id,
|
|
||||||
credential: authenticationCredentialToJson(credential),
|
|
||||||
}),
|
|
||||||
}).then(jsonOrThrow<PasskeyUserResponse>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(fetcher: typeof fetch = fetch): Promise<void> {
|
|
||||||
await fetcher("/api/auth/logout", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
}).then(jsonOrThrow<unknown>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function approveDeviceLogin(
|
|
||||||
userCode: string,
|
|
||||||
fetcher: typeof fetch = fetch,
|
|
||||||
): Promise<DeviceApprovalResponse> {
|
|
||||||
return await fetcher("/api/auth/device-login/approve", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({ user_code: userCode }),
|
|
||||||
}).then(jsonOrThrow<DeviceApprovalResponse>);
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import {
|
|
||||||
base64UrlToBuffer,
|
|
||||||
bufferToBase64Url,
|
|
||||||
prepareLoginOptions,
|
|
||||||
prepareRegistrationOptions,
|
|
||||||
} from "./model.ts";
|
|
||||||
|
|
||||||
declare const Deno: {
|
|
||||||
test(name: string, fn: () => void | Promise<void>): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function assertEquals<T>(actual: T, expected: T): void {
|
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
||||||
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytes(buffer: BufferSource): number[] {
|
|
||||||
if (buffer instanceof ArrayBuffer) {
|
|
||||||
return [...new Uint8Array(buffer)];
|
|
||||||
}
|
|
||||||
return [...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)];
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test("base64url helpers round-trip binary data", () => {
|
|
||||||
const source = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]).buffer;
|
|
||||||
const encoded = bufferToBase64Url(source);
|
|
||||||
assertEquals(encoded.includes("+"), false);
|
|
||||||
assertEquals(encoded.includes("/"), false);
|
|
||||||
assertEquals(encoded.includes("="), false);
|
|
||||||
assertEquals(bytes(base64UrlToBuffer(encoded)), bytes(source));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
|
|
||||||
const options = prepareRegistrationOptions({
|
|
||||||
challenge_id: "challenge-1",
|
|
||||||
public_key: {
|
|
||||||
challenge: "AQID" as unknown as BufferSource,
|
|
||||||
rp: { id: "localhost", name: "Yoi" },
|
|
||||||
user: {
|
|
||||||
id: "BAUG" as unknown as BufferSource,
|
|
||||||
name: "local",
|
|
||||||
displayName: "Local User",
|
|
||||||
},
|
|
||||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
|
||||||
excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
|
||||||
assertEquals(bytes(options.user.id), [4, 5, 6]);
|
|
||||||
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [7, 8, 9]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => {
|
|
||||||
const options = prepareLoginOptions({
|
|
||||||
challenge_id: "challenge-1",
|
|
||||||
public_key: {
|
|
||||||
challenge: "AQID" as unknown as BufferSource,
|
|
||||||
allowCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
|
||||||
assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [7, 8, 9]);
|
|
||||||
});
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
export type AuthenticatedUser = {
|
|
||||||
user_id: string;
|
|
||||||
account_id: string;
|
|
||||||
handle: string;
|
|
||||||
display_name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RequestActor = {
|
|
||||||
user: AuthenticatedUser;
|
|
||||||
auth_kind: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WhoamiResponse = {
|
|
||||||
actor: RequestActor | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PasskeyRegistrationOptionsResponse = {
|
|
||||||
challenge_id: string;
|
|
||||||
public_key: PublicKeyCredentialCreationOptions | { publicKey: PublicKeyCredentialCreationOptions };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PasskeyLoginOptionsResponse = {
|
|
||||||
challenge_id: string;
|
|
||||||
public_key: PublicKeyCredentialRequestOptions | { publicKey: PublicKeyCredentialRequestOptions };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PasskeyUserResponse = {
|
|
||||||
user: AuthenticatedUser;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DeviceApprovalResponse = {
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RegistrationCredentialJson = {
|
|
||||||
id: string;
|
|
||||||
rawId: string;
|
|
||||||
type: string;
|
|
||||||
response: {
|
|
||||||
clientDataJSON: string;
|
|
||||||
attestationObject: string;
|
|
||||||
transports: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuthenticationCredentialJson = {
|
|
||||||
id: string;
|
|
||||||
rawId: string;
|
|
||||||
type: string;
|
|
||||||
response: {
|
|
||||||
clientDataJSON: string;
|
|
||||||
authenticatorData: string;
|
|
||||||
signature: string;
|
|
||||||
userHandle: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function base64UrlToBuffer(value: string): ArrayBuffer {
|
|
||||||
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
|
||||||
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const binary = atob(base64);
|
|
||||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
||||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
|
|
||||||
if (value && typeof value === "object" && "publicKey" in value) {
|
|
||||||
return (value as { publicKey: T }).publicKey;
|
|
||||||
}
|
|
||||||
return value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prepareRegistrationOptions(
|
|
||||||
options: PasskeyRegistrationOptionsResponse,
|
|
||||||
): PublicKeyCredentialCreationOptions {
|
|
||||||
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
|
|
||||||
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
|
|
||||||
publicKey.user = {
|
|
||||||
...publicKey.user,
|
|
||||||
id: base64UrlToBuffer(publicKey.user.id as unknown as string),
|
|
||||||
};
|
|
||||||
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((credential) => ({
|
|
||||||
...credential,
|
|
||||||
id: base64UrlToBuffer(credential.id as unknown as string),
|
|
||||||
}));
|
|
||||||
return publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prepareLoginOptions(
|
|
||||||
options: PasskeyLoginOptionsResponse,
|
|
||||||
): PublicKeyCredentialRequestOptions {
|
|
||||||
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
|
|
||||||
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
|
|
||||||
publicKey.allowCredentials = publicKey.allowCredentials?.map((credential) => ({
|
|
||||||
...credential,
|
|
||||||
id: base64UrlToBuffer(credential.id as unknown as string),
|
|
||||||
}));
|
|
||||||
return publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registrationCredentialToJson(
|
|
||||||
credential: PublicKeyCredential,
|
|
||||||
): RegistrationCredentialJson {
|
|
||||||
const response = credential.response as AuthenticatorAttestationResponse;
|
|
||||||
return {
|
|
||||||
id: credential.id,
|
|
||||||
rawId: bufferToBase64Url(credential.rawId),
|
|
||||||
type: credential.type,
|
|
||||||
response: {
|
|
||||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
|
||||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
|
||||||
transports: response.getTransports?.() ?? [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function authenticationCredentialToJson(
|
|
||||||
credential: PublicKeyCredential,
|
|
||||||
): AuthenticationCredentialJson {
|
|
||||||
const response = credential.response as AuthenticatorAssertionResponse;
|
|
||||||
return {
|
|
||||||
id: credential.id,
|
|
||||||
rawId: bufferToBase64Url(credential.rawId),
|
|
||||||
type: credential.type,
|
|
||||||
response: {
|
|
||||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
|
||||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
|
||||||
signature: bufferToBase64Url(response.signature),
|
|
||||||
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPublicKeyCredential(credential: Credential | null): credential is PublicKeyCredential {
|
|
||||||
return credential != null && credential.type === "public-key";
|
|
||||||
}
|
|
||||||
|
|
@ -27,13 +27,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
workspacePage.includes("ticketsHref") &&
|
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
|
||||||
workspacePage.includes("runtimeSettingsHref") &&
|
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
|
||||||
workspacePage.includes("workersHref") &&
|
"top workspace page should link to Runtimes and Workers pages",
|
||||||
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
|
||||||
workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") &&
|
|
||||||
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
|
|
||||||
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!workspacePage.includes("workerConsoleHref") &&
|
!workspacePage.includes("workerConsoleHref") &&
|
||||||
|
|
@ -55,66 +51,8 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("CompanionNavSection") &&
|
!sidebar.includes("CompanionNavSection") &&
|
||||||
sidebar.includes("TicketsNavSection") &&
|
|
||||||
sidebar.includes("WorkersNavSection"),
|
sidebar.includes("WorkersNavSection"),
|
||||||
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
|
"standalone Companion/Console navigation should not remain canonical",
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => {
|
|
||||||
const ticketsNav = await Deno.readTextFile(
|
|
||||||
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
|
|
||||||
);
|
|
||||||
const ticketsLoad = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url),
|
|
||||||
);
|
|
||||||
const ticketsPage = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url),
|
|
||||||
);
|
|
||||||
const ticketDetailLoad = await Deno.readTextFile(
|
|
||||||
new URL(
|
|
||||||
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
|
|
||||||
import.meta.url,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const ticketDetailPage = await Deno.readTextFile(
|
|
||||||
new URL(
|
|
||||||
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
|
||||||
import.meta.url,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
|
||||||
ticketsNav.includes("Open Tickets"),
|
|
||||||
"Tickets sidebar section should link to the workspace Tickets surface",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
|
|
||||||
ticketsPage.includes("This surface is read-only") &&
|
|
||||||
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
|
|
||||||
"Tickets list should read the workspace-scoped Ticket API and link to Ticket details",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
|
|
||||||
ticketDetailPage.includes("event_count") &&
|
|
||||||
ticketDetailPage.includes("artifact_count") &&
|
|
||||||
ticketDetailPage.includes("<pre>{data.ticket.data.body"),
|
|
||||||
"Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
|
|
||||||
const layoutLoad = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
!layoutLoad.includes("scopedCompatibilityRoute") &&
|
|
||||||
!layoutLoad.includes('pathname === "/runtimes"') &&
|
|
||||||
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
|
|
||||||
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
|
|
||||||
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -250,50 +188,49 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("workspace Runtime inventory lives under Settings admin routes", async () => {
|
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
|
||||||
const sidebar = await Deno.readTextFile(
|
const sidebar = await Deno.readTextFile(
|
||||||
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const settingsModel = await Deno.readTextFile(
|
const runtimesNav = await Deno.readTextFile(
|
||||||
new URL("../settings/model.ts", import.meta.url),
|
new URL("../sidebar/RuntimesNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const runtimesPage = await Deno.readTextFile(
|
const runtimesPage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte",
|
"./../../../routes/w/[workspaceId]/runtimes/+page.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const workdirsPage = await Deno.readTextFile(
|
const workdirsPage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const workdirsLoad = await Deno.readTextFile(
|
const workdirsLoad = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts",
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("RuntimesNavSection") &&
|
sidebar.includes("RuntimesNavSection") &&
|
||||||
settingsModel.includes('id: "runtime-inventory"') &&
|
runtimesNav.includes("href={runtimesHref}") &&
|
||||||
settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'),
|
runtimesNav.includes("/runtimes"),
|
||||||
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
|
"sidebar should expose Runtime management navigation",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
runtimesPage.includes("Runtime Inventory") &&
|
runtimesPage.includes("Open workdirs") &&
|
||||||
runtimesPage.includes("Open workdirs") &&
|
|
||||||
runtimesPage.includes("runtimes-table") &&
|
runtimesPage.includes("runtimes-table") &&
|
||||||
runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"),
|
runtimesPage.includes("/workdirs"),
|
||||||
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
|
"Runtimes page should table Runtimes and link to each Runtime's workdirs",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workdirsPage.includes("Runtime Inventory") &&
|
workdirsPage.includes("Workdirs") &&
|
||||||
workdirsPage.includes("workdirs-table") &&
|
workdirsPage.includes("workdirs-table") &&
|
||||||
workdirsLoad.includes("/working-directories"),
|
workdirsLoad.includes("/working-directories"),
|
||||||
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
|
"Workdirs page should read Runtime-owned working-directory API while using workdir UI language",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -373,46 +310,3 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||||
"target-change effect should load data without depending on manual refresh state reads",
|
"target-change effect should load data without depending on manual refresh state reads",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => {
|
|
||||||
const accountPage = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/account/+page.svelte", import.meta.url),
|
|
||||||
);
|
|
||||||
const devicePage = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
|
|
||||||
);
|
|
||||||
const authApi = await Deno.readTextFile(
|
|
||||||
new URL("../auth/api.ts", import.meta.url),
|
|
||||||
);
|
|
||||||
const rootLayout = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
accountPage.includes("registerPasskey") &&
|
|
||||||
accountPage.includes("loginWithPasskey") &&
|
|
||||||
accountPage.includes("logout") &&
|
|
||||||
accountPage.includes("loadWhoami") &&
|
|
||||||
accountPage.includes("Current user"),
|
|
||||||
"Account page should expose registration, login, logout, and current-session inspection",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
devicePage.includes("approveDeviceLogin") &&
|
|
||||||
devicePage.includes("loginWithPasskey") &&
|
|
||||||
devicePage.includes("user_code") &&
|
|
||||||
devicePage.includes("Approve device login"),
|
|
||||||
"Device login page should approve CLI login without DevTools console",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
authApi.includes("/api/auth/whoami") &&
|
|
||||||
authApi.includes("/api/auth/passkeys/registration/options") &&
|
|
||||||
authApi.includes("/api/auth/passkeys/login/complete") &&
|
|
||||||
authApi.includes("/api/auth/logout") &&
|
|
||||||
authApi.includes("/api/auth/device-login/approve"),
|
|
||||||
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
rootLayout.includes('"/account"') && rootLayout.includes('"/login/device"'),
|
|
||||||
"Root layout should not redirect account and device-login public routes to a workspace",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ export type Diagnostic = {
|
||||||
|
|
||||||
export type SettingsSectionId =
|
export type SettingsSectionId =
|
||||||
| "runtime-connections"
|
| "runtime-connections"
|
||||||
| "runtime-inventory"
|
|
||||||
| "profile-sources"
|
| "profile-sources"
|
||||||
| "backend-config"
|
| "backend-config"
|
||||||
| "workspace-identity";
|
| "workspace-identity";
|
||||||
|
|
@ -86,18 +85,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||||
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "runtime-inventory",
|
|
||||||
label: "Runtime Inventory",
|
|
||||||
status: "read-only",
|
|
||||||
summary:
|
|
||||||
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
|
|
||||||
bullets: [
|
|
||||||
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
|
|
||||||
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
|
|
||||||
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "profile-sources",
|
id: "profile-sources",
|
||||||
label: "Profile Sources",
|
label: "Profile Sources",
|
||||||
|
|
@ -158,8 +145,6 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "runtime-connections":
|
case "runtime-connections":
|
||||||
return `${SETTINGS_ROUTE}/runtime-connections`;
|
return `${SETTINGS_ROUTE}/runtime-connections`;
|
||||||
case "runtime-inventory":
|
|
||||||
return `${SETTINGS_ROUTE}/runtimes`;
|
|
||||||
case "profile-sources":
|
case "profile-sources":
|
||||||
return `${SETTINGS_ROUTE}/profiles`;
|
return `${SETTINGS_ROUTE}/profiles`;
|
||||||
case "workspace-identity":
|
case "workspace-identity":
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
currentPath?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { currentPath = '/', workspaceId }: Props = $props();
|
||||||
|
let runtimesHref = $derived(workspaceId ? `/w/${workspaceId}/runtimes` : '/runtimes');
|
||||||
|
let active = $derived(currentPath === runtimesHref || currentPath.startsWith(`${runtimesHref}/`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="sidebar-section" aria-labelledby="runtimes-heading">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2 id="runtimes-heading">
|
||||||
|
<a
|
||||||
|
class="section-heading-link"
|
||||||
|
class:active
|
||||||
|
href={runtimesHref}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>runtimes</a>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
currentPath?: string;
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { currentPath = '/', workspaceId }: Props = $props();
|
|
||||||
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="nav-section">
|
|
||||||
<header class="section-header">
|
|
||||||
<span>Tickets</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<a class="objective-link" class:active={currentPath.startsWith(ticketsHref)} href={ticketsHref}>
|
|
||||||
<span class="item-title">Open Tickets</span>
|
|
||||||
<span class="item-meta">workspace tickets</span>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
import RuntimesNavSection from './RuntimesNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
||||||
|
|
||||||
|
|
@ -28,7 +28,6 @@
|
||||||
|
|
||||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||||
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
||||||
let accountHref = '/account';
|
|
||||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -88,17 +87,6 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="sidebar-icon-button"
|
|
||||||
href={accountHref}
|
|
||||||
aria-label="Open Account"
|
|
||||||
title="Account"
|
|
||||||
>
|
|
||||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
|
||||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="12" cy="7" r="4" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
class="sidebar-icon-button"
|
class="sidebar-icon-button"
|
||||||
href={settingsHref}
|
href={settingsHref}
|
||||||
|
|
@ -119,7 +107,7 @@
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
<TicketsNavSection {currentPath} {workspaceId} />
|
<RuntimesNavSection {currentPath} {workspaceId} />
|
||||||
<WorkersNavSection {currentPath} {workspaceId} />
|
<WorkersNavSection {currentPath} {workspaceId} />
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -320,31 +320,6 @@ export type TicketSummary = {
|
||||||
record_source?: string;
|
record_source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketListResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
limit: number;
|
|
||||||
items: TicketSummary[];
|
|
||||||
invalid_records: InvalidProjectRecord[];
|
|
||||||
record_authority: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketDetail = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
state: string;
|
|
||||||
priority?: string | null;
|
|
||||||
created_at?: string | null;
|
|
||||||
updated_at?: string | null;
|
|
||||||
queued_by?: string | null;
|
|
||||||
queued_at?: string | null;
|
|
||||||
risk_flags: string[];
|
|
||||||
body: string;
|
|
||||||
body_truncated: boolean;
|
|
||||||
event_count: number;
|
|
||||||
artifact_count: number;
|
|
||||||
record_source: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketKanbanColumn = {
|
export type TicketKanbanColumn = {
|
||||||
state: string;
|
state: string;
|
||||||
items: TicketSummary[];
|
items: TicketSummary[];
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
||||||
const publicRoutes = new Set(["/account", "/login/device"]);
|
if (workspace.data) {
|
||||||
if (workspace.data && !publicRoutes.has(url.pathname)) {
|
const scopedPath = workspaceRoute(
|
||||||
const scopedPath = workspaceRoute(workspace.data.workspace_id);
|
workspace.data.workspace_id,
|
||||||
|
url.pathname === "/" ? "" : url.pathname,
|
||||||
|
);
|
||||||
throw redirect(307, `${scopedPath}${url.search}`);
|
throw redirect(307, `${scopedPath}${url.search}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<main class="workspace-panel-shell">
|
<main class="workspace-panel-shell">
|
||||||
<section class="workspace-card">
|
<section class="workspace-card">
|
||||||
<h1>Redirecting to scoped workspace…</h1>
|
<h1>Redirecting to scoped workspace…</h1>
|
||||||
<p class="section-note">The workspace entry bootstraps the current workspace id and opens the canonical <code>/w/<workspace-id></code> route.</p>
|
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/<workspace-id></code> route.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import {
|
|
||||||
loadWhoami,
|
|
||||||
loginWithPasskey,
|
|
||||||
logout,
|
|
||||||
registerPasskey,
|
|
||||||
} from '$lib/workspace/auth/api';
|
|
||||||
import type { RequestActor } from '$lib/workspace/auth/model';
|
|
||||||
|
|
||||||
let actor = $state<RequestActor | null>(null);
|
|
||||||
let handle = $state('local');
|
|
||||||
let displayName = $state('Local User');
|
|
||||||
let loading = $state(true);
|
|
||||||
let busy = $state(false);
|
|
||||||
let status = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
async function refreshWhoami() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const whoami = await loadWhoami();
|
|
||||||
actor = whoami.actor;
|
|
||||||
if (actor?.user.handle) {
|
|
||||||
handle = actor.user.handle;
|
|
||||||
displayName = actor.user.display_name;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function register() {
|
|
||||||
busy = true;
|
|
||||||
status = 'Waiting for passkey registration…';
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
|
|
||||||
status = 'Passkey registered.';
|
|
||||||
await refreshWhoami();
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
status = '';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
busy = true;
|
|
||||||
status = 'Waiting for passkey login…';
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await loginWithPasskey(handle.trim());
|
|
||||||
status = 'Logged in.';
|
|
||||||
await refreshWhoami();
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
status = '';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signOut() {
|
|
||||||
busy = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await logout();
|
|
||||||
actor = null;
|
|
||||||
status = 'Logged out.';
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void refreshWhoami();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Account · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main class="settings-page">
|
|
||||||
<header class="settings-header">
|
|
||||||
<p class="eyebrow">Account</p>
|
|
||||||
<h1>Account</h1>
|
|
||||||
<p>Register a passkey, sign in, sign out, and inspect the current browser session.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="settings-panel account-panel">
|
|
||||||
<div>
|
|
||||||
<h2>Current user</h2>
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Loading session…</p>
|
|
||||||
{:else if actor}
|
|
||||||
<dl class="account-details">
|
|
||||||
<div>
|
|
||||||
<dt>Handle</dt>
|
|
||||||
<dd>{actor.user.handle}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Display name</dt>
|
|
||||||
<dd>{actor.user.display_name}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>User ID</dt>
|
|
||||||
<dd><code>{actor.user.user_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Account ID</dt>
|
|
||||||
<dd><code>{actor.user.account_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Auth kind</dt>
|
|
||||||
<dd>{actor.auth_kind}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="settings-action-row">
|
|
||||||
<button type="button" disabled={busy} onclick={signOut}>Log out</button>
|
|
||||||
<button class="secondary-button" type="button" disabled={busy} onclick={refreshWhoami}>Refresh</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="muted">No browser session is active.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-panel">
|
|
||||||
<h2>Passkey</h2>
|
|
||||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
|
|
||||||
<label>
|
|
||||||
User handle
|
|
||||||
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Display name
|
|
||||||
<input bind:value={displayName} autocomplete="name" placeholder="Local User" />
|
|
||||||
</label>
|
|
||||||
<div class="settings-action-row">
|
|
||||||
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button>
|
|
||||||
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with passkey</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if status}
|
|
||||||
<p class="status-message">{status}</p>
|
|
||||||
{/if}
|
|
||||||
{#if error}
|
|
||||||
<p class="error-message">{error}</p>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import {
|
|
||||||
approveDeviceLogin,
|
|
||||||
loadWhoami,
|
|
||||||
loginWithPasskey,
|
|
||||||
} from '$lib/workspace/auth/api';
|
|
||||||
import type { RequestActor } from '$lib/workspace/auth/model';
|
|
||||||
|
|
||||||
let actor = $state<RequestActor | null>(null);
|
|
||||||
let handle = $state('local');
|
|
||||||
let userCode = $state('');
|
|
||||||
let loading = $state(true);
|
|
||||||
let busy = $state(false);
|
|
||||||
let status = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
async function refreshWhoami() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const whoami = await loadWhoami();
|
|
||||||
actor = whoami.actor;
|
|
||||||
if (actor?.user.handle) handle = actor.user.handle;
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
busy = true;
|
|
||||||
status = 'Waiting for passkey login…';
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await loginWithPasskey(handle.trim());
|
|
||||||
status = 'Logged in. You can approve the CLI login now.';
|
|
||||||
await refreshWhoami();
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
status = '';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function approve() {
|
|
||||||
busy = true;
|
|
||||||
status = 'Approving device login…';
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const result = await approveDeviceLogin(userCode.trim().toUpperCase());
|
|
||||||
status = result.status === 'approved'
|
|
||||||
? 'Device login approved. You can return to the CLI.'
|
|
||||||
: `Device login status: ${result.status}`;
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
status = '';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
|
|
||||||
void refreshWhoami();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Device Login · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main class="settings-page">
|
|
||||||
<header class="settings-header">
|
|
||||||
<p class="eyebrow">Device Login</p>
|
|
||||||
<h1>Approve CLI login</h1>
|
|
||||||
<p>Use your browser session to approve a pending CLI/TUI login request.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="settings-panel">
|
|
||||||
<h2>Browser session</h2>
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Loading session…</p>
|
|
||||||
{:else if actor}
|
|
||||||
<p>Logged in as <strong>{actor.user.display_name}</strong> <span class="muted">@{actor.user.handle}</span>.</p>
|
|
||||||
{:else}
|
|
||||||
<p class="muted">You need to log in with a passkey before approving a device login.</p>
|
|
||||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}>
|
|
||||||
<label>
|
|
||||||
User handle
|
|
||||||
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={busy || !handle.trim()}>Log in with passkey</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-panel">
|
|
||||||
<h2>Approval code</h2>
|
|
||||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void approve(); }}>
|
|
||||||
<label>
|
|
||||||
User code
|
|
||||||
<input bind:value={userCode} placeholder="ABCD-EFGH" autocapitalize="characters" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={busy || !actor || !userCode.trim()}>Approve device login</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if status}
|
|
||||||
<p class="status-message">{status}</p>
|
|
||||||
{/if}
|
|
||||||
{#if error}
|
|
||||||
<p class="error-message">{error}</p>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
6
web/workspace/src/routes/objectives/+page.svelte
Normal file
6
web/workspace/src/routes/objectives/+page.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<main class="objectives-page">
|
||||||
|
<section class="workspace-card">
|
||||||
|
<h1>Redirecting to scoped objectives…</h1>
|
||||||
|
<p class="section-note">Objectives are available at the canonical <code>/w/<workspace-id>/objectives</code> route.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
3
web/workspace/src/routes/objectives/+page.ts
Normal file
3
web/workspace/src/routes/objectives/+page.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => ({});
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<main class="objective-detail-page">
|
||||||
|
<section class="workspace-card">
|
||||||
|
<h1>Redirecting to scoped objective…</h1>
|
||||||
|
<p class="section-note">Objective detail routes are canonical under <code>/w/<workspace-id>/objectives/<objective-id></code>.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => ({});
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<main class="repository-detail-page">
|
||||||
|
<section class="workspace-card">
|
||||||
|
<h1>Redirecting to scoped repository…</h1>
|
||||||
|
<p class="section-note">Repository routes are canonical under <code>/w/<workspace-id>/repositories/<repository-id></code>.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => ({});
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<main class="worker-console-page">
|
||||||
|
<section class="workspace-card">
|
||||||
|
<h1>Redirecting to scoped Worker Console…</h1>
|
||||||
|
<p class="section-note">Worker Console routes are canonical under <code>/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console</code>.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => ({});
|
||||||
6
web/workspace/src/routes/settings/+page.svelte
Normal file
6
web/workspace/src/routes/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<main class="settings-page">
|
||||||
|
<section class="workspace-card">
|
||||||
|
<h1>Redirecting to scoped settings…</h1>
|
||||||
|
<p class="section-note">Settings are available at the canonical <code>/w/<workspace-id>/settings</code> route.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
||||||
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
|
|
||||||
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
|
|
||||||
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -43,17 +39,12 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="workspace-actions" aria-label="Workspace sections">
|
<section class="workspace-actions" aria-label="Workspace sections">
|
||||||
<a class="workspace-action-card" href={ticketsHref}>
|
<a class="workspace-action-card" href={`/w/${workspaceId}/runtimes`}>
|
||||||
<span>Tickets</span>
|
<span>Runtimes</span>
|
||||||
<strong>Browse workspace tickets</strong>
|
<strong>Manage runtimes and workdirs</strong>
|
||||||
<small>Read typed Ticket records</small>
|
|
||||||
</a>
|
|
||||||
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
|
||||||
<span>Runtime Inventory</span>
|
|
||||||
<strong>Open admin runtime inventory</strong>
|
|
||||||
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
||||||
</a>
|
</a>
|
||||||
<a class="workspace-action-card" href={workersHref}>
|
<a class="workspace-action-card" href={`/w/${workspaceId}/workers`}>
|
||||||
<span>Workers</span>
|
<span>Workers</span>
|
||||||
<strong>Open worker list</strong>
|
<strong>Open worker list</strong>
|
||||||
<small>Inspect status and attach to consoles</small>
|
<small>Inspect status and attach to consoles</small>
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Runtime Inventory · Settings · Yoi Workspace</title>
|
<title>Runtimes · Yoi Workspace</title>
|
||||||
<meta name="description" content="Workspace Runtime inventory" />
|
<meta name="description" content="Workspace Runtimes" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
||||||
<header class="page-header-row">
|
<header class="page-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="runtimes-heading">Runtime Inventory</h1>
|
<h1 id="runtimes-heading">Runtimes</h1>
|
||||||
<p>Admin view of execution backends available to this workspace.</p>
|
<p>Execution backends available to this workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{runtime.capabilities.max_workers} workers</td>
|
<td>{runtime.capabilities.max_workers} workers</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
<a class="inline-link" href={`/w/${data.workspaceId}/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
||||||
Open workdirs
|
Open workdirs
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -105,7 +105,7 @@
|
||||||
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
||||||
<header class="page-header-row">
|
<header class="page-header-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p>
|
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
||||||
<h1 id="workdirs-heading">Workdirs</h1>
|
<h1 id="workdirs-heading">Workdirs</h1>
|
||||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||||
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Tickets · Yoi Workspace</title>
|
|
||||||
<meta name="description" content="Workspace Tickets" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<div class="detail-heading">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Workspace records</p>
|
|
||||||
<h2>Tickets</h2>
|
|
||||||
</div>
|
|
||||||
{#if data.tickets.data}
|
|
||||||
<span>{data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="section-note">
|
|
||||||
Tickets are read from the typed Ticket backend. This surface is read-only; creation and queue operations remain outside the browser UI for now.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if data.tickets.data}
|
|
||||||
{#if data.tickets.data.items.length === 0}
|
|
||||||
<p>No Ticket records are present.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="ticket-list" aria-label="Workspace Tickets">
|
|
||||||
{#each data.tickets.data.items as ticket (ticket.id)}
|
|
||||||
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>
|
|
||||||
<div class="ticket-main">
|
|
||||||
<div class="ticket-title-row">
|
|
||||||
<strong class="ticket-title">{ticket.title}</strong>
|
|
||||||
<span class="state-pill">{ticket.state}</span>
|
|
||||||
</div>
|
|
||||||
<p class="ticket-summary">
|
|
||||||
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ticket-meta" aria-label="Ticket metadata">
|
|
||||||
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span>
|
|
||||||
{#if ticket.queued_at}
|
|
||||||
<span>Queued {formatDate(ticket.queued_at)}</span>
|
|
||||||
{/if}
|
|
||||||
<code>{ticket.id}</code>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.tickets.data.invalid_records.length > 0}
|
|
||||||
<p class="error">{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if data.tickets.error}
|
|
||||||
<p class="error">{data.tickets.error}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
|
||||||
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
|
||||||
const tickets = await loadJson<TicketListResponse>(
|
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, "/tickets"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
workspaceId: params.workspaceId,
|
|
||||||
tickets,
|
|
||||||
};
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
|
|
||||||
<meta name="description" content="Workspace Ticket detail" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
|
|
||||||
|
|
||||||
{#if data.ticket.data}
|
|
||||||
<div class="detail-heading">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">{data.ticket.data.id}</p>
|
|
||||||
<h2>{data.ticket.data.title}</h2>
|
|
||||||
</div>
|
|
||||||
<span class="state-pill">{data.ticket.data.state}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="ticket-detail-grid">
|
|
||||||
<div>
|
|
||||||
<dt>Priority</dt>
|
|
||||||
<dd>{data.ticket.data.priority ?? 'unspecified'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Updated</dt>
|
|
||||||
<dd>{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Created</dt>
|
|
||||||
<dd>{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Events</dt>
|
|
||||||
<dd>{data.ticket.data.event_count}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Artifacts</dt>
|
|
||||||
<dd>{data.ticket.data.artifact_count}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Source</dt>
|
|
||||||
<dd>{data.ticket.data.record_source}</dd>
|
|
||||||
</div>
|
|
||||||
{#if data.ticket.data.queued_at || data.ticket.data.queued_by}
|
|
||||||
<div>
|
|
||||||
<dt>Queued</dt>
|
|
||||||
<dd>
|
|
||||||
{data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{#if data.ticket.data.risk_flags.length > 0}
|
|
||||||
<div class="risk-flags" aria-label="Risk flags">
|
|
||||||
{#each data.ticket.data.risk_flags as flag}
|
|
||||||
<span>{flag}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="ticket-body" aria-labelledby="ticket-body-heading">
|
|
||||||
<div class="detail-heading compact">
|
|
||||||
<h3 id="ticket-body-heading">Body</h3>
|
|
||||||
{#if data.ticket.data.body_truncated}
|
|
||||||
<span class="warning-pill">truncated</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<pre>{data.ticket.data.body || 'No body text is available.'}</pre>
|
|
||||||
</section>
|
|
||||||
{:else if data.ticket.error}
|
|
||||||
<p class="error">{data.ticket.error}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets/{data.ticketId}</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
|
||||||
import type { TicketDetail } from "$lib/workspace/sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
|
||||||
const ticketId = params.ticketId;
|
|
||||||
const ticket = await loadJson<TicketDetail>(
|
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(ticketId)}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
workspaceId: params.workspaceId,
|
|
||||||
ticketId,
|
|
||||||
ticket,
|
|
||||||
};
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
Loading…
Reference in New Issue
Block a user