Compare commits
19 Commits
test/front
...
feat/cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d08974876 | ||
|
|
ca278bddc8 | ||
|
|
7b50047730 | ||
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 | ||
|
|
b459b99fe9 | ||
|
|
f694a6d504 | ||
|
|
70b66451d6 | ||
|
|
c4fa53052d | ||
|
|
2f6840fe3e | ||
|
|
75c815d02a | ||
|
|
d9c3d4d661 | ||
|
|
bef4d34c43 | ||
|
|
99a3ed1b6b | ||
|
|
4644ea4919 |
351
Cargo.lock
generated
351
Cargo.lock
generated
@@ -40,6 +40,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -68,6 +118,21 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert_cmd"
|
||||||
|
version = "2.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"bstr",
|
||||||
|
"libc",
|
||||||
|
"predicates",
|
||||||
|
"predicates-core",
|
||||||
|
"predicates-tree",
|
||||||
|
"wait-timeout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -236,6 +301,17 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bstr"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -302,6 +378,52 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -440,6 +562,12 @@ version = "0.1.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "difflib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -452,6 +580,27 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -516,6 +665,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figment"
|
name = "figment"
|
||||||
version = "0.10.19"
|
version = "0.10.19"
|
||||||
@@ -536,6 +691,15 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-cmp"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1010,6 +1174,12 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1077,6 +1247,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1161,6 +1337,12 @@ dependencies = [
|
|||||||
"spin 0.5.2",
|
"spin 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "normalize-line-endings"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1231,6 +1413,18 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -1335,6 +1529,25 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "picloud-cli"
|
||||||
|
version = "0.6.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"assert_cmd",
|
||||||
|
"clap",
|
||||||
|
"directories",
|
||||||
|
"picloud-shared",
|
||||||
|
"predicates",
|
||||||
|
"reqwest",
|
||||||
|
"rpassword",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1509,6 +1722,36 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates"
|
||||||
|
version = "3.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"difflib",
|
||||||
|
"float-cmp",
|
||||||
|
"normalize-line-endings",
|
||||||
|
"predicates-core",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates-core"
|
||||||
|
version = "1.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates-tree"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
|
||||||
|
dependencies = [
|
||||||
|
"predicates-core",
|
||||||
|
"termtree",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pretty_assertions"
|
name = "pretty_assertions"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@@ -1704,6 +1947,29 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1729,7 +1995,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1812,6 +2080,17 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -1832,6 +2111,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-multipart-rfc7578_2"
|
name = "rust-multipart-rfc7578_2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1853,6 +2142,19 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
@@ -2327,6 +2629,12 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -2364,6 +2672,25 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termtree"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thin-vec"
|
name = "thin-vec"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -2783,6 +3110,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.1"
|
||||||
@@ -2813,6 +3146,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wait-timeout"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3066,6 +3408,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/picloud-manager",
|
"crates/picloud-manager",
|
||||||
"crates/picloud-orchestrator",
|
"crates/picloud-orchestrator",
|
||||||
"crates/picloud-executor",
|
"crates/picloud-executor",
|
||||||
|
"crates/picloud-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -270,10 +270,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
// Delete is gated tighter than Save: editors can edit scripts but
|
||||||
|
// only app_admin / instance admin / owner can remove them. See
|
||||||
|
// blueprint §11.6.
|
||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppWriteScript(script.app_id),
|
Capability::AppAdmin(script.app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ pub struct AppLookupResponse {
|
|||||||
pub redirect_to: Option<String>,
|
pub redirect_to: Option<String>,
|
||||||
/// The caller's role on this app, used by the dashboard to decide
|
/// The caller's role on this app, used by the dashboard to decide
|
||||||
/// whether to render admin-only surfaces (Members tab, settings).
|
/// whether to render admin-only surfaces (Members tab, settings).
|
||||||
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
|
/// `Owner` and `Admin` both map to `app_admin` (implicit per
|
||||||
/// per blueprint §11.6); `Member` carries its explicit
|
/// blueprint §11.6); `Member` carries its explicit
|
||||||
/// `app_members.role`.
|
/// `app_members.role`.
|
||||||
pub my_role: Option<AppRole>,
|
pub my_role: Option<AppRole>,
|
||||||
}
|
}
|
||||||
@@ -226,16 +226,15 @@ async fn get_app(
|
|||||||
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||||
/// the implicit-grant logic in `authz::role_grants` but returns the
|
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||||
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||||
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
|
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
|
||||||
/// everywhere; `Member` consults `app_members`.
|
/// consults `app_members`.
|
||||||
async fn compute_my_role(
|
async fn compute_my_role(
|
||||||
authz: &dyn AuthzRepo,
|
authz: &dyn AuthzRepo,
|
||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Option<AppRole>, AppsApiError> {
|
) -> Result<Option<AppRole>, AppsApiError> {
|
||||||
match principal.instance_role {
|
match principal.instance_role {
|
||||||
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
|
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||||
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
|
|
||||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,21 +199,14 @@ async fn role_grants(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
|
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
|
||||||
/// can create apps and manage users, but NOT touch instance-wide
|
/// They can create apps, manage users, and take any app-scoped action
|
||||||
/// settings or take app-admin-only actions on apps they're not
|
/// on any app without an explicit `app_members` row — single-human
|
||||||
/// explicitly app_admin of. Everything not in this set falls through
|
/// installs would otherwise need to add themselves to every new app.
|
||||||
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
|
||||||
|
/// owner-only.
|
||||||
const fn admin_grants(cap: Capability) -> bool {
|
const fn admin_grants(cap: Capability) -> bool {
|
||||||
matches!(
|
!matches!(cap, Capability::InstanceManageSettings)
|
||||||
cap,
|
|
||||||
Capability::InstanceCreateApp
|
|
||||||
| Capability::InstanceManageUsers
|
|
||||||
| Capability::AppRead(_)
|
|
||||||
| Capability::AppWriteScript(_)
|
|
||||||
| Capability::AppWriteRoute(_)
|
|
||||||
| Capability::AppLogRead(_)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Member has zero instance authority. App authority requires an
|
/// Member has zero instance authority. App authority requires an
|
||||||
@@ -357,10 +350,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
|
async fn admin_cannot_manage_instance_settings() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageSettings)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_is_implicit_app_admin_on_every_app() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
let p = principal(InstanceRole::Admin);
|
let p = principal(InstanceRole::Admin);
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
|
// Instance-scoped allowances.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
@@ -371,36 +377,22 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
// Editor-like + app-admin grants both succeed without any
|
||||||
can(&repo, &p, Capability::InstanceManageSettings)
|
// app_members row.
|
||||||
.await
|
for cap in [
|
||||||
.unwrap(),
|
Capability::AppRead(app),
|
||||||
Decision::Deny,
|
Capability::AppWriteScript(app),
|
||||||
);
|
Capability::AppWriteRoute(app),
|
||||||
// Editor-like grants succeed
|
Capability::AppLogRead(app),
|
||||||
assert_eq!(
|
Capability::AppManageDomains(app),
|
||||||
can(&repo, &p, Capability::AppWriteScript(app))
|
Capability::AppAdmin(app),
|
||||||
.await
|
] {
|
||||||
.unwrap(),
|
assert_eq!(
|
||||||
Decision::Allow,
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
);
|
Decision::Allow,
|
||||||
assert_eq!(
|
"admin denied app-scoped capability {cap:?}"
|
||||||
can(&repo, &p, Capability::AppWriteRoute(app))
|
);
|
||||||
.await
|
}
|
||||||
.unwrap(),
|
|
||||||
Decision::Allow,
|
|
||||||
);
|
|
||||||
// App-admin grants do not
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppManageDomains(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -474,6 +466,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
|
||||||
|
/// (Delete). The script-delete handler gates on the latter so the
|
||||||
|
/// API can't be tricked into letting an editor remove the script
|
||||||
|
/// they were only allowed to edit.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editor_can_write_scripts_but_not_delete_them() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
// Delete is gated on AppAdmin in the handler — editors must be
|
||||||
|
// denied here for that gate to bite.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
|||||||
31
crates/picloud-cli/Cargo.toml
Normal file
31
crates/picloud-cli/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "picloud-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "PiCloud command-line client"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pic"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
picloud-shared.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
directories = "5"
|
||||||
|
rpassword = "7"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
reqwest = { workspace = true, features = ["json", "blocking"] }
|
||||||
333
crates/picloud-cli/src/client.rs
Normal file
333
crates/picloud-cli/src/client.rs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
//! Reqwest-backed HTTP client + minimal wire DTOs.
|
||||||
|
//!
|
||||||
|
//! The CLI deliberately re-declares small request/response structs here
|
||||||
|
//! rather than depending on `manager-core` (and pulling its Postgres
|
||||||
|
//! transitive surface). Fields kept to what the CLI actually sends or
|
||||||
|
//! reads.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, Script};
|
||||||
|
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::config::Credentials;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
http: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn from_creds(creds: &Credentials) -> Result<Self> {
|
||||||
|
Self::new(&creds.url, &creds.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(url: &str, token: &str) -> Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
url: url.trim_end_matches('/').to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> &str {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||||
|
self.http
|
||||||
|
.request(method, format!("{}{path}", self.url))
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/auth/me`
|
||||||
|
pub async fn auth_me(&self) -> Result<AuthMeDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/auth/me")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps`
|
||||||
|
pub async fn apps_list(&self) -> Result<Vec<App>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/apps")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
|
||||||
|
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/apps`
|
||||||
|
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/apps")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts?app={ident}`
|
||||||
|
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/scripts`
|
||||||
|
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/scripts")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||||
|
/// uses PUT despite the field-level update semantics.
|
||||||
|
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||||
|
let body = UpdateScriptBody { source };
|
||||||
|
let resp = self
|
||||||
|
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
|
||||||
|
/// and JSON body (the orchestrator marshals the script's output as
|
||||||
|
/// the HTTP response itself, not a wrapper object).
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
body: Value,
|
||||||
|
headers: &[(String, String)],
|
||||||
|
) -> Result<ExecuteResponse> {
|
||||||
|
let mut req = self
|
||||||
|
.request(Method::POST, &format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&body);
|
||||||
|
for (k, v) in headers {
|
||||||
|
req = req.header(k, v);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
for (k, v) in resp.headers() {
|
||||||
|
if let Ok(val) = v.to_str() {
|
||||||
|
headers_out.insert(k.as_str().to_string(), val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await.context("reading execute response")?;
|
||||||
|
let body_json: Value = if bytes.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
||||||
|
};
|
||||||
|
Ok(ExecuteResponse {
|
||||||
|
status_code: status,
|
||||||
|
headers: headers_out,
|
||||||
|
body: body_json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||||
|
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthMeDto {
|
||||||
|
// Part of the wire shape (and kept for symmetry with the dashboard's
|
||||||
|
// MeDto), even though the CLI never displays it.
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AppLookupDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
|
||||||
|
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
|
||||||
|
#[serde(default)]
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateAppBody<'a> {
|
||||||
|
pub slug: &'a str,
|
||||||
|
pub name: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateScriptBody<'a> {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub name: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
pub source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UpdateScriptBody<'a> {
|
||||||
|
source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExecuteResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
// Captured for completeness; not displayed today, but `pic invoke -v`
|
||||||
|
// could surface them later without changing this struct.
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
|
||||||
|
/// pair. Trims surrounding whitespace on both sides.
|
||||||
|
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
|
||||||
|
let (k, v) = raw
|
||||||
|
.split_once(':')
|
||||||
|
.or_else(|| raw.split_once('='))
|
||||||
|
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
|
||||||
|
let k = k.trim();
|
||||||
|
let v = v.trim();
|
||||||
|
if k.is_empty() {
|
||||||
|
return Err(format!("empty header name in {raw:?}"));
|
||||||
|
}
|
||||||
|
Ok((k.to_string(), v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
// Minimal pass: percent-encode the few chars that break the query.
|
||||||
|
// Slugs and UUIDs don't contain them in practice, but be safe.
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' | '=' | '?' | '#' | ' ' => {
|
||||||
|
out.push_str(&format!("%{:02X}", u32::from(ch)));
|
||||||
|
}
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return resp.json::<T>().await.context("parsing response body");
|
||||||
|
}
|
||||||
|
Err(server_error(resp).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let msg = parse_error_body(&body).unwrap_or(body);
|
||||||
|
let hint = role_hint(status);
|
||||||
|
if hint.is_empty() {
|
||||||
|
anyhow!("HTTP {}: {}", status.as_u16(), msg)
|
||||||
|
} else {
|
||||||
|
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_error_body(s: &str) -> Option<String> {
|
||||||
|
let v: Value = serde_json::from_str(s).ok()?;
|
||||||
|
let obj = v.as_object()?;
|
||||||
|
if let Some(m) = obj.get("message").and_then(Value::as_str) {
|
||||||
|
return Some(m.to_string());
|
||||||
|
}
|
||||||
|
if let Some(e) = obj.get("error").and_then(Value::as_str) {
|
||||||
|
return Some(e.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_hint(status: StatusCode) -> &'static str {
|
||||||
|
match status {
|
||||||
|
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
|
||||||
|
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_colon() {
|
||||||
|
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
|
||||||
|
assert_eq!(k, "X-Foo");
|
||||||
|
assert_eq!(v, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_equals() {
|
||||||
|
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
|
||||||
|
assert_eq!(k, "X-Foo");
|
||||||
|
assert_eq!(v, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_rejects_no_separator() {
|
||||||
|
assert!(parse_kv_header("X-Foo").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_rejects_empty_name() {
|
||||||
|
assert!(parse_kv_header(": bar").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_strip_trailing_slash() {
|
||||||
|
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
|
||||||
|
assert_eq!(c.url(), "http://localhost:8000");
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/picloud-cli/src/cmds/apps.rs
Normal file
40
crates/picloud-cli/src/cmds/apps.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//! `pic apps ls` and `pic apps create`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::{Client, CreateAppBody};
|
||||||
|
use crate::config::load;
|
||||||
|
use crate::output::Table;
|
||||||
|
|
||||||
|
pub async fn ls() -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let apps = client.apps_list().await?;
|
||||||
|
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
||||||
|
for app in apps {
|
||||||
|
// The list endpoint returns App without my_role. We do a per-app
|
||||||
|
// lookup only on demand; for `ls` we leave the column dashed so
|
||||||
|
// the call stays cheap (one HTTP request).
|
||||||
|
table.row([
|
||||||
|
app.slug.clone(),
|
||||||
|
app.name.clone(),
|
||||||
|
"-".to_string(),
|
||||||
|
app.created_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let body = CreateAppBody {
|
||||||
|
slug,
|
||||||
|
name: name.unwrap_or(slug),
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
let app = client.apps_create(&body).await?;
|
||||||
|
println!("Created app {}", app.slug);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
66
crates/picloud-cli/src/cmds/login.rs
Normal file
66
crates/picloud-cli/src/cmds/login.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! `pic login` — interactively (or via PICLOUD_URL/PICLOUD_TOKEN env
|
||||||
|
//! shortcut for non-interactive contexts like CI and integration tests)
|
||||||
|
//! capture the URL + bearer token, validate against `/auth/me`, save.
|
||||||
|
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config::{save, Credentials};
|
||||||
|
|
||||||
|
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
let (url, token) = collect_credentials()?;
|
||||||
|
let client = Client::new(&url, &token)?;
|
||||||
|
let me = client.auth_me().await?;
|
||||||
|
let creds = Credentials {
|
||||||
|
url: client.url().to_string(),
|
||||||
|
token,
|
||||||
|
username: me.username.clone(),
|
||||||
|
};
|
||||||
|
save(&creds)?;
|
||||||
|
println!(
|
||||||
|
"Logged in as {} ({}) at {}",
|
||||||
|
me.username,
|
||||||
|
instance_role_label(&me.instance_role),
|
||||||
|
creds.url
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_credentials() -> Result<(String, String)> {
|
||||||
|
// Non-interactive shortcut: both vars set → use as-is. Used by the
|
||||||
|
// integration test and any CI flow that wants to skip the prompts.
|
||||||
|
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
||||||
|
if !url.is_empty() && !tok.is_empty() {
|
||||||
|
return Ok((url, tok));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||||
|
let token = rpassword::prompt_password("API token: ")?;
|
||||||
|
Ok((url, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||||
|
print!("{label} [{default}]: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().lock().read_line(&mut buf)?;
|
||||||
|
let trimmed = buf.trim();
|
||||||
|
Ok(if trimmed.is_empty() {
|
||||||
|
default.to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_role_label(role: &picloud_shared::InstanceRole) -> &'static str {
|
||||||
|
use picloud_shared::InstanceRole as R;
|
||||||
|
match role {
|
||||||
|
R::Owner => "owner",
|
||||||
|
R::Admin => "admin",
|
||||||
|
R::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/picloud-cli/src/cmds/logs.rs
Normal file
58
crates/picloud-cli/src/cmds/logs.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! `pic logs <script-id>` — print recent execution log rows.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::ExecutionStatus;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config::load;
|
||||||
|
|
||||||
|
pub async fn run(script_id: &str, limit: u32) -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let entries = client.logs_list(script_id, limit).await?;
|
||||||
|
for e in entries {
|
||||||
|
let summary = summarize(&e.response_body, &e.script_logs);
|
||||||
|
println!(
|
||||||
|
"{}\t{}\t{}",
|
||||||
|
e.created_at.to_rfc3339(),
|
||||||
|
status_label(&e.status),
|
||||||
|
truncate(&summary, 120),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
ExecutionStatus::Success => "success",
|
||||||
|
ExecutionStatus::Error => "error",
|
||||||
|
ExecutionStatus::Timeout => "timeout",
|
||||||
|
ExecutionStatus::BudgetExceeded => "budget_exceeded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
|
||||||
|
// Prefer the last script-side log line (often the most useful for
|
||||||
|
// grepping). Fall back to the response body.
|
||||||
|
if let Some(arr) = script_logs.as_array() {
|
||||||
|
if let Some(last) = arr.last() {
|
||||||
|
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
|
||||||
|
return msg.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response_body
|
||||||
|
.as_ref()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
let normalized = s.replace('\n', " ");
|
||||||
|
if normalized.chars().count() <= n {
|
||||||
|
normalized
|
||||||
|
} else {
|
||||||
|
let head: String = normalized.chars().take(n).collect();
|
||||||
|
format!("{head}…")
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/picloud-cli/src/cmds/mod.rs
Normal file
5
crates/picloud-cli/src/cmds/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod apps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod scripts;
|
||||||
|
pub mod whoami;
|
||||||
178
crates/picloud-cli/src/cmds/scripts.rs
Normal file
178
crates/picloud-cli/src/cmds/scripts.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
//! `pic scripts ls | deploy | invoke`.
|
||||||
|
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::client::{Client, CreateScriptBody};
|
||||||
|
use crate::config::load;
|
||||||
|
use crate::output::Table;
|
||||||
|
|
||||||
|
pub async fn ls(app: Option<&str>) -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||||
|
|
||||||
|
if let Some(ident) = app {
|
||||||
|
let app = client.apps_get(ident).await?;
|
||||||
|
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||||
|
for s in scripts {
|
||||||
|
table.row([
|
||||||
|
s.id.to_string(),
|
||||||
|
app.app.slug.clone(),
|
||||||
|
s.name,
|
||||||
|
s.version.to_string(),
|
||||||
|
s.updated_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No filter → walk every accessible app. One request per app is
|
||||||
|
// fine at MVP scale (handful of apps); a bulk endpoint can come
|
||||||
|
// later if the count grows.
|
||||||
|
let apps = client.apps_list().await?;
|
||||||
|
for a in apps {
|
||||||
|
let scripts = client.scripts_list_by_app(&a.slug).await?;
|
||||||
|
for s in scripts {
|
||||||
|
table.row([
|
||||||
|
s.id.to_string(),
|
||||||
|
a.slug.clone(),
|
||||||
|
s.name,
|
||||||
|
s.version.to_string(),
|
||||||
|
s.updated_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.print();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deploy(
|
||||||
|
file: &Path,
|
||||||
|
app_ident: &str,
|
||||||
|
name_override: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let source =
|
||||||
|
std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||||
|
let name = match name_override {
|
||||||
|
Some(n) => n.to_string(),
|
||||||
|
None => file
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"could not derive script name from path {} (use --name)",
|
||||||
|
file.display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||||
|
// gives us the canonical app_id needed for create.
|
||||||
|
let app = client.apps_get(app_ident).await?;
|
||||||
|
|
||||||
|
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||||
|
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||||
|
let updated = client
|
||||||
|
.scripts_update_source(&s.id.to_string(), &source)
|
||||||
|
.await?;
|
||||||
|
println!("Updated {} v{}", updated.name, updated.version);
|
||||||
|
} else {
|
||||||
|
let body = CreateScriptBody {
|
||||||
|
app_id: app.app.id,
|
||||||
|
name: &name,
|
||||||
|
description,
|
||||||
|
source: &source,
|
||||||
|
};
|
||||||
|
let created = client.scripts_create(&body).await?;
|
||||||
|
println!("Created {} v{}", created.name, created.version);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let body = parse_body_arg(body_arg)?;
|
||||||
|
let resp = client.execute(id, body, headers).await?;
|
||||||
|
// Status to stderr so stdout stays JSON for piping into jq.
|
||||||
|
let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
|
||||||
|
let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
|
||||||
|
println!("{pretty}");
|
||||||
|
if (200..400).contains(&resp.status_code) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("execute returned HTTP {}", resp.status_code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
|
||||||
|
match arg {
|
||||||
|
None => Ok(Value::Object(serde_json::Map::new())),
|
||||||
|
Some("@-") => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_to_string(&mut buf)
|
||||||
|
.context("reading stdin")?;
|
||||||
|
parse_or_string(&buf)
|
||||||
|
}
|
||||||
|
Some(raw) if raw.starts_with('@') => {
|
||||||
|
let path = &raw[1..];
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading body file {path}"))?;
|
||||||
|
parse_or_string(&text)
|
||||||
|
}
|
||||||
|
Some(raw) => parse_or_string(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_or_string(s: &str) -> Result<Value> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
serde_json::from_str(trimmed)
|
||||||
|
.with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
if s.len() <= n {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_inline_json() {
|
||||||
|
let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
|
||||||
|
assert_eq!(v["x"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_none_is_empty_object() {
|
||||||
|
let v = parse_body_arg(None).unwrap();
|
||||||
|
assert!(v.is_object());
|
||||||
|
assert_eq!(v.as_object().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_invalid_json_reports() {
|
||||||
|
let err = parse_body_arg(Some("not-json{")).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(msg.contains("not valid JSON"), "got: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/picloud-cli/src/cmds/whoami.rs
Normal file
22
crates/picloud-cli/src/cmds/whoami.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||||
|
//! every time. Cached username in the credentials file is for
|
||||||
|
//! display-only contexts; this command is the source of truth.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config::load;
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
let creds = load()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let me = client.auth_me().await?;
|
||||||
|
let role = match me.instance_role {
|
||||||
|
picloud_shared::InstanceRole::Owner => "owner",
|
||||||
|
picloud_shared::InstanceRole::Admin => "admin",
|
||||||
|
picloud_shared::InstanceRole::Member => "member",
|
||||||
|
};
|
||||||
|
let email = me.email.as_deref().unwrap_or("-");
|
||||||
|
println!("{}\t{role}\t{email}\t{}", me.username, creds.url);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
118
crates/picloud-cli/src/config.rs
Normal file
118
crates/picloud-cli/src/config.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//! On-disk credentials store.
|
||||||
|
//!
|
||||||
|
//! Path is resolved via `directories::ProjectDirs` so the file lives in
|
||||||
|
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
|
||||||
|
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
|
||||||
|
//! pasted bearer token isn't world-readable.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
|
||||||
|
/// override (used by tests to redirect to a tempdir) before falling
|
||||||
|
/// back to the platform default.
|
||||||
|
pub fn credentials_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
|
||||||
|
return Ok(PathBuf::from(dir).join("credentials"));
|
||||||
|
}
|
||||||
|
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
|
||||||
|
.ok_or_else(|| anyhow!("could not determine config directory"))?;
|
||||||
|
Ok(dirs.config_dir().join("credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Credentials> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
let body = fs::read_to_string(&path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no credentials at {}. run `pic login` first",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(creds: &Credentials) -> Result<()> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let body = toml::to_string(creds).context("serializing credentials")?;
|
||||||
|
write_private(&path, body.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut f = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("opening {}", path.display()))?;
|
||||||
|
f.write_all(bytes)
|
||||||
|
.with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
// Belt-and-suspenders: re-set perms in case the file already existed
|
||||||
|
// with a wider mode (mode() on create doesn't downgrade existing).
|
||||||
|
let mut perms = fs::metadata(path)?.permissions();
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(path, perms)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_toml() {
|
||||||
|
let creds = Credentials {
|
||||||
|
url: "http://localhost:8000".to_string(),
|
||||||
|
token: "pic_abc".to_string(),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
};
|
||||||
|
let serialized = toml::to_string(&creds).unwrap();
|
||||||
|
let parsed: Credentials = toml::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(creds, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn posix_mode_is_0600() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
|
||||||
|
let creds = Credentials {
|
||||||
|
url: "http://localhost:8000".to_string(),
|
||||||
|
token: "pic_secret".to_string(),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
};
|
||||||
|
save(&creds).unwrap();
|
||||||
|
let path = credentials_path().unwrap();
|
||||||
|
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
|
||||||
|
std::env::remove_var("PICLOUD_CONFIG_DIR");
|
||||||
|
}
|
||||||
|
}
|
||||||
142
crates/picloud-cli/src/main.rs
Normal file
142
crates/picloud-cli/src/main.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
//! PiCloud command-line client.
|
||||||
|
//!
|
||||||
|
//! Thin client over the existing admin + execute HTTP surface — the
|
||||||
|
//! server gains nothing for the CLI; the CLI is just a developer
|
||||||
|
//! ergonomics layer over endpoints the dashboard already uses.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod cmds;
|
||||||
|
mod config;
|
||||||
|
mod output;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Save URL + bearer token to `~/.picloud/credentials`.
|
||||||
|
Login,
|
||||||
|
|
||||||
|
/// Print the principal the saved token resolves to.
|
||||||
|
Whoami,
|
||||||
|
|
||||||
|
/// App management.
|
||||||
|
Apps {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: AppsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Script management.
|
||||||
|
Scripts {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ScriptsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Tail recent execution logs for a script.
|
||||||
|
Logs(LogsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AppsCmd {
|
||||||
|
/// List apps the caller can see.
|
||||||
|
Ls,
|
||||||
|
|
||||||
|
/// Create a new app.
|
||||||
|
Create {
|
||||||
|
slug: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ScriptsCmd {
|
||||||
|
/// List scripts. With `--app`, scoped to one app; without,
|
||||||
|
/// iterates over every app the caller can see.
|
||||||
|
Ls {
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Upload a `.rhai` file. Patches the existing script with the
|
||||||
|
/// matching name in `--app` if one exists, otherwise creates it.
|
||||||
|
Deploy {
|
||||||
|
file: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
app: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||||
|
/// `--body @-` for stdin, or inline JSON.
|
||||||
|
Invoke {
|
||||||
|
id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct LogsArgs {
|
||||||
|
script_id: String,
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let result = match cli.cmd {
|
||||||
|
Cmd::Login => cmds::login::run().await,
|
||||||
|
Cmd::Whoami => cmds::whoami::run().await,
|
||||||
|
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd:
|
||||||
|
AppsCmd::Create {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Ls { app },
|
||||||
|
} => cmds::scripts::ls(app.as_deref()).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd:
|
||||||
|
ScriptsCmd::Deploy {
|
||||||
|
file,
|
||||||
|
app,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Invoke { id, body, headers },
|
||||||
|
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await,
|
||||||
|
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
output::print_error(&err);
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
crates/picloud-cli/src/output.rs
Normal file
103
crates/picloud-cli/src/output.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! Tab-separated table writer + error formatting.
|
||||||
|
//!
|
||||||
|
//! Aligned columns are nice for humans but `\t`-separated stays
|
||||||
|
//! pipe-friendly: `pic apps ls | awk -F'\t' '{print $1}'` works without
|
||||||
|
//! parsing box-drawing.
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
pub struct Table {
|
||||||
|
headers: Vec<String>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
pub fn new<I, S>(headers: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
headers: headers.into_iter().map(Into::into).collect(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
self.rows.push(cells.into_iter().map(Into::into).collect());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&self) -> String {
|
||||||
|
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
||||||
|
for row in &self.rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i >= widths.len() {
|
||||||
|
widths.push(cell.len());
|
||||||
|
} else if cell.len() > widths[i] {
|
||||||
|
widths[i] = cell.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
write_row(&mut out, &self.headers, &widths);
|
||||||
|
for row in &self.rows {
|
||||||
|
write_row(&mut out, row, &widths);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print(&self) {
|
||||||
|
let s = self.render();
|
||||||
|
// Best-effort write — broken pipe from `| head` etc. shouldn't
|
||||||
|
// surface as an error.
|
||||||
|
let _ = io::stdout().write_all(s.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('\t');
|
||||||
|
}
|
||||||
|
out.push_str(cell);
|
||||||
|
// Right-pad with spaces so tabs land on the column grid for
|
||||||
|
// human readers. Skip on the final column.
|
||||||
|
if i + 1 < row.len() {
|
||||||
|
let w = widths.get(i).copied().unwrap_or(cell.len());
|
||||||
|
for _ in cell.len()..w {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_error(err: &anyhow::Error) {
|
||||||
|
let mut stderr = io::stderr();
|
||||||
|
let _ = writeln!(stderr, "error: {err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_aligns_columns() {
|
||||||
|
let mut t = Table::new(["slug", "name"]);
|
||||||
|
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||||
|
let out = t.render();
|
||||||
|
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_empty_rows() {
|
||||||
|
let t = Table::new(["a", "b"]);
|
||||||
|
assert_eq!(t.render(), "a\tb\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
371
crates/picloud-cli/tests/cli.rs
Normal file
371
crates/picloud-cli/tests/cli.rs
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
//! Bare-metal end-to-end integration test.
|
||||||
|
//!
|
||||||
|
//! Spawns a `picloud` subprocess against `DATABASE_URL` on a private
|
||||||
|
//! port, logs in over HTTP to mint a bearer token, then drives the
|
||||||
|
//! `pic` binary through the full edit-deploy-invoke-tail loop and
|
||||||
|
//! cleans up the app it created.
|
||||||
|
//!
|
||||||
|
//! Gated on `DATABASE_URL`. To run:
|
||||||
|
//!
|
||||||
|
//! docker compose up -d postgres
|
||||||
|
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||||
|
//! cargo test -p picloud-cli --test cli -- --include-ignored
|
||||||
|
|
||||||
|
#![allow(clippy::too_many_lines)]
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command as StdCommand, Stdio};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// The bootstrap env vars are inert once any admin row exists, so we
|
||||||
|
// can't carve out a dedicated test admin against the dev database. The
|
||||||
|
// dev stack seeds `admin`/`admin` (see CLAUDE.md); we use those.
|
||||||
|
// `PICLOUD_CLI_E2E_USERNAME` / `_PASSWORD` let CI override.
|
||||||
|
fn admin_username() -> String {
|
||||||
|
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_password() -> String {
|
||||||
|
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn end_to_end_login_deploy_invoke_logs() {
|
||||||
|
let Ok(database_url) = std::env::var("DATABASE_URL") else {
|
||||||
|
eprintln!("skipping: DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let port = pick_free_port();
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
let mut server = spawn_picloud(&database_url, port);
|
||||||
|
if let Err(e) = wait_for_health(&url, Duration::from_secs(60)) {
|
||||||
|
kill_subprocess(&mut server);
|
||||||
|
panic!("picloud failed to become healthy: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
run_flow(&url);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Always tear down regardless of outcome so a failed test doesn't
|
||||||
|
// leak a child process.
|
||||||
|
kill_subprocess(&mut server);
|
||||||
|
|
||||||
|
if let Err(p) = outcome {
|
||||||
|
std::panic::resume_unwind(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_flow(url: &str) {
|
||||||
|
let token = login_for_bearer_token(url);
|
||||||
|
|
||||||
|
let cfg_dir = TempDir::new().expect("tempdir");
|
||||||
|
let home = TempDir::new().expect("home tempdir");
|
||||||
|
let env = TestEnv {
|
||||||
|
url: url.to_string(),
|
||||||
|
token,
|
||||||
|
config_dir: cfg_dir.path().to_path_buf(),
|
||||||
|
home: home.path().to_path_buf(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug carries the wall-clock so reruns against a long-lived dev
|
||||||
|
// database don't collide on the unique-slug constraint.
|
||||||
|
let slug = format!(
|
||||||
|
"pic-cli-e2e-{}",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis()
|
||||||
|
);
|
||||||
|
|
||||||
|
let username = admin_username();
|
||||||
|
|
||||||
|
// 1) login
|
||||||
|
pic(&env)
|
||||||
|
.args(["login"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Logged in as {username}")));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.join("credentials");
|
||||||
|
assert!(
|
||||||
|
creds_path.exists(),
|
||||||
|
"credentials file should exist after login"
|
||||||
|
);
|
||||||
|
let body = std::fs::read_to_string(&creds_path).unwrap();
|
||||||
|
assert!(body.contains(&env.url), "creds should contain url: {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains(&username),
|
||||||
|
"creds should contain username: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) whoami
|
||||||
|
pic(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(username.clone()));
|
||||||
|
|
||||||
|
// 3) apps create
|
||||||
|
pic(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Created app {slug}")));
|
||||||
|
|
||||||
|
// Ensure the app is cleaned up no matter what subsequent assertions do.
|
||||||
|
let _guard = AppGuard {
|
||||||
|
url: env.url.clone(),
|
||||||
|
token: env.token.clone(),
|
||||||
|
slug: slug.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4) apps ls
|
||||||
|
pic(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(slug.as_str()));
|
||||||
|
|
||||||
|
// 5) scripts deploy (create then update)
|
||||||
|
let fixture = fixture_path("hello.rhai");
|
||||||
|
pic(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Created hello v1"));
|
||||||
|
|
||||||
|
pic(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Updated hello v2"));
|
||||||
|
|
||||||
|
// 6) scripts ls and capture the id
|
||||||
|
let ls_out = pic(&env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}");
|
||||||
|
let id = parse_first_id(std::str::from_utf8(&ls_out.stdout).unwrap())
|
||||||
|
.expect("scripts ls should print at least one row");
|
||||||
|
|
||||||
|
// 7) invoke
|
||||||
|
let invoke_out = pic(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("scripts invoke");
|
||||||
|
assert!(
|
||||||
|
invoke_out.status.success(),
|
||||||
|
"invoke failed: {}",
|
||||||
|
String::from_utf8_lossy(&invoke_out.stderr)
|
||||||
|
);
|
||||||
|
let parsed: Value =
|
||||||
|
serde_json::from_slice(&invoke_out.stdout).expect("invoke stdout should be JSON");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["ok"], true,
|
||||||
|
"expected hello.rhai response, got {parsed}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8) logs (the invoke above should have produced exactly one row)
|
||||||
|
let logs_out = pic(&env).args(["logs", &id]).output().expect("pic logs");
|
||||||
|
assert!(logs_out.status.success(), "logs failed: {logs_out:?}");
|
||||||
|
let stdout = String::from_utf8_lossy(&logs_out.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.lines().any(|l| !l.trim().is_empty()),
|
||||||
|
"logs should have at least one row, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct TestEnv {
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
config_dir: PathBuf,
|
||||||
|
home: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pic(env: &TestEnv) -> AssertCommand {
|
||||||
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
||||||
|
cmd.env("PICLOUD_URL", &env.url)
|
||||||
|
.env("PICLOUD_TOKEN", &env.token)
|
||||||
|
.env("PICLOUD_CONFIG_DIR", &env.config_dir)
|
||||||
|
.env("HOME", &env.home);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_path(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests")
|
||||||
|
.join("fixtures")
|
||||||
|
.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn picloud_binary_path() -> PathBuf {
|
||||||
|
// The integration test binary lives at
|
||||||
|
// `<target>/debug/deps/cli-<hash>`. CARGO_MANIFEST_DIR points at the
|
||||||
|
// crate; the workspace target dir is two levels up. `picloud` lands
|
||||||
|
// next to our own test executable.
|
||||||
|
let exe = std::env::current_exe().expect("current_exe");
|
||||||
|
// current_exe is `.../target/debug/deps/cli-<hash>`. Walk up twice
|
||||||
|
// to reach `.../target/debug`, then look for `picloud`.
|
||||||
|
let debug_dir = exe
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.expect("test binary should live under target/debug/deps");
|
||||||
|
debug_dir.join(if cfg!(windows) {
|
||||||
|
"picloud.exe"
|
||||||
|
} else {
|
||||||
|
"picloud"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_free_port() -> u16 {
|
||||||
|
// Bind to :0, read the assigned port, drop the listener.
|
||||||
|
let listener =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
|
||||||
|
listener.local_addr().expect("local addr").port()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_picloud(database_url: &str, port: u16) -> Child {
|
||||||
|
// Execute the pre-built `picloud` binary directly. Going through
|
||||||
|
// `cargo run -p picloud` while inside `cargo test` would contend on
|
||||||
|
// the same build lock and can deadlock. We assume the binary was
|
||||||
|
// built as part of the workspace compile that produced this test —
|
||||||
|
// and check explicitly so the panic is informative if not.
|
||||||
|
let binary = picloud_binary_path();
|
||||||
|
assert!(
|
||||||
|
binary.exists(),
|
||||||
|
"expected picloud binary at {}. Run `cargo build -p picloud` first \
|
||||||
|
(or use `cargo test --workspace -- --include-ignored` which builds it)",
|
||||||
|
binary.display()
|
||||||
|
);
|
||||||
|
let mut child = StdCommand::new(&binary)
|
||||||
|
.env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
|
||||||
|
.env("DATABASE_URL", database_url)
|
||||||
|
.env("PICLOUD_ADMIN_USERNAME", admin_username())
|
||||||
|
.env("PICLOUD_ADMIN_PASSWORD", admin_password())
|
||||||
|
.env("RUST_LOG", "warn")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn picloud");
|
||||||
|
|
||||||
|
// Drain stderr in a side thread so the pipe buffer doesn't fill and
|
||||||
|
// block the server. We only echo to test output on failure.
|
||||||
|
if let Some(err) = child.stderr.take().map(BufReader::new) {
|
||||||
|
let (tx, _rx) = mpsc::channel::<String>();
|
||||||
|
thread::spawn(move || {
|
||||||
|
for line in err.lines().map_while(Result::ok) {
|
||||||
|
let _ = tx.send(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
child
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
Err(format!("/healthz never returned 200 within {timeout:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_for_bearer_token(url: &str) -> String {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{url}/api/v1/admin/auth/login"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": admin_username(),
|
||||||
|
"password": admin_password(),
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.expect("login request");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"login should succeed, got {}: {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
let v: Value = resp.json().expect("login json");
|
||||||
|
v["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login returns token")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_first_id(table: &str) -> Option<String> {
|
||||||
|
// The header line starts with "id"; the first row's first
|
||||||
|
// tab-delimited cell is the script UUID.
|
||||||
|
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
|
||||||
|
let header = lines.next()?;
|
||||||
|
if !header.starts_with("id") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let row = lines.next()?;
|
||||||
|
let first = row.split('\t').next()?.trim();
|
||||||
|
if first.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(first.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_subprocess(child: &mut Child) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppGuard {
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AppGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.delete(format!(
|
||||||
|
"{}/api/v1/admin/apps/{}?force=true",
|
||||||
|
self.url, self.slug
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Smallest possible Rhai script for the integration test: returns a JSON
|
||||||
|
// object so the orchestrator wraps it as the HTTP response body.
|
||||||
|
let body = #{ ok: true, greeting: "hello from pic" };
|
||||||
|
body
|
||||||
@@ -293,7 +293,7 @@ async fn owner_access_matrix(pool: PgPool) {
|
|||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
|
||||||
let s = boot(pool.clone()).await;
|
let s = boot(pool.clone()).await;
|
||||||
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
let token = login_token(&s.server, "alice", "alice-pw").await;
|
let token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
@@ -305,24 +305,34 @@ async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: read default app (admin is implicit editor everywhere).
|
// Allowed: read default app — admin is implicit app_admin
|
||||||
|
// everywhere (per blueprint §11.6).
|
||||||
s.server
|
s.server
|
||||||
.get("/api/v1/admin/apps/default")
|
.get("/api/v1/admin/apps/default")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: write scripts (implicit editor).
|
// Allowed: write scripts.
|
||||||
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
||||||
assert!(script["id"].is_string());
|
assert!(script["id"].is_string());
|
||||||
|
|
||||||
// Denied: delete the default app (AppAdmin only).
|
// Allowed: list app members (AppAdmin gate). Pre-3.5.x this
|
||||||
let denied = s
|
// 403'd; now it's the same allow as the owner sees.
|
||||||
.server
|
s.server
|
||||||
.delete("/api/v1/admin/apps/default")
|
.get("/api/v1/admin/apps/default/members")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await;
|
.await
|
||||||
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: delete the default app (AppAdmin). ?force=true because
|
||||||
|
// the script we created above pushes us past the soft no-cascade
|
||||||
|
// guard — this test is about the capability, not the cascade.
|
||||||
|
s.server
|
||||||
|
.delete("/api/v1/admin/apps/default?force=true")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
@@ -735,7 +745,7 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
|
|||||||
"owner reports app_admin"
|
"owner reports app_admin"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Admin → implicit editor everywhere.
|
// Admin → implicit app_admin everywhere (post-§11.6 update).
|
||||||
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
|
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
let r = s
|
let r = s
|
||||||
@@ -746,8 +756,8 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
|
|||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.json::<Value>()["my_role"].as_str(),
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
Some("editor"),
|
Some("app_admin"),
|
||||||
"admin reports editor"
|
"admin reports app_admin"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Member with explicit `viewer` membership → viewer.
|
// Member with explicit `viewer` membership → viewer.
|
||||||
|
|||||||
@@ -25,12 +25,18 @@
|
|||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
language = 'rhai' as Language,
|
language = 'rhai' as Language,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
minHeight = '12rem'
|
minHeight = '12rem',
|
||||||
|
readOnly = false
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
language?: Language;
|
language?: Language;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
|
/** When true the editor renders without a cursor and rejects
|
||||||
|
* keystrokes. Parent-driven `value` changes still apply via
|
||||||
|
* the dispatch path below — this only blocks user edits.
|
||||||
|
* Not reactive after mount; re-mount via `{#key}` if needed. */
|
||||||
|
readOnly?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let host: HTMLDivElement | null = null;
|
let host: HTMLDivElement | null = null;
|
||||||
@@ -48,6 +54,12 @@
|
|||||||
keymap.of([indentWithTab]),
|
keymap.of([indentWithTab]),
|
||||||
dashboardSyntaxHighlighting,
|
dashboardSyntaxHighlighting,
|
||||||
dashboardTheme,
|
dashboardTheme,
|
||||||
|
// readOnly + editable together: readOnly blocks the
|
||||||
|
// underlying transactions, editable suppresses the caret
|
||||||
|
// + selection visuals so the user can see it's not
|
||||||
|
// editable.
|
||||||
|
EditorState.readOnly.of(readOnly),
|
||||||
|
EditorView.editable.of(!readOnly),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged && !pushingFromOutside) {
|
if (update.docChanged && !pushingFromOutside) {
|
||||||
value = update.state.doc.toString();
|
value = update.state.doc.toString();
|
||||||
|
|||||||
60
dashboard/src/lib/capabilities.test.ts
Normal file
60
dashboard/src/lib/capabilities.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { AppRole, MeDto } from './api';
|
||||||
|
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
|
||||||
|
|
||||||
|
function me(role: MeDto['instance_role']): MeDto {
|
||||||
|
return { id: 'u', username: 'u', instance_role: role, email: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
|
||||||
|
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
|
||||||
|
|
||||||
|
describe('capabilities', () => {
|
||||||
|
it('null caller is denied everything', () => {
|
||||||
|
expect(canCreateApp(null)).toBe(false);
|
||||||
|
expect(canManageUsers(null)).toBe(false);
|
||||||
|
expect(canWriteApp(null, 'app_admin')).toBe(false);
|
||||||
|
expect(canAdminApp(null, 'app_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
|
||||||
|
expect(canCreateApp(me('owner'))).toBe(true);
|
||||||
|
expect(canCreateApp(me('admin'))).toBe(true);
|
||||||
|
expect(canCreateApp(me('member'))).toBe(false);
|
||||||
|
expect(canManageUsers(me('owner'))).toBe(true);
|
||||||
|
expect(canManageUsers(me('admin'))).toBe(true);
|
||||||
|
expect(canManageUsers(me('member'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner + admin can write and admin every app regardless of my_role', () => {
|
||||||
|
for (const role of ['owner', 'admin'] as const) {
|
||||||
|
for (const appRole of APP_ROLES) {
|
||||||
|
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||||
|
expect(canAdminApp(me(role), appRole)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('member: write requires app_admin or editor; admin requires app_admin', () => {
|
||||||
|
const m = me('member');
|
||||||
|
expect(canWriteApp(m, 'app_admin')).toBe(true);
|
||||||
|
expect(canWriteApp(m, 'editor')).toBe(true);
|
||||||
|
expect(canWriteApp(m, 'viewer')).toBe(false);
|
||||||
|
expect(canWriteApp(m, null)).toBe(false);
|
||||||
|
|
||||||
|
expect(canAdminApp(m, 'app_admin')).toBe(true);
|
||||||
|
expect(canAdminApp(m, 'editor')).toBe(false);
|
||||||
|
expect(canAdminApp(m, 'viewer')).toBe(false);
|
||||||
|
expect(canAdminApp(m, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canAdminApp implies canWriteApp for every combination', () => {
|
||||||
|
for (const role of ROLES) {
|
||||||
|
for (const appRole of APP_ROLES) {
|
||||||
|
if (canAdminApp(me(role), appRole)) {
|
||||||
|
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
dashboard/src/lib/capabilities.ts
Normal file
43
dashboard/src/lib/capabilities.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Permission predicates the dashboard uses to shadow create / edit /
|
||||||
|
// delete affordances. Mirrors the canonical role → capability rules in
|
||||||
|
// crates/manager-core/src/authz.rs:
|
||||||
|
//
|
||||||
|
// owner / admin instance role → implicit app_admin on every app
|
||||||
|
// app_admin → settings, domain claims, delete app, delete scripts
|
||||||
|
// editor → CRUD on scripts, routes, sandbox config (no script delete)
|
||||||
|
// viewer → read scripts + execution logs
|
||||||
|
// member with no membership → no access
|
||||||
|
//
|
||||||
|
// These helpers are read-only and have no Svelte runes — callers pass
|
||||||
|
// the current `MeDto` and (when relevant) the per-app `my_role` they
|
||||||
|
// already hold. Hiding here never authorizes anything; the backend's
|
||||||
|
// `require(Capability::…)` is always the ground truth.
|
||||||
|
|
||||||
|
import type { AppRole, MeDto } from './api';
|
||||||
|
|
||||||
|
/** Owner + admin only. Members never see "New app". */
|
||||||
|
export function canCreateApp(me: MeDto | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner + admin only — the "Users" admin page is also gated this way. */
|
||||||
|
export function canManageUsers(me: MeDto | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can mutate scripts and routes (Save, +Add route, remove route). */
|
||||||
|
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||||
|
return appMyRole === 'app_admin' || appMyRole === 'editor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can take app-admin actions: app settings, domain claims, delete
|
||||||
|
* app, delete scripts, manage members. */
|
||||||
|
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||||
|
return appMyRole === 'app_admin';
|
||||||
|
}
|
||||||
54
dashboard/src/lib/password-gen.test.ts
Normal file
54
dashboard/src/lib/password-gen.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generatePassword } from './password-gen';
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
describe('generatePassword', () => {
|
||||||
|
it('rejects lengths under 8', () => {
|
||||||
|
expect(() => generatePassword(7)).toThrowError(/at least 8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the requested length', () => {
|
||||||
|
for (const len of [8, 16, 32, 64]) {
|
||||||
|
expect(generatePassword(len)).toHaveLength(len);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only characters from the documented charset', () => {
|
||||||
|
const set = new Set(CHARSET);
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
for (const c of generatePassword(32)) {
|
||||||
|
expect(set.has(c)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rejection-sampling sanity. With N = 71 the expected count per
|
||||||
|
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
|
||||||
|
// any byte-level bias (biased modulo would push the first 38
|
||||||
|
// chars by ~16 ppm — too small for this band to flag on its
|
||||||
|
// own, but a regression to `% N` over Uint16/Uint32 with a
|
||||||
|
// non-power-of-two charset would still produce visible drift in
|
||||||
|
// pathological codepaths). Mostly this guards against
|
||||||
|
// fundamental mistakes (off-by-one in the loop, returning the
|
||||||
|
// same byte stream every time, etc.).
|
||||||
|
it('distribution stays within a wide tolerance band', () => {
|
||||||
|
const samples = 100_000;
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const c = generatePassword(8)[0];
|
||||||
|
counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const expected = samples / CHARSET.length;
|
||||||
|
const sigma = Math.sqrt(expected);
|
||||||
|
const band = 6 * sigma;
|
||||||
|
for (const c of CHARSET) {
|
||||||
|
const observed = counts.get(c) ?? 0;
|
||||||
|
const drift = Math.abs(observed - expected);
|
||||||
|
expect(
|
||||||
|
drift,
|
||||||
|
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
|
||||||
|
).toBeLessThan(band);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
||||||
// avoidant of characters that ship awkwardly through chat clients
|
// avoidant of characters that ship awkwardly through chat clients
|
||||||
// (no quotes, slashes, or backticks).
|
// (no quotes, slashes, or backticks).
|
||||||
|
//
|
||||||
|
// Sampling: rejection sampling against a Uint8 stream. The naive
|
||||||
|
// `byte % CHARSET.length` would slightly overweight the first
|
||||||
|
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
|
||||||
|
// safe at 16 chars but easy to remove.
|
||||||
|
|
||||||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
@@ -15,11 +20,18 @@ export function generatePassword(length = 16): string {
|
|||||||
if (length < 8) {
|
if (length < 8) {
|
||||||
throw new Error('password length must be at least 8');
|
throw new Error('password length must be at least 8');
|
||||||
}
|
}
|
||||||
const buf = new Uint32Array(length);
|
const n = CHARSET.length;
|
||||||
crypto.getRandomValues(buf);
|
// Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
|
||||||
|
// rejected to remove modulo bias.
|
||||||
|
const max = 256 - (256 % n);
|
||||||
|
const buf = new Uint8Array(length);
|
||||||
let out = '';
|
let out = '';
|
||||||
for (let i = 0; i < length; i++) {
|
while (out.length < length) {
|
||||||
out += CHARSET[buf[i] % CHARSET.length];
|
crypto.getRandomValues(buf);
|
||||||
|
for (let i = 0; i < buf.length && out.length < length; i++) {
|
||||||
|
const byte = buf[i];
|
||||||
|
if (byte < max) out += CHARSET[byte % n];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type App } from '$lib/api';
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
|
import { canCreateApp } from '$lib/capabilities';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const canCreate = $derived(canCreateApp(me));
|
||||||
|
|
||||||
let apps = $state<App[] | null>(null);
|
let apps = $state<App[] | null>(null);
|
||||||
let listError = $state<string | null>(null);
|
let listError = $state<string | null>(null);
|
||||||
@@ -99,18 +104,20 @@
|
|||||||
<section>
|
<section>
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Apps</h1>
|
<h1>Apps</h1>
|
||||||
<button
|
{#if canCreate}
|
||||||
type="button"
|
<button
|
||||||
onclick={() => {
|
type="button"
|
||||||
showCreate = !showCreate;
|
onclick={() => {
|
||||||
if (!showCreate) resetCreate();
|
showCreate = !showCreate;
|
||||||
}}
|
if (!showCreate) resetCreate();
|
||||||
>
|
}}
|
||||||
{showCreate ? 'Cancel' : 'New app'}
|
>
|
||||||
</button>
|
{showCreate ? 'Cancel' : 'New app'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showCreate}
|
{#if showCreate && canCreate}
|
||||||
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import ActionMenu from '$lib/ActionMenu.svelte';
|
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||||
import RoleChip from '$lib/RoleChip.svelte';
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
import { currentUser } from '$lib/auth';
|
import { currentUser } from '$lib/auth';
|
||||||
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||||||
|
|
||||||
const me = $derived($currentUser);
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
@@ -36,7 +37,12 @@
|
|||||||
let domains = $state<AppDomain[]>([]);
|
let domains = $state<AppDomain[]>([]);
|
||||||
let members = $state<AppMemberDto[]>([]);
|
let members = $state<AppMemberDto[]>([]);
|
||||||
|
|
||||||
const canAdminMembers = $derived(myRole === 'app_admin');
|
// Derive UI gates from the capabilities helper so the rules stay
|
||||||
|
// in lockstep with the backend's `can()`. canAdminApp also covers
|
||||||
|
// the Members + Settings + Domains-mutation tabs; canWriteApp
|
||||||
|
// covers New script.
|
||||||
|
const canWrite = $derived(canWriteApp(me, myRole));
|
||||||
|
const canAdmin = $derived(canAdminApp(me, myRole));
|
||||||
|
|
||||||
// Script create
|
// Script create
|
||||||
let showCreateScript = $state(false);
|
let showCreateScript = $state(false);
|
||||||
@@ -102,7 +108,7 @@
|
|||||||
editDescription = app.description ?? '';
|
editDescription = app.description ?? '';
|
||||||
editSlug = app.slug;
|
editSlug = app.slug;
|
||||||
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
|
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
|
||||||
if (canAdminMembers) {
|
if (canAdmin) {
|
||||||
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
||||||
}
|
}
|
||||||
await Promise.all(loaders);
|
await Promise.all(loaders);
|
||||||
@@ -362,6 +368,16 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void loadApp();
|
void loadApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: a viewer / editor following a stale link to
|
||||||
|
// the Settings or Members tab gets bounced back to Scripts. The
|
||||||
|
// backend still 403s the underlying calls, but no point showing an
|
||||||
|
// empty tab.
|
||||||
|
$effect(() => {
|
||||||
|
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
|
||||||
|
activeTab = 'scripts';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading && !app}
|
{#if loading && !app}
|
||||||
@@ -394,33 +410,35 @@
|
|||||||
class:active={activeTab === 'domains'}
|
class:active={activeTab === 'domains'}
|
||||||
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
>
|
>
|
||||||
{#if canAdminMembers}
|
{#if canAdmin}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={activeTab === 'members'}
|
class:active={activeTab === 'members'}
|
||||||
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'settings'}
|
||||||
|
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class:active={activeTab === 'settings'}
|
|
||||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
|
||||||
>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if activeTab === 'scripts'}
|
{#if activeTab === 'scripts'}
|
||||||
<section>
|
<section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Scripts</h2>
|
<h2>Scripts</h2>
|
||||||
<button
|
{#if canWrite}
|
||||||
type="button"
|
<button
|
||||||
onclick={() => (showCreateScript = !showCreateScript)}
|
type="button"
|
||||||
>
|
onclick={() => (showCreateScript = !showCreateScript)}
|
||||||
{showCreateScript ? 'Cancel' : 'New script'}
|
>
|
||||||
</button>
|
{showCreateScript ? 'Cancel' : 'New script'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateScript}
|
{#if showCreateScript && canWrite}
|
||||||
<form class="create-form" onsubmit={submitCreateScript}>
|
<form class="create-form" onsubmit={submitCreateScript}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
@@ -473,18 +491,20 @@
|
|||||||
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
||||||
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||||||
</p>
|
</p>
|
||||||
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
{#if canAdmin}
|
||||||
<input
|
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||||
bind:value={createDomainPattern}
|
<input
|
||||||
required
|
bind:value={createDomainPattern}
|
||||||
placeholder="app.example.com"
|
required
|
||||||
/>
|
placeholder="app.example.com"
|
||||||
<button type="submit" disabled={creatingDomain}>
|
/>
|
||||||
{creatingDomain ? 'Adding…' : 'Add domain'}
|
<button type="submit" disabled={creatingDomain}>
|
||||||
</button>
|
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||||||
</form>
|
</button>
|
||||||
{#if createDomainError}
|
</form>
|
||||||
<div class="error">{createDomainError}</div>
|
{#if createDomainError}
|
||||||
|
<div class="error">{createDomainError}</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if domains.length === 0}
|
{#if domains.length === 0}
|
||||||
<p class="muted">No domain claims yet.</p>
|
<p class="muted">No domain claims yet.</p>
|
||||||
@@ -496,19 +516,21 @@
|
|||||||
<code>{d.pattern}</code>
|
<code>{d.pattern}</code>
|
||||||
<span class="muted">— {d.shape}</span>
|
<span class="muted">— {d.shape}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{#if canAdmin}
|
||||||
type="button"
|
<button
|
||||||
class="secondary danger"
|
type="button"
|
||||||
onclick={() => askRemoveDomain(d)}
|
class="secondary danger"
|
||||||
>
|
onclick={() => askRemoveDomain(d)}
|
||||||
Delete
|
>
|
||||||
</button>
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'members' && canAdminMembers}
|
{:else if activeTab === 'members' && canAdmin}
|
||||||
<section>
|
<section>
|
||||||
<h2>Members</h2>
|
<h2>Members</h2>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@@ -623,7 +645,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'settings'}
|
{:else if activeTab === 'settings' && canAdmin}
|
||||||
<section>
|
<section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
type AppDomain,
|
type AppDomain,
|
||||||
|
type AppRole,
|
||||||
type ExecutionLog,
|
type ExecutionLog,
|
||||||
type Route,
|
type Route,
|
||||||
type RouteInput,
|
type RouteInput,
|
||||||
type Script,
|
type Script,
|
||||||
type VersionInfo
|
type VersionInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import {
|
import {
|
||||||
checkHostAgainstClaims,
|
checkHostAgainstClaims,
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
pathKindMismatchWarning
|
pathKindMismatchWarning
|
||||||
} from '$lib/route-utils';
|
} from '$lib/route-utils';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
import { format as formatRhai } from '$lib/rhai';
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
/// Pretty-print a JSON string in place, leaving it untouched if the
|
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||||
@@ -47,6 +51,11 @@
|
|||||||
|
|
||||||
let appSlug = $state<string | null>(null);
|
let appSlug = $state<string | null>(null);
|
||||||
let appDomains = $state<AppDomain[]>([]);
|
let appDomains = $state<AppDomain[]>([]);
|
||||||
|
let appMyRole = $state<AppRole | null>(null);
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const canWrite = $derived(canWriteApp(me, appMyRole));
|
||||||
|
const canAdmin = $derived(canAdminApp(me, appMyRole));
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
@@ -58,15 +67,16 @@
|
|||||||
editableDescription = script.description ?? '';
|
editableDescription = script.description ?? '';
|
||||||
editableTimeout = script.timeout_seconds;
|
editableTimeout = script.timeout_seconds;
|
||||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||||
// Resolve the owning app's slug for the breadcrumb and its
|
// Resolve the owning app for the breadcrumb (slug),
|
||||||
// domain claims for the route form's suggestions + live
|
// route-form host suggestions (domain claims), and UI
|
||||||
// validation. Both are non-fatal — the page works without
|
// shadowing (my_role on this app). All non-fatal — the
|
||||||
// them.
|
// page renders without them, just with reduced fidelity.
|
||||||
const appId = script.app_id;
|
const appId = script.app_id;
|
||||||
void api.apps
|
void api.apps
|
||||||
.get(appId)
|
.get(appId)
|
||||||
.then((a) => {
|
.then((a) => {
|
||||||
appSlug = a.slug;
|
appSlug = a.slug;
|
||||||
|
appMyRole = a.my_role ?? null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
void api.domains
|
void api.domains
|
||||||
@@ -366,16 +376,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- deletion ----------------
|
// ---------------- deletion ----------------
|
||||||
|
let confirmingDelete = $state(false);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
async function remove() {
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function askDelete() {
|
||||||
|
deleteError = null;
|
||||||
|
confirmingDelete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
|
|
||||||
deleting = true;
|
deleting = true;
|
||||||
|
deleteError = null;
|
||||||
try {
|
try {
|
||||||
await api.scripts.remove(id);
|
await api.scripts.remove(id);
|
||||||
await goto(base + '/');
|
await goto(base + '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : String(e));
|
deleteError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
deleting = false;
|
deleting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +405,15 @@
|
|||||||
void loadRoutes();
|
void loadRoutes();
|
||||||
void loadLogs();
|
void loadLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: anyone non-admin who lands on the Settings
|
||||||
|
// tab via a stale link gets bounced back to Edit. The tab button
|
||||||
|
// itself is also hidden.
|
||||||
|
$effect(() => {
|
||||||
|
if (!canAdmin && tab === 'settings') {
|
||||||
|
tab = 'edit';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -410,9 +438,11 @@
|
|||||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="danger" onclick={remove} disabled={deleting}>
|
{#if canAdmin}
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
<button type="button" class="danger" onclick={askDelete} disabled={deleting}>
|
||||||
</button>
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
@@ -423,7 +453,9 @@
|
|||||||
<span class="badge-count">{routes.length}</span>
|
<span class="badge-count">{routes.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
{#if canAdmin}
|
||||||
|
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
||||||
|
{/if}
|
||||||
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
||||||
Executions
|
Executions
|
||||||
</button>
|
</button>
|
||||||
@@ -435,26 +467,35 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<header class="editor-header">
|
<header class="editor-header">
|
||||||
<h2>Source</h2>
|
<h2>Source</h2>
|
||||||
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
{#if canWrite}
|
||||||
Format
|
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||||
</button>
|
Format
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
<CodeEditor
|
||||||
|
bind:value={editableSource}
|
||||||
|
language="rhai"
|
||||||
|
minHeight="22rem"
|
||||||
|
readOnly={!canWrite}
|
||||||
|
/>
|
||||||
{#if rhaiFormatError}
|
{#if rhaiFormatError}
|
||||||
<div class="error inline">{rhaiFormatError}</div>
|
<div class="error inline">{rhaiFormatError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if saveSourceError}
|
{#if saveSourceError}
|
||||||
<div class="error inline">{saveSourceError}</div>
|
<div class="error inline">{saveSourceError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="actions">
|
{#if canWrite}
|
||||||
<button
|
<div class="actions">
|
||||||
type="button"
|
<button
|
||||||
onclick={saveSource}
|
type="button"
|
||||||
disabled={savingSource || editableSource === script.source}
|
onclick={saveSource}
|
||||||
>
|
disabled={savingSource || editableSource === script.source}
|
||||||
{savingSource ? 'Saving…' : 'Save'}
|
>
|
||||||
</button>
|
{savingSource ? 'Saving…' : 'Save'}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -510,12 +551,14 @@
|
|||||||
<section class="card wide">
|
<section class="card wide">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<h2>Routes</h2>
|
<h2>Routes</h2>
|
||||||
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
{#if canWrite}
|
||||||
{showAddRoute ? 'Cancel' : '+ Add route'}
|
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
||||||
</button>
|
{showAddRoute ? 'Cancel' : '+ Add route'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showAddRoute}
|
{#if showAddRoute && canWrite}
|
||||||
<form class="route-form" onsubmit={submitRoute}>
|
<form class="route-form" onsubmit={submitRoute}>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
@@ -626,9 +669,11 @@
|
|||||||
: r.host}
|
: r.host}
|
||||||
</span>
|
</span>
|
||||||
<span class="path">{r.path}</span>
|
<span class="path">{r.path}</span>
|
||||||
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
{#if canWrite}
|
||||||
remove
|
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
||||||
</button>
|
remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if info}
|
{#if info}
|
||||||
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
||||||
@@ -670,7 +715,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===================================================== SETTINGS ===== -->
|
<!-- ===================================================== SETTINGS ===== -->
|
||||||
{:else if tab === 'settings'}
|
{:else if tab === 'settings' && canAdmin}
|
||||||
<section class="card wide">
|
<section class="card wide">
|
||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<label>
|
<label>
|
||||||
@@ -786,6 +831,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingDelete && script}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete script “{script.name}”"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete script"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
confirmPhrase={script.name}
|
||||||
|
confirmPhrasePrompt="Type the script name to confirm:"
|
||||||
|
busy={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (confirmingDelete = false)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This will <strong>permanently delete</strong>
|
||||||
|
<strong>{script.name}</strong>, all its routes, and all its
|
||||||
|
execution logs. There is no undo.
|
||||||
|
</p>
|
||||||
|
{#if routes.length > 0}
|
||||||
|
<p class="muted">
|
||||||
|
{routes.length} route{routes.length === 1 ? '' : 's'} bound to
|
||||||
|
this script will be removed.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="modal-error">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,13 @@
|
|||||||
let deleteTarget = $state<AdminDto | null>(null);
|
let deleteTarget = $state<AdminDto | null>(null);
|
||||||
let deletePending = $state(false);
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
// Deactivate modal -------------------------------------------------------
|
||||||
|
// Reactivate is one-click (non-destructive); deactivate routes
|
||||||
|
// through the modal because it signs the user out and expires
|
||||||
|
// every API key they hold.
|
||||||
|
let deactivateTarget = $state<AdminDto | null>(null);
|
||||||
|
let deactivatePending = $state(false);
|
||||||
|
|
||||||
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
||||||
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@@ -219,19 +226,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(row: AdminDto) {
|
async function reactivate(row: AdminDto) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
const updated = await api.admins.update(row.id, { is_active: true });
|
||||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
flash(
|
flash('info', `${updated.username} reactivated.`);
|
||||||
'info',
|
|
||||||
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askDeactivate(row: AdminDto) {
|
||||||
|
deactivateTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeactivate() {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
|
deactivatePending = true;
|
||||||
|
const target = deactivateTarget;
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(target.id, { is_active: false });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
deactivateTarget = null;
|
||||||
|
flash('info', `${updated.username} deactivated.`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
|
} finally {
|
||||||
|
deactivatePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDelete(row: AdminDto) {
|
function openDelete(row: AdminDto) {
|
||||||
deleteTarget = row;
|
deleteTarget = row;
|
||||||
}
|
}
|
||||||
@@ -353,7 +377,8 @@
|
|||||||
{ label: 'Edit', onClick: () => openEdit(row) },
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
{
|
{
|
||||||
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
onClick: () => toggleActive(row)
|
onClick: () =>
|
||||||
|
row.is_active ? askDeactivate(row) : reactivate(row)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
@@ -571,6 +596,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deactivate confirmation -->
|
||||||
|
{#if deactivateTarget}
|
||||||
|
{@const dt = deactivateTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Deactivate {dt.username}?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Deactivate"
|
||||||
|
busyLabel="Deactivating…"
|
||||||
|
busy={deactivatePending}
|
||||||
|
onConfirm={confirmDeactivate}
|
||||||
|
onCancel={() => (deactivateTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Deactivating signs <strong>{dt.username}</strong> out immediately and
|
||||||
|
expires <strong>every API key</strong> they hold. Their sessions and keys
|
||||||
|
won't come back if you reactivate — they'll need to log in again and
|
||||||
|
mint new keys.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Reactivation is one click — this isn't permanent.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Delete confirmation -->
|
<!-- Delete confirmation -->
|
||||||
{#if deleteTarget}
|
{#if deleteTarget}
|
||||||
{@const dt = deleteTarget}
|
{@const dt = deleteTarget}
|
||||||
|
|||||||
@@ -2,6 +2,36 @@ import { expect, type Page } from '@playwright/test';
|
|||||||
import { test } from '../fixtures/ids';
|
import { test } from '../fixtures/ids';
|
||||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
import { adminApi } from '../fixtures/api';
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor' | 'app_admin';
|
||||||
|
}): Promise<{ appId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { appId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
||||||
// historical-slug takeover flow and adversarial inputs.
|
// historical-slug takeover flow and adversarial inputs.
|
||||||
@@ -224,3 +254,82 @@ test.describe('B2 apps adversarial', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps role shadowing', () => {
|
||||||
|
test('viewer member sees no "New app" on the apps list', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vlist');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
// Member can see the apps list (just the one they belong to)
|
||||||
|
// but the create-app affordance is hidden.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vdom');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||||
|
).toBeVisible();
|
||||||
|
// Settings tab is absent.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
// Domains tab still listable, but no Add-domain submit.
|
||||||
|
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor sees New script but no Settings tab', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,28 +3,40 @@ import { adminApi } from './api';
|
|||||||
|
|
||||||
// Resources to delete after a test, in LIFO order. Tests register
|
// Resources to delete after a test, in LIFO order. Tests register
|
||||||
// their creations and the registry tears everything down in
|
// their creations and the registry tears everything down in
|
||||||
// `cleanupRegistered` — typically called from `test.afterEach`.
|
// `run()` — typically called from `test.afterEach`.
|
||||||
|
//
|
||||||
|
// A non-2xx status (other than 404) is treated as a real failure and
|
||||||
|
// logged to stderr. The previous shape silently swallowed every
|
||||||
|
// error, so a backend that started returning 500 on cleanup would
|
||||||
|
// have leaked orphans invisibly across runs. 404 stays tolerated —
|
||||||
|
// the test may have already deleted the resource itself.
|
||||||
|
|
||||||
type Cleanup = (api: APIRequestContext) => Promise<void>;
|
interface CleanupItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CleanupRegistry {
|
export class CleanupRegistry {
|
||||||
private items: Cleanup[] = [];
|
private items: CleanupItem[] = [];
|
||||||
|
|
||||||
app(slugOrId: string): void {
|
app(slugOrId: string): void {
|
||||||
this.items.push(async (api) => {
|
this.items.push({
|
||||||
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`);
|
label: `app=${slugOrId}`,
|
||||||
|
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
adminUser(userId: string): void {
|
adminUser(userId: string): void {
|
||||||
this.items.push(async (api) => {
|
this.items.push({
|
||||||
await api.delete(`/api/v1/admin/admins/${userId}`);
|
label: `admin=${userId}`,
|
||||||
|
path: `/api/v1/admin/admins/${userId}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey(keyId: string): void {
|
apiKey(keyId: string): void {
|
||||||
this.items.push(async (api) => {
|
this.items.push({
|
||||||
await api.delete(`/api/v1/admin/api-keys/${keyId}`);
|
label: `key=${keyId}`,
|
||||||
|
path: `/api/v1/admin/api-keys/${keyId}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +44,11 @@ export class CleanupRegistry {
|
|||||||
if (this.items.length === 0) return;
|
if (this.items.length === 0) return;
|
||||||
const api = await adminApi();
|
const api = await adminApi();
|
||||||
try {
|
try {
|
||||||
for (const item of this.items.reverse()) {
|
// Copy-then-reverse so a defensive double-`run()` (or a
|
||||||
try {
|
// caller that inspects the registry after a partial
|
||||||
await item(api);
|
// teardown) doesn't see the items in a re-reversed order.
|
||||||
} catch {
|
for (const item of [...this.items].reverse()) {
|
||||||
// Best-effort cleanup — a missing resource (already
|
await deleteAndReport(api, item);
|
||||||
// deleted by the test) shouldn't fail the suite.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await api.dispose();
|
await api.dispose();
|
||||||
@@ -46,3 +56,22 @@ export class CleanupRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAndReport(
|
||||||
|
api: APIRequestContext,
|
||||||
|
item: CleanupItem
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await api.delete(item.path);
|
||||||
|
// 2xx and 404 are both "this resource is no longer here" — fine.
|
||||||
|
if (!res.ok() && res.status() !== 404) {
|
||||||
|
console.warn(
|
||||||
|
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network-level failure (request never reached the server,
|
||||||
|
// timeout, etc.). Log so a leak doesn't accumulate silently.
|
||||||
|
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Helpers for tests that drive the dashboard as a non-bootstrap admin
|
||||||
|
// (member with an app-membership row, custom InstanceRole, etc.).
|
||||||
|
//
|
||||||
|
// `loginAsUserToken` exchanges username/password for a bearer token
|
||||||
|
// via the admin API. `pageWithUserToken` opens a fresh browser
|
||||||
|
// context, seeds the dashboard's localStorage entry, and returns the
|
||||||
|
// page ready to navigate. Callers are responsible for closing the
|
||||||
|
// returned page's context.
|
||||||
|
|
||||||
|
import { expect, request, type Browser, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
export async function loginAsUserToken(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const probe = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await probe.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username, password },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { token: string }).token;
|
||||||
|
} finally {
|
||||||
|
await probe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pageWithUserToken(
|
||||||
|
browser: Browser,
|
||||||
|
token: string
|
||||||
|
): Promise<Page> {
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
// Seed localStorage on the right origin, then navigate normally.
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export default async function globalSetup(): Promise<void> {
|
|||||||
await assertBackendUp();
|
await assertBackendUp();
|
||||||
await fs.mkdir(AUTH_DIR, { recursive: true });
|
await fs.mkdir(AUTH_DIR, { recursive: true });
|
||||||
const token = await loginAsAdmin();
|
const token = await loginAsAdmin();
|
||||||
|
await sweepOrphans(token);
|
||||||
await persistAdminStorageState(token);
|
await persistAdminStorageState(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,57 @@ async function loginAsAdmin(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up apps + admin users left over from a previous crashed run.
|
||||||
|
// The convention is that every e2e-created resource has a slug
|
||||||
|
// starting with `e2e-` (apps) or a username starting with `e2e`
|
||||||
|
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
|
||||||
|
// not stop the suite from running.
|
||||||
|
async function sweepOrphans(token: string): Promise<void> {
|
||||||
|
const ctx = await request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const res = await ctx.get('/api/v1/admin/apps');
|
||||||
|
if (res.ok()) {
|
||||||
|
const apps = (await res.json()) as Array<{ slug: string }>;
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!app.slug.startsWith('e2e-')) continue;
|
||||||
|
try {
|
||||||
|
await ctx.delete(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Individual delete failure is non-fatal — the per-test
|
||||||
|
// cleanup will catch it on the next run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Listing failed; nothing to do but proceed.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await ctx.get('/api/v1/admin/admins');
|
||||||
|
if (res.ok()) {
|
||||||
|
const admins = (await res.json()) as Array<{ id: string; username: string }>;
|
||||||
|
for (const a of admins) {
|
||||||
|
if (!/^e2e/.test(a.username)) continue;
|
||||||
|
try {
|
||||||
|
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
|
||||||
|
} catch {
|
||||||
|
// Same per-row tolerance as above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Listing failed; same as above.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The dashboard reads its session from localStorage under the key
|
// The dashboard reads its session from localStorage under the key
|
||||||
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
|
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
|
||||||
// localStorage without a browser context, so launch a throwaway one,
|
// localStorage without a browser context, so launch a throwaway one,
|
||||||
|
|||||||
@@ -87,9 +87,12 @@ test('end-to-end: app + domain + script + route via dashboard → invoke via pub
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
||||||
page
|
page,
|
||||||
|
uniqueUsername
|
||||||
}) => {
|
}) => {
|
||||||
const name = `e2e-cli-${Date.now()}`;
|
// Worker-aware unique helper instead of Date.now() — keeps two
|
||||||
|
// workers from minting the same name on the same millisecond.
|
||||||
|
const name = uniqueUsername('cli');
|
||||||
|
|
||||||
// 1. Mint the key from /profile and capture the revealed token.
|
// 1. Mint the key from /profile and capture the revealed token.
|
||||||
await page.goto('/admin/profile');
|
await page.goto('/admin/profile');
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { expect, type Browser, type Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { test } from '../fixtures/ids';
|
import { test } from '../fixtures/ids';
|
||||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
import { adminApi } from '../fixtures/api';
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
// Phase B5 — App Members. Setup creates one or two extra admin
|
// Phase B5 — App Members. Setup creates one or two extra admin
|
||||||
// users via the API; tests drive the Members tab through the
|
// users via the API; tests drive the Members tab through the
|
||||||
// dashboard like a real app admin would.
|
// dashboard like a real app admin would.
|
||||||
|
|
||||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
|
||||||
|
|
||||||
const cleanup = new CleanupRegistry();
|
const cleanup = new CleanupRegistry();
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
await cleanup.run();
|
await cleanup.run();
|
||||||
@@ -38,36 +37,6 @@ async function createMemberUser(username: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginAsUserToken(username: string, password: string): Promise<string> {
|
|
||||||
const probe = await (await import('@playwright/test')).request.newContext({
|
|
||||||
baseURL: API_BASE
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const res = await probe.post('/api/v1/admin/auth/login', {
|
|
||||||
data: { username, password },
|
|
||||||
headers: { 'content-type': 'application/json' }
|
|
||||||
});
|
|
||||||
expect(res.ok()).toBe(true);
|
|
||||||
return ((await res.json()) as { token: string }).token;
|
|
||||||
} finally {
|
|
||||||
await probe.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pageWithUserToken(browser: Browser, token: string): Promise<Page> {
|
|
||||||
const ctx = await browser.newContext({ storageState: undefined });
|
|
||||||
const page = await ctx.newPage();
|
|
||||||
// Seed localStorage on the right origin, then navigate normally.
|
|
||||||
await page.goto('/admin/login');
|
|
||||||
await page.evaluate(
|
|
||||||
([key, value]) => {
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
},
|
|
||||||
['picloud.admin.token', token]
|
|
||||||
);
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('B5 app members', () => {
|
test.describe('B5 app members', () => {
|
||||||
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
||||||
const slug = uniqueSlug('mem');
|
const slug = uniqueSlug('mem');
|
||||||
|
|||||||
@@ -2,6 +2,41 @@ import { expect, type Page } from '@playwright/test';
|
|||||||
import { test } from '../fixtures/ids';
|
import { test } from '../fixtures/ids';
|
||||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
import { adminApi } from '../fixtures/api';
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppScriptAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor';
|
||||||
|
}): Promise<{ scriptId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
|
||||||
|
});
|
||||||
|
expect(scriptRes.ok()).toBe(true);
|
||||||
|
const scriptId = ((await scriptRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { scriptId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
||||||
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
||||||
@@ -175,6 +210,105 @@ test.describe('B3 settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('B3 scripts role shadowing', () => {
|
||||||
|
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vscr');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Header Delete is hidden for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Save/Format on the Edit tab are hidden for viewers.
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.locator('.editor-header').getByRole('button', { name: 'Format' })
|
||||||
|
).toHaveCount(0);
|
||||||
|
// Test invoke is still visible (everyone with read access).
|
||||||
|
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
|
||||||
|
// Routing tab loads, no +Add route.
|
||||||
|
await page.getByRole('button', { name: /Routing/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
|
||||||
|
// Settings tab is absent for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer: CodeMirror is read-only', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vro');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
const cm = page.locator('.cm-content').first();
|
||||||
|
await expect(cm).toBeVisible();
|
||||||
|
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
|
||||||
|
// is in effect; that's the canonical signal for read-only mode.
|
||||||
|
await expect(cm).toHaveAttribute('contenteditable', 'false');
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor: Save visible, Delete header hidden', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('escr');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'editor'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Editor sees Save (disabled until the buffer changes — that's fine).
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
|
||||||
|
// Delete stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Settings stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('B3 adversarial', () => {
|
test.describe('B3 adversarial', () => {
|
||||||
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
||||||
const slug = uniqueSlug('loop');
|
const slug = uniqueSlug('loop');
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ test.describe('B6 instance users', () => {
|
|||||||
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deactivate then reactivate toggles the inactive indicator', async ({
|
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
|
||||||
page,
|
page,
|
||||||
uniqueUsername
|
uniqueUsername
|
||||||
}) => {
|
}) => {
|
||||||
@@ -127,10 +127,25 @@ test.describe('B6 instance users', () => {
|
|||||||
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Deactivate opens the confirm modal.
|
||||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(username);
|
||||||
|
|
||||||
|
// Cancel leaves the user active.
|
||||||
|
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Open again and confirm — user becomes inactive.
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
|
||||||
await expect(row).toContainText(/inactive/i);
|
await expect(row).toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Reactivate is still one-click (non-destructive — no modal).
|
||||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
||||||
await expect(row).not.toContainText(/inactive/i);
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['src/lib/rhai/**/*.test.ts'],
|
include: ['src/lib/**/*.test.ts'],
|
||||||
environment: 'node'
|
environment: 'node'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PICLOUD_HOST_PORT:-8000}:80"
|
- "${PICLOUD_HOST_PORT:-8000}:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1049,7 +1049,7 @@ pub struct Principal {
|
|||||||
| Role | Powers |
|
| Role | Powers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||||
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
| `admin` | create apps, invite users, implicit `app_admin` on every app. Cannot manage instance-wide settings (sandbox ceiling, etc.) or other owners. |
|
||||||
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||||
|
|
||||||
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||||
@@ -1058,11 +1058,13 @@ The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'`
|
|||||||
|
|
||||||
| Role | Grants |
|
| Role | Grants |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app_admin` | settings, domain claims, delete app |
|
| `app_admin` | settings, domain claims, delete app, **delete scripts** |
|
||||||
| `editor` | CRUD on scripts, routes, sandbox config |
|
| `editor` | create + edit scripts, routes, sandbox config (no script delete) |
|
||||||
| `viewer` | read scripts + execution logs |
|
| `viewer` | read scripts + execution logs |
|
||||||
|
|
||||||
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
Implicit grants from instance role: every `owner` and every `admin` is `app_admin` on every app — a single-human install would otherwise have to add itself to each new app's `app_members`. Explicit `app_members` rows are the only path for `member` users.
|
||||||
|
|
||||||
|
Script **save** uses `AppWriteScript` (editor+); script **delete** uses `AppAdmin` (app_admin+). Editors can iterate on a script's source freely but cannot remove it — destructive cleanup stays with the role that also owns the app.
|
||||||
|
|
||||||
### Auth Methods — Same Principal, Different Extractor
|
### Auth Methods — Same Principal, Different Extractor
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user