authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:48:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
loga7246389ae8115bab036e4edf5f5240d06901251
treeebec2479e86771c559bfbd03ba70cb505e354be5
parent2f9d63330af4cabbe5e6d66c465e4ee9fa529f5a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Move Snowglobe and Files authentication into the Rust dashboard

Preserve account and credential IDs, import existing password hashes and passkeys, and add revocable invitations with optional passkey onboarding. Use native host-bound sessions for Snowglobe and Copyparty while retaining password-protected file shares and Keycloak for other services. Back up and restore native account and connection stores with verified SQLite copies. Include the verified MCP catalog and consent pages plus prompted cross-platform agent installers. Redirect the unsupported nested userscript URL to discord-pluralkit-predict. Assisted-by: gpt-6.1-sol

47 files changed, 3776 insertions(+), 809 deletions(-)

dashboard/.gitignore+1
...@@ -3,3 +3,4 @@ dist/...@@ -3,3 +3,4 @@ dist/
3.cache/3.cache/
4data/4data/
5target/5target/
6agent/relay.mjs
dashboard/Cargo.lock+443-8
...@@ -20,6 +20,57 @@ dependencies = [...@@ -20,6 +20,57 @@ dependencies = [
20 "libc",20 "libc",
21]21]
2222
23[[package]]
24name = "argon2"
25version = "0.5.3"
26source = "registry+https://github.com/rust-lang/crates.io-index"
27checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
28dependencies = [
29 "base64ct",
30 "blake2",
31 "cpufeatures 0.2.17",
32 "password-hash",
33]
34
35[[package]]
36name = "asn1-rs"
37version = "0.6.2"
38source = "registry+https://github.com/rust-lang/crates.io-index"
39checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
40dependencies = [
41 "asn1-rs-derive",
42 "asn1-rs-impl",
43 "displaydoc",
44 "nom",
45 "num-traits",
46 "rusticata-macros",
47 "thiserror 1.0.69",
48 "time",
49]
50
51[[package]]
52name = "asn1-rs-derive"
53version = "0.5.1"
54source = "registry+https://github.com/rust-lang/crates.io-index"
55checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
56dependencies = [
57 "proc-macro2",
58 "quote",
59 "syn 2.0.119",
60 "synstructure 0.13.2",
61]
62
63[[package]]
64name = "asn1-rs-impl"
65version = "0.2.0"
66source = "registry+https://github.com/rust-lang/crates.io-index"
67checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
68dependencies = [
69 "proc-macro2",
70 "quote",
71 "syn 2.0.119",
72]
73
23[[package]]74[[package]]
24name = "async-trait"75name = "async-trait"
25version = "0.1.92"76version = "0.1.92"
...@@ -99,6 +150,12 @@ dependencies = [...@@ -99,6 +150,12 @@ dependencies = [
99 "tracing",150 "tracing",
100]151]
101152
153[[package]]
154name = "base64"
155version = "0.21.7"
156source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
158
102[[package]]159[[package]]
103name = "base64"160name = "base64"
104version = "0.22.1"161version = "0.22.1"
...@@ -111,12 +168,38 @@ version = "0.23.1"...@@ -111,12 +168,38 @@ version = "0.23.1"
111source = "registry+https://github.com/rust-lang/crates.io-index"168source = "registry+https://github.com/rust-lang/crates.io-index"
112checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"169checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
113170
171[[package]]
172name = "base64ct"
173version = "1.8.3"
174source = "registry+https://github.com/rust-lang/crates.io-index"
175checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
176
177[[package]]
178name = "base64urlsafedata"
179version = "0.5.5"
180source = "registry+https://github.com/rust-lang/crates.io-index"
181checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
182dependencies = [
183 "base64 0.21.7",
184 "pastey 0.1.1",
185 "serde",
186]
187
114[[package]]188[[package]]
115name = "bitflags"189name = "bitflags"
116version = "2.13.2"190version = "2.13.2"
117source = "registry+https://github.com/rust-lang/crates.io-index"191source = "registry+https://github.com/rust-lang/crates.io-index"
118checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"192checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
119193
194[[package]]
195name = "blake2"
196version = "0.10.6"
197source = "registry+https://github.com/rust-lang/crates.io-index"
198checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
199dependencies = [
200 "digest",
201]
202
120[[package]]203[[package]]
121name = "block-buffer"204name = "block-buffer"
122version = "0.10.4"205version = "0.10.4"
...@@ -225,6 +308,12 @@ dependencies = [...@@ -225,6 +308,12 @@ dependencies = [
225 "libc",308 "libc",
226]309]
227310
311[[package]]
312name = "crunchy"
313version = "0.2.4"
314source = "registry+https://github.com/rust-lang/crates.io-index"
315checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
316
228[[package]]317[[package]]
229name = "crypto-common"318name = "crypto-common"
230version = "0.1.7"319version = "0.1.7"
...@@ -264,6 +353,26 @@ version = "2.11.1"...@@ -264,6 +353,26 @@ version = "2.11.1"
264source = "registry+https://github.com/rust-lang/crates.io-index"353source = "registry+https://github.com/rust-lang/crates.io-index"
265checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"354checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
266355
356[[package]]
357name = "der-parser"
358version = "9.0.0"
359source = "registry+https://github.com/rust-lang/crates.io-index"
360checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
361dependencies = [
362 "asn1-rs",
363 "displaydoc",
364 "nom",
365 "num-bigint",
366 "num-traits",
367 "rusticata-macros",
368]
369
370[[package]]
371name = "deranged"
372version = "0.5.8"
373source = "registry+https://github.com/rust-lang/crates.io-index"
374checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
375
267[[package]]376[[package]]
268name = "derive_more"377name = "derive_more"
269version = "2.1.1"378version = "2.1.1"
...@@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"...@@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
293dependencies = [402dependencies = [
294 "block-buffer",403 "block-buffer",
295 "crypto-common",404 "crypto-common",
405 "subtle",
296]406]
297407
298[[package]]408[[package]]
...@@ -393,6 +503,21 @@ version = "0.1.5"...@@ -393,6 +503,21 @@ version = "0.1.5"
393source = "registry+https://github.com/rust-lang/crates.io-index"503source = "registry+https://github.com/rust-lang/crates.io-index"
394checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"504checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
395505
506[[package]]
507name = "foreign-types"
508version = "0.3.2"
509source = "registry+https://github.com/rust-lang/crates.io-index"
510checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
511dependencies = [
512 "foreign-types-shared",
513]
514
515[[package]]
516name = "foreign-types-shared"
517version = "0.1.1"
518source = "registry+https://github.com/rust-lang/crates.io-index"
519checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
520
396[[package]]521[[package]]
397name = "form_urlencoded"522name = "form_urlencoded"
398version = "1.2.2"523version = "1.2.2"
...@@ -552,6 +677,17 @@ dependencies = [...@@ -552,6 +677,17 @@ dependencies = [
552 "regex-syntax",677 "regex-syntax",
553]678]
554679
680[[package]]
681name = "half"
682version = "2.7.1"
683source = "registry+https://github.com/rust-lang/crates.io-index"
684checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
685dependencies = [
686 "cfg-if",
687 "crunchy",
688 "zerocopy",
689]
690
555[[package]]691[[package]]
556name = "hashbrown"692name = "hashbrown"
557version = "0.15.5"693version = "0.15.5"
...@@ -576,10 +712,17 @@ dependencies = [...@@ -576,10 +712,17 @@ dependencies = [
576 "hashbrown 0.15.5",712 "hashbrown 0.15.5",
577]713]
578714
715[[package]]
716name = "hex"
717version = "0.4.3"
718source = "registry+https://github.com/rust-lang/crates.io-index"
719checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
720
579[[package]]721[[package]]
580name = "home-dashboard"722name = "home-dashboard"
581version = "0.1.0"723version = "0.1.0"
582dependencies = [724dependencies = [
725 "argon2",
583 "axum",726 "axum",
584 "base64 0.22.1",727 "base64 0.22.1",
585 "bytes",728 "bytes",
...@@ -592,6 +735,8 @@ dependencies = [...@@ -592,6 +735,8 @@ dependencies = [
592 "rmcp",735 "rmcp",
593 "rusqlite",736 "rusqlite",
594 "scraper",737 "scraper",
738 "serde",
739 "serde_cbor_2",
595 "serde_json",740 "serde_json",
596 "sha1",741 "sha1",
597 "sha2",742 "sha2",
...@@ -602,6 +747,7 @@ dependencies = [...@@ -602,6 +747,7 @@ dependencies = [
602 "url",747 "url",
603 "uuid",748 "uuid",
604 "walkdir",749 "walkdir",
750 "webauthn-rs",
605]751]
606752
607[[package]]753[[package]]
...@@ -889,6 +1035,12 @@ dependencies = [...@@ -889,6 +1035,12 @@ dependencies = [
889 "wasm-bindgen",1035 "wasm-bindgen",
890]1036]
8911037
1038[[package]]
1039name = "lazy_static"
1040version = "1.5.1"
1041source = "registry+https://github.com/rust-lang/crates.io-index"
1042checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1043
892[[package]]1044[[package]]
893name = "libc"1045name = "libc"
894version = "0.2.189"1046version = "0.2.189"
...@@ -972,6 +1124,12 @@ dependencies = [...@@ -972,6 +1124,12 @@ dependencies = [
972 "unicase",1124 "unicase",
973]1125]
9741126
1127[[package]]
1128name = "minimal-lexical"
1129version = "0.2.1"
1130source = "registry+https://github.com/rust-lang/crates.io-index"
1131checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1132
975[[package]]1133[[package]]
976name = "mio"1134name = "mio"
977version = "1.2.3"1135version = "1.2.3"
...@@ -1012,6 +1170,41 @@ version = "1.0.6"...@@ -1012,6 +1170,41 @@ version = "1.0.6"
1012source = "registry+https://github.com/rust-lang/crates.io-index"1170source = "registry+https://github.com/rust-lang/crates.io-index"
1013checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"1171checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
10141172
1173[[package]]
1174name = "nom"
1175version = "7.1.3"
1176source = "registry+https://github.com/rust-lang/crates.io-index"
1177checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1178dependencies = [
1179 "memchr",
1180 "minimal-lexical",
1181]
1182
1183[[package]]
1184name = "num-bigint"
1185version = "0.4.8"
1186source = "registry+https://github.com/rust-lang/crates.io-index"
1187checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
1188dependencies = [
1189 "num-integer",
1190 "num-traits",
1191]
1192
1193[[package]]
1194name = "num-conv"
1195version = "0.2.2"
1196source = "registry+https://github.com/rust-lang/crates.io-index"
1197checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1198
1199[[package]]
1200name = "num-integer"
1201version = "0.1.47"
1202source = "registry+https://github.com/rust-lang/crates.io-index"
1203checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1204dependencies = [
1205 "num-traits",
1206]
1207
1015[[package]]1208[[package]]
1016name = "num-traits"1209name = "num-traits"
1017version = "0.2.19"1210version = "0.2.19"
...@@ -1021,12 +1214,58 @@ dependencies = [...@@ -1021,12 +1214,58 @@ dependencies = [
1021 "autocfg",1214 "autocfg",
1022]1215]
10231216
1217[[package]]
1218name = "oid-registry"
1219version = "0.7.1"
1220source = "registry+https://github.com/rust-lang/crates.io-index"
1221checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
1222dependencies = [
1223 "asn1-rs",
1224]
1225
1024[[package]]1226[[package]]
1025name = "once_cell"1227name = "once_cell"
1026version = "1.21.4"1228version = "1.21.4"
1027source = "registry+https://github.com/rust-lang/crates.io-index"1229source = "registry+https://github.com/rust-lang/crates.io-index"
1028checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"1230checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
10291231
1232[[package]]
1233name = "openssl"
1234version = "0.10.81"
1235source = "registry+https://github.com/rust-lang/crates.io-index"
1236checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
1237dependencies = [
1238 "bitflags",
1239 "cfg-if",
1240 "foreign-types",
1241 "libc",
1242 "openssl-macros",
1243 "openssl-sys",
1244]
1245
1246[[package]]
1247name = "openssl-macros"
1248version = "0.1.1"
1249source = "registry+https://github.com/rust-lang/crates.io-index"
1250checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1251dependencies = [
1252 "proc-macro2",
1253 "quote",
1254 "syn 2.0.119",
1255]
1256
1257[[package]]
1258name = "openssl-sys"
1259version = "0.9.117"
1260source = "registry+https://github.com/rust-lang/crates.io-index"
1261checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
1262dependencies = [
1263 "cc",
1264 "libc",
1265 "pkg-config",
1266 "vcpkg",
1267]
1268
1030[[package]]1269[[package]]
1031name = "parking_lot"1270name = "parking_lot"
1032version = "0.12.5"1271version = "0.12.5"
...@@ -1050,6 +1289,23 @@ dependencies = [...@@ -1050,6 +1289,23 @@ dependencies = [
1050 "windows-link",1289 "windows-link",
1051]1290]
10521291
1292[[package]]
1293name = "password-hash"
1294version = "0.5.0"
1295source = "registry+https://github.com/rust-lang/crates.io-index"
1296checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
1297dependencies = [
1298 "base64ct",
1299 "rand_core 0.6.4",
1300 "subtle",
1301]
1302
1303[[package]]
1304name = "pastey"
1305version = "0.1.1"
1306source = "registry+https://github.com/rust-lang/crates.io-index"
1307checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
1308
1053[[package]]1309[[package]]
1054name = "pastey"1310name = "pastey"
1055version = "0.2.3"1311version = "0.2.3"
...@@ -1136,6 +1392,12 @@ dependencies = [...@@ -1136,6 +1392,12 @@ dependencies = [
1136 "zerovec",1392 "zerovec",
1137]1393]
11381394
1395[[package]]
1396name = "powerfmt"
1397version = "0.2.0"
1398source = "registry+https://github.com/rust-lang/crates.io-index"
1399checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1400
1139[[package]]1401[[package]]
1140name = "ppv-lite86"1402name = "ppv-lite86"
1141version = "0.2.21"1403version = "0.2.21"
...@@ -1174,7 +1436,7 @@ dependencies = [...@@ -1174,7 +1436,7 @@ dependencies = [
1174 "rustc-hash",1436 "rustc-hash",
1175 "rustls",1437 "rustls",
1176 "socket2",1438 "socket2",
1177 "thiserror",1439 "thiserror 2.0.21",
1178 "tokio",1440 "tokio",
1179 "tracing",1441 "tracing",
1180 "web-time",1442 "web-time",
...@@ -1196,7 +1458,7 @@ dependencies = [...@@ -1196,7 +1458,7 @@ dependencies = [
1196 "rustls",1458 "rustls",
1197 "rustls-pki-types",1459 "rustls-pki-types",
1198 "slab",1460 "slab",
1199 "thiserror",1461 "thiserror 2.0.21",
1200 "tinyvec",1462 "tinyvec",
1201 "tracing",1463 "tracing",
1202 "web-time",1464 "web-time",
...@@ -1268,6 +1530,12 @@ dependencies = [...@@ -1268,6 +1530,12 @@ dependencies = [
1268 "rand_core 0.9.5",1530 "rand_core 0.9.5",
1269]1531]
12701532
1533[[package]]
1534name = "rand_core"
1535version = "0.6.4"
1536source = "registry+https://github.com/rust-lang/crates.io-index"
1537checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1538
1271[[package]]1539[[package]]
1272name = "rand_core"1540name = "rand_core"
1273version = "0.9.5"1541version = "0.9.5"
...@@ -1419,14 +1687,14 @@ dependencies = [...@@ -1419,14 +1687,14 @@ dependencies = [
1419 "http-body",1687 "http-body",
1420 "http-body-util",1688 "http-body-util",
1421 "indexmap",1689 "indexmap",
1422 "pastey",1690 "pastey 0.2.3",
1423 "pin-project-lite",1691 "pin-project-lite",
1424 "rand 0.10.3",1692 "rand 0.10.3",
1425 "schemars",1693 "schemars",
1426 "serde",1694 "serde",
1427 "serde_json",1695 "serde_json",
1428 "sse-stream",1696 "sse-stream",
1429 "thiserror",1697 "thiserror 2.0.21",
1430 "tokio",1698 "tokio",
1431 "tokio-stream",1699 "tokio-stream",
1432 "tokio-util",1700 "tokio-util",
...@@ -1464,6 +1732,15 @@ dependencies = [...@@ -1464,6 +1732,15 @@ dependencies = [
1464 "semver",1732 "semver",
1465]1733]
14661734
1735[[package]]
1736name = "rusticata-macros"
1737version = "4.1.0"
1738source = "registry+https://github.com/rust-lang/crates.io-index"
1739checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
1740dependencies = [
1741 "nom",
1742]
1743
1467[[package]]1744[[package]]
1468name = "rustls"1745name = "rustls"
1469version = "0.23.45"1746version = "0.23.45"
...@@ -1601,6 +1878,16 @@ dependencies = [...@@ -1601,6 +1878,16 @@ dependencies = [
1601 "serde_derive",1878 "serde_derive",
1602]1879]
16031880
1881[[package]]
1882name = "serde_cbor_2"
1883version = "0.13.0"
1884source = "registry+https://github.com/rust-lang/crates.io-index"
1885checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
1886dependencies = [
1887 "half",
1888 "serde",
1889]
1890
1604[[package]]1891[[package]]
1605name = "serde_core"1892name = "serde_core"
1606version = "1.0.229"1893version = "1.0.229"
...@@ -1835,6 +2122,17 @@ dependencies = [...@@ -1835,6 +2122,17 @@ dependencies = [
1835 "futures-core",2122 "futures-core",
1836]2123]
18372124
2125[[package]]
2126name = "synstructure"
2127version = "0.13.2"
2128source = "registry+https://github.com/rust-lang/crates.io-index"
2129checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
2130dependencies = [
2131 "proc-macro2",
2132 "quote",
2133 "syn 2.0.119",
2134]
2135
1838[[package]]2136[[package]]
1839name = "synstructure"2137name = "synstructure"
1840version = "0.14.0"2138version = "0.14.0"
...@@ -1855,13 +2153,33 @@ dependencies = [...@@ -1855,13 +2153,33 @@ dependencies = [
1855 "new_debug_unreachable",2153 "new_debug_unreachable",
1856]2154]
18572155
2156[[package]]
2157name = "thiserror"
2158version = "1.0.69"
2159source = "registry+https://github.com/rust-lang/crates.io-index"
2160checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2161dependencies = [
2162 "thiserror-impl 1.0.69",
2163]
2164
1858[[package]]2165[[package]]
1859name = "thiserror"2166name = "thiserror"
1860version = "2.0.21"2167version = "2.0.21"
1861source = "registry+https://github.com/rust-lang/crates.io-index"2168source = "registry+https://github.com/rust-lang/crates.io-index"
1862checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"2169checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
1863dependencies = [2170dependencies = [
1864 "thiserror-impl",2171 "thiserror-impl 2.0.21",
2172]
2173
2174[[package]]
2175name = "thiserror-impl"
2176version = "1.0.69"
2177source = "registry+https://github.com/rust-lang/crates.io-index"
2178checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
2179dependencies = [
2180 "proc-macro2",
2181 "quote",
2182 "syn 2.0.119",
1865]2183]
18662184
1867[[package]]2185[[package]]
...@@ -1875,6 +2193,36 @@ dependencies = [...@@ -1875,6 +2193,36 @@ dependencies = [
1875 "syn 3.0.6",2193 "syn 3.0.6",
1876]2194]
18772195
2196[[package]]
2197name = "time"
2198version = "0.3.55"
2199source = "registry+https://github.com/rust-lang/crates.io-index"
2200checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
2201dependencies = [
2202 "deranged",
2203 "num-conv",
2204 "powerfmt",
2205 "serde_core",
2206 "time-core",
2207 "time-macros",
2208]
2209
2210[[package]]
2211name = "time-core"
2212version = "0.1.9"
2213source = "registry+https://github.com/rust-lang/crates.io-index"
2214checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
2215
2216[[package]]
2217name = "time-macros"
2218version = "0.2.32"
2219source = "registry+https://github.com/rust-lang/crates.io-index"
2220checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
2221dependencies = [
2222 "num-conv",
2223 "time-core",
2224]
2225
1878[[package]]2226[[package]]
1879name = "tinystr"2227name = "tinystr"
1880version = "0.8.4"2228version = "0.8.4"
...@@ -2073,7 +2421,7 @@ dependencies = [...@@ -2073,7 +2421,7 @@ dependencies = [
2073 "log",2421 "log",
2074 "rand 0.9.5",2422 "rand 0.9.5",
2075 "sha1",2423 "sha1",
2076 "thiserror",2424 "thiserror 2.0.21",
2077]2425]
20782426
2079[[package]]2427[[package]]
...@@ -2110,6 +2458,7 @@ dependencies = [...@@ -2110,6 +2458,7 @@ dependencies = [
2110 "idna",2458 "idna",
2111 "percent-encoding",2459 "percent-encoding",
2112 "serde",2460 "serde",
2461 "serde_derive",
2113]2462]
21142463
2115[[package]]2464[[package]]
...@@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"...@@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
2126dependencies = [2475dependencies = [
2127 "getrandom 0.4.3",2476 "getrandom 0.4.3",
2128 "js-sys",2477 "js-sys",
2478 "serde_core",
2129 "wasm-bindgen",2479 "wasm-bindgen",
2130]2480]
21312481
...@@ -2263,6 +2613,74 @@ dependencies = [...@@ -2263,6 +2613,74 @@ dependencies = [
2263 "string_cache_codegen",2613 "string_cache_codegen",
2264]2614]
22652615
2616[[package]]
2617name = "webauthn-attestation-ca"
2618version = "0.5.5"
2619source = "registry+https://github.com/rust-lang/crates.io-index"
2620checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e"
2621dependencies = [
2622 "base64urlsafedata",
2623 "openssl",
2624 "openssl-sys",
2625 "serde",
2626 "tracing",
2627 "uuid",
2628]
2629
2630[[package]]
2631name = "webauthn-rs"
2632version = "0.5.5"
2633source = "registry+https://github.com/rust-lang/crates.io-index"
2634checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422"
2635dependencies = [
2636 "base64urlsafedata",
2637 "serde",
2638 "tracing",
2639 "url",
2640 "uuid",
2641 "webauthn-rs-core",
2642]
2643
2644[[package]]
2645name = "webauthn-rs-core"
2646version = "0.5.5"
2647source = "registry+https://github.com/rust-lang/crates.io-index"
2648checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a"
2649dependencies = [
2650 "base64 0.21.7",
2651 "base64urlsafedata",
2652 "der-parser",
2653 "hex",
2654 "nom",
2655 "openssl",
2656 "openssl-sys",
2657 "rand 0.9.5",
2658 "rand_chacha",
2659 "serde",
2660 "serde_cbor_2",
2661 "serde_json",
2662 "thiserror 1.0.69",
2663 "tracing",
2664 "url",
2665 "uuid",
2666 "webauthn-attestation-ca",
2667 "webauthn-rs-proto",
2668 "x509-parser",
2669]
2670
2671[[package]]
2672name = "webauthn-rs-proto"
2673version = "0.5.5"
2674source = "registry+https://github.com/rust-lang/crates.io-index"
2675checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e"
2676dependencies = [
2677 "base64 0.21.7",
2678 "base64urlsafedata",
2679 "serde",
2680 "serde_json",
2681 "url",
2682]
2683
2266[[package]]2684[[package]]
2267name = "webpki-roots"2685name = "webpki-roots"
2268version = "1.0.9"2686version = "1.0.9"
...@@ -2434,6 +2852,23 @@ version = "0.6.4"...@@ -2434,6 +2852,23 @@ version = "0.6.4"
2434source = "registry+https://github.com/rust-lang/crates.io-index"2852source = "registry+https://github.com/rust-lang/crates.io-index"
2435checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"2853checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
24362854
2855[[package]]
2856name = "x509-parser"
2857version = "0.16.0"
2858source = "registry+https://github.com/rust-lang/crates.io-index"
2859checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
2860dependencies = [
2861 "asn1-rs",
2862 "data-encoding",
2863 "der-parser",
2864 "lazy_static",
2865 "nom",
2866 "oid-registry",
2867 "rusticata-macros",
2868 "thiserror 1.0.69",
2869 "time",
2870]
2871
2437[[package]]2872[[package]]
2438name = "yoke"2873name = "yoke"
2439version = "0.8.3"2874version = "0.8.3"
...@@ -2454,7 +2889,7 @@ dependencies = [...@@ -2454,7 +2889,7 @@ dependencies = [
2454 "proc-macro2",2889 "proc-macro2",
2455 "quote",2890 "quote",
2456 "syn 3.0.6",2891 "syn 3.0.6",
2457 "synstructure",2892 "synstructure 0.14.0",
2458]2893]
24592894
2460[[package]]2895[[package]]
...@@ -2495,7 +2930,7 @@ dependencies = [...@@ -2495,7 +2930,7 @@ dependencies = [
2495 "proc-macro2",2930 "proc-macro2",
2496 "quote",2931 "quote",
2497 "syn 3.0.6",2932 "syn 3.0.6",
2498 "synstructure",2933 "synstructure 0.14.0",
2499]2934]
25002935
2501[[package]]2936[[package]]
dashboard/Cargo.toml+4
...@@ -4,6 +4,7 @@ version = "0.1.0"...@@ -4,6 +4,7 @@ version = "0.1.0"
4edition = "2024"4edition = "2024"
55
6[dependencies]6[dependencies]
7argon2 = "0.5"
7axum = { version = "0.8.9", features = ["multipart", "ws"] }8axum = { version = "0.8.9", features = ["multipart", "ws"] }
8base64 = "0.22"9base64 = "0.22"
9bytes = "1"10bytes = "1"
...@@ -15,6 +16,8 @@ regex = "1"...@@ -15,6 +16,8 @@ regex = "1"
15reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }16reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
16rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }17rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
17rusqlite = { version = "0.37", features = ["bundled"] }18rusqlite = { version = "0.37", features = ["bundled"] }
19serde = { version = "1", features = ["derive"] }
20serde_cbor_2 = "0.13"
18scraper = { version = "0.27", default-features = false }21scraper = { version = "0.27", default-features = false }
19serde_json = "1"22serde_json = "1"
20sha1 = "0.10"23sha1 = "0.10"
...@@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] }...@@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] }
26url = "2"29url = "2"
27uuid = { version = "1", features = ["v4"] }30uuid = { version = "1", features = ["v4"] }
28walkdir = "2"31walkdir = "2"
32webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
2933
30[profile.release]34[profile.release]
31lto = "thin"35lto = "thin"
dashboard/agent/install.ps1 created+41
...@@ -0,0 +1,41 @@
1$ErrorActionPreference = 'Stop'
2$Server = __SERVER__
3
4function Install-Agent {
5 $Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
6 $Principal = New-Object Security.Principal.WindowsPrincipal($Identity)
7 if ($Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
8 throw 'Run this installer from your usual PowerShell window, without administrator privileges.'
9 }
10 $Base = Join-Path $env:LOCALAPPDATA 'AgentRelay'
11 $Stage = Join-Path $Base ('.install.' + [guid]::NewGuid().ToString('N'))
12 New-Item -ItemType Directory -Force $Base | Out-Null
13 & icacls.exe $Base /inheritance:r /grant:r ('*' + $Identity.User.Value + ':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' | Out-Null
14 if ($LASTEXITCODE -ne 0) { throw 'Unable to protect the agent folder. Check its permissions and retry.' }
15 try {
16 New-Item -ItemType Directory -Force $Stage | Out-Null
17 $Node = Join-Path $Base 'node.exe'
18 if (!(Test-Path $Node)) {
19 Write-Host 'Downloading the agent runtime…'
20 $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'arm64' } else { 'x64' }
21 $Checksums = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 'https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt').Content
22 $Match = [regex]::Match($Checksums, "(?m)^([a-f0-9]{64})\s+(node-(v24\.[0-9]+\.[0-9]+)-win-$Arch\.zip)\s*$")
23 if (!$Match.Success) { throw 'No runtime download is available for this machine.' }
24 $Archive = Join-Path $Stage $Match.Groups[2].Value
25 Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 ("https://nodejs.org/dist/" + $Match.Groups[3].Value + '/' + $Match.Groups[2].Value) -OutFile $Archive
26 if ((Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Match.Groups[1].Value) {
27 throw 'The runtime checksum did not match. Run the installer again.'
28 }
29 Expand-Archive $Archive $Stage
30 Copy-Item (Join-Path $Stage ($Match.Groups[2].Value.Replace('.zip', '') + '\node.exe')) $Node
31 }
32 Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/relay.mjs" -OutFile (Join-Path $Stage 'relay.mjs')
33 Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/setup.mjs" -OutFile (Join-Path $Stage 'setup.mjs')
34 & $Node (Join-Path $Stage 'setup.mjs') install $Server $Base
35 if ($LASTEXITCODE -ne 0) { throw 'Installation stopped. Correct the problem above, then run the installer again.' }
36 } finally {
37 Remove-Item -Recurse -Force $Stage
38 }
39}
40
41Install-Agent
dashboard/agent/install.sh created+53
...@@ -0,0 +1,53 @@
1#!/bin/sh
2set -eu
3umask 077
4server=__SERVER__
5
6install_agent() {
7 [ "$(id -u)" != 0 ] || { echo 'Run this installer as your login user, without sudo.' >&2; return 1; }
8 case $(uname -s) in
9 Linux) os=linux; base=${XDG_DATA_HOME:-"$HOME/.local/share"}/agent-relay
10 command -v systemctl >/dev/null || { echo 'This installer needs a systemd user session.' >&2; return 1; }
11 systemctl --user show-environment >/dev/null || { echo 'Sign in to a systemd user session, then run the installer again.' >&2; return 1; } ;;
12 Darwin) os=darwin; base="$HOME/Library/Application Support/AgentRelay" ;;
13 *) echo "Use $server/agent/install.ps1 from Windows PowerShell." >&2; return 1 ;;
14 esac
15 case $(uname -m) in
16 x86_64|amd64) arch=x64 ;;
17 aarch64|arm64) arch=arm64 ;;
18 *) echo 'This installer supports x64 and arm64 machines.' >&2; return 1 ;;
19 esac
20 command -v curl >/dev/null || { echo 'Install curl, then run this installer again.' >&2; return 1; }
21 mkdir -p "$base"
22 chmod 700 "$base"
23 stage=$(mktemp -d "$base/.install.XXXXXX")
24 trap 'rm -rf "$stage"' EXIT HUP INT TERM
25 if [ ! -x "$base/node" ] && [ -f /etc/NIXOS ]; then
26 echo 'Installing the agent runtime…'
27 nix --extra-experimental-features 'nix-command flakes' build nixpkgs#nodejs_24 --out-link "$base/node-runtime"
28 ln -sf "$base/node-runtime/bin/node" "$base/node"
29 elif [ ! -x "$base/node" ]; then
30 echo 'Downloading the agent runtime…'
31 curl -fsSL https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt -o "$stage/checksums"
32 archive=$(awk -v suffix="-$os-$arch.tar.gz" '$2 ~ /^node-v24\./ && substr($2, length($2)-length(suffix)+1) == suffix {print $2}' "$stage/checksums")
33 [ -n "$archive" ] || { echo 'No runtime download is available for this machine.' >&2; return 1; }
34 version=${archive#node-}; version=${version%%-$os-*}
35 curl -fsSL "https://nodejs.org/dist/$version/$archive" -o "$stage/$archive"
36 expected=$(awk -v file="$archive" '$2 == file {print $1}' "$stage/checksums")
37 if command -v sha256sum >/dev/null; then
38 actual=$(sha256sum "$stage/$archive"); actual=${actual%% *}
39 else
40 actual=$(shasum -a 256 "$stage/$archive"); actual=${actual%% *}
41 fi
42 [ "$actual" = "$expected" ] || { echo 'The runtime checksum did not match. Run the installer again.' >&2; return 1; }
43 tar -xzf "$stage/$archive" -C "$stage"
44 cp "$stage/${archive%.tar.gz}/bin/node" "$base/node"
45 chmod 700 "$base/node"
46 fi
47 curl -fsSL "$server/agent/relay.mjs" -o "$stage/relay.mjs"
48 curl -fsSL "$server/agent/setup.mjs" -o "$stage/setup.mjs"
49 "$base/node" "$stage/setup.mjs" install "$server" "$base" </dev/tty
50}
51
52# The shell must read the whole script before the installer opens the terminal.
53install_agent
dashboard/agent/setup.mjs created+192
...@@ -0,0 +1,192 @@
1import { execFileSync, spawn, spawnSync } from 'node:child_process';
2import { copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3import { homedir, hostname } from 'node:os';
4import { delimiter, dirname, join, resolve } from 'node:path';
5import { createInterface } from 'node:readline/promises';
6import { setTimeout as sleep } from 'node:timers/promises';
7
8const [action = 'status', server, installDir] = process.argv.slice(2);
9const base = installDir ?? dirname(process.argv[1]);
10const windows = process.platform === 'win32';
11const mac = process.platform === 'darwin';
12const data = windows ? join(base, 'config') : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'agent-relay');
13const unit = mac ? join(homedir(), 'Library/LaunchAgents/net.paperclover.agent-relay.plist') :
14 join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'systemd/user/agent-relay.service');
15const task = windows ? 'AgentRelay-' + execFileSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8' }).match(/S-1-5-[\d-]+/)[0] : '';
16const domain = mac ? `gui/${process.getuid()}` : '';
17const ps = (script) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' });
18const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'";
19const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'";
20
21async function stop() {
22 if (windows) ps(`$ErrorActionPreference = 'Stop'
23if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} }
24Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object {
25 $_.ExecutablePath -eq ${psQuote(join(base, 'node.exe'))} -and $_.CommandLine.Contains(${psQuote(join(base, 'relay.mjs'))})
26} | ForEach-Object {
27 & taskkill.exe /PID $_.ProcessId /T /F | Out-Null
28 if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue)) { throw 'Unable to stop the previous agent. Close it and run the installer again.' }
29}`);
30 else if (mac) {
31 if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status !== 0) return;
32 execFileSync('launchctl', ['bootout', `${domain}/net.paperclover.agent-relay`]);
33 for (let attempt = 0; attempt < 40; attempt++) {
34 try { execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); }
35 catch { return; }
36 await sleep(250);
37 }
38 throw new Error('The previous agent is still stopping. Wait and run the installer again.');
39 }
40 else if (spawnSync('systemctl', ['--user', 'show', '-P', 'LoadState', 'agent-relay.service'], { encoding: 'utf8' }).stdout.trim() === 'loaded') {
41 execFileSync('systemctl', ['--user', 'stop', 'agent-relay.service']);
42 }
43}
44
45async function main() {
46 if (action === 'stop') { await stop(); return; }
47 if (action === 'start') {
48 if (windows) ps(`$ErrorActionPreference = 'Stop'; Start-ScheduledTask -TaskName ${psQuote(task)}`);
49 else if (mac) {
50 if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status === 0) {
51 execFileSync('launchctl', ['kickstart', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' });
52 } else execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' });
53 }
54 else execFileSync('systemctl', ['--user', 'start', 'agent-relay.service'], { stdio: 'inherit' });
55 return;
56 }
57 if (action === 'status') {
58 if (windows) ps(`$ErrorActionPreference = 'Stop'; Get-ScheduledTask -TaskName ${psQuote(task)} | Select-Object TaskName,State`);
59 else if (mac) execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' });
60 else execFileSync('systemctl', ['--user', 'status', '--no-pager', 'agent-relay.service'], { stdio: 'inherit' });
61 return;
62 }
63 if (action === 'uninstall') {
64 await stop();
65 if (windows) ps(`$ErrorActionPreference = 'Stop'; Unregister-ScheduledTask -TaskName ${psQuote(task)} -Confirm:$false`);
66 else {
67 if (!mac) execFileSync('systemctl', ['--user', 'disable', 'agent-relay.service'], { stdio: 'inherit' });
68 await rm(unit, { force: true });
69 if (!mac) execFileSync('systemctl', ['--user', 'daemon-reload']);
70 }
71 console.log(`Startup removed. Pairing and files remain in ${base} and ${data}.`);
72 return;
73 }
74 if (action !== 'install' || !server || !installDir) throw new Error('Use install, status, start, stop, or uninstall.');
75 const origin = new URL(server);
76 if (origin.origin !== server || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) {
77 throw new Error('Use an HTTPS dashboard origin, or localhost for a preview.');
78 }
79 const staged = dirname(process.argv[1]);
80 let previous;
81 try { previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); }
82 catch (error) { if (error.code !== 'ENOENT') throw error; }
83 if (previous && previous.server !== server) throw new Error(`This machine is paired to ${previous.server}. Unlink it there before changing dashboards.`);
84 const input = createInterface({ input: process.stdin, output: process.stdout });
85 const closed = new AbortController();
86 input.once('close', () => closed.abort());
87 const ask = (text) => input.question(text, { signal: closed.signal });
88 let name, desktopWrite;
89 const roots = [];
90 try {
91 console.log(`Agent Relay · ${server}\nCodex and Claude Code must already be installed and signed in.`);
92 console.log('Linked clients can read saved Codex and Claude Code chats on this machine.');
93 name = previous ? 'this machine' : (await ask(`Machine name [${hostname()}]: `)).trim() || hostname();
94 if (previous) console.log('The existing machine pairing will be kept.');
95 if (name.length > 100) throw new Error('Enter a machine name up to 100 characters.');
96 console.log('Allowed folders apply to new chats. Existing chats keep their own permissions.');
97 if (previous?.roots?.length) console.log(`Current folders: ${previous.roots.join(', ')}`);
98 console.log('Enter one project folder at a time. Leave blank to finish.');
99 if (previous) console.log('Leave the first answer blank to keep the current folders. Enter - to clear them.');
100 while (true) {
101 const answer = (await ask('Project folder: ')).trim();
102 if (!answer) { if (!roots.length && previous) roots.push(...previous.roots); break; }
103 if (answer === '-' && !roots.length) break;
104 const path = await realpath(resolve(answer === '~' ? homedir() : answer.startsWith('~/') ? join(homedir(), answer.slice(2)) : answer));
105 if (!(await stat(path)).isDirectory()) throw new Error('Choose an existing project folder.');
106 if (!roots.includes(path)) roots.push(path);
107 }
108 if (!windows) {
109 console.log('Experimental Codex desktop control lets linked clients send messages and interrupt chats. App updates may break it.');
110 const answer = (await ask(`Enable desktop control? [${previous?.desktopWrite ? 'Y/n' : 'y/N'}]: `)).trim().toLowerCase();
111 if (answer && !['y', 'yes', 'n', 'no'].includes(answer)) throw new Error('Answer yes or no.');
112 desktopWrite = answer ? ['y', 'yes'].includes(answer) : previous?.desktopWrite ?? false;
113 } else desktopWrite = false;
114 } catch (error) {
115 if (closed.signal.aborted) throw new Error('Keep the terminal open to answer the install prompts, then run the installer again.');
116 throw error;
117 } finally { input.close(); }
118 await mkdir(data, { recursive: true, mode: 0o700 });
119 if (previous) {
120 const response = await fetch(`${server}/pairing`, { headers: { Authorization: `Bearer ${previous.token}` }, signal: AbortSignal.timeout(10_000) });
121 if (!response.ok) throw new Error(`The saved pairing is unavailable (${response.status}). Check the dashboard before reinstalling.`);
122 } else {
123 await new Promise((accept, reject) => {
124 const child = spawn(process.execPath, [join(staged, 'relay.mjs'), 'pair', '--server', server, '--name', name, '--data-dir', data,
125 ...roots.flatMap((root) => ['--allow-root', root]), ...(desktopWrite ? ['--codex-desktop-write'] : [])], { stdio: 'inherit' });
126 child.on('error', reject);
127 child.on('exit', (code) => code === 0 ? accept() : reject(new Error('Pairing stopped. Run the installer again for a new code.')));
128 });
129 previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8'));
130 }
131 const config = { ...previous, roots, desktopWrite };
132 const binaries = {};
133 for (const cli of ['codex', 'claude']) {
134 try {
135 const found = windows ? execFileSync('where.exe', [cli], { encoding: 'utf8' }).trim().split(/\r?\n/)[0] :
136 execFileSync('/bin/sh', ['-c', 'command -v "$1"', 'sh', cli], { encoding: 'utf8' }).trim();
137 if (found) binaries[cli] = found;
138 } catch { console.log(`${cli} was not found. Install it and rerun this installer to enable its chats.`); }
139 }
140 await stop();
141 await writeFile(join(data, 'agent.json.pending'), JSON.stringify(config) + '\n', { mode: 0o600 });
142 await rename(join(data, 'agent.json.pending'), join(data, 'agent.json'));
143 for (const file of ['relay.mjs', 'setup.mjs']) await copyFile(join(staged, file), join(base, file));
144 const args = [join(base, 'relay.mjs'), 'run', '--data-dir', data,
145 ...Object.entries(binaries).flatMap(([cli, path]) => [`--${cli}-bin`, path])];
146 const environment = Object.fromEntries(['PATH', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
147 environment.PATH = [base, environment.PATH].filter(Boolean).join(delimiter);
148 if (windows) {
149 const runner = join(base, 'run.ps1');
150 await writeFile(runner, "$ErrorActionPreference = 'Stop'\n" + Object.entries(environment).map(([key, value]) => `$env:${key} = ${psQuote(value)}`).join('\n') +
151 `\n& ${psQuote(process.execPath)} ${args.map(psQuote).join(' ')} *>> ${psQuote(join(base, 'agent.log'))}\nexit $LASTEXITCODE\n`);
152 ps(`$ErrorActionPreference = 'Stop'
153$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
154$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${psQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${runner}"`)}
155$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
156$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
157$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
158Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
159Start-ScheduledTask -TaskName ${psQuote(task)}
160if ((Get-ScheduledTask -TaskName ${psQuote(task)}).State -eq 'Disabled') { throw 'Enable the Agent Relay task and run the installer again.' }`);
161 await writeFile(join(base, 'agent-relay.cmd'), `@echo off\r\n"${process.execPath}" "${join(base, 'setup.mjs')}" %*\r\n`);
162 } else {
163 await mkdir(dirname(unit), { recursive: true });
164 if (mac) {
165 const xml = (text) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
166 await writeFile(unit, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
167<key>Label</key><string>net.paperclover.agent-relay</string>
168<key>ProgramArguments</key><array>${[process.execPath, ...args].map((arg) => `<string>${xml(arg)}</string>`).join('')}</array>
169<key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([key, value]) => `<key>${key}</key><string>${xml(value)}</string>`).join('')}</dict>
170<key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer>
171<key>StandardOutPath</key><string>${xml(join(base, 'agent.log'))}</string>
172<key>StandardErrorPath</key><string>${xml(join(base, 'agent.log'))}</string>
173</dict></plist>\n`);
174 execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' });
175 execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' });
176 } else {
177 const quote = (text) => '"' + text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('%', '%%') + '"';
178 await writeFile(unit, `[Unit]\nDescription=Agent Relay\n\n[Service]\nExecStart=${[process.execPath, ...args].map((arg) => quote(arg).replaceAll('$', '$$')).join(' ')}\n` +
179 Object.entries(environment).map(([key, value]) => `Environment=${quote(`${key}=${value}`)}`).join('\n') +
180 '\nRestart=on-failure\nRestartSec=30\nUMask=0077\n\n[Install]\nWantedBy=default.target\n');
181 execFileSync('systemctl', ['--user', 'daemon-reload']);
182 execFileSync('systemctl', ['--user', 'enable', '--now', 'agent-relay.service'], { stdio: 'inherit' });
183 execFileSync('systemctl', ['--user', 'is-active', '--quiet', 'agent-relay.service']);
184 }
185 await writeFile(join(base, 'agent-relay'), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(join(base, 'setup.mjs'))} "$@"\n`, { mode: 0o700 });
186 }
187 console.log(`Installed. Agent Relay starts at login.\nOpen ${server}/mcp/settings/agents and check that ${name} is online.`);
188 const manage = join(base, windows ? 'agent-relay.cmd' : 'agent-relay');
189 console.log(`Check startup: ${windows ? '& ' + psQuote(manage) : shellQuote(manage)} status\nUse start, stop, or uninstall in place of status.`);
190}
191
192main().catch((error) => { console.error(error.message); process.exitCode = 1; });
dashboard/agent/source.json created+4
...@@ -0,0 +1,4 @@
1{
2 "source": "../../../agent-relay",
3 "entry": "src/agent.ts"
4}
dashboard/package.json+1
...@@ -15,6 +15,7 @@...@@ -15,6 +15,7 @@
15 "@solidjs/router": "^1.0.0",15 "@solidjs/router": "^1.0.0",
16 "@types/node": "^26.6.2",16 "@types/node": "^26.6.2",
17 "concurrently": "^10.0.5",17 "concurrently": "^10.0.5",
18 "esbuild": "0.28.2",
18 "lucide-solid": "^1.48.0",19 "lucide-solid": "^1.48.0",
19 "solid-js": "^1.9.15",20 "solid-js": "^1.9.15",
20 "typescript": "^7.0.2",21 "typescript": "^7.0.2",
dashboard/pnpm-lock.yaml+3-1
...@@ -21,6 +21,9 @@ importers:...@@ -21,6 +21,9 @@ importers:
21 concurrently:21 concurrently:
22 specifier: ^10.0.522 specifier: ^10.0.5
23 version: 10.0.523 version: 10.0.5
24 esbuild:
25 specifier: 0.28.2
26 version: 0.28.2
24 lucide-solid:27 lucide-solid:
25 specifier: ^1.48.028 specifier: ^1.48.0
26 version: 1.48.0(solid-js@1.9.15)29 version: 1.48.0(solid-js@1.9.15)
...@@ -1379,7 +1382,6 @@ snapshots:...@@ -1379,7 +1382,6 @@ snapshots:
1379 '@esbuild/win32-arm64': 0.28.21382 '@esbuild/win32-arm64': 0.28.2
1380 '@esbuild/win32-ia32': 0.28.21383 '@esbuild/win32-ia32': 0.28.2
1381 '@esbuild/win32-x64': 0.28.21384 '@esbuild/win32-x64': 0.28.2
1382 optional: true
13831385
1384 escalade@3.2.0: {}1386 escalade@3.2.0: {}
13851387
dashboard/src/auth.rs created+1114
...@@ -0,0 +1,1114 @@
1use crate::*;
2use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
3use base64::{
4 Engine,
5 engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD},
6};
7use rusqlite::{Connection, OptionalExtension, params as sql};
8use std::os::unix::fs::PermissionsExt;
9use webauthn_rs::prelude::*;
10
11const COOKIE: &str = "__Host-snow-session";
12const FLOW_COOKIE: &str = "__Host-snow-flow";
13const SESSION_TTL: i64 = 30 * 86400;
14const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm"];
15
16pub struct Store {
17 pub db: Mutex<Connection>,
18 pub origin: url::Url,
19 file: url::Url,
20 webauthn: Webauthn,
21 passwords: Semaphore,
22}
23
24pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
25 headers
26 .get("cookie")?
27 .to_str()
28 .ok()?
29 .split(';')
30 .find_map(|part| {
31 let (key, value) = part.trim().split_once('=')?;
32 (key == name).then(|| value.to_owned())
33 })
34}
35fn set_cookie(name: &str, value: &str, ttl: i64) -> String {
36 format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}")
37}
38fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> {
39 let value: Option<String> = db.query_row(statement, [key], |r| r.get(0)).optional()?;
40 Ok(value
41 .map(|s| serde_json::from_str(&s))
42 .transpose()?
43 .unwrap_or(Value::Null))
44}
45fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result<Value> {
46 let value: Option<String> = db
47 .query_row(
48 "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?",
49 sql![mcp::hash(token), kind, now() as i64],
50 |r| r.get(0),
51 )
52 .optional()?;
53 if consume && value.is_some() {
54 db.execute("DELETE FROM pending WHERE hash=?", [mcp::hash(token)])?;
55 }
56 Ok(value
57 .map(|s| serde_json::from_str(&s))
58 .transpose()?
59 .unwrap_or(Value::Null))
60}
61fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result<String> {
62 db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?;
63 let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?;
64 if count >= 4096 {
65 return Err(Error::new(
66 429,
67 "Too many sign-in requests. Try again in a few minutes.",
68 ));
69 }
70 let token = mcp::secret();
71 db.execute(
72 "INSERT INTO pending VALUES (?,?,?,?)",
73 sql![
74 mcp::hash(&token),
75 kind,
76 value.to_string(),
77 now() as i64 + ttl
78 ],
79 )?;
80 Ok(token)
81}
82pub fn user(db: &Connection, id: &str) -> Result<Value> {
83 let mut profile = row(db, "SELECT profile FROM users WHERE id=?", id)?;
84 if profile.is_null() {
85 return Err(Error::new(
86 404,
87 "This account no longer exists. Sign in again.",
88 ));
89 }
90 profile["id"] = json!(id);
91 let mut statement = db.prepare("SELECT roles.id, roles.name FROM roles JOIN memberships ON roles.id=memberships.role_id WHERE user_id=? ORDER BY roles.name")?;
92 profile["groups"] = json!(
93 statement
94 .query_map([id], |r| Ok(
95 json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?})
96 ))?
97 .collect::<std::result::Result<Vec<_>, _>>()?
98 );
99 Ok(profile)
100}
101pub fn credentials(db: &Connection, id: &str) -> Result<Value> {
102 let mut statement = db.prepare(
103 "SELECT id, kind, label, created FROM credentials WHERE user_id=? ORDER BY created",
104 )?;
105 Ok(json!(statement.query_map([id], |r| Ok(json!({"id":r.get::<_,String>(0)?,"type":r.get::<_,String>(1)?,"userLabel":r.get::<_,Option<String>>(2)?,"createdDate":r.get::<_,i64>(3)?})))?.collect::<std::result::Result<Vec<_>,_>>()?))
106}
107pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> {
108 for key in [
109 "id",
110 "groups",
111 "sessions",
112 "credentials",
113 "picture",
114 "console",
115 ] {
116 profile.as_object_mut().unwrap().remove(key);
117 }
118 db.execute(
119 "UPDATE users SET profile=? WHERE id=?",
120 sql![profile.to_string(), id],
121 )
122 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
123 Ok(())
124}
125pub fn password_hash(password: &str) -> Result<String> {
126 let salt = SaltString::encode_b64(&rand::random::<[u8; 16]>())
127 .map_err(|_| Error::new(500, "Couldn't prepare password storage."))?;
128 Ok(Argon2::default()
129 .hash_password(password.as_bytes(), &salt)
130 .map_err(|_| Error::new(500, "Couldn't store the password."))?
131 .to_string())
132}
133pub fn set_password(db: &Connection, id: &str, hash: &str) -> Result<()> {
134 db.execute(
135 "DELETE FROM credentials WHERE user_id=? AND kind='password'",
136 [id],
137 )?;
138 db.execute(
139 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
140 sql![
141 uuid::Uuid::new_v4().to_string(),
142 id,
143 "password",
144 Option::<String>::None,
145 (now() * 1000.0) as i64,
146 json!({"phc":hash}).to_string()
147 ],
148 )?;
149 Ok(())
150}
151
152impl Store {
153 pub fn new(data: &std::path::Path, origin: &str, file: &str, rp: &str) -> Result<Self> {
154 let origin = url::Url::parse(origin)?;
155 let file = url::Url::parse(file)?;
156 if origin.scheme() != "https"
157 || file.scheme() != "https"
158 || origin.path() != "/"
159 || file.path() != "/"
160 || origin.origin() == file.origin()
161 {
162 return Err(Error::new(
163 500,
164 "Set separate HTTPS origins for Snowglobe and Files.",
165 ));
166 }
167 let rp_origin = url::Url::parse(&format!("https://{rp}"))?;
168 let webauthn = WebauthnBuilder::new(rp, &rp_origin)?
169 .append_allowed_origin(&origin)
170 .rp_name("snow globe")
171 .build()?;
172 std::fs::create_dir_all(data)?;
173 let path = data.canonicalize()?.join("accounts.sqlite");
174 let db = Connection::open_with_flags(
175 &path,
176 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
177 | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
178 | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW,
179 )?;
180 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
181 db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
182 CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, profile TEXT NOT NULL, username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE);
183 CREATE UNIQUE INDEX IF NOT EXISTS verified_email ON users(lower(json_extract(profile,'$.email'))) WHERE json_extract(profile,'$.emailVerified')=1 AND json_extract(profile,'$.email') IS NOT NULL;
184 CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE);
185 CREATE TABLE IF NOT EXISTS memberships (user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id), PRIMARY KEY(user_id,role_id));
186 CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,kind TEXT NOT NULL,label TEXT,created INTEGER NOT NULL,data TEXT NOT NULL);
187 CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,client TEXT NOT NULL CHECK(client IN ('dashboard','file')),expires INTEGER NOT NULL,ip TEXT NOT NULL,created INTEGER NOT NULL,last_used INTEGER NOT NULL,auth_time INTEGER NOT NULL);
188 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
189 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);
190 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;
191 Ok(Self {
192 db: Mutex::new(db),
193 origin,
194 file,
195 webauthn,
196 passwords: Semaphore::new(2),
197 })
198 }
199 pub fn ready(&self) -> bool {
200 self.db
201 .lock()
202 .unwrap()
203 .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |r| r.get(0))
204 .unwrap_or(false)
205 }
206 pub fn import(&self, export: Value) -> Result<Value> {
207 if self
208 .webauthn
209 .get_allowed_origins()
210 .first()
211 .and_then(|u| u.host_str())
212 != export["rpId"].as_str()
213 {
214 return Err(Error::new(
215 400,
216 "The export's passkey domain does not match this server.",
217 ));
218 }
219 let mut db = self.db.lock().unwrap();
220 let digest = mcp::hash(&export.to_string());
221 if db.query_row(
222 "SELECT EXISTS(SELECT 1 FROM migration WHERE digest=?)",
223 [&digest],
224 |r| r.get::<_, bool>(0),
225 )? {
226 return Ok(
227 json!({"accounts":array(&export["users"]).len(),"credentials":array(&export["users"]).iter().map(|u|array(&u["credentials"]).len()).sum::<usize>()}),
228 );
229 }
230 if db.query_row("SELECT count(*) FROM users", [], |r| r.get::<_, i64>(0))? != 0 {
231 return Err(Error::new(
232 409,
233 "Accounts already exist. Import into an empty store.",
234 ));
235 }
236 let transaction = db.transaction()?;
237 for role in array(&export["roles"]) {
238 if GROUPS.contains(&string(&role["name"])) {
239 transaction.execute(
240 "INSERT INTO roles VALUES (?,?)",
241 sql![string(&role["id"]), string(&role["name"])],
242 )?;
243 }
244 }
245 let mut count = 0;
246 for profile in array(&export["users"]) {
247 let id = string(&profile["id"]);
248 uuid::Uuid::parse_str(id)?;
249 string(&profile["username"])
250 .parse::<axum::http::HeaderValue>()
251 .map_err(|_| Error::new(400, "The source has an invalid username."))?;
252 let mut value = profile.clone();
253 value["requiredActions"] = json!(
254 array(&profile["requiredActions"])
255 .iter()
256 .filter(|v| **v == "UPDATE_PASSWORD" || **v == "UPDATE_PROFILE")
257 .collect::<Vec<_>>()
258 );
259 for key in ["id", "roles", "credentials"] {
260 value.as_object_mut().unwrap().remove(key);
261 }
262 transaction.execute(
263 "INSERT INTO users(id,profile) VALUES (?,?)",
264 sql![id, value.to_string()],
265 )?;
266 for role in array(&profile["roles"]) {
267 transaction.execute(
268 "INSERT INTO memberships SELECT ?,id FROM roles WHERE id=?",
269 sql![id, string(role)],
270 )?;
271 }
272 for credential in array(&profile["credentials"]) {
273 let kind = string(&credential["type"]);
274 let source = &credential["credentialData"];
275 let data = match kind {
276 "password" => {
277 if source["algorithm"] != "argon2"
278 || source["additionalParameters"]["type"][0] != "id"
279 {
280 return Err(Error::new(
281 500,
282 "The source uses an unsupported password format.",
283 ));
284 }
285 let parameters = &source["additionalParameters"];
286 let salt = STANDARD_NO_PAD
287 .encode(STANDARD.decode(string(&credential["secretData"]["salt"]))?);
288 let hash = STANDARD_NO_PAD
289 .encode(STANDARD.decode(string(&credential["secretData"]["value"]))?);
290 let phc = format!(
291 "$argon2id$v=19$m={},t={},p={}${}${}",
292 string(&parameters["memory"][0]),
293 source["hashIterations"],
294 string(&parameters["parallelism"][0]),
295 salt,
296 hash
297 );
298 PasswordHash::new(&phc).map_err(|_| {
299 Error::new(500, "The source password hash couldn't be imported.")
300 })?;
301 json!({"phc":phc})
302 }
303 "webauthn-passwordless" => {
304 let key: serde_cbor_2::Value = serde_cbor_2::from_slice(
305 &URL_SAFE_NO_PAD.decode(string(&source["credentialPublicKey"]))?,
306 )?;
307 let public_key = COSEKey::try_from(&key)?;
308 let cred = Credential {
309 cred_id: STANDARD.decode(string(&source["credentialId"]))?.into(),
310 cred: public_key,
311 counter: source["counter"].as_u64().unwrap_or(0).try_into()?,
312 transports: serde_json::from_value(source["transports"].clone())
313 .unwrap_or(None),
314 user_verified: true,
315 backup_eligible: false,
316 backup_state: false,
317 registration_policy: serde_json::from_value(json!("required"))?,
318 extensions: Default::default(),
319 attestation: Default::default(),
320 attestation_format: AttestationFormat::None,
321 };
322 // Keycloak omits backup flags; learn them only from the first verified assertion.
323 json!({"passkey":Passkey::from(cred),"handle":URL_SAFE_NO_PAD.encode(id.as_bytes()),"backupUnknown":true})
324 }
325 _ => {
326 return Err(Error::new(
327 500,
328 "The source has a credential type this import doesn't support.",
329 ));
330 }
331 };
332 transaction.execute(
333 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
334 sql![
335 string(&credential["id"]),
336 id,
337 kind,
338 credential["userLabel"].as_str(),
339 credential["createdDate"].as_i64().unwrap_or(0),
340 data.to_string()
341 ],
342 )?;
343 count += 1;
344 }
345 }
346 transaction.execute("INSERT INTO migration VALUES (?)", [digest])?;
347 transaction.commit()?;
348 Ok(json!({"accounts":array(&export["users"]).len(),"credentials":count}))
349 }
350 pub fn session(&self, headers: &HeaderMap, client: &str) -> Result<Value> {
351 let Some(token) = cookie(headers, COOKIE) else {
352 return Ok(Value::Null);
353 };
354 let db = self.db.lock().unwrap();
355 let id: Option<String> = db
356 .query_row(
357 "SELECT user_id FROM sessions WHERE hash=? AND client=? AND expires>?",
358 sql![mcp::hash(&token), client, now() as i64],
359 |r| r.get(0),
360 )
361 .optional()?;
362 let Some(id) = id else {
363 return Ok(Value::Null);
364 };
365 let user = user(&db, &id)?;
366 if user["enabled"] != true {
367 return Ok(Value::Null);
368 }
369 db.execute(
370 "UPDATE sessions SET last_used=? WHERE hash=? AND last_used<?",
371 sql![
372 (now() * 1000.0) as i64,
373 mcp::hash(&token),
374 (now() * 1000.0) as i64 - 60000
375 ],
376 )?;
377 Ok(user)
378 }
379 fn create_session(
380 &self,
381 id: &str,
382 client: &str,
383 headers: &HeaderMap,
384 password: Option<&Value>,
385 ) -> Result<String> {
386 let token = mcp::secret();
387 let db = self.db.lock().unwrap();
388 if user(&db, id)?["enabled"] != true {
389 return Err(Error::new(403, "This account is disabled."));
390 }
391 if let Some(expected) = password {
392 if row(
393 &db,
394 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
395 id,
396 )? != *expected
397 {
398 return Err(Error::new(401, "Your password changed. Sign in again."));
399 }
400 }
401 db.execute("DELETE FROM sessions WHERE expires<=?", [now() as i64])?;
402 let ip = headers
403 .get("X-Studio-Client-IP")
404 .and_then(|v| v.to_str().ok())
405 .unwrap_or("unknown");
406 db.execute(
407 "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)",
408 sql![
409 mcp::hash(&token),
410 id,
411 client,
412 now() as i64 + SESSION_TTL,
413 ip,
414 (now() * 1000.0) as i64,
415 (now() * 1000.0) as i64,
416 now() as i64
417 ],
418 )?;
419 Ok(set_cookie(COOKIE, &token, SESSION_TTL))
420 }
421 fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> {
422 let ip = headers
423 .get("X-Studio-Client-IP")
424 .and_then(|v| v.to_str().ok())
425 .unwrap_or("unknown");
426 let db = self.db.lock().unwrap();
427 db.execute("DELETE FROM attempts WHERE expires<=?", [now() as i64])?;
428 let address = mcp::hash(ip);
429 db.execute(
430 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
431 sql![address, now() as i64 + 300],
432 )?;
433 let total: i64 =
434 db.query_row("SELECT count FROM attempts WHERE key=?", [address], |r| {
435 r.get(0)
436 })?;
437 if total > 100 {
438 return Err(Error::new(
439 429,
440 "Too many attempts. Try again in five minutes.",
441 ));
442 }
443 let key = mcp::hash(&format!("{ip}:{name}"));
444 db.execute(
445 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
446 sql![key, now() as i64 + 300],
447 )?;
448 let count: i64 = db.query_row("SELECT count FROM attempts WHERE key=?", [key], |r| {
449 r.get(0)
450 })?;
451 if count > 20 {
452 return Err(Error::new(
453 429,
454 "Too many attempts. Try again in five minutes.",
455 ));
456 }
457 Ok(())
458 }
459 fn csrf(&self, headers: &HeaderMap, body: &Value) -> Result<()> {
460 if headers.get("origin").and_then(|v| v.to_str().ok())
461 != Some(self.origin.origin().ascii_serialization().as_str())
462 {
463 return Err(Error::new(403, "Open sign-in on Snowglobe and try again."));
464 }
465 let cookie = cookie(headers, FLOW_COOKIE).unwrap_or_default();
466 if cookie.is_empty()
467 || !bool::from(cookie.as_bytes().ct_eq(string(&body["csrf"]).as_bytes()))
468 || pending(&self.db.lock().unwrap(), &cookie, "csrf", false)?.is_null()
469 {
470 return Err(Error::new(
471 403,
472 "Sign-in expired. Reload the page and try again.",
473 ));
474 }
475 Ok(())
476 }
477 fn next(&self, id: &str, flow: &str, path: &str) -> Result<String> {
478 if flow.is_empty() {
479 if !path.starts_with('/')
480 || path.starts_with("//")
481 || path.contains('\\')
482 || path.chars().any(char::is_control)
483 {
484 return Ok("/".into());
485 }
486 return Ok(path.to_owned());
487 }
488 let db = self.db.lock().unwrap();
489 if !array(&user(&db, id)?["requiredActions"]).is_empty() {
490 return Ok("/account".into());
491 }
492 let value = pending(&db, flow, "file", false)?;
493 if value.is_null() {
494 return Err(Error::new(
495 400,
496 "File sign-in expired. Open Files and try again.",
497 ));
498 }
499 let code = issue(&db, "handoff", json!({"user":id,"flow":flow}), 60)?;
500 Ok(format!(
501 "{}auth/file/callback?code={}",
502 self.file,
503 encoded(&code)
504 ))
505 }
506 pub fn sessions(db: &Connection, id: &str) -> Result<Value> {
507 let mut statement = db.prepare("SELECT hash,ip,created,last_used,client FROM sessions WHERE user_id=? AND expires>? ORDER BY last_used DESC")?;
508 Ok(json!(statement.query_map(sql![id,now() as i64],|r|Ok(json!({"id":r.get::<_,String>(0)?,"ipAddress":r.get::<_,String>(1)?,"start":r.get::<_,i64>(2)?,"lastAccess":r.get::<_,i64>(3)?,"clients":{"snow":r.get::<_,String>(4)?}})))?.collect::<std::result::Result<Vec<_>,_>>()?))
509 }
510 pub async fn hash_password(&self, password: &str) -> Result<String> {
511 let _slot = self
512 .passwords
513 .try_acquire()
514 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
515 let password = password.to_owned();
516 tokio::task::spawn_blocking(move || password_hash(&password)).await?
517 }
518 pub fn recent(&self, headers: &HeaderMap) -> Result<()> {
519 let token = cookie(headers, COOKIE).unwrap_or_default();
520 let valid: bool = self.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE hash=? AND client='dashboard' AND expires>? AND auth_time>?)",sql![mcp::hash(&token),now() as i64,now() as i64-900],|r|r.get(0))?;
521 if !valid {
522 return Err(Error::new(
523 403,
524 "Sign out and sign in again before changing sign-in methods.",
525 ));
526 }
527 Ok(())
528 }
529 pub fn setup_link(&self, id: &str) -> Result<String> {
530 let db = self.db.lock().unwrap();
531 let profile = user(&db, id)?;
532 if profile["enabled"] != true {
533 return Err(Error::new(
534 400,
535 "Enable this account before creating a setup link.",
536 ));
537 }
538 db.execute(
539 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
540 [id],
541 )?;
542 let token = issue(&db, "setup", json!({"user":id}), 86400)?;
543 Ok(format!("{}sign-in?setup={}", self.origin, token))
544 }
545}
546
547pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
548 let auth = &app.auth;
549 let path = request.uri().path().to_owned();
550 let method = request.method().clone();
551 let query: HashMap<String, String> =
552 url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
553 .into_owned()
554 .collect();
555 let headers = request.headers().clone();
556 if path == "/auth/file/check" && method == Method::GET {
557 let user = auth.session(&headers, "file")?;
558 if user.is_null() || !array(&user["requiredActions"]).is_empty() {
559 return Ok(StatusCode::UNAUTHORIZED.into_response());
560 }
561 let groups = array(&user["groups"])
562 .iter()
563 .map(|g| string(&g["name"]))
564 .collect::<Vec<_>>()
565 .join(",");
566 return Ok((
567 StatusCode::NO_CONTENT,
568 [
569 (
570 "X-Auth-Request-Preferred-Username",
571 string(&user["username"]).to_owned(),
572 ),
573 ("X-Auth-Request-Groups", groups),
574 ],
575 )
576 .into_response());
577 }
578 if path == "/auth/file/sign-in" && method == Method::GET {
579 let target = query
580 .get("rd")
581 .map(String::as_str)
582 .unwrap_or(auth.file.as_str());
583 let destination = auth.file.join(target)?;
584 if destination.origin() != auth.file.origin()
585 || !destination.username().is_empty()
586 || destination.password().is_some()
587 {
588 return Err(Error::new(400, "Open Files to sign in."));
589 }
590 let flow = issue(
591 &auth.db.lock().unwrap(),
592 "file",
593 json!({"next":destination}),
594 300,
595 )?;
596 return Ok((
597 StatusCode::FOUND,
598 [
599 (
600 "location",
601 format!("{}auth/continue?flow={flow}", auth.origin),
602 ),
603 ("set-cookie", set_cookie(FLOW_COOKIE, &flow, 300)),
604 ],
605 )
606 .into_response());
607 }
608 if path == "/auth/continue" && method == Method::GET {
609 let flow = query.get("flow").cloned().unwrap_or_default();
610 let user = auth.session(&headers, "dashboard")?;
611 let next = if user.is_null() {
612 format!("/sign-in?flow={}", encoded(&flow))
613 } else {
614 auth.next(string(&user["id"]), &flow, "/")?
615 };
616 return Ok((StatusCode::FOUND, [("location", next)]).into_response());
617 }
618 if path == "/auth/file/callback" && method == Method::GET {
619 let token = query.get("code").cloned().unwrap_or_default();
620 let (id, next) = {
621 let mut db = auth.db.lock().unwrap();
622 let transaction = db.transaction()?;
623 let code = pending(&transaction, &token, "handoff", false)?;
624 let flow = cookie(&headers, FLOW_COOKIE).unwrap_or_default();
625 if code.is_null()
626 || flow.is_empty()
627 || !bool::from(flow.as_bytes().ct_eq(string(&code["flow"]).as_bytes()))
628 {
629 return Err(Error::new(
630 403,
631 "File sign-in expired. Open Files and try again.",
632 ));
633 }
634 let target = pending(&transaction, &flow, "file", true)?;
635 if target.is_null() {
636 return Err(Error::new(
637 403,
638 "File sign-in expired. Open Files and try again.",
639 ));
640 }
641 pending(&transaction, &token, "handoff", true)?;
642 let user = user(&transaction, string(&code["user"]))?;
643 if user["enabled"] != true {
644 return Err(Error::new(403, "This account is disabled. Contact Clover."));
645 }
646 let result = (
647 string(&code["user"]).to_owned(),
648 string(&target["next"]).to_owned(),
649 );
650 transaction.commit()?;
651 result
652 };
653 let session = auth.create_session(&id, "file", &headers, None)?;
654 return Ok((
655 StatusCode::FOUND,
656 [("location", next), ("set-cookie", session)],
657 )
658 .into_response());
659 }
660 if path == "/auth/status" && method == Method::GET {
661 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;
662 let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?});
663 if let Some(setup) = query.get("setup") {
664 let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?;
665 if entry.is_null() {
666 return Err(Error::new(
667 410,
668 "This link expired. Ask Clover for a new one.",
669 ));
670 }
671 value["setup"] =
672 user(&auth.db.lock().unwrap(), string(&entry["user"]))?["username"].clone();
673 }
674 return Ok((
675 [
676 ("set-cookie", set_cookie(FLOW_COOKIE, &csrf, 900)),
677 ("cache-control", "no-store".into()),
678 ],
679 axum::Json(value),
680 )
681 .into_response());
682 }
683 if path == "/auth/sign-out" || path == "/auth/file/sign-out" {
684 if method == Method::GET && path == "/auth/file/sign-out" {
685 return Ok(axum::response::Html("<!doctype html><html><meta name=viewport content='width=device-width'><title>Sign out of Files</title><body><form method=post action='/auth/file/sign-out'><button>sign out of Files</button></form></body></html>").into_response());
686 }
687 if method != Method::POST {
688 return Err(Error::new(405, "Use the sign-out button."));
689 }
690 let expected = if path.contains("/file/") {
691 &auth.file
692 } else {
693 &auth.origin
694 };
695 if headers.get("origin").and_then(|v| v.to_str().ok())
696 != Some(expected.origin().ascii_serialization().as_str())
697 {
698 return Err(Error::new(403, "Open your account to sign out."));
699 }
700 if let Some(token) = cookie(&headers, COOKIE) {
701 auth.db
702 .lock()
703 .unwrap()
704 .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?;
705 }
706 if path.contains("/file/") {
707 return Ok((
708 StatusCode::SEE_OTHER,
709 [
710 ("set-cookie", set_cookie(COOKIE, "", 0)),
711 ("location", "/".into()),
712 ],
713 )
714 .into_response());
715 }
716 return Ok((
717 [("set-cookie", set_cookie(COOKIE, "", 0))],
718 axum::Json(json!({"next":"/sign-in"})),
719 )
720 .into_response());
721 }
722 if method != Method::POST {
723 return Err(Error::new(404, "No sign-in action here."));
724 }
725 let body: Value =
726 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 128 * 1024).await?)
727 .map_err(|_| Error::new(400, "Reload the form and try again."))?;
728 auth.csrf(&headers, &body)?;
729 if path == "/auth/password" || path == "/auth/passkey/start" {
730 let name = string(&body["username"]).trim().to_lowercase();
731 if name.len() > 254 || name.is_empty() {
732 return Err(Error::new(400, "Enter your username."));
733 }
734 auth.limit(&headers, &name)?;
735 let id: Option<String> = auth.db.lock().unwrap().query_row("SELECT id FROM users WHERE username=? OR (lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1) ORDER BY username=? DESC LIMIT 1",sql![name,name,name],|r|r.get(0)).optional()?;
736 let user = id
737 .as_ref()
738 .map(|id| user(&auth.db.lock().unwrap(), id))
739 .transpose()?
740 .unwrap_or(Value::Null);
741 if path == "/auth/password" {
742 let password = string(&body["password"]).to_owned();
743 if password.len() > 1024 {
744 return Err(Error::new(400, "That password is too long."));
745 }
746 let data = row(
747 &auth.db.lock().unwrap(),
748 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
749 id.as_deref().unwrap_or(""),
750 )?;
751 let phc = string(&data["phc"]).to_owned();
752 let _slot = auth
753 .passwords
754 .try_acquire()
755 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
756 let verified = tokio::task::spawn_blocking(move || {
757 if phc.is_empty() {
758 let _ = password_hash(&password);
759 return false;
760 }
761 PasswordHash::new(&phc).is_ok_and(|hash| {
762 Argon2::default()
763 .verify_password(password.as_bytes(), &hash)
764 .is_ok()
765 })
766 })
767 .await?;
768 if !verified || user["enabled"] != true {
769 return Err(Error::new(
770 401,
771 "That username or password doesn't match. Try again.",
772 ));
773 }
774 let id = id.unwrap();
775 let session = auth.create_session(&id, "dashboard", &headers, Some(&data))?;
776 let next = if !array(&user["requiredActions"]).is_empty() {
777 "/account".into()
778 } else {
779 auth.next(&id, string(&body["flow"]), string(&body["next"]))?
780 };
781 return Ok(
782 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),
783 );
784 }
785 if user["enabled"] != true {
786 return Err(Error::new(
787 401,
788 "No passkey is available for that username. Try your password.",
789 ));
790 }
791 let id = id.unwrap();
792 let keys = passkeys(&auth.db.lock().unwrap(), &id)?;
793 if keys.is_empty() {
794 return Err(Error::new(
795 401,
796 "No passkey is available for that username. Try your password.",
797 ));
798 }
799 let (options, state) = auth.webauthn.start_passkey_authentication(&keys)?;
800 let token = issue(
801 &auth.db.lock().unwrap(),
802 "authentication",
803 json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"]}),
804 300,
805 )?;
806 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
807 }
808 if path == "/auth/passkey/finish" {
809 let value = pending(
810 &auth.db.lock().unwrap(),
811 string(&body["token"]),
812 "authentication",
813 true,
814 )?;
815 if value.is_null() || value["csrf"] != body["csrf"] {
816 return Err(Error::new(403, "Passkey sign-in expired. Try again."));
817 }
818 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())
819 .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?;
820 let mut state = value["state"].clone();
821 let mut allowed: Vec<Credential> =
822 serde_json::from_value(state["ast"]["credentials"].clone())?;
823 {
824 let db = auth.db.lock().unwrap();
825 let mut statement = db.prepare(
826 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
827 )?;
828 for stored in
829 statement.query_map([string(&value["user"])], |r| r.get::<_, String>(0))?
830 {
831 let stored: Value = serde_json::from_str(&stored?)?;
832 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;
833 if stored["backupUnknown"] == true
834 && passkey.cred_id().as_slice() == credential.get_credential_id()
835 {
836 let flags = credential
837 .response
838 .authenticator_data
839 .as_slice()
840 .get(32)
841 .copied()
842 .ok_or_else(|| Error::new(400, "The passkey response was incomplete."))?;
843 for key in &mut allowed {
844 if key.cred_id == *passkey.cred_id() {
845 key.backup_eligible = flags & 8 != 0;
846 key.backup_state = flags & 16 != 0;
847 }
848 }
849 }
850 }
851 }
852 state["ast"]["credentials"] = json!(allowed);
853 let state: PasskeyAuthentication = serde_json::from_value(state)?;
854 let result = auth
855 .webauthn
856 .finish_passkey_authentication(&credential, &state)
857 .map_err(|error| {
858 eprintln!("passkey authentication: {error:?}");
859 Error::new(
860 401,
861 "That passkey couldn't sign in. Try again or use your password.",
862 )
863 })?;
864 let id = string(&value["user"]);
865 {
866 let db = auth.db.lock().unwrap();
867 let user = user(&db, id)?;
868 if user["enabled"] != true {
869 return Err(Error::new(403, "This account is disabled. Contact Clover."));
870 }
871 let mut statement = db.prepare(
872 "SELECT id,data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
873 )?;
874 let rows = statement
875 .query_map([id], |r| {
876 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
877 })?
878 .collect::<std::result::Result<Vec<_>, _>>()?;
879 let mut matched = false;
880 for (key, data) in rows {
881 let mut data: Value = serde_json::from_str(&data)?;
882 let mut passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
883 if passkey.cred_id() == result.cred_id() {
884 if let Some(handle) = body["credential"]["response"]["userHandle"].as_str() {
885 if !handle.is_empty() && handle != string(&data["handle"]) {
886 return Err(Error::new(
887 401,
888 "That passkey belongs to a different account.",
889 ));
890 }
891 }
892 matched = true;
893 let current: Credential = passkey.clone().into();
894 if (current.counter != 0 || result.counter() != 0)
895 && result.counter() <= current.counter
896 {
897 return Err(Error::new(
898 401,
899 "This passkey returned an old counter. Try another sign-in method.",
900 ));
901 }
902 if data["backupUnknown"] == true {
903 let mut key: Credential = passkey.into();
904 key.backup_eligible = result.backup_eligible();
905 key.backup_state = result.backup_state();
906 passkey = key.into();
907 data.as_object_mut().unwrap().remove("backupUnknown");
908 }
909 passkey.update_credential(&result);
910 data["passkey"] = json!(passkey);
911 db.execute(
912 "UPDATE credentials SET data=? WHERE id=?",
913 sql![data.to_string(), key],
914 )?;
915 break;
916 }
917 }
918 if !matched {
919 return Err(Error::new(
920 401,
921 "This passkey was removed. Try another sign-in method.",
922 ));
923 }
924 }
925 let session = auth.create_session(id, "dashboard", &headers, None)?;
926 let next =
927 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {
928 "/account".into()
929 } else {
930 auth.next(id, string(&value["flow"]), string(&value["next"]))?
931 };
932 return Ok(([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response());
933 }
934 if path == "/auth/setup" {
935 let email = string(&body["email"]).trim();
936 if !email.contains('@') || email.len() > 254 {
937 return Err(Error::new(400, "Enter your email address."));
938 }
939 let password = string(&body["password"]).to_owned();
940 if password.chars().count() < 8 || password.len() > 1024 {
941 return Err(Error::new(
942 400,
943 "Use a password with at least 8 characters.",
944 ));
945 }
946 let _slot = auth
947 .passwords
948 .try_acquire()
949 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
950 let hash = tokio::task::spawn_blocking(move || password_hash(&password)).await??;
951 let id = {
952 let mut db = auth.db.lock().unwrap();
953 let transaction = db.transaction()?;
954 let entry = pending(&transaction, string(&body["setup"]), "setup", true)?;
955 if entry.is_null() {
956 return Err(Error::new(
957 410,
958 "This link expired. Ask Clover for a new one.",
959 ));
960 }
961 let id = string(&entry["user"]).to_owned();
962 let mut profile = user(&transaction, &id)?;
963 profile["email"] = json!(email);
964 profile["emailVerified"] = json!(false);
965 if profile["enabled"] != true {
966 return Err(Error::new(
967 403,
968 "This account is disabled. Ask Clover for a new link.",
969 ));
970 }
971 profile["requiredActions"] = json!([]);
972 set_password(&transaction, &id, &hash)?;
973 save_user(&transaction, &id, profile)?;
974 transaction.execute("DELETE FROM sessions WHERE user_id=?", [&id])?;
975 transaction.commit()?;
976 id
977 };
978 users::revoke_connections(&app, &id)?;
979 return Ok((
980 [(
981 "set-cookie",
982 auth.create_session(&id, "dashboard", &headers, None)?,
983 )],
984 axum::Json(json!({"next":"/account?welcome=1"})),
985 )
986 .into_response());
987 }
988 let user = auth.session(&headers, "dashboard")?;
989 if user.is_null() {
990 return Err(Error::new(401, "Sign in to manage your account."));
991 }
992 let id = string(&user["id"]);
993 if path == "/auth/passkey/register" {
994 auth.recent(&headers)?;
995 let db = auth.db.lock().unwrap();
996 let keys = passkeys(&db, id)?;
997 let ids = keys.iter().map(|key| key.cred_id().clone()).collect();
998 let uuid = uuid::Uuid::parse_str(id)?;
999 let (options, state) = auth.webauthn.start_passkey_registration(
1000 uuid,
1001 string(&user["username"]),
1002 string(&user["username"]),
1003 Some(ids),
1004 )?;
1005 let token = issue(
1006 &db,
1007 "registration",
1008 json!({"user":id,"csrf":body["csrf"],"state":state}),
1009 300,
1010 )?;
1011 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
1012 }
1013 if path == "/auth/passkey/save" {
1014 auth.recent(&headers)?;
1015 let db = auth.db.lock().unwrap();
1016 let value = pending(&db, string(&body["token"]), "registration", true)?;
1017 if value.is_null() || value["user"] != user["id"] || value["csrf"] != body["csrf"] {
1018 return Err(Error::new(403, "Passkey setup expired. Try again."));
1019 }
1020 let credential: RegisterPublicKeyCredential =
1021 serde_json::from_value(body["credential"].clone()).map_err(|_| {
1022 Error::new(400, "The browser couldn't create your passkey. Try again.")
1023 })?;
1024 let state: PasskeyRegistration = serde_json::from_value(value["state"].clone())?;
1025 let passkey = auth
1026 .webauthn
1027 .finish_passkey_registration(&credential, &state)
1028 .map_err(|_| Error::new(400, "That passkey couldn't be added. Try again."))?;
1029 let label = string(&body["label"]).trim();
1030 if label.len() > 100 {
1031 return Err(Error::new(400, "Use a shorter passkey name."));
1032 }
1033 db.execute("INSERT INTO credentials VALUES (?,?,?,?,?,?)",sql![uuid::Uuid::new_v4().to_string(),id,"webauthn-passwordless",if label.is_empty(){"passkey"}else{label},(now()*1000.0) as i64,json!({"passkey":passkey,"handle":URL_SAFE_NO_PAD.encode(uuid::Uuid::parse_str(id)?.as_bytes())}).to_string()])?;
1034 return Ok(StatusCode::NO_CONTENT.into_response());
1035 }
1036 if path == "/auth/password/change" {
1037 auth.recent(&headers)?;
1038 let data = row(
1039 &auth.db.lock().unwrap(),
1040 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
1041 id,
1042 )?;
1043 let phc = string(&data["phc"]).to_owned();
1044 let current = string(&body["current"]).to_owned();
1045 let password = string(&body["password"]).to_owned();
1046 if password.chars().count() < 8 || password.len() > 1024 || current.len() > 1024 {
1047 return Err(Error::new(
1048 400,
1049 "Use a password with at least 8 characters.",
1050 ));
1051 }
1052 auth.limit(&headers, id)?;
1053 let _slot = auth
1054 .passwords
1055 .try_acquire()
1056 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
1057 let hash = tokio::task::spawn_blocking(move || {
1058 if !phc.is_empty()
1059 && !PasswordHash::new(&phc).is_ok_and(|hash| {
1060 Argon2::default()
1061 .verify_password(current.as_bytes(), &hash)
1062 .is_ok()
1063 })
1064 {
1065 return Err(Error::new(
1066 401,
1067 "Your current password doesn't match. Try again.",
1068 ));
1069 }
1070 password_hash(&password)
1071 })
1072 .await??;
1073 {
1074 let mut db = auth.db.lock().unwrap();
1075 let transaction = db.transaction()?;
1076 let mut profile = self::user(&transaction, id)?;
1077 if profile["enabled"] != true {
1078 return Err(Error::new(403, "This account is disabled."));
1079 }
1080 set_password(&transaction, id, &hash)?;
1081 profile["requiredActions"] = json!(
1082 array(&user["requiredActions"])
1083 .iter()
1084 .filter(|v| **v != "UPDATE_PASSWORD")
1085 .collect::<Vec<_>>()
1086 );
1087 save_user(&transaction, id, profile)?;
1088 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
1089 transaction.commit()?;
1090 }
1091 users::revoke_connections(&app, id)?;
1092 return Ok((
1093 [(
1094 "set-cookie",
1095 auth.create_session(id, "dashboard", &headers, None)?,
1096 )],
1097 StatusCode::NO_CONTENT,
1098 )
1099 .into_response());
1100 }
1101 Err(Error::new(404, "No account action here."))
1102}
1103
1104fn passkeys(db: &Connection, id: &str) -> Result<Vec<Passkey>> {
1105 let mut statement = db
1106 .prepare("SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'")?;
1107 statement
1108 .query_map([id], |r| r.get::<_, String>(0))?
1109 .map(|data| {
1110 let value: Value = serde_json::from_str(&data?)?;
1111 Ok(serde_json::from_value(value["passkey"].clone())?)
1112 })
1113 .collect()
1114}
dashboard/src/cache.rs-73
...@@ -51,34 +51,6 @@ impl Cache {...@@ -51,34 +51,6 @@ impl Cache {
51 Ok(entry)51 Ok(entry)
52 }52 }
5353
54 pub async fn coalesce<F, Fut>(&self, key: String, load: F) -> Result<Arc<Document>>
55 where
56 F: FnOnce() -> Fut + Send + 'static,
57 Fut: Future<Output = Result<serde_json::Value>> + Send + 'static,
58 {
59 let started = Instant::now();
60 let entry = self.entry(key)?;
61 let guard = entry.loading.clone().lock_owned().await;
62 {
63 let state = entry.state.lock().unwrap();
64 if let Some((at, value)) = &state.value
65 && *at >= started
66 {
67 return Ok(value.clone());
68 }
69 if let Some((at, error)) = &state.failure
70 && *at >= started
71 {
72 return Err(error.clone());
73 }
74 }
75 tokio::spawn(async move {
76 let _guard = guard;
77 entry.store(load().await)
78 })
79 .await?
80 }
81
82 pub fn invalidate(&self, key: &str) {54 pub fn invalidate(&self, key: &str) {
83 self.0.lock().unwrap().remove(key);55 self.0.lock().unwrap().remove(key);
84 }56 }
...@@ -180,51 +152,6 @@ mod tests {...@@ -180,51 +152,6 @@ mod tests {
180 use super::*;152 use super::*;
181 use std::sync::atomic::{AtomicUsize, Ordering};153 use std::sync::atomic::{AtomicUsize, Ordering};
182 #[tokio::test]154 #[tokio::test]
183 async fn coalesced_identity_reads_do_not_reuse_completed_or_failed_results() {
184 let cache = Arc::new(Cache::default());
185 let calls = Arc::new(AtomicUsize::new(0));
186 let mut readers = Vec::new();
187 for _ in 0..100 {
188 let (cache, calls) = (cache.clone(), calls.clone());
189 readers.push(tokio::spawn(async move {
190 cache
191 .coalesce("identity:owner".into(), move || async move {
192 calls.fetch_add(1, Ordering::SeqCst);
193 tokio::time::sleep(Duration::from_millis(20)).await;
194 Ok(serde_json::json!({"enabled":true}))
195 })
196 .await
197 .unwrap()
198 }));
199 }
200 for reader in readers {
201 assert_eq!(reader.await.unwrap().value["enabled"], true);
202 }
203 assert_eq!(calls.load(Ordering::SeqCst), 1);
204 let disabled = cache
205 .coalesce("identity:owner".into(), || async {
206 Ok(serde_json::json!({"enabled":false}))
207 })
208 .await
209 .unwrap();
210 assert_eq!(disabled.value["enabled"], false);
211 assert!(
212 cache
213 .coalesce("identity:owner".into(), || async {
214 Err(Error::new(502, "unavailable"))
215 })
216 .await
217 .is_err()
218 );
219 let recovered = cache
220 .coalesce("identity:owner".into(), || async {
221 Ok(serde_json::json!({"enabled":true}))
222 })
223 .await
224 .unwrap();
225 assert_eq!(recovered.value["enabled"], true);
226 }
227 #[tokio::test]
228 async fn disconnecting_reader_does_not_cancel_shared_load() {155 async fn disconnecting_reader_does_not_cancel_shared_load() {
229 let cache = Arc::new(Cache::default());156 let cache = Arc::new(Cache::default());
230 let started = Arc::new(tokio::sync::Notify::new());157 let started = Arc::new(tokio::sync::Notify::new());
dashboard/src/core.rs+7-2
...@@ -839,7 +839,9 @@ mod tests {...@@ -839,7 +839,9 @@ mod tests {
839 #[tokio::test]839 #[tokio::test]
840 async fn launcher_excludes_directories_and_grouped_definitions_before_import() {840 async fn launcher_excludes_directories_and_grouped_definitions_before_import() {
841 let root = std::env::temp_dir().join(format!("studio-launcher-{}", uuid::Uuid::new_v4()));841 let root = std::env::temp_dir().join(format!("studio-launcher-{}", uuid::Uuid::new_v4()));
842 tokio::fs::create_dir_all(root.join("config")).await.unwrap();842 tokio::fs::create_dir_all(root.join("config"))
843 .await
844 .unwrap();
843 for directory in ["retired", "personal"] {845 for directory in ["retired", "personal"] {
844 tokio::fs::create_dir_all(root.join("service").join(directory))846 tokio::fs::create_dir_all(root.join("service").join(directory))
845 .await847 .await
...@@ -864,7 +866,10 @@ mod tests {...@@ -864,7 +866,10 @@ mod tests {
864 assert!(module.contains("/personal/service.pkl"));866 assert!(module.contains("/personal/service.pkl"));
865 assert!(module.contains("/personal/fixture.pkl"));867 assert!(module.contains("/personal/fixture.pkl"));
866 assert_eq!(868 assert_eq!(
867 module.lines().filter(|line| line.starts_with("import ")).count(),869 module
870 .lines()
871 .filter(|line| line.starts_with("import "))
872 .count(),
868 2,873 2,
869 );874 );
870 tokio::fs::remove_dir_all(root).await.unwrap();875 tokio::fs::remove_dir_all(root).await.unwrap();
dashboard/src/main.rs+113-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1mod apps;1mod apps;
2mod auth;
2mod cache;3mod cache;
3mod core;4mod core;
4mod deploys;5mod deploys;
...@@ -82,6 +83,7 @@ impl Document {...@@ -82,6 +83,7 @@ impl Document {
82}83}
8384
84struct App {85struct App {
86 auth: auth::Store,
85 mcp: mcp::Store,87 mcp: mcp::Store,
86 relay: relay::Broker,88 relay: relay::Broker,
87 shale: shale::Backend,89 shale: shale::Backend,
...@@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
388 "STUDIO_PUBLIC_ORIGIN",390 "STUDIO_PUBLIC_ORIGIN",
389 &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")),391 &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")),
390 );392 );
393 let auth = auth::Store::new(
394 &PathBuf::from(env("STUDIO_DATA_DIR", "data")),
395 &origin,
396 &env(
397 "STUDIO_FILE_ORIGIN",
398 &format!("https://file.{}", env("STUDIO_DOMAIN", "studio.test")),
399 ),
400 &env(
401 "STUDIO_AUTH_RP_ID",
402 &format!("auth.{}", env("STUDIO_DOMAIN", "studio.test")),
403 ),
404 )
405 .map_err(|error| std::io::Error::other(error.message))?;
406 if let Some(path) = std::env::args().skip(1).next() {
407 if path != "--import-accounts" {
408 return Err(std::io::Error::other("Unknown dashboard argument.").into());
409 }
410 let path = std::env::args()
411 .nth(2)
412 .ok_or_else(|| std::io::Error::other("Provide an account export path."))?;
413 let result = auth
414 .import(serde_json::from_slice(&std::fs::read(path)?)?)
415 .map_err(|error| std::io::Error::other(error.message))?;
416 println!("{result}");
417 return Ok(());
418 }
419 let import = PathBuf::from(env("STUDIO_DATA_DIR", "data")).join("accounts-import.json");
420 if import.exists() {
421 auth.import(serde_json::from_slice(&std::fs::read(&import)?)?)
422 .map_err(|error| std::io::Error::other(error.message))?;
423 std::fs::remove_file(&import)?;
424 }
425 if env("STUDIO_AUTH_REQUIRED", "0") == "1" && !auth.ready() {
426 return Err(std::io::Error::other(
427 "Import accounts before starting native authentication.",
428 )
429 .into());
430 }
391 let app = Arc::new(App {431 let app = Arc::new(App {
432 auth,
392 mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin)433 mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin)
393 .map_err(|error| std::io::Error::other(error.message))?,434 .map_err(|error| std::io::Error::other(error.message))?,
394 relay: relay::Broker::default(),435 relay: relay::Broker::default(),
...@@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
437 let dist = env("STUDIO_WEB_DIR", "dist");478 let dist = env("STUDIO_WEB_DIR", "dist");
438 let router = Router::new()479 let router = Router::new()
439 .route("/api/{*path}", any(api))480 .route("/api/{*path}", any(api))
481 .route("/auth/{*path}", any(auth::route))
440 .route("/oauth/{*path}", any(mcp::oauth))482 .route("/oauth/{*path}", any(mcp::oauth))
441 .route("/.well-known/{*path}", any(mcp::oauth))483 .route("/.well-known/{*path}", any(mcp::oauth))
442 .nest_service("/assets", ServeDir::new(format!("{dist}/assets")))484 .nest_service("/assets", ServeDir::new(format!("{dist}/assets")))
443 .with_state(app.clone())485 .with_state(app.clone())
444 .merge(observability::router(app.clone()))486 .merge(observability::router(app.clone()))
445 .merge(shale::router(app.clone()))487 .merge(shale::router(app.clone()))
446 .merge(relay::router(app))488 .merge(relay::router(app.clone()))
447 .fallback_service(489 .fallback_service(
448 ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))),490 ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))),
449 )491 )
450 .layer(axum::middleware::from_fn(492 .layer(axum::middleware::from_fn(
451 move |mut request: Request, next: axum::middleware::Next| {493 move |mut request: Request, next: axum::middleware::Next| {
452 let proof = proof.clone();494 let proof = proof.clone();
495 let app = app.clone();
453 async move {496 async move {
454 if mcp::public(request.uri().path()) {497 if mcp::public(request.uri().path()) {
455 request.headers_mut().remove("Studio-Proxy-Token");498 request.headers_mut().remove("Studio-Proxy-Token");
...@@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
467 }510 }
468 request.headers_mut().remove("Studio-Proxy-Token");511 request.headers_mut().remove("Studio-Proxy-Token");
469 }512 }
470 let asset = request.uri().path().starts_with("/assets/");513 let path = request.uri().path().to_owned();
514 let asset = path.starts_with("/assets/");
515 if app.auth.ready()
516 && !mcp::public(&path)
517 && !path.starts_with("/auth/")
518 && !asset
519 && path != "/sign-in"
520 {
521 request.headers_mut().remove("User-Name");
522 request.headers_mut().remove("User-Groups");
523 let account = match app.auth.session(request.headers(), "dashboard") {
524 Ok(account) => account,
525 Err(error) => return error.into_response(),
526 };
527 if account.is_null() {
528 return if path.starts_with("/api/") {
529 Error::new(401, "Sign in to Snowglobe.").into_response()
530 } else {
531 (
532 StatusCode::FOUND,
533 [(
534 "location",
535 format!(
536 "/sign-in?next={}",
537 encoded(&request.uri().to_string())
538 ),
539 )],
540 )
541 .into_response()
542 };
543 }
544 if !matches!(
545 *request.method(),
546 Method::GET | Method::HEAD | Method::OPTIONS
547 ) && request
548 .headers()
549 .get("origin")
550 .and_then(|v| v.to_str().ok())
551 != Some(app.auth.origin.origin().ascii_serialization().as_str())
552 {
553 return Error::new(403, "Open Snowglobe and try again.")
554 .into_response();
555 }
556 if !array(&account["requiredActions"]).is_empty()
557 && !path.starts_with("/api/account")
558 && path.starts_with("/api/")
559 && path != "/api/me"
560 {
561 return Error::new(
562 403,
563 "Change your temporary password in your account first.",
564 )
565 .into_response();
566 }
567 let groups = array(&account["groups"])
568 .iter()
569 .map(|v| string(&v["name"]))
570 .collect::<Vec<_>>()
571 .join(",");
572 request
573 .headers_mut()
574 .insert("User-Name", string(&account["username"]).parse().unwrap());
575 request
576 .headers_mut()
577 .insert("User-Groups", groups.parse().unwrap());
578 }
471 let document = !asset && !request.uri().path().starts_with("/api/");579 let document = !asset && !request.uri().path().starts_with("/api/");
472 if document {580 if document {
473 request.headers_mut().remove("if-modified-since");581 request.headers_mut().remove("if-modified-since");
474 request.headers_mut().remove("if-none-match");582 request.headers_mut().remove("if-none-match");
475 }583 }
476 let mut response = next.run(request).await;584 let mut response = next.run(request).await;
585 response.headers_mut().insert("referrer-policy", "no-referrer".parse().unwrap());
586 response.headers_mut().insert("x-content-type-options", "nosniff".parse().unwrap());
587 response.headers_mut().insert("x-frame-options", "DENY".parse().unwrap());
477 if asset && response.status().is_success() {588 if asset && response.status().is_success() {
478 response.headers_mut().insert(589 response.headers_mut().insert(
479 "cache-control",590 "cache-control",
dashboard/src/mcp.rs+143-47
...@@ -445,7 +445,7 @@ impl Store {...@@ -445,7 +445,7 @@ impl Store {
445 tx.commit()?;445 tx.commit()?;
446 return Ok((446 return Ok((
447 StatusCode::FOUND,447 StatusCode::FOUND,
448 [("location", format!("/mcp?request={}", encoded(&pending)))],448 [("location", format!("/connect/{}", encoded(&pending)))],
449 )449 )
450 .into_response());450 .into_response());
451 }451 }
...@@ -490,6 +490,16 @@ impl Store {...@@ -490,6 +490,16 @@ impl Store {
490490
491#[derive(Clone)]491#[derive(Clone)]
492pub(crate) struct Grant(pub Value);492pub(crate) struct Grant(pub Value);
493pub(crate) fn active_owner(app: &App, grant: &Value) -> Result<bool> {
494 let profile = match auth::user(&app.auth.db.lock().unwrap(), string(&grant["user"])) {
495 Ok(profile) => profile,
496 Err(error) if error.status == 404 => return Ok(false),
497 Err(error) => return Err(error),
498 };
499 Ok(profile["enabled"] == true
500 && (grant["resource"] != app.mcp.resource("observability")
501 || array(&profile["groups"]).iter().any(|role| role["name"] == "infra-admin")))
502}
493pub fn router<H: rmcp::ServerHandler>(503pub fn router<H: rmcp::ServerHandler>(
494 app: Arc<App>,504 app: Arc<App>,
495 catalog: &str,505 catalog: &str,
...@@ -529,7 +539,9 @@ pub fn router<H: rmcp::ServerHandler>(...@@ -529,7 +539,9 @@ pub fn router<H: rmcp::ServerHandler>(
529 return Error::new(403, "This origin cannot use the connector.")539 return Error::new(403, "This origin cannot use the connector.")
530 .into_response();540 .into_response();
531 }541 }
532 match app.mcp.authenticate(request.headers(), &resource) {542 match app.mcp.authenticate(request.headers(), &resource).and_then(|grant| {
543 if active_owner(&app, &grant)? { Ok(grant) } else { Err(Error::new(401, "invalid_token")) }
544 }) {
533 Ok(grant) => {545 Ok(grant) => {
534 request.extensions_mut().insert(Grant(grant));546 request.extensions_mut().insert(Grant(grant));
535 if let Some(value) = request.headers_mut().get_mut("authorization") {547 if let Some(value) = request.headers_mut().get_mut("authorization") {
...@@ -554,10 +566,16 @@ pub fn router<H: rmcp::ServerHandler>(...@@ -554,10 +566,16 @@ pub fn router<H: rmcp::ServerHandler>(
554566
555pub fn public(path: &str) -> bool {567pub fn public(path: &str) -> bool {
556 path.starts_with("/oauth/")568 path.starts_with("/oauth/")
557 || path.starts_with("/mcp/")569 || CATALOGS.iter().any(|(id, _, _)| {
570 path == format!("/mcp/{id}") || path.starts_with(&format!("/mcp/{id}/"))
571 })
558 || path.starts_with("/.well-known/oauth-")572 || path.starts_with("/.well-known/oauth-")
559 || path == "/pairing"573 || path == "/pairing"
560 || path == "/agent/connect"574 || path == "/agent/connect"
575 || matches!(
576 path,
577 "/agent/install.sh" | "/agent/install.ps1" | "/agent/setup.mjs" | "/agent/relay.mjs"
578 )
561 || path.starts_with("/api/v1/")579 || path.starts_with("/api/v1/")
562}580}
563pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {581pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
...@@ -590,19 +608,7 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {...@@ -590,19 +608,7 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
590 let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null};608 let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null};
591 if path == "/oauth/token" && method == Method::POST {609 if path == "/oauth/token" && method == Method::POST {
592 let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?;610 let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?;
593 let id = string(&grant["user"]);611 if !active_owner(&app, &grant)? {
594 let (profile, roles) = match tokio::try_join!(
595 host::call(json!({"operation":"iam.request","path":format!("/users/{id}"),"method":"GET","body":null})),
596 host::call(json!({"operation":"iam.request","path":format!("/users/{id}/role-mappings/realm"),"method":"GET","body":null}))
597 ) {
598 Ok(identity) => identity,
599 Err(error) if error.status == 404 => {
600 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;
601 return Err(fail("invalid_grant"));
602 }
603 Err(error) => return Err(error),
604 };
605 if profile["body"]["enabled"] != true || grant["resource"] == app.mcp.resource("observability") && !array(&roles["body"]).iter().any(|role| role["name"] == "infra-admin") {
606 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;612 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;
607 return Err(fail("invalid_grant"));613 return Err(fail("invalid_grant"));
608 }614 }
...@@ -633,6 +639,25 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {...@@ -633,6 +639,25 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
633 response639 response
634}640}
635641
642fn chosen_resources(body: &Value, resources: &[Value], shale: bool) -> Result<Value> {
643 if shale && body["resources"] == "all" {
644 return Ok(json!("all"));
645 }
646 let chosen = body["resources"]
647 .as_array()
648 .filter(|items| !items.is_empty() && items.len() <= resources.len())
649 .ok_or_else(|| Error::new(400, "Choose each available resource once."))?;
650 if chosen.iter().enumerate().any(|(index, item)| {
651 !resources.iter().any(|resource| item == &resource["id"]) || chosen[..index].contains(item)
652 }) {
653 return Err(Error::new(
654 403,
655 "Choose resources available to your account.",
656 ));
657 }
658 Ok(json!(chosen))
659}
660
636pub async fn manage(661pub async fn manage(
637 app: Arc<App>,662 app: Arc<App>,
638 method: &Method,663 method: &Method,
...@@ -672,32 +697,66 @@ pub async fn manage(...@@ -672,32 +697,66 @@ pub async fn manage(
672 .keep_alive(axum::response::sse::KeepAlive::default())697 .keep_alive(axum::response::sse::KeepAlive::default())
673 .into_response());698 .into_response());
674 }699 }
675 let consent_resource = if let ["consent", id] = parts {700 let consent_resource = if let ["connections", id] = parts
676 get(701 && method != Method::DELETE
702 {
703 let grant = get(&app.mcp.db.lock().unwrap(), &format!("grant:{id}"))?;
704 if grant["user"] != owner_id {
705 return Err(Error::new(404, "No connection with that ID."));
706 }
707 string(&grant["resource"]).to_owned()
708 } else if let ["consent", id] = parts {
709 let pending = get(
677 &app.mcp.db.lock().unwrap(),710 &app.mcp.db.lock().unwrap(),
678 &format!("pending:{}", hash(id)),711 &format!("pending:{}", hash(id)),
679 )?["resource"]712 )?;
680 .as_str()713 if pending.is_null() {
681 .unwrap_or_default()714 return Err(Error::new(
682 .to_owned()715 404,
716 "This connection request expired. Start it again.",
717 ));
718 }
719 if !pending["owner"].is_null() && pending["owner"] != owner_id {
720 return Err(Error::new(
721 403,
722 "This connection request belongs to another account.",
723 ));
724 }
725 string(&pending["resource"]).to_owned()
683 } else {726 } else {
684 String::new()727 String::new()
685 };728 };
686 let agent_consent = consent_resource == app.mcp.resource("agents");729 let agent_consent = consent_resource == app.mcp.resource("agents");
687 let shale_consent = consent_resource == app.mcp.resource("shale");730 let shale_consent = consent_resource == app.mcp.resource("shale");
731 let catalog = CATALOGS
732 .iter()
733 .find(|(id, _, _)| consent_resource == app.mcp.resource(id))
734 .map(|(id, _, _)| *id);
688 let mut linked = true;735 let mut linked = true;
689 let resources: Vec<Value> = if agent_consent {736 let mut resource_error = None;
737 let resources: Vec<Value> = if body["deny"] == true {
738 Vec::new()
739 } else if agent_consent {
690 relay::machines(&app.mcp.db.lock().unwrap(), owner_id)?740 relay::machines(&app.mcp.db.lock().unwrap(), owner_id)?
691 .into_iter()741 .into_iter()
692 .map(|m| json!({"id":m["id"],"name":m["name"]}))742 .map(|m| json!({"id":m["id"],"name":m["name"]}))
693 .collect()743 .collect()
694 } else if shale_consent && body["deny"] != true {744 } else if shale_consent {
695 match shale::repositories(&app, owner_id).await {745 let available = if body["resources"] == "all" {
746 shale::verified_session(&app, owner_id).await.map(|_| Vec::new())
747 } else {
748 shale::repositories(&app, owner_id).await
749 };
750 match available {
696 Ok(repositories) => repositories,751 Ok(repositories) => repositories,
697 Err(error) if error.status == 401 => {752 Err(error) if error.status == 401 => {
698 linked = false;753 linked = false;
699 Vec::new()754 Vec::new()
700 }755 }
756 Err(error) if method == Method::GET => {
757 resource_error = Some(error.message);
758 Vec::new()
759 }
701 Err(error) => return Err(error),760 Err(error) => return Err(error),
702 }761 }
703 } else if consent_resource == app.mcp.resource("observability")762 } else if consent_resource == app.mcp.resource("observability")
...@@ -726,13 +785,14 @@ pub async fn manage(...@@ -726,13 +785,14 @@ pub async fn manage(
726 let machines = relay::machines(&tx, owner_id)?;785 let machines = relay::machines(&tx, owner_id)?;
727 let connections = grants.iter().map(|grant| {786 let connections = grants.iter().map(|grant| {
728 let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()};787 let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()};
729 let resources: Vec<_> = array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect();788 let resources = if grant["resource"] == app.mcp.resource("shale") && grant["resources"] == "all" {json!("all")} else {json!(array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect::<Vec<_>>())};
730 Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]}))789 let catalog = CATALOGS.iter().find(|(id, _, _)| grant["resource"] == app.mcp.resource(id)).map(|(id, _, _)| *id);
790 Ok(json!({"id":grant["id"],"name":name,"catalog":catalog,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]}))
731 }).collect::<Result<Vec<_>>>()?;791 }).collect::<Result<Vec<_>>>()?;
732 let shale = get(&tx, &format!("shale-session:{owner_id}"))?;792 let shale = get(&tx, &format!("shale-session:{owner_id}"))?;
733 let catalogs: Vec<_> = CATALOGS793 let catalogs: Vec<_> = CATALOGS
734 .iter()794 .iter()
735 .map(|(id, name, _)| json!({"name":name,"endpoint":app.mcp.resource(id)}))795 .map(|(id, name, _)| json!({"id":id,"name":name,"endpoint":app.mcp.resource(id)}))
736 .collect();796 .collect();
737 json!({"catalogs":catalogs,"connections":connections,"machines":app.relay.view(machines,None),"shale":if shale["origin"] != app.shale.origin.as_str() {Value::Null} else {json!({"linkedAt":shale["linkedAt"]})}})797 json!({"catalogs":catalogs,"connections":connections,"machines":app.relay.view(machines,None),"shale":if shale["origin"] != app.shale.origin.as_str() {Value::Null} else {json!({"linkedAt":shale["linkedAt"]})}})
738 }798 }
...@@ -770,29 +830,19 @@ pub async fn manage(...@@ -770,29 +830,19 @@ pub async fn manage(
770 "UPDATE records SET value=? WHERE key=?",830 "UPDATE records SET value=? WHERE key=?",
771 rusqlite::params![pending.to_string(), key],831 rusqlite::params![pending.to_string(), key],
772 )?;832 )?;
773 json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"scopes":pending["scopes"],"resources":resources,"linked":linked})833 json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"catalog":catalog,"account":owner["username"],"redirectHost":redirect(string(&pending["redirect"]))?.host_str(),"scopes":pending["scopes"],"resources":resources,"linked":linked,"resourceError":resource_error})
774 } else {834 } else {
775 if shale_consent835 if shale_consent
776 && get(&tx, &format!("shale-session:{owner_id}"))?["origin"]836 && (!linked
777 != app.shale.origin.as_str()837 || get(&tx, &format!("shale-session:{owner_id}"))?["origin"]
838 != app.shale.origin.as_str())
778 {839 {
779 return Err(Error::new(840 return Err(Error::new(
780 401,841 401,
781 "Link your Shale account before allowing repository access.",842 "Link your Shale account before allowing repository access.",
782 ));843 ));
783 }844 }
784 let chosen = body["resources"]845 let chosen = chosen_resources(&body, &resources, shale_consent)?;
785 .as_array()
786 .filter(|a| !a.is_empty() && a.len() <= resources.len())
787 .ok_or_else(|| Error::new(400, "Choose each available resource once."))?;
788 if chosen.iter().enumerate().any(|(index, r)| {
789 !resources.iter().any(|id| r == &id["id"]) || chosen[..index].contains(r)
790 }) {
791 return Err(Error::new(
792 403,
793 "Choose resources available to your account.",
794 ));
795 }
796 if list(&tx, "grant:")?846 if list(&tx, "grant:")?
797 .iter()847 .iter()
798 .filter(|g| g["user"] == owner_id)848 .filter(|g| g["user"] == owner_id)
...@@ -826,13 +876,37 @@ pub async fn manage(...@@ -826,13 +876,37 @@ pub async fn manage(
826 }876 }
827 }877 }
828 ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?,878 ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?,
829 ["connections", id] if method == Method::DELETE => {879 ["connections", id]
830 let grant = get(&tx, &format!("grant:{id}"))?;880 if method == Method::GET || method == Method::POST || method == Method::DELETE =>
881 {
882 let key = format!("grant:{id}");
883 let mut grant = get(&tx, &key)?;
831 if grant["user"] != owner_id {884 if grant["user"] != owner_id {
832 return Err(Error::new(404, "No connection with that ID."));885 return Err(Error::new(404, "No connection with that ID."));
833 }886 }
834 revoke(&tx, id)?;887 if method == Method::DELETE {
835 Value::Null888 revoke(&tx, id)?;
889 Value::Null
890 } else if method == Method::GET {
891 json!({"resources": resources, "selected": grant["resources"], "linked": linked,"resourceError":resource_error})
892 } else {
893 if shale_consent && !linked {
894 return Err(Error::new(
895 401,
896 "Link your Shale account before allowing repository access.",
897 ));
898 }
899 let chosen = chosen_resources(&body, &resources, shale_consent)?;
900 grant["resources"] = json!(chosen);
901 if agent_consent {
902 grant["targets"] = json!(chosen);
903 }
904 tx.execute(
905 "UPDATE records SET value=? WHERE key=?",
906 rusqlite::params![grant.to_string(), key],
907 )?;
908 Value::Null
909 }
836 }910 }
837 _ => return Err(Error::new(404, "No endpoint here.")),911 _ => return Err(Error::new(404, "No endpoint here.")),
838 };912 };
...@@ -849,6 +923,28 @@ pub async fn manage(...@@ -849,6 +923,28 @@ pub async fn manage(
849923
850#[cfg(test)]924#[cfg(test)]
851mod tests {925mod tests {
926 #[test]
927 fn resource_updates_reject_empty_duplicates_and_foreign_choices() {
928 let resources = vec![json!({"id":"alpha"}), json!({"id":"beta"})];
929 assert_eq!(
930 chosen_resources(&json!({"resources":["beta"]}), &resources, false).unwrap(),
931 json!(["beta"])
932 );
933 for selected in [
934 json!([]),
935 json!(["alpha", "alpha"]),
936 json!(["foreign"]),
937 json!([null]),
938 ] {
939 assert!(chosen_resources(&json!({"resources":selected}), &resources, false).is_err());
940 }
941 assert_eq!(
942 chosen_resources(&json!({"resources":"all"}), &[], true).unwrap(),
943 json!("all")
944 );
945 assert!(chosen_resources(&json!({"resources":"all"}), &resources, false).is_err());
946 }
947
852 use super::*;948 use super::*;
853 struct Fixture {949 struct Fixture {
854 store: Arc<Store>,950 store: Arc<Store>,
dashboard/src/relay.rs+27
...@@ -618,6 +618,9 @@ async fn rest(State(app): State<Arc<App>>, request: Request) -> Response {...@@ -618,6 +618,9 @@ async fn rest(State(app): State<Arc<App>>, request: Request) -> Response {
618 let grant = app618 let grant = app
619 .mcp619 .mcp
620 .authenticate(request.headers(), &app.mcp.resource("agents"))?;620 .authenticate(request.headers(), &app.mcp.resource("agents"))?;
621 if !mcp::active_owner(&app, &grant)? {
622 return Err(Error::new(401, "This connection's account is no longer authorized."));
623 }
621 let id = string(&grant["id"]);624 let id = string(&grant["id"]);
622 let method = request.method().clone();625 let method = request.method().clone();
623 let path = request626 let path = request
...@@ -778,13 +781,37 @@ impl ServerHandler for Agents {...@@ -778,13 +781,37 @@ impl ServerHandler for Agents {
778 .into())781 .into())
779 }782 }
780}783}
784async fn installer(State(app): State<Arc<App>>, request: Request) -> Response {
785 let origin = app.mcp.origin.origin().ascii_serialization();
786 let source = if request.uri().path().ends_with(".ps1") {
787 include_str!("../agent/install.ps1")
788 .replace("__SERVER__", &format!("'{}'", origin.replace('\'', "''")))
789 } else {
790 include_str!("../agent/install.sh").replace(
791 "__SERVER__",
792 &format!("'{}'", origin.replace('\'', "'\\''")),
793 )
794 };
795 ([("content-type", "text/plain; charset=utf-8")], source).into_response()
796}
781pub fn router(app: Arc<App>) -> Router {797pub fn router(app: Arc<App>) -> Router {
782 let state = app.clone();798 let state = app.clone();
783 let expected_host =799 let expected_host =
784 app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned();800 app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned();
801 let agent = env("STUDIO_AGENT_DIR", "agent");
785 Router::new()802 Router::new()
786 .route("/pairing", any(pairing))803 .route("/pairing", any(pairing))
787 .route("/agent/connect", any(connect))804 .route("/agent/connect", any(connect))
805 .route("/agent/install.sh", axum::routing::get(installer))
806 .route("/agent/install.ps1", axum::routing::get(installer))
807 .route_service(
808 "/agent/setup.mjs",
809 ServeFile::new(format!("{agent}/setup.mjs")),
810 )
811 .route_service(
812 "/agent/relay.mjs",
813 ServeFile::new(format!("{agent}/relay.mjs")),
814 )
788 .route("/api/v1/{*path}", any(rest))815 .route("/api/v1/{*path}", any(rest))
789 .with_state(app.clone())816 .with_state(app.clone())
790 .merge(mcp::router(app, "agents", move || {817 .merge(mcp::router(app, "agents", move || {
dashboard/src/shale.rs+41-11
...@@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String {...@@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String {
149fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> {149fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> {
150 if repository.is_empty()150 if repository.is_empty()
151 || repository.len() > 255151 || repository.len() > 255
152 || matches!(repository, "." | ".." | "-")152 || repository
153 .split('/')
154 .any(|part| matches!(part, "" | "." | ".." | "-"))
153 || repository155 || repository
154 .chars()156 .chars()
155 .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c))157 .any(|c| c.is_control() || c.is_whitespace() || "\\%?#".contains(c))
156 {158 {
157 return Err(Error::new(159 return Err(Error::new(
158 400,160 400,
...@@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu...@@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu
164 .path_segments_mut()166 .path_segments_mut()
165 .unwrap()167 .unwrap()
166 .clear()168 .clear()
167 .push(repository)169 .extend(repository.split('/'))
168 .extend(suffix.iter().copied());170 .extend(suffix.iter().copied());
169 Ok(target)171 Ok(target)
170}172}
...@@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result<String> {...@@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result<String> {
184 .map(str::to_owned)186 .map(str::to_owned)
185 .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab."))187 .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab."))
186}188}
187pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> {189pub(crate) async fn verified_session(app: &App, owner: &str) -> Result<String> {
188 let session = session(app, owner)?;190 let session = session(app, owner)?;
189 username(191 username(
190 &app.shale192 &app.shale
191 .page(&app.shale.origin.join("/-/settings")?, &session)193 .page(&app.shale.origin.join("/-/settings")?, &session)
192 .await?,194 .await?,
193 )?;195 )?;
196 Ok(session)
197}
198pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> {
199 let session = verified_session(app, owner).await?;
194 let body = app.shale.page(&app.shale.origin, &session).await?;200 let body = app.shale.page(&app.shale.origin, &session).await?;
195 let document = document(&body, "page-index", None)?;201 let document = document(&body, "page-index", None)?;
196 let mut repositories = Vec::new();202 let mut repositories = Vec::new();
...@@ -359,12 +365,12 @@ impl ServerHandler for Shale {...@@ -359,12 +365,12 @@ impl ServerHandler for Shale {
359 current(app, grant, &credential)?;365 current(app, grant, &credential)?;
360 if name == "list_repositories" {366 if name == "list_repositories" {
361 let mut repositories = repositories(app, string(&grant["user"])).await?;367 let mut repositories = repositories(app, string(&grant["user"])).await?;
362 repositories.retain(|r| array(&grant["resources"]).contains(&r["id"]));368 repositories.retain(|r| grant["resources"] == "all" || array(&grant["resources"]).contains(&r["id"]));
363 current(app, grant, &credential)?;369 current(app, grant, &credential)?;
364 return Ok(json!({"repositories":repositories}));370 return Ok(json!({"repositories":repositories}));
365 }371 }
366 let repository = arguments.get("repository").and_then(Value::as_str)372 let repository = arguments.get("repository").and_then(Value::as_str)
367 .filter(|r| array(&grant["resources"]).iter().any(|id| id == *r))373 .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r))
368 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;374 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;
369 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;375 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;
370 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {376 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {
...@@ -556,6 +562,15 @@ pub async fn manage(...@@ -556,6 +562,15 @@ pub async fn manage(
556 let owner_id = string(&owner["id"]);562 let owner_id = string(&owner["id"]);
557 let key = format!("shale-session:{owner_id}");563 let key = format!("shale-session:{owner_id}");
558 match *method {564 match *method {
565 Method::GET => match repositories(&app, owner_id).await {
566 Ok(resources) => {
567 Ok(axum::Json(json!({"linked":true,"resources":resources})).into_response())
568 }
569 Err(error) if error.status == 401 => {
570 Ok(axum::Json(json!({"linked":false,"resources":[]})).into_response())
571 }
572 Err(error) => Err(error),
573 },
559 Method::POST => {574 Method::POST => {
560 let pending = if let Some(id) = body["request"].as_str() {575 let pending = if let Some(id) = body["request"].as_str() {
561 let db = app.mcp.db.lock().unwrap();576 let db = app.mcp.db.lock().unwrap();
...@@ -740,10 +755,10 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {...@@ -740,10 +755,10 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
740 let _ = app.shale.get("/-/logout", Some(&session)).await;755 let _ = app.shale.get("/-/logout", Some(&session)).await;
741 return Err(error);756 return Err(error);
742 }757 }
743 let mut target = app.mcp.origin.join("mcp")?;758 let target = app.mcp.origin.join(&match link["request"].as_str() {
744 if let Some(request) = link["request"].as_str() {759 Some(request) => format!("connect/{request}"),
745 target.query_pairs_mut().append_pair("request", request);760 None => "mcp/settings/shale".to_owned(),
746 }761 })?;
747 Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response())762 Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response())
748 }.await;763 }.await;
749 let mut response = match result {764 let mut response = match result {
...@@ -779,7 +794,11 @@ mod tests {...@@ -779,7 +794,11 @@ mod tests {
779 "..",794 "..",
780 "-",795 "-",
781 "../other",796 "../other",
782 "one/two",797 "one//two",
798 "one/../two",
799 "one/./two",
800 "one/-/two",
801 "one/",
783 "one\\two",802 "one\\two",
784 "%2e%2e",803 "%2e%2e",
785 "one?x",804 "one?x",
...@@ -795,6 +814,17 @@ mod tests {...@@ -795,6 +814,17 @@ mod tests {
795 let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap();814 let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap();
796 assert_eq!(path.origin(), origin.origin());815 assert_eq!(path.origin(), origin.origin());
797 assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1");816 assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1");
817 let path = repository_path(
818 &origin,
819 "userscripts/discord-pluralkit-predict",
820 &["issues", "1"],
821 )
822 .unwrap();
823 assert_eq!(path.origin(), origin.origin());
824 assert_eq!(
825 path.path(),
826 "/userscripts/discord-pluralkit-predict/issues/1"
827 );
798 }828 }
799 #[test]829 #[test]
800 fn issue_pages_must_match_repository_identity_and_issue_number() {830 fn issue_pages_must_match_repository_identity_and_issue_number() {
dashboard/src/telemetry.rs+19-5
...@@ -823,12 +823,20 @@ pub fn start(app: Arc<App>) {...@@ -823,12 +823,20 @@ pub fn start(app: Arc<App>) {
823 .unwrap()823 .unwrap()
824 .iter()824 .iter()
825 .flat_map(|(id, job)| {825 .flat_map(|(id, job)| {
826 array(&job["job"]["TaskGroups"]).iter()826 array(&job["job"]["TaskGroups"])
827 .iter()
827 .flat_map(|group| array(&group["Services"]))828 .flat_map(|group| array(&group["Services"]))
828 .flat_map(move |service| {829 .flat_map(move |service| {
829 array(&service["Tags"]).iter().filter_map(move |tag| {830 array(&service["Tags"]).iter().filter_map(move |tag| {
830 string(tag).strip_prefix("studio-metrics-path=")831 string(tag).strip_prefix("studio-metrics-path=").map(
831 .map(|path| (id.clone(), string(&service["Name"]).to_owned(), path.to_owned()))832 |path| {
833 (
834 id.clone(),
835 string(&service["Name"]).to_owned(),
836 path.to_owned(),
837 )
838 },
839 )
832 })840 })
833 })841 })
834 })842 })
...@@ -842,7 +850,10 @@ pub fn start(app: Arc<App>) {...@@ -842,7 +850,10 @@ pub fn start(app: Arc<App>) {
842 let response = app850 let response = app
843 .request(851 .request(
844 Method::GET,852 Method::GET,
845 &format!("{}{path}", endpoint(app.clone(), &service).await?),853 &format!(
854 "{}{path}",
855 endpoint(app.clone(), &service).await?
856 ),
846 )?857 )?
847 .timeout(Duration::from_secs(5))858 .timeout(Duration::from_secs(5))
848 .send()859 .send()
...@@ -852,7 +863,10 @@ pub fn start(app: Arc<App>) {...@@ -852,7 +863,10 @@ pub fn start(app: Arc<App>) {
852 Method::POST,863 Method::POST,
853 &format!(864 &format!(
854 "{base}/api/v1/import/prometheus?{}",865 "{base}/api/v1/import/prometheus?{}",
855 params(&[("extra_label", format!("service={id}")), ("extra_label", format!("instance={service}"))])866 params(&[
867 ("extra_label", format!("service={id}")),
868 ("extra_label", format!("instance={service}"))
869 ])
856 ),870 ),
857 )?871 )?
858 .body(response.text().await?)872 .body(response.text().await?)
dashboard/src/users.rs+264-369
...@@ -1,85 +1,7 @@...@@ -1,85 +1,7 @@
1use crate::*;1use crate::*;
2use axum::extract::{FromRequest, Multipart};2use axum::extract::{FromRequest, Multipart};
3use futures::{StreamExt, stream};3use rusqlite::{OptionalExtension, params as sql};
44
5async fn call(path: &str, method: Method, body: Option<Value>) -> Result<Value> {
6 host::call(json!({"operation":"iam.request", "path":path,
7 "method":method.as_str(), "body":body}))
8 .await
9}
10async fn get(path: &str) -> Result<Value> {
11 Ok(call(path, Method::GET, None).await?["body"].take())
12}
13async fn list() -> Result<Value> {
14 let list = get("/users?max=1000").await?;
15 let found = stream::iter(array(&list).iter().cloned())
16 .map(|mut user| async move {
17 user["groups"] = get(&format!(
18 "/users/{}/role-mappings/realm",
19 encoded(string(&user["id"]))
20 ))
21 .await?;
22 for key in ["email", "firstName", "lastName"] {
23 if user.get(key).is_none() {
24 user[key] = Value::Null;
25 }
26 }
27 Ok::<_, Error>(user)
28 })
29 .buffered(4)
30 .collect::<Vec<_>>()
31 .await
32 .into_iter()
33 .collect::<Result<Vec<_>>>()?;
34 Ok(json!(found))
35}
36async fn directory(app: Arc<App>) -> Result<Arc<Document>> {
37 app.cache
38 .get(
39 "users".into(),
40 Duration::from_secs(300),
41 move || async move {
42 let (list, groups) = tokio::try_join!(list(), get("/roles"))?;
43 let users = stream::iter(array(&list).iter().cloned())
44 .map(|mut user| async move {
45 user["sessions"] =
46 get(&format!("/users/{}/sessions", encoded(string(&user["id"]))))
47 .await?;
48 Ok::<_, Error>(user)
49 })
50 .buffered(4)
51 .collect::<Vec<_>>()
52 .await
53 .into_iter()
54 .collect::<Result<Vec<_>>>()?;
55 Ok(json!({"users":users,"groups":groups}))
56 },
57 )
58 .await
59}
60async fn found(id: &str) -> Result<Value> {
61 array(&list().await?)
62 .iter()
63 .find(|u| u["id"] == id)
64 .cloned()
65 .ok_or_else(|| Error::new(404, "No user with that id"))
66}
67async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> {
68 let user = found(id).await?;
69 if user["username"] == me["name"] {
70 let keeps_admin = remove_role.is_some()
71 && array(&user["groups"])
72 .iter()
73 .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap());
74 if !keeps_admin {
75 return Err(Error::new(
76 400,
77 "That would lock you out of this page. Sign in as another admin to change it.",
78 ));
79 }
80 }
81 Ok(())
82}
83fn uuid(id: &str) -> Result<()> {5fn uuid(id: &str) -> Result<()> {
84 if regex::Regex::new(6 if regex::Regex::new(
85 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",7 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
...@@ -113,7 +35,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> {...@@ -113,7 +35,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
113 let value = match key {35 let value = match key {
114 "username" => {36 "username" => {
115 let v = string(value).trim().to_lowercase();37 let v = string(value).trim().to_lowercase();
116 if !username_pattern.is_match(&v) {38 if v.len() > 254 || !username_pattern.is_match(&v) {
117 return Err(Error::new(39 return Err(Error::new(
118 400,40 400,
119 "Usernames use lowercase letters, digits, dots, dashes, and @",41 "Usernames use lowercase letters, digits, dots, dashes, and @",
...@@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> {...@@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
126 .as_str()48 .as_str()
127 .ok_or_else(|| Error::new(400, "Enter a name or email address."))?49 .ok_or_else(|| Error::new(400, "Enter a name or email address."))?
128 .trim();50 .trim();
51 if v.len() > 254 {
52 return Err(Error::new(400, "Use a shorter name or email address."));
53 }
129 if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) {54 if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) {
130 return Err(Error::new(400, "Enter a full email address"));55 return Err(Error::new(400, "Enter a full email address"));
131 }56 }
...@@ -138,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> {...@@ -138,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
138 value.clone()63 value.clone()
139 }64 }
140 _ => {65 _ => {
141 if !value.is_array() || array(value).iter().any(|v| !v.is_string()) {66 if !value.is_array() || array(value).iter().any(|v| v != "UPDATE_PASSWORD") {
142 return Err(Error::new(400, "Invalid required actions."));67 return Err(Error::new(400, "Invalid required actions."));
143 }68 }
144 value.clone()69 value.clone()
...@@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result<Value> {...@@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
151fn password(value: &Value) -> Result<&str> {76fn password(value: &Value) -> Result<&str> {
152 value77 value
153 .as_str()78 .as_str()
154 .filter(|s| s.chars().count() >= 8)79 .filter(|s| s.chars().count() >= 8 && s.len() <= 1024)
155 .ok_or_else(|| Error::new(400, "Use at least 8 characters"))80 .ok_or_else(|| Error::new(400, "Use at least 8 characters"))
156}81}
15782
83pub(crate) fn revoke_connections(app: &App, id: &str) -> Result<()> {
84 let mut db = app.mcp.db.lock().unwrap();
85 let transaction = db.transaction()?;
86 for grant in mcp::list(&transaction, "grant:")? {
87 if grant["user"] == id {
88 mcp::revoke(&transaction, string(&grant["id"]))?;
89 }
90 }
91 transaction.execute(
92 "DELETE FROM records WHERE json_extract(value,'$.owner')=?",
93 [id],
94 )?;
95 transaction.commit()?;
96 Ok(())
97}
98
99pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
100 let db = app.auth.db.lock().unwrap();
101 let id: Option<String> = db
102 .query_row(
103 "SELECT id FROM users WHERE username=?",
104 [string(&me["name"])],
105 |r| r.get(0),
106 )
107 .optional()?;
108 auth::user(
109 &db,
110 &id.ok_or_else(|| Error::new(401, "Sign in again to open your account."))?,
111 )
112}
113
158pub async fn route(114pub async fn route(
159 app: Arc<App>,115 app: Arc<App>,
160 method: &Method,116 method: &Method,
...@@ -163,142 +119,207 @@ pub async fn route(...@@ -163,142 +119,207 @@ pub async fn route(
163 body: Value,119 body: Value,
164) -> Result<Response> {120) -> Result<Response> {
165 if parts.is_empty() && method == Method::GET {121 if parts.is_empty() && method == Method::GET {
166 return Ok(directory(app).await?.response());122 let db = app.auth.db.lock().unwrap();
167 }123 let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?;
168 if let Some(id) = parts.first() {124 let ids = statement
169 uuid(id)?;125 .query_map([], |r| r.get::<_, String>(0))?
126 .collect::<std::result::Result<Vec<_>, _>>()?;
127 let users = ids
128 .iter()
129 .map(|id| {
130 let mut user = auth::user(&db, id)?;
131 user["sessions"] = auth::Store::sessions(&db, id)?;
132 Ok(user)
133 })
134 .collect::<Result<Vec<_>>>()?;
135 let mut statement = db.prepare("SELECT id,name FROM roles ORDER BY name")?;
136 let groups = statement
137 .query_map([], |r| {
138 Ok(json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?}))
139 })?
140 .collect::<std::result::Result<Vec<_>, _>>()?;
141 return Ok(Document::new(json!({"users":users,"groups":groups})).response());
170 }142 }
171 let value = match parts {143 if parts.is_empty() && method == Method::POST {
172 [] if method == Method::POST => {144 let mut profile = profile(&body["profile"], true)?;
173 let mut profile = profile(&body["profile"], true)?;145 let setup = string(&body["setup"]["kind"]);
174 let setup = string(&body["setup"]["kind"]);146 if setup != "invite" && setup != "password" {
175 if setup == "email" && profile["email"].is_null() {147 return Err(Error::new(400, "Choose an invitation or a password."));
176 return Err(Error::new(400, "Add an email address to send a setup link"));148 }
177 }149 let hash = if setup == "password" {
178 if setup != "email" && setup != "password" {150 Some(
179 return Err(Error::new(400, "Choose how this user signs in."));151 app.auth
180 }152 .hash_password(password(&body["setup"]["password"])?)
181 if setup == "password" {153 .await?,
182 password(&body["setup"]["password"])?;154 )
183 }155 } else {
184 if !body["groups"].is_array() {156 None
185 return Err(Error::new(400, "Choose groups."));157 };
186 }158 if !body["groups"].is_array() {
159 return Err(Error::new(400, "Choose groups."));
160 }
161 let id = uuid::Uuid::new_v4().to_string();
162 profile["enabled"] = json!(true);
163 profile["emailVerified"] = json!(false);
164 profile["requiredActions"] = json!([if hash.is_some() {
165 "UPDATE_PASSWORD"
166 } else {
167 "SETUP"
168 }]);
169 profile["createdTimestamp"] = json!((now() * 1000.0) as i64);
170 profile["attributes"] = json!({});
171 {
172 let mut db = app.auth.db.lock().unwrap();
173 let transaction = db.transaction()?;
174 transaction
175 .execute(
176 "INSERT INTO users(id,profile) VALUES (?,?)",
177 sql![id, profile.to_string()],
178 )
179 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
187 for group in array(&body["groups"]) {180 for group in array(&body["groups"]) {
188 uuid(string(group))?;181 let group = string(group);
182 uuid(group)?;
183 if transaction.execute(
184 "INSERT OR IGNORE INTO memberships SELECT ?,id FROM roles WHERE id=?",
185 sql![id, group],
186 )? == 0
187 {
188 return Err(Error::new(400, "Choose an available group."));
189 }
189 }190 }
190 let actions = if setup == "email" {191 if let Some(hash) = &hash {
191 json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"])192 auth::set_password(&transaction, &id, hash)?;
192 } else {
193 json!([])
194 };
195 profile["enabled"] = json!(true);
196 profile["emailVerified"] = json!(false);
197 profile["requiredActions"] = actions.clone();
198 let response = call("/users", Method::POST, Some(profile)).await?;
199 let id = response["id"]
200 .as_str()
201 .ok_or_else(|| {
202 Error::new(
203 502,
204 "Keycloak created the user but did not return its ID. Reload the page.",
205 )
206 })?
207 .to_owned();
208 for group in array(&body["groups"]) {
209 change_role(&id, string(group), Method::POST).await?;
210 }193 }
211 if setup == "email" {194 transaction.commit()?;
212 call(
213 &format!("/users/{id}/execute-actions-email"),
214 Method::PUT,
215 Some(actions),
216 )
217 .await?;
218 } else {
219 call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?;
220 }
221 app.cache.invalidate("users");
222 return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response());
223 }195 }
224 [id] if method == Method::PATCH => {196 return Ok((StatusCode::CREATED,axum::Json(json!({"id":id,"url":if setup=="invite" {Some(app.auth.setup_link(&id)?)}else{None}}))).into_response());
225 let profile = profile(&body, false)?;197 }
226 if profile["enabled"] == false {198 let id = parts
227 spare(me, id, None).await?;199 .first()
200 .ok_or_else(|| Error::new(404, "No user here."))?;
201 uuid(id)?;
202 if *parts == [*id, "setup-link"] && method == Method::POST {
203 return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());
204 }
205 let hash = if *parts == [*id, "password"] && method == Method::PUT {
206 Some(app.auth.hash_password(password(&body["password"])?).await?)
207 } else {
208 None
209 };
210 let mut db = app.auth.db.lock().unwrap();
211 let transaction = db.transaction()?;
212 let mut user = auth::user(&transaction, id)?;
213 let own = user["username"] == me["name"];
214 let value = match parts {
215 [_] if method == Method::PATCH => {
216 let patch = profile(&body, false)?;
217 if own && patch["enabled"] == false {
218 return Err(Error::new(
219 400,
220 "Sign in as another admin to disable your account.",
221 ));
228 }222 }
229 call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?;223 if patch.get("username").is_some() && patch["username"] != user["username"] {
224 return Err(Error::new(
225 400,
226 "Usernames are fixed to preserve service identities.",
227 ));
228 }
229 user.as_object_mut()
230 .unwrap()
231 .extend(patch.as_object().unwrap().clone());
232 auth::save_user(&transaction, id, user)?;
230 Value::Null233 Value::Null
231 }234 }
232 [id] if method == Method::DELETE => {235 [_] if method == Method::DELETE => {
233 spare(me, id, None).await?;236 if own {
234 call(&format!("/users/{id}"), Method::DELETE, None).await?;237 return Err(Error::new(
238 400,
239 "Sign in as another admin to delete your account.",
240 ));
241 }
242 transaction.execute(
243 "DELETE FROM pending WHERE json_extract(data,'$.user')=?",
244 [id],
245 )?;
246 transaction.execute("DELETE FROM users WHERE id=?", [id])?;
235 Value::Null247 Value::Null
236 }248 }
237 [id, "groups", group] if method == Method::PUT || method == Method::DELETE => {249 [_, "groups", group] if method == Method::PUT || method == Method::DELETE => {
238 uuid(group)?;250 let name: Option<String> = transaction
239 if method == Method::DELETE {251 .query_row("SELECT name FROM roles WHERE id=?", [group], |r| r.get(0))
240 let groups = get("/roles").await?;252 .optional()?;
241 let name = array(&groups)253 let name = name
242 .iter()254 .ok_or_else(|| Error::new(404, "This group no longer exists. Reload the page."))?;
243 .find(|g| g["id"] == *group)255 if own && method == Method::DELETE && name == "infra-admin" {
244 .map(|g| string(&g["name"]));256 return Err(Error::new(
245 spare(me, id, name).await?;257 400,
258 "Sign in as another admin to remove your admin access.",
259 ));
260 }
261 if method == Method::PUT {
262 transaction.execute(
263 "INSERT OR IGNORE INTO memberships VALUES (?,?)",
264 sql![id, group],
265 )?;
266 } else {
267 transaction.execute(
268 "DELETE FROM memberships WHERE user_id=? AND role_id=?",
269 sql![id, group],
270 )?;
246 }271 }
247 change_role(
248 id,
249 group,
250 if method == Method::PUT {
251 Method::POST
252 } else {
253 Method::DELETE
254 },
255 )
256 .await?;
257 Value::Null272 Value::Null
258 }273 }
259 [id, "credentials"] if method == Method::GET => {274 [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,
260 get(&format!("/users/{id}/credentials")).await?275 [_, "logout"] if method == Method::POST => {
261 }276 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
262 [id, "logout"] if method == Method::POST => {
263 call(&format!("/users/{id}/logout"), Method::POST, None).await?;
264 Value::Null277 Value::Null
265 }278 }
266 [id, "actions-email"] if method == Method::POST => {279 [_, "setup-link"] if method == Method::DELETE => {
267 let user = found(id).await?;280 transaction.execute(
268 if user["email"].is_null() {281 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
269 return Err(Error::new(400, "Add an email address first"));282 [id],
270 }283 )?;
271 if array(&user["requiredActions"]).is_empty() {
272 return Err(Error::new(400, "Pick at least one required action first"));
273 }
274 call(
275 &format!("/users/{id}/execute-actions-email"),
276 Method::PUT,
277 Some(user["requiredActions"].clone()),
278 )
279 .await?;
280 Value::Null284 Value::Null
281 }285 }
282 [id, "password"] if method == Method::PUT => {286 [_, "password"] if method == Method::PUT => {
283 let password = password(&body["password"])?;
284 if !body["temporary"].is_boolean() {287 if !body["temporary"].is_boolean() {
285 return Err(Error::new(288 return Err(Error::new(
286 400,289 400,
287 "Choose whether this password is temporary.",290 "Choose whether this password is temporary.",
288 ));291 ));
289 }292 }
290 call(293 auth::set_password(&transaction, id, hash.as_deref().unwrap())?;
291 &format!("/users/{id}/reset-password"),294 user["requiredActions"] = if body["temporary"] == true {
292 Method::PUT,295 json!(["UPDATE_PASSWORD"])
293 Some(json!({"type":"password","value":password,"temporary":body["temporary"]})),296 } else {
294 )297 json!([])
295 .await?;298 };
299 auth::save_user(&transaction, id, user)?;
300 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
296 Value::Null301 Value::Null
297 }302 }
298 _ => return Err(Error::new(404, "Not Found")),303 _ => return Err(Error::new(404, "No account action here.")),
299 };304 };
300 if method != Method::GET {305 if method != Method::GET {
301 app.cache.invalidate("users");306 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?",[id])?;
307 if body["enabled"] == false {
308 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
309 transaction.execute(
310 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
311 [id],
312 )?;
313 }
314 }
315 transaction.commit()?;
316 drop(db);
317 if body["enabled"] == false
318 || hash.is_some()
319 || (method == Method::DELETE && !matches!(parts, [_, "setup-link"]))
320 || matches!(parts, [_, "logout"])
321 {
322 revoke_connections(&app, id)?;
302 }323 }
303 Ok(if value.is_null() {324 Ok(if value.is_null() {
304 StatusCode::NO_CONTENT.into_response()325 StatusCode::NO_CONTENT.into_response()
...@@ -306,54 +327,6 @@ pub async fn route(...@@ -306,54 +327,6 @@ pub async fn route(
306 Document::new(value).response()327 Document::new(value).response()
307 })328 })
308}329}
309async fn change_role(id: &str, group: &str, method: Method) -> Result<()> {
310 let roles = get("/roles").await?;
311 let role = array(&roles)
312 .iter()
313 .find(|g| g["id"] == group)
314 .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?;
315 call(
316 &format!("/users/{id}/role-mappings/realm"),
317 method,
318 Some(json!([role])),
319 )
320 .await?;
321 Ok(())
322}
323pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
324 let name = string(&me["name"]).to_owned();
325 let value = app
326 .cache
327 .coalesce(format!("identity:{name}"), move || async move {
328 let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?;
329 let mut user = array(&found)
330 .iter()
331 .find(|u| u["username"] == name)
332 .cloned()
333 .ok_or_else(|| {
334 Error::new(
335 404,
336 format!(
337 "Keycloak has no user named {}. Sign out, then sign in again.",
338 name
339 ),
340 )
341 })?;
342 user["groups"] = get(&format!(
343 "/users/{}/role-mappings/realm",
344 encoded(string(&user["id"]))
345 ))
346 .await?;
347 for key in ["email", "firstName", "lastName"] {
348 if user.get(key).is_none() {
349 user[key] = Value::Null;
350 }
351 }
352 Ok(user)
353 })
354 .await?;
355 Ok(value.value.clone())
356}
357fn image_type(bytes: &[u8]) -> Option<&'static str> {330fn image_type(bytes: &[u8]) -> Option<&'static str> {
358 if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {331 if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
359 Some("image/webp")332 Some("image/webp")
...@@ -396,130 +369,62 @@ pub async fn account(...@@ -396,130 +369,62 @@ pub async fn account(
396 me: &Value,369 me: &Value,
397) -> Result<Response> {370) -> Result<Response> {
398 let method = request.method().clone();371 let method = request.method().clone();
399 let headers = request.headers();
400 let origin = format!(
401 "{}://{}",
402 headers
403 .get("X-Forwarded-Proto")
404 .and_then(|h| h.to_str().ok())
405 .unwrap_or("http"),
406 headers
407 .get("X-Forwarded-Host")
408 .or(headers.get("Host"))
409 .and_then(|h| h.to_str().ok())
410 .unwrap_or("localhost")
411 );
412 let realm = || {
413 std::env::var("STUDIO_KEYCLOAK_URL")
414 .map(|s| format!("{s}/realms/master"))
415 .map_err(|_| {
416 Error::new(
417 501,
418 "Keycloak isn't connected to this home server. Connect it, then retry.",
419 )
420 })
421 };
422 if parts == ["sign-out"] && method == Method::GET {
423 let logout = format!(
424 "{}/protocol/openid-connect/logout?{}",
425 realm()?,
426 params(&[
427 ("client_id", "forward-auth".into()),
428 ("post_logout_redirect_uri", format!("{origin}/"))
429 ])
430 );
431 return Ok((
432 StatusCode::FOUND,
433 [(
434 "location",
435 format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])),
436 )],
437 )
438 .into_response());
439 }
440 if let ["actions", action] = parts {
441 if method != Method::GET
442 || (![
443 "webauthn-register-passwordless",
444 "UPDATE_PASSWORD",
445 "UPDATE_EMAIL",
446 ]
447 .contains(action)
448 && !regex::Regex::new(r"^delete_credential:[\w-]+$")
449 .unwrap()
450 .is_match(action))
451 {
452 return Err(Error::new(
453 400,
454 "Keycloak can't start that action from here",
455 ));
456 }
457 return Ok((
458 StatusCode::FOUND,
459 [(
460 "location",
461 format!(
462 "{}/protocol/openid-connect/auth?{}",
463 realm()?,
464 params(&[
465 ("client_id", "forward-auth".into()),
466 ("redirect_uri", format!("{origin}/account")),
467 ("response_type", "code".into()),
468 ("scope", "openid".into()),
469 ("kc_action", action.to_string())
470 ])
471 ),
472 )],
473 )
474 .into_response());
475 }
476 let mut user = self_user(&app, me).await?;372 let mut user = self_user(&app, me).await?;
477 let id = string(&user["id"]).to_owned();373 let id = string(&user["id"]).to_owned();
478 let value = match parts {374 let value = match parts {
479 [] if method == Method::GET => {375 [] if method == Method::GET => {
480 let attributes = user376 user["picture"] = user["attributes"]["picture"][0].clone();
481 .as_object_mut()377 user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;
482 .unwrap()378 user.as_object_mut().unwrap().remove("attributes");
483 .remove("attributes")
484 .unwrap_or(Value::Null);
485 user["picture"] = attributes["picture"][0].clone();
486 user["credentials"] = get(&format!("/users/{id}/credentials")).await?;
487 user["console"] = json!(format!("{}/account", realm()?));
488 user379 user
489 }380 }
490 [] if method == Method::PATCH => {381 [] if method == Method::PATCH => {
491 let body: Value = serde_json::from_slice(382 let body: Value =
492 &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?,383 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?;
493 )384 for key in ["firstName", "lastName", "email"] {
494 .map_err(|_| Error::new(400, "Invalid profile."))?;385 if body.get(key).is_some() {
495 let mut value = serde_json::Map::new();386 let mut field = serde_json::Map::new();
496 for key in ["firstName", "lastName"] {387 field.insert(key.to_owned(), body[key].clone());
497 if let Some(v) = body.get(key) {388 let patch = profile(&Value::Object(field), false)?;
498 let v = v389 user[key] = patch[key].clone();
499 .as_str()390 if key == "email" {
500 .ok_or_else(|| Error::new(400, "Enter a name."))?391 user["emailVerified"] = json!(false);
501 .trim();392 }
502 value.insert(
503 key.into(),
504 if v.is_empty() { Value::Null } else { json!(v) },
505 );
506 }393 }
507 }394 }
508 call(395 user["requiredActions"] = json!(
509 &format!("/users/{id}"),396 array(&user["requiredActions"])
510 Method::PUT,397 .iter()
511 Some(Value::Object(value)),398 .filter(|v| **v != "UPDATE_PROFILE")
512 )399 .collect::<Vec<_>>()
513 .await?;400 );
401 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
514 Value::Null402 Value::Null
515 }403 }
516 ["verify-email"] if method == Method::POST => {404 ["credentials", credential] if method == Method::DELETE => {
517 call(405 app.auth.recent(request.headers())?;
518 &format!("/users/{id}/execute-actions-email"),406 let mut db = app.auth.db.lock().unwrap();
519 Method::PUT,407 let transaction = db.transaction()?;
520 Some(json!(["VERIFY_EMAIL"])),408 let count: i64 = transaction.query_row(
521 )409 "SELECT count(*) FROM credentials WHERE user_id=?",
522 .await?;410 [&id],
411 |r| r.get(0),
412 )?;
413 if count <= 1 {
414 return Err(Error::new(
415 400,
416 "Add another sign-in method before removing this one.",
417 ));
418 }
419 if transaction.execute(
420 "DELETE FROM credentials WHERE user_id=? AND id=?",
421 sql![id, credential],
422 )? == 0
423 {
424 return Err(Error::new(404, "This sign-in method was already removed."));
425 }
426 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration') AND json_extract(data,'$.user')=?",[&id])?;
427 transaction.commit()?;
523 Value::Null428 Value::Null
524 }429 }
525 ["picture"] if method == Method::PUT => {430 ["picture"] if method == Method::PUT => {
...@@ -554,32 +459,22 @@ pub async fn account(...@@ -554,32 +459,22 @@ pub async fn account(
554 tokio::fs::create_dir_all(app.data.join("pictures")).await?;459 tokio::fs::create_dir_all(app.data.join("pictures")).await?;
555 tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?;460 tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?;
556 let picture = format!(461 let picture = format!(
557 "{origin}/api/account/pictures/{id}?v={}",462 "{}/api/account/pictures/{id}?v={}",
463 app.auth.origin.origin().ascii_serialization(),
558 (now() * 1000.0) as u64464 (now() * 1000.0) as u64
559 );465 );
560 call(466 user["attributes"]["picture"] = json!([picture]);
561 &format!("/users/{id}"),467 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
562 Method::PUT,
563 Some(json!({"attributes":{"picture":[picture]}})),
564 )
565 .await?;
566 json!({"picture":picture})468 json!({"picture":picture})
567 }469 }
568 ["picture"] if method == Method::DELETE => {470 ["picture"] if method == Method::DELETE => {
569 call(471 user["attributes"]["picture"] = Value::Null;
570 &format!("/users/{id}"),472 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
571 Method::PUT,
572 Some(json!({"attributes":{"picture":null}})),
573 )
574 .await?;
575 let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await;473 let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await;
576 Value::Null474 Value::Null
577 }475 }
578 _ => return Err(Error::new(404, "Not Found")),476 _ => return Err(Error::new(404, "No account action here.")),
579 };477 };
580 if method != Method::GET {
581 app.cache.invalidate("users");
582 }
583 Ok(if value.is_null() {478 Ok(if value.is_null() {
584 StatusCode::NO_CONTENT.into_response()479 StatusCode::NO_CONTENT.into_response()
585 } else {480 } else {
dashboard/web/api.contract.ts+16-17
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { Connections, Consent } from "./types/mcp.ts";1import type { Access, Connections, Consent, Resources } from "./types/mcp.ts";
2import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts";2import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts";
3import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts";3import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts";
4import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts";4import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts";
...@@ -55,10 +55,10 @@ type ExplorerRoutes<Prefix extends string> = { [P in keyof Explorer as `${Prefix...@@ -55,10 +55,10 @@ type ExplorerRoutes<Prefix extends string> = { [P in keyof Explorer as `${Prefix
5555
56export type Api = Hono<{}, {56export type Api = Hono<{}, {
57 "/mcp": { $get: Endpoint<Connections>; };57 "/mcp": { $get: Endpoint<Connections>; };
58 "/mcp/shale": { $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint<null, {}, 204>; };58 "/mcp/shale": { $get: Endpoint<{ linked: boolean; resources: Resources }>; $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint<null, {}, 204>; };
59 "/mcp/consent/:id": {59 "/mcp/consent/:id": {
60 $get: Endpoint<Consent, { param: { id: string } }>;60 $get: Endpoint<Consent, { param: { id: string } }>;
61 $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: string[] } | { deny: true } }>;61 $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: Access } | { deny: true } }>;
62 };62 };
63 "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; };63 "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; };
64 "/mcp/relay/machines/:id": {64 "/mcp/relay/machines/:id": {
...@@ -66,7 +66,11 @@ export type Api = Hono<{}, {...@@ -66,7 +66,11 @@ export type Api = Hono<{}, {
66 $delete: Endpoint<null, { param: { id: string } }, 204>;66 $delete: Endpoint<null, { param: { id: string } }, 204>;
67 };67 };
68 "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; };68 "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; };
69 "/mcp/connections/:id": { $delete: Endpoint<null, { param: { id: string } }, 204>; };69 "/mcp/connections/:id": {
70 $get: Endpoint<{ resources: Resources; selected: Access; linked: boolean; resourceError: string | null }, { param: { id: string } }>;
71 $post: Endpoint<null, { param: { id: string }; json: { resources: Access } }, 204>;
72 $delete: Endpoint<null, { param: { id: string } }, 204>;
73 };
7074
71 "/me": {75 "/me": {
72 $get: Endpoint<Me>;76 $get: Endpoint<Me>;
...@@ -210,7 +214,7 @@ export type Api = Hono<{}, {...@@ -210,7 +214,7 @@ export type Api = Hono<{}, {
210 };214 };
211 "/users": {215 "/users": {
212 $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>;216 $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>;
213 $post: Endpoint<{ id: string; }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "email"; } | { kind: "password"; password: string; }; }; }, 201>;217 $post: Endpoint<{ id: string; url: string | null }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "invite"; } | { kind: "password"; password: string; }; }; }, 201>;
214 };218 };
215 "/users/:id": {219 "/users/:id": {
216 $patch: Endpoint<null, { param: { id: string; }; } & { json: { username?: string | undefined; email?: string | undefined; firstName?: string | undefined; lastName?: string | undefined; enabled?: boolean | undefined; emailVerified?: boolean | undefined; requiredActions?: string[] | undefined; }; }, 204, "body">;220 $patch: Endpoint<null, { param: { id: string; }; } & { json: { username?: string | undefined; email?: string | undefined; firstName?: string | undefined; lastName?: string | undefined; enabled?: boolean | undefined; emailVerified?: boolean | undefined; requiredActions?: string[] | undefined; }; }, 204, "body">;
...@@ -226,8 +230,9 @@ export type Api = Hono<{}, {...@@ -226,8 +230,9 @@ export type Api = Hono<{}, {
226 "/users/:id/logout": {230 "/users/:id/logout": {
227 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;231 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
228 };232 };
229 "/users/:id/actions-email": {233 "/users/:id/setup-link": {
230 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;234 $post: Endpoint<{url:string}, { param: { id: string; }; }>;
235 $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">;
231 };236 };
232 "/users/:id/password": {237 "/users/:id/password": {
233 $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">;238 $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">;
...@@ -283,11 +288,8 @@ export type Api = Hono<{}, {...@@ -283,11 +288,8 @@ export type Api = Hono<{}, {
283 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;288 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;
284 };289 };
285 "/account": {290 "/account": {
286 $get: Endpoint<User & {picture: string | null; credentials:Credential[]; console: string | null}>;291 $get: Endpoint<User & {picture: string | null; credentials:Credential[]}>;
287 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; }; }, 204, "body">;292 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; email?: string | undefined; }; }, 204, "body">;
288 };
289 "/account/verify-email": {
290 $post: Endpoint<null, {}, 204, "body">;
291 };293 };
292 "/account/picture": {294 "/account/picture": {
293 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;295 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;
...@@ -296,10 +298,7 @@ export type Api = Hono<{}, {...@@ -296,10 +298,7 @@ export type Api = Hono<{}, {
296 "/account/pictures/:id": {298 "/account/pictures/:id": {
297 $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">;299 $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">;
298 };300 };
299 "/account/actions/:action": {301 "/account/credentials/:id": {
300 $get: Endpoint<undefined, { param: { action: string; }; }, 302, "redirect">;302 $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">;
301 };
302 "/account/sign-out": {
303 $get: Endpoint<undefined, {}, 302, "redirect">;
304 };303 };
305} & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>;304} & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>;
dashboard/web/auth.ts created+24
...@@ -0,0 +1,24 @@
1export async function authRequest<T>(path: string, body?: object): Promise<T> {
2 const response = await fetch(`/auth/${path}`, body ? {
3 method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
4 } : undefined);
5 if (!response.ok) throw new DetailedError(response.statusText, { statusCode: response.status, detail: { data: await response.text() } });
6 return response.status === 204 ? undefined as T : response.json();
7}
8
9export async function addPasskey(label: string) {
10 const { csrf } = await authRequest<{ csrf: string }>("status");
11 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialCreationOptionsJSON }; token: string }>("passkey/register", { csrf });
12 const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.publicKey) });
13 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey setup was canceled. Try again when you're ready.");
14 await authRequest("passkey/save", { csrf, token, credential: credential.toJSON(), label });
15}
16
17export async function signOut() {
18 await authRequest("sign-out", {});
19 window.location.assign("/sign-in");
20}
21
22export const authReason = (failure: unknown) => failure instanceof DetailedError ? reason(failure) : failure instanceof Error ? failure.message : "Couldn't finish sign-in. Try again.";
23import { DetailedError } from "hono/client";
24import { reason } from "./api.ts";
dashboard/web/components/Sidebar.tsx+2-1
...@@ -19,6 +19,7 @@ import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts";...@@ -19,6 +19,7 @@ import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts";
19import { queries } from "../api.ts";19import { queries } from "../api.ts";
20import snowflake from "../snowflake.svg";20import snowflake from "../snowflake.svg";
21import { bytes, cores, plural } from "../format.ts";21import { bytes, cores, plural } from "../format.ts";
22import { signOut } from "../auth.ts";
22import { account, Avatar, displayName } from "../pages/Account.tsx";23import { account, Avatar, displayName } from "../pages/Account.tsx";
23import { status } from "../pages/Overview.tsx";24import { status } from "../pages/Overview.tsx";
24import { TABS as STORAGE_TABS } from "../pages/Storage.tsx";25import { TABS as STORAGE_TABS } from "../pages/Storage.tsx";
...@@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) {...@@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) {
243 </button>244 </button>
244 <div ref={menu} id="account-menu" popover class="account-menu">245 <div ref={menu} id="account-menu" popover class="account-menu">
245 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>246 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>
246 <a href="/api/account/sign-out" rel="external" class="nav-item"><LogOut class="icon" /><span class="label">sign out</span></a>247 <button class="nav-item" onClick={signOut}><LogOut class="icon" /><span class="label">sign out</span></button>
247 <Show when={props.me.viewing || props.me.sections.includes("admin")}>248 <Show when={props.me.viewing || props.me.sections.includes("admin")}>
248 <form class="view-as" onSubmit={(event) => {249 <form class="view-as" onSubmit={(event) => {
249 event.preventDefault();250 event.preventDefault();
dashboard/web/main.tsx+8
...@@ -20,6 +20,8 @@ import { Deploys } from "./pages/Deploys.tsx";...@@ -20,6 +20,8 @@ import { Deploys } from "./pages/Deploys.tsx";
20import { Deploy } from "./pages/Deploy.tsx";20import { Deploy } from "./pages/Deploy.tsx";
21import { VMs } from "./pages/VMs.tsx";21import { VMs } from "./pages/VMs.tsx";
22import { MCP } from "./pages/MCP.tsx";22import { MCP } from "./pages/MCP.tsx";
23import { MCPConsent } from "./pages/MCPConsent.tsx";
24import { SignIn } from "./pages/SignIn.tsx";
23import { Account } from "./pages/Account.tsx";25import { Account } from "./pages/Account.tsx";
24import "./styles.css";26import "./styles.css";
2527
...@@ -30,10 +32,12 @@ const preload = (...list: { preload(): void }[]) => () => list.forEach((query) =...@@ -30,10 +32,12 @@ const preload = (...list: { preload(): void }[]) => () => list.forEach((query) =
3032
31function Shell(props: RouteSectionProps) {33function Shell(props: RouteSectionProps) {
32 const location = useLocation();34 const location = useLocation();
35 const consent = () => location.pathname.startsWith("/connect/") || location.pathname === "/mcp" && new URLSearchParams(location.search).has("request");
33 const section = () => location.pathname.startsWith("/services/") ? "admin"36 const section = () => location.pathname.startsWith("/services/") ? "admin"
34 : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section;37 : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section;
35 return (38 return (
36 <>39 <>
40 <Show when={location.pathname !== "/sign-in" && !consent()} fallback={<Show when={consent()} fallback={props.children}><MCPConsent /></Show>}>
37 <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}>41 <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}>
38 {(user) => (42 {(user) => (
39 <div class="shell">43 <div class="shell">
...@@ -55,6 +59,7 @@ function Shell(props: RouteSectionProps) {...@@ -55,6 +59,7 @@ function Shell(props: RouteSectionProps) {
55 </div>59 </div>
56 )}60 )}
57 </Loaded>61 </Loaded>
62 </Show>
58 <Tooltips />63 <Tooltips />
59 <Toasts />64 <Toasts />
60 </>65 </>
...@@ -63,6 +68,7 @@ function Shell(props: RouteSectionProps) {...@@ -63,6 +68,7 @@ function Shell(props: RouteSectionProps) {
6368
64render(() => (69render(() => (
65 <Router root={Shell}>70 <Router root={Shell}>
71 <Route path="/sign-in" component={SignIn} />
66 <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => {72 <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => {
67 queries.launcher.preload();73 queries.launcher.preload();
68 if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)();74 if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)();
...@@ -81,6 +87,8 @@ render(() => (...@@ -81,6 +87,8 @@ render(() => (
81 <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} />87 <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} />
82 <Route path="/vms" component={VMs} preload={preload(queries.vms)} />88 <Route path="/vms" component={VMs} preload={preload(queries.vms)} />
83 <Route path="/mcp" component={MCP} />89 <Route path="/mcp" component={MCP} />
90 <Route path="/mcp/settings/:catalog" component={MCP} />
91 <Route path="/connect/:id" component={MCPConsent} />
84 <Route path="/account" component={Account} preload={preload(queries.launcher)} />92 <Route path="/account" component={Account} preload={preload(queries.launcher)} />
85 <Route path="*" component={() => <div class="empty">No page here</div>} />93 <Route path="*" component={() => <div class="empty">No page here</div>} />
86 </Router>94 </Router>
dashboard/web/pages/Account.tsx+65-69
...@@ -1,17 +1,18 @@...@@ -1,17 +1,18 @@
1import { useNavigate, useSearchParams } from "@solidjs/router";1import { useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, onMount, Show } from "solid-js";2import { createResource, createSignal, For, Show } from "solid-js";
3import { parseResponse } from "hono/client";3import { parseResponse } from "hono/client";
4import type { User } from "../types/users.ts";4import type { User } from "../types/users.ts";
5import { api, queries, reason } from "../api.ts";5import { api, reason } from "../api.ts";
6import { Ago } from "../components/Ago.tsx";6import { Ago } from "../components/Ago.tsx";
7import { lastGood, Loaded } from "../components/Loaded.tsx";7import { lastGood, Loaded } from "../components/Loaded.tsx";
8import { OpenApp } from "../components/OpenApp.tsx";8import { showConfirmDialog } from "../components/Dialog.tsx";
9import { addPasskey, authReason, authRequest } from "../auth.ts";
9import { Reveal } from "../components/Reveal.tsx";10import { Reveal } from "../components/Reveal.tsx";
10import { toast } from "../components/Toast.tsx";11import { toast } from "../components/Toast.tsx";
11import "./Account.css";12import "./Account.css";
1213
13/** The signed-in user's Keycloak record, shared with the sidebar corner. */14/** Shared with the sidebar corner. */
14export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get()));15export const [account, { refetch: refetchAccount }] = createResource(() => window.location.pathname !== "/sign-in", () => parseResponse(api.account.$get()));
1516
16export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) =>17export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) =>
17 [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;18 [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
...@@ -25,14 +26,7 @@ export function Avatar(props: { picture: string | null; name: string }) {...@@ -25,14 +26,7 @@ export function Avatar(props: { picture: string | null; name: string }) {
25}26}
2627
27const PICTURE_SIZE = 256;28const PICTURE_SIZE = 256;
28const FIELDS = ["firstName", "lastName"] as const;29const FIELDS = ["firstName", "lastName", "email"] as const;
29
30const DONE: Record<string, string> = {
31 "webauthn-register-passwordless": "Added a passkey",
32 UPDATE_PASSWORD: "Changed your password",
33 UPDATE_EMAIL: "Sent a link to confirm your new email",
34 delete_credential: "Removed the passkey",
35};
3630
37/** Center-crops to a square and encodes WebP, or PNG where the browser can't encode WebP; null if it can't read the file. */31/** Center-crops to a square and encodes WebP, or PNG where the browser can't encode WebP; null if it can't read the file. */
38async function square(file: File) {32async function square(file: File) {
...@@ -48,24 +42,12 @@ async function square(file: File) {...@@ -48,24 +42,12 @@ async function square(file: File) {
48}42}
4943
50export function Account() {44export function Account() {
51 const [params] = useSearchParams<{ kc_action?: string; kc_action_status?: string }>();45 const [params] = useSearchParams<{ welcome?: string }>();
52 const navigate = useNavigate();46 const [adding, setAdding] = createSignal(false);
53 const apps = lastGood(queries.launcher.use()[0]);
54 onMount(() => {
55 const { kc_action: action, kc_action_status: status } = params;
56 if (!status) return;
57 if (status === "success" && action) toast(DONE[action] ?? "Done");
58 if (status === "error") toast("Keycloak couldn't finish that. Try again.");
59 navigate("/account", { replace: true });
60 });
61
62 return (47 return (
63 <div class="page account-page">48 <div class="page account-page">
64 <div class="page-head">49 <div class="page-head">
65 <h1>profile</h1>50 <h1>profile</h1>
66 <Show when={!account.error && account.latest?.console}>
67 {(href) => <OpenApp app={apps()?.find((app) => app.id === "keycloak") ?? { id: "keycloak", name: "Keycloak", icon: null }} href={href()} />}
68 </Show>
69 </div>51 </div>
70 <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={52 <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={
71 <div class="account-grid">53 <div class="account-grid">
...@@ -75,6 +57,20 @@ export function Account() {...@@ -75,6 +57,20 @@ export function Account() {
75 }>57 }>
76 {(user) => (58 {(user) => (
77 <div class="account-grid">59 <div class="account-grid">
60 <Show when={params.welcome}>
61 <section class="card">
62 <h2 class="card-title">want to add a passkey?</h2>
63 <p class="muted">Sign in with your fingerprint, face, or device PIN. Your password stays available.</p>
64 <button class="button primary" disabled={adding()} aria-busy={adding()} onClick={async () => {
65 setAdding(true);
66 try { await addPasskey("passkey"); await refetchAccount(); window.history.replaceState(null,"","/account"); toast("Added a passkey"); }
67 catch(failure) { toast(authReason(failure)); }
68 finally { setAdding(false); }
69 }}>add a passkey</button>{" "}
70 <a class="button" href="/">maybe later</a>
71 </section>
72 </Show>
73 <Show when={user().requiredActions.length}><section class="card"><p class="muted">Finish your profile and change any temporary password to open Snowglobe and Files.</p></section></Show>
78 <Profile user={user()} />74 <Profile user={user()} />
79 <SignIn user={user()} />75 <SignIn user={user()} />
80 </div>76 </div>
...@@ -88,7 +84,7 @@ type Self = NonNullable<typeof account.latest>;...@@ -88,7 +84,7 @@ type Self = NonNullable<typeof account.latest>;
8884
89function Profile(props: { user: Self }) {85function Profile(props: { user: Self }) {
90 const [dirty, setDirty] = createSignal(false);86 const [dirty, setDirty] = createSignal(false);
91 const [pending, setPending] = createSignal<"save" | "picture" | "verify">();87 const [pending, setPending] = createSignal<"save" | "picture">();
92 const [error, setError] = createSignal("");88 const [error, setError] = createSignal("");
93 const [pictureError, setPictureError] = createSignal("");89 const [pictureError, setPictureError] = createSignal("");
94 const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>;90 const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>;
...@@ -103,7 +99,7 @@ function Profile(props: { user: Self }) {...@@ -103,7 +99,7 @@ function Profile(props: { user: Self }) {
103 setError("");99 setError("");
104 };100 };
105101
106 const busy = async (key: "save" | "picture" | "verify", work: () => Promise<unknown>, fail = setError) => {102 const busy = async (key: "save" | "picture", work: () => Promise<unknown>, fail = setError) => {
107 setPending(key);103 setPending(key);
108 fail("");104 fail("");
109 try {105 try {
...@@ -141,11 +137,6 @@ function Profile(props: { user: Self }) {...@@ -141,11 +137,6 @@ function Profile(props: { user: Self }) {
141 await refetchAccount();137 await refetchAccount();
142 }, setPictureError);138 }, setPictureError);
143139
144 const resend = () => busy("verify", async () => {
145 await parseResponse(api.account["verify-email"].$post());
146 toast(`Sent a link to ${props.user.email}`);
147 });
148
149 return (140 return (
150 <section class="card">141 <section class="card">
151 <div class="picture-row">142 <div class="picture-row">
...@@ -175,16 +166,8 @@ function Profile(props: { user: Self }) {...@@ -175,16 +166,8 @@ function Profile(props: { user: Self }) {
175 <label class="label" for="last-name">last name</label>166 <label class="label" for="last-name">last name</label>
176 <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name"167 <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name"
177 readOnly={pending() === "save"} />168 readOnly={pending() === "save"} />
178 <span class="label">email</span>169 <label class="label" for="profile-email">email</label>
179 <span class="email">170 <input id="profile-email" ref={inputs.email} class="search" type="email" value={props.user.email ?? ""} autocomplete="email" readOnly={pending() === "save"} />
180 <span class="address">{props.user.email ?? <span class="muted">none</span>}</span>
181 <Show when={props.user.email && !props.user.emailVerified}>
182 <span class="chip warn">unverified</span>
183 <button type="button" class="button small" disabled={pending() === "verify"} aria-busy={pending() === "verify"}
184 onClick={resend}>resend link</button>
185 </Show>
186 <a class="button small" href="/api/account/actions/UPDATE_EMAIL">{props.user.email ? "change" : "add email"}</a>
187 </span>
188 <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show>171 <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show>
189 <span />172 <span />
190 <Reveal when={dirty()}>173 <Reveal when={dirty()}>
...@@ -206,28 +189,41 @@ function Profile(props: { user: Self }) {...@@ -206,28 +189,41 @@ function Profile(props: { user: Self }) {
206function SignIn(props: { user: Self }) {189function SignIn(props: { user: Self }) {
207 const password = () => props.user.credentials.find((credential) => credential.type === "password");190 const password = () => props.user.credentials.find((credential) => credential.type === "password");
208 const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn"));191 const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn"));
209 return (192 const [busy, setBusy] = createSignal(false);
210 <section class="card">193 const [error, setError] = createSignal("");
211 <h2 class="card-title">sign-in</h2>194 const run = async (work: () => Promise<unknown>) => {
212 <div class="credentials">195 setBusy(true); setError("");
213 <span class="label">password</span>196 try { await work(); await refetchAccount(); } catch(failure) { setError(authReason(failure)); } finally { setBusy(false); }
214 <span>197 };
215 <Show when={password()} fallback={<span class="muted">none</span>}>{(set) => <>set <Ago t={set().createdDate / 1000} /></>}</Show>198 const change = () => showConfirmDialog({
216 </span>199 title: password() ? "Change password" : "Set password", confirmLabel: "save password",
217 <a class="button small" href="/api/account/actions/UPDATE_PASSWORD">{password() ? "change" : "set password"}</a>200 body: <>
218 <span class="label">passkeys</span>201 <Show when={password()}><label class="field">current password<input name="current" class="search" type="password" required autocomplete="current-password" /></label></Show>
219 <span><Show when={!passkeys().length}><span class="muted">none</span></Show></span>202 <label class="field">new password<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label>
220 <a class="button small" href="/api/account/actions/webauthn-register-passwordless">add passkey</a>203 </>,
221 <For each={passkeys()}>204 onConfirm: async (form) => {
222 {(passkey) => (205 try {
223 <>206 const {csrf} = await authRequest<{csrf:string}>("status");
224 <span />207 await authRequest("password/change", {csrf, current: String(form.get("current") ?? ""), password: String(form.get("password") ?? "")});
225 <span class="passkey">{passkey.userLabel ?? "unnamed"} <span class="muted"><Ago t={passkey.createdDate / 1000} /></span></span>208 await refetchAccount(); toast("Saved your password");
226 <a class="button small" href={`/api/account/actions/delete_credential:${passkey.id}`}>remove</a>209 } catch(failure) { setError(authReason(failure)); throw failure; }
227 </>210 },
228 )}211 });
229 </For>212 return <section class="card">
230 </div>213 <h2 class="card-title">sign-in</h2>
231 </section>214 <p class="muted">These sign-in methods work with Snowglobe and Files.</p>
232 );215 <div class="credentials">
216 <span class="label">password</span><span>{password() ? "set" : "none"}</span>
217 <button class="button small" onClick={change}>{password() ? "change" : "set password"}</button>
218 <span class="label">passkeys</span><span>{passkeys().length ? "" : "none"}</span>
219 <button class="button small" disabled={busy()} onClick={() => run(() => addPasskey("passkey"))}>add passkey</button>
220 <For each={passkeys()}>{(key) => <>
221 <span /><span>{key.userLabel ?? "unnamed"} <span class="muted"><Ago t={key.createdDate / 1000} /></span></span>
222 <button class="button small" disabled={busy() || props.user.credentials.length <= 1} onClick={() => run(async () => {
223 await parseResponse(api.account.credentials[":id"].$delete({param:{id:key.id}}));
224 })}>remove</button>
225 </>}</For>
226 </div>
227 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
228 </section>;
233}229}
dashboard/web/pages/MCP.css+68-11
...@@ -1,12 +1,69 @@...@@ -1,12 +1,69 @@
1.mcp-page h2 { margin-top: 2rem; }1.mcp-page { max-width: 1100px; }
2.mcp-connector, .mcp-consent { padding: 1.5rem; border: 1px solid var(--line); border-radius: 8px; margin: 1rem 0; }2.mcp-page h2 { font-size: 17px; margin: 0 0 12px; color: var(--text); }
3.mcp-connector h3, .mcp-consent h2 { margin-top: 0; }3.mcp-page h3 { font-size: 15px; margin: 0; }
4.mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin: 1.5rem 0; }4.mcp-page p { color: var(--text-2); }
5.mcp-actions { display: flex; gap: .75rem; }5.mcp-navigation { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin: 20px 0 24px; }
6.mcp-navigation a { padding: 8px 12px; border-radius: 6px; color: var(--text-2); }
7.mcp-navigation a:hover { background: var(--hover); }
8.mcp-navigation a.active { background: var(--accent-wash); color: var(--accent); }
9.mcp-catalogs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
10.mcp-catalog-card, .mcp-panel, .mcp-endpoint { border: 1px solid var(--line); border-radius: 10px; padding: 20px; background: var(--surface); }
11.mcp-catalog-card:hover { border-color: var(--accent); }
12.mcp-card-heading, .mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
13.mcp-catalog-card p { min-height: 40px; }
14.mcp-card-status { display: grid; gap: 6px; font-size: 12px; color: var(--muted); margin-top: 24px; }
15.mcp-panel, .mcp-endpoint { margin: 20px 0; }
16.mcp-endpoint p { margin: 0 0 16px; }
17.mcp-endpoint .copy { max-width: 100%; }
18.mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; }
19.mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; }
20.mcp-access legend { font-weight: 600; margin-bottom: 12px; }
21.mcp-access-modes { display: grid; gap: 10px; }
22.mcp-access-modes > label { display: flex; align-items: start; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
23.mcp-access-modes > label:has(input:checked) { border-color: var(--accent); background: var(--accent-wash); }
24.mcp-access-modes input { margin: 4px 0 0; accent-color: var(--accent); }
25.mcp-access-modes span span { display: block; font-size: 12px; margin-top: 4px; }
26.mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 16px 0; }
27.mcp-resources .checkbox { align-items: start; overflow-wrap: anywhere; }
28.mcp-resources small { display: block; margin-top: 4px; }
29.mcp-repository-list > div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 10px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
30.mcp-client { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; padding: 18px 0; border-top: 1px solid var(--line); }
31.mcp-client > .mcp-actions { align-self: start; }
32.mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; }
33.mcp-edit-access { grid-column: 1 / -1; }
6.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; }34.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; }
7.mcp-page th, .mcp-page td { padding: .75rem; border-bottom: 1px solid var(--line); }35.mcp-page th, .mcp-page td { padding: 12px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
836.mcp-page form { margin: 16px 0; }
9.mcp-page form { margin: 1rem 0; }37.mcp-page form > label { display: flex; align-items: center; gap: 12px; }
10.mcp-page form label { display: flex; align-items: center; gap: .75rem; }38.mcp-page input { padding: 8px; }
11.mcp-page input { padding: .5rem; }39.mcp-page form > button { margin-top: 12px; }
12.mcp-page form > button { margin-top: .75rem; }40.mcp-help { margin: 24px 0; }
41.mcp-help summary { cursor: pointer; }
42.mcp-help li { margin: 12px 0; }
43.mcp-connector { padding: 20px; border: 1px solid var(--line); border-radius: 8px; margin: 16px 0; }
44.mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; }
45.mcp-approval { width: min(100%, 600px); padding: 32px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
46.mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; }
47.mcp-approval h1 { font-size: 28px; line-height: 1.2; margin: 0; overflow-wrap: anywhere; }
48.mcp-approval h2 { font-size: 15px; margin: 0 0 12px; }
49.mcp-approval-intro { font-size: 16px; color: var(--text-2); margin: 12px 0 24px; }
50.mcp-request-identity { padding: 16px 0; border-block: 1px solid var(--line); margin: 0; }
51.mcp-request-identity > div { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 16px; margin: 6px 0; }
52.mcp-request-identity dt { color: var(--muted); }
53.mcp-request-identity dd { margin: 0; overflow-wrap: anywhere; }
54.mcp-permissions, .mcp-link-step { padding: 24px 0; border-bottom: 1px solid var(--line); }
55.mcp-permissions p { margin: 8px 0; }
56.mcp-approval-actions { display: flex; justify-content: space-between; gap: 16px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; }
57.mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; }
58@media (max-width: 760px) {
59 .mcp-catalogs { grid-template-columns: 1fr; }
60 .mcp-catalog-card p { min-height: 0; }
61 .mcp-card-status { margin-top: 16px; }
62 .mcp-client { grid-template-columns: 1fr; }
63 .mcp-section-heading { flex-wrap: wrap; }
64 .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; }
65 .mcp-page form > label { flex-wrap: wrap; }
66 .mcp-page input { max-width: 100%; min-width: 0; }
67 .mcp-authorization { padding: 20px 12px; align-items: start; }
68 .mcp-approval { padding: 24px 20px; }
69}
dashboard/web/pages/MCP.tsx+115-74
...@@ -1,94 +1,106 @@...@@ -1,94 +1,106 @@
1import { useLocation } from "@solidjs/router";1import { A, useParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";3import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
4import { api, reason } from "../api.ts";4import { api, reason } from "../api.ts";
5import { Ago } from "../components/Ago.tsx";5import { Ago } from "../components/Ago.tsx";
6import { Checkbox } from "../components/Checkbox.tsx";6import { Checkbox } from "../components/Checkbox.tsx";
7import { Copy } from "../components/Copy.tsx";7import { Copy } from "../components/Copy.tsx";
8import { showConfirmDialog } from "../components/Dialog.tsx";
8import { Loaded } from "../components/Loaded.tsx";9import { Loaded } from "../components/Loaded.tsx";
9import { toast } from "../components/Toast.tsx";10import { toast } from "../components/Toast.tsx";
11import { MCPAccess } from "./MCPAccess.tsx";
12import type { Access, Catalog } from "../types/mcp.ts";
10import "./MCP.css";13import "./MCP.css";
1114
15const descriptions: Record<Catalog, string> = {
16 shale: "Read and edit issues across your Shale repositories.",
17 agents: "Connect to Codex and Claude Code on your machines.",
18 observability: "Read logs and traces from your services.",
19};
20
12export function MCP() {21export function MCP() {
13 const location = useLocation();22 const params = useParams<{ catalog?: string }>();
14 const request = () => new URLSearchParams(location.search).get("request");
15 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));23 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));
16 const [consent, { refetch: retryConsent }] = createResource(() => request() || false,24 const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale",
17 (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } })));25 () => parseResponse(api.mcp.shale.$get()));
18 const [picked, setPicked] = createSignal<string[]>([]);26 const [editing, setEditing] = createSignal("");
27 const [selection, setSelection] = createSignal<Access>([]);
28 const [connection, { refetch: retryConnection, mutate: clearConnection }] = createResource(() => editing() || false, async (id) => {
29 clearConnection(undefined);
30 const result = await parseResponse(api.mcp.connections[":id"].$get({ param: { id } }));
31 if (editing() === id) setSelection(result.selected === "all" ? "all" : result.selected.filter((id) => result.resources.some((resource) => resource.id === id)));
32 return result;
33 });
19 const [busy, setBusy] = createSignal(false);34 const [busy, setBusy] = createSignal(false);
20 const [code, setCode] = createSignal("");35 const [code, setCode] = createSignal("");
21 const [keyName, setKeyName] = createSignal("");36 const [keyName, setKeyName] = createSignal("");
22 const [keyMachines, setKeyMachines] = createSignal<string[]>([]);37 const [keyMachines, setKeyMachines] = createSignal<string[]>([]);
23 const [control, setControl] = createSignal(false);38 const [control, setControl] = createSignal(false);
24 const [key, setKey] = createSignal("");39 const [key, setKey] = createSignal("");
25 createEffect(() => { request(); setPicked([]); setBusy(false); });40 createEffect(() => { params.catalog; setEditing(""); setKey(""); });
26 let events: EventSource | undefined;
27 createEffect(() => {41 createEffect(() => {
28 if (!overview() || events) return;42 if (params.catalog !== "agents") return;
29 events = new EventSource("/api/mcp/relay/live");43 const events = new EventSource("/api/mcp/relay/live");
30 events.onmessage = (event) => {44 events.onmessage = (event) => {
31 const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data);45 const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data);
32 mutate((previous) => previous && { ...previous, machines });46 mutate((previous) => previous && { ...previous, machines });
33 };47 };
48 onCleanup(() => events.close());
34 });49 });
35 onCleanup(() => events?.close());
36 const linkShale = async () => {50 const linkShale = async () => {
37 setBusy(true);51 setBusy(true);
38 try {52 try { const result = await parseResponse(api.mcp.shale.$post({ json: {} })); window.location.assign(result.redirect); }
39 const result = await parseResponse(api.mcp.shale.$post({ json: { request: consent()?.scopes.includes("shale:read") ? request() || undefined : undefined } }));53 catch (error) { toast(reason(error)); setBusy(false); }
40 window.location.assign(result.redirect);
41 } catch (error) { toast(reason(error)); setBusy(false); }
42 };
43 const answer = async (deny: boolean) => {
44 setBusy(true);
45 try {
46 const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request()! }, json: deny ? { deny: true } : { resources: picked() } }));
47 window.location.assign(result.redirect);
48 } catch (error) { toast(reason(error)); setBusy(false); }
49 };54 };
50 return <div class="page mcp-page">55 return <div class="page mcp-page">
51 <header class="page-header"><h1>MCP</h1></header>56 <Loaded data={overview} what="MCP settings" retry={refetch}>
52 <Show when={request()}>57 {(data) => {
53 <Loaded data={consent} what="connection request" retry={retryConsent}>58 const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog);
54 {(details) => <section class="mcp-consent">59 const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id);
55 <h2>Connect {details().client}</h2>60 return <>
56 <p>{details().scopes.includes("shale:read") ? "Choose repositories this connection can read issues from." : details().scopes.includes("sessions:read") ? "Choose machines this connection can read sessions from." : "Choose services this connection can read logs and traces from."}</p>61 <header class="page-head"><div><h1>{catalog()?.name || "MCP"}</h1><p class="sub">{catalog() ? descriptions[catalog()!.id] : "Connect your AI clients and manage their access."}</p></div></header>
57 <Show when={details().scopes.includes("sessions:write")}><p>This connection can send messages, start sessions, and interrupt turns on the selected machines.</p></Show>62 <nav class="mcp-navigation" aria-label="MCP settings">
58 <Show when={details().scopes.includes("shale:write")}><p>This connection can create issues, comment, and change issue status in the selected repositories.</p></Show>63 <A href="/mcp" end>Overview</A>
59 <div class="mcp-resources"><For each={details().resources}>{(resource) =>64 <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`}>{catalog.name}</A>}</For>
60 <Checkbox checked={picked().includes(resource.id)} onChange={(checked) => setPicked(checked ? [...picked(), resource.id] : picked().filter((item) => item !== resource.id))}>{resource.name}</Checkbox>65 </nav>
61 }</For></div>66 <Show when={!params.catalog}>
62 <Show when={!details().linked}><p>Link your Shale account to choose repositories.</p></Show>67 <div class="mcp-catalogs"><For each={data().catalogs}>{(catalog) => {
63 <Show when={details().linked && !details().resources.length}><p>No resources are available to your account.</p></Show>68 const count = () => data().connections.filter((connection) => connection.catalog === catalog.id).length;
64 <Show when={details().scopes.includes("offline_access")}><p class="muted">This connection can refresh access without another sign-in.</p></Show>69 return <A href={`/mcp/settings/${catalog.id}`} class="mcp-catalog-card">
65 <div class="mcp-actions"><Show when={details().linked} fallback={<button class="button" disabled={busy()} onClick={linkShale}>Link account</button>}><button class="button" disabled={!picked().length || busy()} onClick={() => answer(false)}>Allow access</button></Show>70 <div class="mcp-card-heading"><h2>{catalog.name}</h2><span aria-hidden="true">↗</span></div>
66 <button class="button secondary" disabled={busy()} onClick={() => answer(true)}>Decline</button></div>71 <p>{descriptions[catalog.id]}</p>
67 </section>}72 <div class="mcp-card-status"><span>{catalog.id === "shale" ? data().shale ? "Account linked" : "Account not linked" : catalog.id === "agents" ? `${data().machines.filter((machine) => machine.online).length} machines online` : "Services selected per connection"}</span>
68 </Loaded>73 <span>{count()} {count() === 1 ? "connection" : "connections"}</span></div>
69 </Show>74 </A>; }}</For></div>
70 <Loaded data={overview} what="MCP connections" retry={refetch}>75 <p class="muted">Open a connector to copy its installation URL or change a client’s access.</p>
71 {(data) => <>
72 <h2>Connectors</h2>
73 <For each={data().catalogs}>{(catalog) => <section class="mcp-connector">
74 <h3>{catalog.name}</h3><p>Add this endpoint to your AI client. Access is granted when you connect.</p>
75 <Copy value={catalog.endpoint} label="connector endpoint" />
76 </section>}</For>
77 <section class="mcp-connector"><h3>Shale account</h3>
78 <Show when={data().shale} fallback={<p>Link your Shale account to grant clients access to its repositories.</p>}>
79 {(shale) => <p>Account linked <Ago t={shale().linkedAt} /></p>}
80 </Show>76 </Show>
81 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>77 <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show>
82 <Show when={data().shale}><button class="button secondary" disabled={busy()} onClick={async () => {78 <Show when={catalog()}>{(current) => <>
83 setBusy(true);79 <section class="mcp-endpoint"><div><h2>Connect a client</h2><p>Paste this URL into your AI client’s MCP settings, then approve access.</p></div><Copy value={current().endpoint} label="connector URL" /></section>
84 try { await parseResponse(api.mcp.shale.$delete()); await refetch(); }80 <Show when={current().id === "shale"}>
85 catch (error) { toast(reason(error)); }81 <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions">
86 finally { setBusy(false); }82 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>
87 }}>Unlink</button></Show>83 <Show when={data().shale}><button class="button" disabled={busy()} onClick={() => showConfirmDialog({ title: "Unlink Shale?", description: "Every Shale client connection will lose access.", confirmLabel: "Unlink", destructive: true, onConfirm: async () => {
88 </section>84 await parseResponse(api.mcp.shale.$delete()); await refetch(); await retryShale();
85 } })}>Unlink</button></Show>
86 </div></div>
87 <Loaded data={shale} what="Shale repositories" retry={retryShale}>
88 {(account) => <Show when={account().linked} fallback={<p class="muted">Link your Shale account to connect clients.</p>}>
89 <p><strong>Repository access verified</strong><Show when={data().shale}><span class="muted"> · Linked <Ago t={data().shale!.linkedAt} /></span></Show></p>
90 <div class="mcp-repository-list"><For each={account().resources}>{(resource) => <div><span>{resource.name}</span><span class="muted">{resource.description}</span></div>}</For></div>
91 <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show>
92 </Show>}
93 </Loaded>
94 </section>
95 </Show>
96 <Show when={current().id === "agents"}>
89 <h2>Local machines</h2>97 <h2>Local machines</h2>
90 <p>Run the Agent Relay local agent with this dashboard's origin, then enter its pairing code.</p>98 <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p>
91 <Copy value={`npm run agent -- run --server ${window.location.origin}`} label="local agent command" />99 <h3>macOS or Linux</h3>
100 <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" />
101 <h3>Windows PowerShell</h3>
102 <Copy value={`irm ${window.location.origin}/agent/install.ps1 | iex`} label="Windows install command" />
103 <p class="muted">Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.</p>
92 <form class="mcp-actions" onSubmit={async (event) => {104 <form class="mcp-actions" onSubmit={async (event) => {
93 event.preventDefault(); setBusy(true);105 event.preventDefault(); setBusy(true);
94 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }106 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }
...@@ -96,7 +108,7 @@ export function MCP() {...@@ -96,7 +108,7 @@ export function MCP() {
96 finally { setBusy(false); }108 finally { setBusy(false); }
97 }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label>109 }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label>
98 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>110 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>
99 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Pair a local agent to connect it.</p>}>111 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Run the installer on a machine to link it.</p>}>
100 <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody>112 <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody>
101 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>113 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>
102 <button class="button small" onClick={async () => {114 <button class="button small" onClick={async () => {
...@@ -118,19 +130,48 @@ export function MCP() {...@@ -118,19 +130,48 @@ export function MCP() {
118 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>130 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>
119 </form>131 </form>
120 </Show>132 </Show>
133 <details class="mcp-help"><summary>How to use linked machines</summary>
134 <ol>
135 <li>Add the Local agents endpoint above to your AI client's MCP connectors. Sign in and choose the machines it can access.</li>
136 <li>Ask the client to list machines, choose a target, and list or read its Codex and Claude Code chats.</li>
137 <li>To start a chat, name the machine, provider, and an existing project folder allowed during installation.</li>
138 </ol>
139 <p>Session control lets a client send messages, start chats, and interrupt turns. Desktop Codex control needs the installer's experimental option.</p>
140 <p>Linked clients can read saved chats on granted machines. Allowed project folders limit where new chats start; existing chats keep their own permissions.</p>
141 <p>For a script or another local tool, create an API key for selected machines. Enable session control only when it needs to write.</p>
142 <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p>
143 </details>
121 <Show when={key()}><section class="mcp-connector"><h3>New API key</h3><p>Copy this key now. It won't be shown again.</p><Copy value={key()} label="API key" /><button class="button secondary" onClick={() => setKey("")}>Dismiss</button></section></Show>144 <Show when={key()}><section class="mcp-connector"><h3>New API key</h3><p>Copy this key now. It won't be shown again.</p><Copy value={key()} label="API key" /><button class="button secondary" onClick={() => setKey("")}>Dismiss</button></section></Show>
122 <h2>Connections</h2>145 </Show>
123 <Show when={data().connections.length} fallback={<p class="muted">No clients connected yet. Add a connector endpoint to your AI client to get started.</p>}>146 <Show when={current().id === "observability"}><section class="mcp-panel"><h2>Service access</h2><p>Choose services when approving a client. Change its selection below at any time.</p><p class="muted">This connector grants read access to logs and traces.</p></section></Show>
124 <table><thead><tr><th>Client</th><th>Access</th><th>Added</th><th /></tr></thead><tbody>147 <section class="mcp-panel"><h2>Connected clients</h2>
125 <For each={data().connections}>{(connection) => <tr><td>{connection.name}</td><td>{connection.resources.join(", ")}<Show when={connection.scopes.includes("sessions:write")}><span class="muted"> · Session control</span></Show><Show when={connection.scopes.includes("shale:write")}><span class="muted"> · Issue editing</span></Show></td><td><Ago t={connection.createdAt} /></td><td>148 <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}>
126 <button class="button small" onClick={async () => {149 <div class="mcp-client-list"><For each={connections()}>{(client) => <article class="mcp-client">
127 try { await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: connection.id } })); await refetch(); toast("Connection revoked"); }150 <div><h3>{client.name}</h3><p>{client.resources === "all" ? "All Repositories" : client.resources.join(", ")}</p><p class="muted">{client.scopes.includes("sessions:write") ? "Session control" : client.scopes.includes("shale:write") ? "Issue editing" : "Read only"} · Connected <Ago t={client.createdAt} /></p></div>
128 catch (error) { toast(reason(error)); }151 <div class="mcp-actions"><button class="button small" disabled={busy()} onClick={() => setEditing(editing() === client.id ? "" : client.id)}>Edit access</button>
129 }}>Revoke</button>152 <button class="button small" disabled={busy()} onClick={() => showConfirmDialog({ title: "Revoke this connection?", description: `${client.name} will lose access immediately.`, confirmLabel: "Revoke", destructive: true, onConfirm: async () => {
130 </td></tr>}</For>153 await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: client.id } })); if (editing() === client.id) setEditing(""); await refetch(); toast("Connection revoked");
131 </tbody></table>154 } })}>Revoke</button></div>
132 </Show>155 <Show when={editing() === client.id}><div class="mcp-edit-access">
133 </>}156 <Loaded data={connection} what="connection access" retry={retryConnection}>
157 {(details) => <Show when={details().linked} fallback={<><p>Link your Shale account to change repository access.</p><button class="button" disabled={busy()} onClick={linkShale}>Link account</button></>}>
158 <MCPAccess catalog={current().id} resources={details().resources} value={selection()} onChange={setSelection} disabled={busy()} />
159 <Show when={details().resourceError}><p class="error" role="alert">{details().resourceError}</p></Show>
160 <div class="mcp-actions"><button class="button primary" disabled={busy() || (selection() !== "all" && !selection().length)} onClick={async () => {
161 setBusy(true);
162 try { await parseResponse(api.mcp.connections[":id"].$post({ param: { id: client.id }, json: { resources: selection() } })); setEditing(""); await refetch(); }
163 catch (error) { toast(reason(error)); }
164 finally { setBusy(false); }
165 }}>Save access</button><button class="button" disabled={busy()} onClick={() => setEditing("")}>Cancel</button></div>
166 </Show>}
167 </Loaded>
168 </div></Show>
169 </article>}</For></div>
170 </Show>
171 </section>
172 </>}</Show>
173 </>;
174 }}
134 </Loaded>175 </Loaded>
135 </div>;176 </div>;
136}177}
dashboard/web/pages/MCPAccess.tsx created+34
...@@ -0,0 +1,34 @@
1import { For, Show } from "solid-js";
2import { Checkbox } from "../components/Checkbox.tsx";
3import type { Access, Catalog, Resources } from "../types/mcp.ts";
4
5export function MCPAccess(props: {
6 catalog: Catalog;
7 resources: Resources;
8 value: Access;
9 onChange: (value: Access) => void;
10 disabled: boolean;
11}) {
12 return <fieldset class="mcp-access" disabled={props.disabled}>
13 <legend>{props.catalog === "shale" ? "Repositories" : props.catalog === "agents" ? "Machines" : "Services"}</legend>
14 <Show when={props.catalog === "shale"}>
15 <div class="mcp-access-modes">
16 <label><input type="radio" name="repository-access" checked={props.value === "all"} onChange={() => props.onChange("all")} />
17 <span><strong>All Repositories</strong><span class="muted">Includes repositories you can access now and in the future.</span></span>
18 </label>
19 <label><input type="radio" name="repository-access" checked={props.value !== "all"} onChange={() => props.onChange([])} />
20 <span><strong>Selected repositories</strong><span class="muted">Limit this connection to the repositories you choose.</span></span>
21 </label>
22 </div>
23 </Show>
24 <Show when={props.value !== "all"}>
25 <div class="mcp-resources"><For each={props.resources}>{(resource) =>
26 <Checkbox checked={props.value !== "all" && props.value.includes(resource.id)} disabled={props.disabled} onChange={(checked) => {
27 const selected = props.value === "all" ? [] : props.value;
28 props.onChange(checked ? [...selected, resource.id] : selected.filter((id) => id !== resource.id));
29 }}><span>{resource.name}<Show when={resource.description}><small class="muted">{resource.description}</small></Show></span></Checkbox>
30 }</For></div>
31 <Show when={!props.resources.length}><p class="muted">{props.catalog === "agents" ? "No machines linked. Link a machine in MCP settings before connecting a client." : props.catalog === "shale" ? "No repositories available to select." : "No services available to your account."}</p></Show>
32 </Show>
33 </fieldset>;
34}
dashboard/web/pages/MCPConsent.tsx created+84
...@@ -0,0 +1,84 @@
1import { useBeforeLeave, useLocation, useParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, on, onCleanup, onMount, Show } from "solid-js";
4import { api, reason } from "../api.ts";
5import { showConfirmDialog } from "../components/Dialog.tsx";
6import { MCPAccess } from "./MCPAccess.tsx";
7import type { Access } from "../types/mcp.ts";
8import "./MCP.css";
9
10export function MCPConsent() {
11 const params = useParams<{ id: string }>();
12 const location = useLocation();
13 const request = () => params.id || new URLSearchParams(location.search).get("request") || "";
14 const [consent, { refetch }] = createResource(request,
15 (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } })));
16 const details = () => consent.state === "ready" ? consent.latest : undefined;
17 const [selection, setSelection] = createSignal<Access>();
18 const access = () => selection() ?? (details()?.catalog === "shale" ? "all" : []);
19 const [busy, setBusy] = createSignal(false);
20 const [error, setError] = createSignal("");
21 let leaving = false;
22 createEffect(on(request, () => { setSelection(undefined); setError(""); }));
23 const redirect = (target: string) => { leaving = true; window.location.assign(target); };
24 const answer = async (deny: boolean) => {
25 setBusy(true); setError("");
26 try {
27 const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request() }, json: deny ? { deny: true } : { resources: access() } }));
28 redirect(result.redirect);
29 } catch (error) { setError(reason(error)); setBusy(false); throw error; }
30 };
31 useBeforeLeave((event) => {
32 if (leaving) return;
33 event.preventDefault();
34 if (busy()) return;
35 showConfirmDialog({ title: "Decline this connection?", description: "The client will receive no access.", confirmLabel: "Decline", onConfirm: () => answer(true) });
36 });
37 onMount(() => {
38 const guard = (event: BeforeUnloadEvent) => { if (!leaving) event.preventDefault(); };
39 window.addEventListener("beforeunload", guard);
40 onCleanup(() => window.removeEventListener("beforeunload", guard));
41 });
42 const linkShale = async () => {
43 setBusy(true); setError("");
44 try {
45 const result = await parseResponse(api.mcp.shale.$post({ json: { request: request() } }));
46 redirect(result.redirect);
47 } catch (error) { setError(reason(error)); setBusy(false); }
48 };
49 return <main class="mcp-authorization">
50 <section class="mcp-approval" aria-labelledby="connection-title">
51 <div class="mcp-approval-brand">Snowglobe <span class="muted">· MCP connection</span></div>
52 <Show when={details()} fallback={<>
53 <h1 id="connection-title">{consent.state === "errored" ? "Connection unavailable" : "Loading connection…"}</h1>
54 <Show when={consent.state === "errored"} fallback={<div class="skeleton" style={{ height: "120px" }} aria-label="Loading connection" />}>
55 <p class="error" role="alert">{reason(consent.error)}</p>
56 <button class="button" onClick={() => refetch()}>Retry</button>
57 </Show>
58 </>}>
59 {(data) => <>
60 <h1 id="connection-title">Connect {data().client}</h1>
61 <p class="mcp-approval-intro">{data().catalog === "shale" ? "Grant access to Shale issues." : data().catalog === "agents" ? "Grant access to your local agents." : "Grant access to logs and traces."}</p>
62 <dl class="mcp-request-identity"><div><dt>Signed in as</dt><dd>{data().account}</dd></div><div><dt>Return to</dt><dd>{data().redirectHost}</dd></div></dl>
63 <div class="mcp-permissions"><h2>Requested access</h2>
64 <p>{data().catalog === "shale" ? "Read issues and comments" : data().catalog === "agents" ? "Read saved chats" : "Read logs and traces"}</p>
65 <Show when={data().scopes.includes("shale:write")}><p>Create issues, comment, and edit issue titles and status</p></Show>
66 <Show when={data().scopes.includes("sessions:write")}><p>Send messages, start chats, and interrupt turns</p></Show>
67 <Show when={data().scopes.includes("offline_access")}><p>Stay connected without signing in again</p></Show>
68 </div>
69 <Show when={data().linked} fallback={<div class="mcp-link-step"><h2>Link your Shale account</h2><p>Sign in to Shale, then return here to approve access.</p><button class="button primary" disabled={busy()} onClick={linkShale}>Link account</button></div>}>
70 <MCPAccess catalog={data().catalog} resources={data().resources} value={access()} onChange={setSelection} disabled={busy()} />
71 <Show when={data().resourceError}><p class="error" role="alert">{data().resourceError} <button class="button small" disabled={busy()} onClick={() => refetch()}>Retry</button></p></Show>
72 <p class="muted">You can change this connection’s access later in MCP settings.</p>
73 </Show>
74 </>}
75 </Show>
76 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
77 <div class="mcp-approval-actions">
78 <button class="button" disabled={busy()} onClick={() => answer(true).catch(() => {})}>Decline</button>
79 <Show when={details()?.linked}><button class="button primary" disabled={busy() || (access() !== "all" && !access().length)} onClick={() => answer(false).catch(() => {})}>Allow access</button></Show>
80 </div>
81 <Show when={consent.state === "errored"}><button class="mcp-close" onClick={() => redirect("/mcp")}>Close request</button></Show>
82 </section>
83 </main>;
84}
dashboard/web/pages/SignIn.css created+10
...@@ -0,0 +1,10 @@
1.sign-in-page { min-height: 100dvh; display: grid; align-content: center; justify-items: center; gap: 24px; padding: 24px; }
2.sign-in-brand { font-size: 24px; font-weight: 650; letter-spacing: -.5px; }
3.sign-in-card { width: min(100%, 380px); padding: 28px; }
4.sign-in-card h1 { margin: 0 0 24px; font-size: 22px; }
5.sign-in-card form, .sign-in-card label { display: grid; gap: 8px; }
6.sign-in-card form { gap: 18px; }
7.sign-in-card p { margin: 0; font-size: 13px; line-height: 1.5; }
8.sign-in-card label { color: var(--text-2); font-size: 13px; }
9.sign-in-card input { width: 100%; height: 38px; }
10.sign-in-card button { min-height: 38px; }
dashboard/web/pages/SignIn.tsx created+58
...@@ -0,0 +1,58 @@
1import { createResource, createSignal, Show } from "solid-js";
2import { authReason, authRequest } from "../auth.ts";
3import "./SignIn.css";
4
5export function SignIn() {
6 const params = new URLSearchParams(window.location.search);
7 const setup = params.get("setup");
8 const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string }>(`status${setup ? `?setup=${encodeURIComponent(setup)}` : ""}`));
9 const [username, setUsername] = createSignal("");
10 const [password, setPassword] = createSignal("");
11 const [email, setEmail] = createSignal("");
12 const [busy, setBusy] = createSignal(false);
13 const [error, setError] = createSignal("");
14 const complete = async (passkey = false) => {
15 if (!status() || busy()) return;
16 setBusy(true); setError("");
17 try {
18 const body = { csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,
19 flow: params.get("flow") ?? "", next: params.get("next") ?? "/" };
20 let result: { next: string };
21 if (passkey) {
22 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", body);
23 const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey) });
24 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
25 result = await authRequest("passkey/finish", { csrf: body.csrf, token, credential: credential.toJSON() });
26 } else result = await authRequest(setup ? "setup" : "password", body);
27 window.location.assign(result.next);
28 } catch (failure) {
29 setError(authReason(failure));
30 } finally { setBusy(false); }
31 };
32 return (
33 <main class="sign-in-page">
34 <div class="sign-in-brand">snow globe</div>
35 <section class="card sign-in-card">
36 <h1>{setup ? "welcome" : "sign in"}</h1>
37 <Show when={!status.error} fallback={<><p class="error" role="alert">{authReason(status.error)}</p><button class="button" onClick={() => refetch()}>try again</button></>}>
38 <form onSubmit={(event) => { event.preventDefault(); void complete(); }}>
39 <Show when={setup} fallback={
40 <label>username<input class="search" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus /></label>
41 }>
42 <p>Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
43 <label>email<input class="search" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} /></label>
44 </Show>
45 <label>{setup ? "choose a password" : "password"}<input class="search" type="password" required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} /></label>
46 <Show when={setup}><p class="muted">Use at least 8 characters. You can add a passkey next.</p></Show>
47 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
48 <button class="button primary" disabled={busy() || !status()} aria-busy={busy()}>{setup ? "create account" : "sign in"}</button>
49 <Show when={!setup}>
50 <button class="button" type="button" disabled={busy() || !status() || !username().trim()} onClick={() => complete(true)}>use a passkey</button>
51 <p class="muted">Need access or a password reset? Ask Clover for an invitation link.</p>
52 </Show>
53 </form>
54 </Show>
55 </section>
56 </main>
57 );
58}
dashboard/web/pages/Users.tsx+29-71
...@@ -15,7 +15,6 @@ import { AppIcon } from "../components/AppIcon.tsx";...@@ -15,7 +15,6 @@ import { AppIcon } from "../components/AppIcon.tsx";
15import { Copy } from "../components/Copy.tsx";15import { Copy } from "../components/Copy.tsx";
16import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";16import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
17import { ListPage } from "../components/ListPage.tsx";17import { ListPage } from "../components/ListPage.tsx";
18import { OpenApp } from "../components/OpenApp.tsx";
19import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";18import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
20import { Reveal } from "../components/Reveal.tsx";19import { Reveal } from "../components/Reveal.tsx";
21import { PAGES } from "../components/Sidebar.tsx";20import { PAGES } from "../components/Sidebar.tsx";
...@@ -24,13 +23,7 @@ import { toast } from "../components/Toast.tsx";...@@ -24,13 +23,7 @@ import { toast } from "../components/Toast.tsx";
24import { ago, count, date, datetime, plural } from "../format.ts";23import { ago, count, date, datetime, plural } from "../format.ts";
25import "./Users.css";24import "./Users.css";
2625
27const STEPS: [string, string][] = [26const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]];
28 ["VERIFY_EMAIL", "verify email"],
29 ["UPDATE_PASSWORD", "new password"],
30 ["UPDATE_PROFILE", "check profile"],
31 ["CONFIGURE_TOTP", "add authenticator"],
32 ["webauthn-register-passwordless", "add passkey"],
33];
3427
35const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const;28const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const;
3629
...@@ -50,7 +43,7 @@ const inState = (user: User, state: keyof typeof STATES) =>...@@ -50,7 +43,7 @@ const inState = (user: User, state: keyof typeof STATES) =>
5043
51/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */44/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */
52function reach(groups: string[], services: ServiceSummary[]) {45function reach(groups: string[], services: ServiceSummary[]) {
53 const apps = services.filter((app) => app.url);46 const apps = services.filter((app) => app.id === "copyparty" && app.url);
54 const open = {47 const open = {
55 apps: apps.filter((app) => canOpen(groups, app.access)),48 apps: apps.filter((app) => canOpen(groups, app.access)),
56 pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)),49 pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)),
...@@ -79,9 +72,9 @@ function groupTip(group: string, d: Data) {...@@ -79,9 +72,9 @@ function groupTip(group: string, d: Data) {
79 return `${plural(members, "member")} · opens ${opens}`;72 return `${plural(members, "member")} · opens ${opens}`;
80}73}
8174
82/** The app behind a Keycloak `clientId`, which is its service id. */75
83const appName = (clientId: string, services: ServiceSummary[]) =>76const appName = (clientId: string, services: ServiceSummary[]) =>
84 services.find((service) => service.id === clientId)?.name ?? clientId;77 clientId === "dashboard" ? "Snowglobe" : clientId === "file" ? "Files" : services.find((service) => service.id === clientId)?.name ?? clientId;
8578
86/** "active 3h ago in Jellyfin, Shale", from their open sessions. */79/** "active 3h ago in Jellyfin, Shale", from their open sessions. */
87function seen(user: User, services: ServiceSummary[]) {80function seen(user: User, services: ServiceSummary[]) {
...@@ -98,15 +91,6 @@ function network(ip: string) {...@@ -98,15 +91,6 @@ function network(ip: string) {
98 if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network";91 if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network";
99}92}
10093
101/** Keycloak's admin console at `path` inside the realm. */
102function KeycloakLink(props: { services: ServiceSummary[] | undefined; path: string }) {
103 return (
104 <Show when={props.services?.find((service) => service.id === "keycloak" && service.url)}>
105 {(keycloak) => <OpenApp app={keycloak()} href={`${keycloak().url}/admin/master/console/#/master/${props.path}`} />}
106 </Show>
107 );
108}
109
110/** Where the list was scrolled when a user page opened, so going back lands in the same place. */94/** Where the list was scrolled when a user page opened, so going back lands in the same place. */
111let listScroll = 0;95let listScroll = 0;
112/** Whether the open user page was reached from the list, so "back" can return to it with its filters. */96/** Whether the open user page was reached from the list, so "back" can return to it with its filters. */
...@@ -204,13 +188,13 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;...@@ -204,13 +188,13 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;
204 <div class="field">188 <div class="field">
205 first sign-in189 first sign-in
206 <TabBar label="First sign-in">190 <TabBar label="First sign-in">
207 <button type="button" aria-pressed={invite()} onClick={() => setInvite(true)}>email an invite</button>191 <button type="button" aria-pressed={invite()} onClick={() => setInvite(true)}>create an invitation link</button>
208 <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button>192 <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button>
209 </TabBar>193 </TabBar>
210 </div>194 </div>
211 <Show when={!invite()}>195 <Show when={!invite()}>
212 <label class="field">temporary password196 <label class="field">temporary password
213 <input name="password" class="search" required minLength={8} autocomplete="off" spellcheck={false} />197 <input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" />
214 <span class="hint">They pick their own at first sign-in</span>198 <span class="hint">They pick their own at first sign-in</span>
215 </label>199 </label>
216 </Show>200 </Show>
...@@ -219,16 +203,17 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;...@@ -219,16 +203,17 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;
219 },203 },
220 onConfirm: async (form) => {204 onConfirm: async (form) => {
221 const text = (name: string) => String(form.get(name) ?? "");205 const text = (name: string) => String(form.get(name) ?? "");
222 const { id } = await parseResponse(api.users.$post({206 const { id, url } = await parseResponse(api.users.$post({
223 json: {207 json: {
224 profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") },208 profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") },
225 groups: form.getAll("groups").map(String),209 groups: form.getAll("groups").map(String),
226 setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "email" },210 setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "invite" },
227 },211 },
228 }));212 }));
229 await props.refetch();213 await props.refetch();
230 const user = ready()?.users.find((user) => user.id === id);214 const user = ready()?.users.find((user) => user.id === id);
231 if (user) open(user);215 if (user) open(user);
216 if (url) showConfirmDialog({ title: "Invitation link", description: "Works once and expires in 24 hours. Send it to this person.", body: <Copy value={url} />, confirmLabel: "done", onConfirm: async () => {} });
232 },217 },
233 });218 });
234219
...@@ -236,7 +221,6 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;...@@ -236,7 +221,6 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;
236 <ListPage id="users" flush head={221 <ListPage id="users" flush head={
237 <div class="page-head">222 <div class="page-head">
238 <h1>users</h1>223 <h1>users</h1>
239 <KeycloakLink services={ready()?.services} path="users" />
240 <span class="spacer" />224 <span class="spacer" />
241 <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}>225 <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}>
242 <UserPlus size={14} />new user226 <UserPlus size={14} />new user
...@@ -418,7 +402,6 @@ function Person(props: { name: string; data: Resource<Data>; refetch: () => unkn...@@ -418,7 +402,6 @@ function Person(props: { name: string; data: Resource<Data>; refetch: () => unkn
418}402}
419403
420function Profile(props: { user: User; data: Data; refetch: () => unknown }) {404function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
421 const navigate = useNavigate();
422 const back = useBack();405 const back = useBack();
423 const param = () => ({ id: props.user.id });406 const param = () => ({ id: props.user.id });
424 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);407 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);
...@@ -450,23 +433,22 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -450,23 +433,22 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
450 await patch({ enabled });433 await patch({ enabled });
451 if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) });434 if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) });
452 });435 });
453 const toggleStep = (step: string) => run(step, () => patch({436 const [setupLink, setSetupLink] = createSignal("");
454 requiredActions: props.user.requiredActions.includes(step)437 const createLink = () => run("link", async () => {
455 ? props.user.requiredActions.filter((a) => a !== step)438 const { url } = await parseResponse(api.users[":id"]["setup-link"].$post({param:param()}));
456 : [...props.user.requiredActions, step],439 setSetupLink(url);
457 }));440 });
458 const emailSteps = () => run("email", async () => {441 const revokeLink = () => run("link", async () => {
459 await parseResponse(api.users[":id"]["actions-email"].$post({ param: param() }));442 await parseResponse(api.users[":id"]["setup-link"].$delete({param:param()}));
460 toast(`Emailed ${props.user.email} a link`);443 setSetupLink(""); toast("Revoked the setup link");
461 });444 });
462445
463 const setPassword = () => showTextDialog({446 const setPassword = () => showConfirmDialog({
464 title: `Set ${props.user.username}'s password`,447 title: `Set ${props.user.username}'s password`,
465 label: "new password, at least 8 characters",448 body: <><label class="field">new password, at least 8 characters<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label><Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox></>,
466 body: <Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox>,
467 confirmLabel: "set password",449 confirmLabel: "set password",
468 validateInput: (value) => value.length >= 8,450 onConfirm: async (form) => {
469 onConfirm: async (password, form) => {451 const password = String(form.get("password") ?? "");
470 await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } }));452 await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } }));
471 await Promise.all([props.refetch(), credentialActions.refetch()]);453 await Promise.all([props.refetch(), credentialActions.refetch()]);
472 },454 },
...@@ -484,7 +466,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -484,7 +466,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
484 const name = props.user.username;466 const name = props.user.username;
485 showTextDialog({467 showTextDialog({
486 title: `Delete ${name}?`,468 title: `Delete ${name}?`,
487 description: `${name} is signed out and loses access to everything. This can't be undone.`,469 description: `${name} is signed out and loses access to Snowglobe and Files. This can't be undone.`,
488 label: `type ${name} to confirm`,470 label: `type ${name} to confirm`,
489 validateInput: (value) => value === name,471 validateInput: (value) => value === name,
490 confirmLabel: "delete user",472 confirmLabel: "delete user",
...@@ -499,7 +481,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -499,7 +481,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
499 };481 };
500482
501 const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services);483 const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services);
502 /** Last use of each app, by the Keycloak `clientId` its sessions went through. */484
503 const used = () => {485 const used = () => {
504 const last = new Map<string, number>();486 const last = new Map<string, number>();
505 for (const session of props.user.sessions) {487 for (const session of props.user.sessions) {
...@@ -517,7 +499,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -517,7 +499,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
517 <h1>{props.user.username}</h1>499 <h1>{props.user.username}</h1>
518 <span class="sub">{fullName(props.user)}</span>500 <span class="sub">{fullName(props.user)}</span>
519 <StateTag user={props.user} />501 <StateTag user={props.user} />
520 <KeycloakLink services={props.data.services} path={`users/${props.user.id}/settings`} />
521 <span class="spacer" />502 <span class="spacer" />
522 <button class="button danger" onClick={remove}>delete user</button>503 <button class="button danger" onClick={remove}>delete user</button>
523 </div>504 </div>
...@@ -556,7 +537,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -556,7 +537,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
556 {(list) => {537 {(list) => {
557 const password = () => list().find((c) => c.type === "password");538 const password = () => list().find((c) => c.type === "password");
558 const passkeys = () => list().filter((c) => c.type.startsWith("webauthn"));539 const passkeys = () => list().filter((c) => c.type.startsWith("webauthn"));
559 const otp = () => list().find((c) => c.type === "otp");
560 return (540 return (
561 <dl class="kv">541 <dl class="kv">
562 <dt>password</dt>542 <dt>password</dt>
...@@ -567,33 +547,15 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -567,33 +547,15 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
567 {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>}547 {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>}
568 </For>548 </For>
569 </dd>549 </dd>
570 <dt>authenticator</dt>
571 <dd>{otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}</dd>
572 </dl>550 </dl>
573 );551 );
574 }}552 }}
575 </Loaded>553 </Loaded>
576 <h2 class="card-title opens">554 <h2 class="card-title opens">setup link</h2>
577 next sign-in555 <p class="muted">One use, valid for 24 hours. A new link replaces the previous one.</p>
578 <span class="spacer" />556 <button class="button small" disabled={!props.user.enabled || busy() === "link"} onClick={createLink}>create setup link</button>{" "}
579 <button class="button small" disabled={!props.user.email || !props.user.requiredActions.length || busy() === "email"}557 <button class="button small" disabled={busy() === "link"} onClick={revokeLink}>revoke link</button>
580 aria-busy={busy() === "email"}558 <Show when={setupLink()}><p style={{"overflow-wrap":"anywhere"}}><Copy value={setupLink()} /></p></Show>
581 data-tip={!props.user.email ? "Add an email address first" : !props.user.requiredActions.length
582 ? "Pick a step first" : `Send ${props.user.email} a link to do these steps now`}
583 onClick={emailSteps}>
584 send email
585 </button>
586 </h2>
587 <div class="toggles" role="group" aria-label="Steps at next sign-in">
588 <For each={STEPS}>
589 {([step, label]) => (
590 <button class="chip toggle" aria-pressed={props.user.requiredActions.includes(step)} disabled={busy() === step}
591 onClick={() => toggleStep(step)}>
592 {label}
593 </button>
594 )}
595 </For>
596 </div>
597 </section>559 </section>
598 </div>560 </div>
599561
...@@ -601,11 +563,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -601,11 +563,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
601 <section class="card">563 <section class="card">
602 <h2 class="card-title">profile</h2>564 <h2 class="card-title">profile</h2>
603 <div class="fields">565 <div class="fields">
604 <Field label="username" value={props.user.username} onSave={async (username) => {566 <span class="label">username</span><span>{props.user.username}</span>
605 await parseResponse(api.users[":id"].$patch({ param: param(), json: { username } }));
606 await props.refetch();
607 navigate(`/users/${username.trim().toLowerCase()}`, { replace: true });
608 }} />
609 <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} />567 <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} />
610 <span />568 <span />
611 <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"}569 <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"}
dashboard/web/types/mcp.ts+11-3
...@@ -1,12 +1,20 @@...@@ -1,12 +1,20 @@
1export type Catalog = "shale" | "agents" | "observability";
2export type Access = "all" | string[];
3export type Resources = { id: string; name: string; description?: string }[];
4
1export interface Connections {5export interface Connections {
2 catalogs: { name: string; endpoint: string }[];6 catalogs: { id: Catalog; name: string; endpoint: string }[];
3 connections: { id: string; name: string; resources: string[]; scopes: string[]; createdAt: number }[];7 connections: { id: string; name: string; catalog: Catalog; resources: Access; scopes: string[]; createdAt: number }[];
4 machines: { id: string; name: string; platform: string; online: boolean }[];8 machines: { id: string; name: string; platform: string; online: boolean }[];
5 shale: { linkedAt: number } | null;9 shale: { linkedAt: number } | null;
6}10}
7export interface Consent {11export interface Consent {
8 linked: boolean;12 linked: boolean;
9 client: string;13 client: string;
14 catalog: Catalog;
15 account: string;
16 redirectHost: string;
10 scopes: string[];17 scopes: string[];
11 resources: { id: string; name: string }[];18 resources: Resources;
19 resourceError: string | null;
12}20}
dashboard/web/types/users.ts-3
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1/** Field names and millisecond timestamps follow Keycloak's admin representations. */
2export interface User {1export interface User {
3 id: string;2 id: string;
4 username: string;3 username: string;
...@@ -10,7 +9,6 @@ export interface User {...@@ -10,7 +9,6 @@ export interface User {
10 createdTimestamp: number;9 createdTimestamp: number;
11 requiredActions: string[];10 requiredActions: string[];
12 groups: Group[];11 groups: Group[];
13 /** Keycloak replaces the whole map on update, so send it merged. */
14 attributes?: Record<string, string[]>;12 attributes?: Record<string, string[]>;
15}13}
1614
...@@ -31,6 +29,5 @@ export interface Session {...@@ -31,6 +29,5 @@ export interface Session {
31 ipAddress: string;29 ipAddress: string;
32 start: number;30 start: number;
33 lastAccess: number;31 lastAccess: number;
34 /** Client UUID to `clientId`. */
35 clients: Record<string, string>;32 clients: Record<string, string>;
36}33}
nixos/configuration.nix+3
...@@ -158,6 +158,9 @@ in...@@ -158,6 +158,9 @@ in
158 STUDIO_INDEX_DIR = "/data/index";158 STUDIO_INDEX_DIR = "/data/index";
159 STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}";159 STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}";
160 STUDIO_FILES_URL = "https://file.${config.environment.variables.STUDIO_DOMAIN}";160 STUDIO_FILES_URL = "https://file.${config.environment.variables.STUDIO_DOMAIN}";
161 STUDIO_AUTH_REQUIRED = "1";
162 STUDIO_AUTH_RP_ID = "auth.${config.environment.variables.STUDIO_DOMAIN}";
163 STUDIO_FILE_ORIGIN = "https://file.${config.environment.variables.STUDIO_DOMAIN}";
161 STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}";164 STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}";
162 STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}";165 STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}";
163 STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}";166 STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}";
nixos/dashboard.nix+4-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1{ stdenv, lib, rustPlatform, runCommand, nodejs_24, pnpm_10, fetchPnpmDeps, pnpmConfigHook, sqlite, pkg-config, dockerTools, coreutils, callPackage, python3, yt-dlp, ffmpeg }:1{ stdenv, lib, rustPlatform, runCommand, nodejs_24, pnpm_10, fetchPnpmDeps, pnpmConfigHook, sqlite, openssl, pkg-config, dockerTools, coreutils, callPackage, python3, yt-dlp, ffmpeg }:
2let2let
3 nativePkl = callPackage ./pkl.nix { };3 nativePkl = callPackage ./pkl.nix { };
4 youtubePython = python3.withPackages (packages: [ packages.pyyaml ]);4 youtubePython = python3.withPackages (packages: [ packages.pyyaml ]);
...@@ -38,11 +38,12 @@ let...@@ -38,11 +38,12 @@ let
38 root = ../dashboard;38 root = ../dashboard;
39 fileset = lib.fileset.unions [39 fileset = lib.fileset.unions [
40 ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock40 ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock
41 ../dashboard/agent/install.sh ../dashboard/agent/install.ps1
41 ];42 ];
42 };43 };
43 cargoLock.lockFile = ../dashboard/Cargo.lock;44 cargoLock.lockFile = ../dashboard/Cargo.lock;
44 nativeBuildInputs = [ pkg-config ];45 nativeBuildInputs = [ pkg-config ];
45 buildInputs = [ sqlite ];46 buildInputs = [ sqlite openssl ];
46 LIBSQLITE3_SYS_USE_PKG_CONFIG = "1";47 LIBSQLITE3_SYS_USE_PKG_CONFIG = "1";
47 };48 };
48 dashboard = runCommand "home-dashboard-0.1.0" {49 dashboard = runCommand "home-dashboard-0.1.0" {
...@@ -67,5 +68,6 @@ let...@@ -67,5 +68,6 @@ let
67 ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard68 ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard
68 ln -s ${web} $out/lib/home-dashboard/dist69 ln -s ${web} $out/lib/home-dashboard/dist
69 ln -s ${../dashboard/server} $out/lib/home-dashboard/server70 ln -s ${../dashboard/server} $out/lib/home-dashboard/server
71 ln -s ${../dashboard/agent} $out/lib/home-dashboard/agent
70'';72'';
71in dashboard73in dashboard
readme.md+109-1
...@@ -123,8 +123,24 @@ root, private network, resource limits, and explicit data mounts. The small...@@ -123,8 +123,24 @@ root, private network, resource limits, and explicit data mounts. The small
123performs bounded ZFS, VM, deployment, host-sampling, and identity operations.123performs bounded ZFS, VM, deployment, host-sampling, and identity operations.
124Host control sockets and management credentials stay outside the container.124Host control sockets and management credentials stay outside the container.
125125
126Snowglobe and Copyparty use the Rust dashboard's accounts and host-only sessions.
127Account state lives in `/var/lib/studio/dashboard/accounts.sqlite`. Deployment
128backups include consistent SQLite copies and profile pictures; `data-restore`
129accepts `dashboard` and preserves a safety copy before restoring. Invitations reserve a username and groups,
130expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment.
131Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related
132origin metadata for Snowglobe. Keycloak remains available for other services.
133
134`tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json`
135exports account IDs, groups, password hashes and public passkey credentials without
136changing the source realm. Keep the export private. The dashboard imports
137`accounts-import.json` from its data directory at startup and removes it after
138success; importing the same export again is safe, while a different export is
139refused once accounts exist. `home-dashboard --import-accounts /private/path/accounts.json`
140supports an offline rehearsal with a separate `STUDIO_DATA_DIR`.
141
126The MCP tab manages separate observability, agent, and Shale catalogs through142The MCP tab manages separate observability, agent, and Shale catalogs through
127the existing Keycloak realm. Each connection has explicit service, machine, or143the native dashboard account. Each connection has explicit service, machine, or
128repository grants; Shale credentials belong to the signed-in user. Agent Relay's144repository grants; Shale credentials belong to the signed-in user. Agent Relay's
129existing outbound client protocol connects to the Rust server.145existing outbound client protocol connects to the Rust server.
130146
...@@ -134,3 +150,95 @@ rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP...@@ -134,3 +150,95 @@ rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP
134connectors with disposable fixtures. `--relay-agent-dir` includes the existing150connectors with disposable fixtures. `--relay-agent-dir` includes the existing
135Agent Relay client interoperability check; `--browser-ready-file` temporarily151Agent Relay client interoperability check; `--browser-ready-file` temporarily
136routes the public dashboard to the fixture for browser and SSO load checks.152routes the public dashboard to the fixture for browser and SSO load checks.
153
154## local agents
155
156On each Mac or Linux machine, run this from your usual terminal:
157
158```sh
159curl -fsSL https://snowglobe.paperclover.net/agent/install.sh | sh
160```
161
162On Windows, use a PowerShell window without administrator privileges:
163
164```powershell
165irm https://snowglobe.paperclover.net/agent/install.ps1 | iex
166```
167
168The installer downloads a private Node runtime, verifies its SHA-256 checksum,
169and installs the agent without npm or a repository checkout. On NixOS, it
170installs the runtime through Nix into the agent folder. Prompts ask for a
171machine name, existing project folders where new chats may start, and optional
172experimental Codex desktop control on macOS or Linux. No folders or desktop
173control are enabled on a fresh install unless selected. Codex or Claude Code
174must already be installed and signed in as your login user.
175
176While the installer waits, open **MCP → Settings → Local agents**, enter the printed
177pairing code, and click **Link machine**. Finish installation in the terminal.
178The machine appears **Online** when its background agent connects. Pairing
179belongs to the signed-in dashboard account; each machine connects outward and
180needs no incoming firewall port. Startup uses a systemd user service on Linux,
181a LaunchAgent on macOS, and a current-user scheduled task at logon on Windows.
182Linux needs an active systemd user session. The agent starts immediately after
183installation and again at login.
184
185Copy **Local agents**' endpoint, `https://snowglobe.paperclover.net/mcp/agents`,
186into the AI client's MCP connector settings using OAuth. Sign in to the
187dashboard and select the machines the client may access. The consent screen
188shows whether the connection requests session control. A granted machine
189exposes saved Codex and Claude Code chats; project folders constrain **new**
190chats, not saved-chat reads or the permissions of existing chats.
191
192Example requests to the connected AI client:
193
194```text
195List my machines, target "Work PC", and show its recent Codex chats.
196Read the latest chat in that list.
197Start a Codex chat on "Work PC" in C:\Users\Clover\dev\site:
198check the build and fix the failing tests.
199Read that chat again to check the result.
200```
201
202Read access supports listing machines and chats and reading transcripts.
203Session control adds starting chats, sending messages, and interrupting turns.
204Writes always select one machine. Agent-owned Codex and Claude Code chats
205support these operations; existing Codex desktop control is experimental and
206requires the installer option. Existing Claude Code chats accept messages only
207when their local inbox supports delivery. Windows installs support saved-chat
208reads and agent-owned CLI chats; this installer does not enable existing
209desktop chat control there. A submitted message acknowledges delivery; read
210the chat again for its result. Commands needing local approval are refused.
211
212For an external script, create an **API key** in the MCP tab, select its
213machines, and enable **Allow session control** only if required. Use the key
214as a bearer token with `/api/v1/machines` and
215`/api/v1/machines/{id}/commands`. Keep it in the script's secret store. Unlinking
216a machine disconnects it and revokes its machine credential; revoking a client
217connection removes only that client's access.
218
219Rerun the install command to update the agent or change project folders. It
220keeps the machine identity and pairing. Leaving the first folder answer blank
221keeps existing folders; entering `-` clears them. A reinstall needs the existing
222pairing to remain active. Unlink first, then remove the saved `agent.json` if
223you want a new pairing or a different dashboard account.
224
225The installed `agent-relay` command accepts `status`, `start`, `stop`, and
226`uninstall`. Use its full path:
227
228| Platform | Command | Logs |
229| --- | --- | --- |
230| Linux | `~/.local/share/agent-relay/agent-relay status` | `journalctl --user -u agent-relay -f` |
231| macOS | `"$HOME/Library/Application Support/AgentRelay/agent-relay" status` | `~/Library/Application Support/AgentRelay/agent.log` |
232| Windows | `& "$env:LOCALAPPDATA\AgentRelay\agent-relay.cmd" status` | `%LOCALAPPDATA%\AgentRelay\agent.log` |
233
234Linux honors `XDG_DATA_HOME` and `XDG_CONFIG_HOME`. Uninstall removes startup
235registration and stops the agent, preserving pairing and session files. Pairing
236is in `~/.config/agent-relay/agent.json` on macOS/Linux, or
237`%LOCALAPPDATA%\AgentRelay\config\agent.json` on Windows.
238
239The local client source remains in the sibling Agent Relay project identified
240by `dashboard/agent/source.json`. `tools/deploy.py` bundles it into each frozen
241release before computing its digest. For a direct dashboard build or local
242preview, run `pnpm --dir dashboard install --frozen-lockfile` and
243`python3 tools/build-agent.py` first. The generated `relay.mjs` is a deployment
244artifact and is not checked into this repository.
service/copyparty/copyparty.conf+4-3
...@@ -15,12 +15,13 @@...@@ -15,12 +15,13 @@
1515
16 xff-src: lan16 xff-src: lan
17 rproxy: 117 rproxy: 1
18 auth-ord: pw,idp,ipu
18 idp-h-usr: user-name19 idp-h-usr: user-name
19 idp-h-grp: user-groups20 idp-h-grp: user-groups
20 idp-h-key: STUDIO_IDP_HEADER21 idp-h-key: STUDIO_IDP_HEADER
21 idp-login: /snow.oauth2/sign_in?rd={dst}22 idp-login: /auth/file/sign-in?rd={dst}
22 idp-logout: /snow.oauth2/sign_out23 idp-logout: /auth/file/sign-out
23 idp-login-t: with sso (snow sign on)24 idp-login-t: with snow globe
24 html-head: <link rel="stylesheet" href="/.static/copyparty.css">25 html-head: <link rel="stylesheet" href="/.static/copyparty.css">
2526
26[/]27[/]
tools/build-agent.py created+29
...@@ -0,0 +1,29 @@
1#!/usr/bin/env python3
2import argparse
3import json
4from pathlib import Path
5import subprocess
6
7
8def build(repo, destination, manifest):
9 spec = json.loads(manifest.read_text())
10 source = (repo / "dashboard/agent" / spec["source"]).resolve(strict=True)
11 entry = (source / spec["entry"]).resolve(strict=True)
12 if not source.is_relative_to(repo.parent) or not entry.is_relative_to(source):
13 raise ValueError("agent source must stay inside the workspace")
14 destination.parent.mkdir(parents=True, exist_ok=True)
15 subprocess.run([
16 str(repo / "dashboard/node_modules/.bin/esbuild"), str(entry),
17 "--bundle", "--platform=node", "--format=esm", "--target=node24",
18 "--external:bufferutil", "--external:utf-8-validate",
19 "--banner:js=import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
20 "--outfile=" + str(destination),
21 ], check=True)
22
23
24if __name__ == "__main__":
25 parser = argparse.ArgumentParser()
26 parser.add_argument("--output", type=Path)
27 args = parser.parse_args()
28 repo = Path(__file__).resolve().parent.parent
29 build(repo, args.output or repo / "dashboard/agent/relay.mjs", repo / "dashboard/agent/source.json")
tools/dashboard-agent-test.py created+175
...@@ -0,0 +1,175 @@
1#!/usr/bin/env python3
2import argparse
3import fcntl
4import json
5import os
6from pathlib import Path
7import pty
8import re
9import select
10import shlex
11import sqlite3
12import subprocess
13import tempfile
14import termios
15import time
16import urllib.error
17import urllib.request
18import uuid
19
20
21def main():
22 parser = argparse.ArgumentParser()
23 parser.add_argument("--url", required=True)
24 parser.add_argument("--output", type=Path)
25 parser.add_argument("--home", type=Path)
26 args = parser.parse_args()
27 origin = args.url.rstrip("/")
28 if os.uname().sysname == "Darwin":
29 existing = subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/net.paperclover.agent-relay"], capture_output=True)
30 assert existing.returncode != 0, "stop the installed agent before running this disposable fixture"
31
32 def http(path, body=None, token=None, status=200):
33 request = urllib.request.Request(origin + path, data=json.dumps(body).encode() if body is not None else None,
34 headers={"Content-Type": "application/json", "Origin": origin, **({"Authorization": "Bearer " + token} if token else {})})
35 try:
36 response = urllib.request.urlopen(request, timeout=15)
37 except urllib.error.HTTPError as error:
38 response = error
39 with response:
40 raw = response.read().decode()
41 assert response.status == status, (path, response.status, raw[:200])
42 try:
43 return json.loads(raw) if raw else None
44 except ValueError:
45 return raw
46
47 with tempfile.TemporaryDirectory(prefix="agent-relay-native-") as temporary:
48 root = args.home.resolve() if args.home else Path(temporary).resolve() / "home with spaces $dollar %percent"
49 root.mkdir(exist_ok=True)
50 assert not (root / ".config/agent-relay/agent.json").exists(), "use a disposable home without an agent pairing"
51 projects = root / "projects"
52 projects.mkdir()
53 codex = root / "codex"
54 codex.mkdir()
55 thread = str(uuid.uuid4())
56 db = sqlite3.connect(codex / "state_5.sqlite")
57 db.execute("CREATE TABLE threads (id TEXT,title TEXT,cwd TEXT,updated_at INTEGER,rollout_path TEXT,archived INTEGER)")
58 db.execute("INSERT INTO threads VALUES (?,?,?,?,?,0)", (thread, "Installer fixture", str(projects), int(time.time()), str(root / "fixture.jsonl")))
59 db.commit()
60 db.close()
61 env = {**os.environ, "HOME": str(root), "XDG_CONFIG_HOME": str(root / ".config"), "XDG_DATA_HOME": str(root / ".local/share"), "CODEX_HOME": str(codex), "CLAUDE_CONFIG_DIR": str(root / "claude")}
62 base = root / "Library/Application Support/AgentRelay" if os.uname().sysname == "Darwin" else root / ".local/share/agent-relay"
63 data = root / ".config/agent-relay"
64 machine = None
65 child = None
66 master = None
67 transcript = ""
68
69 def install(fresh):
70 nonlocal child, master, transcript, machine
71 master, slave = pty.openpty()
72
73 def terminal():
74 os.setsid()
75 fcntl.ioctl(0, termios.TIOCSCTTY, 0)
76
77 child = subprocess.Popen(["sh", "-c", f"curl -fsSL {shlex.quote(origin + '/agent/install.sh')} | sh"], stdin=slave, stdout=slave, stderr=slave, env=env, preexec_fn=terminal)
78 os.close(slave)
79 transcript = ""
80 cursor = 0
81
82 def expect(pattern, timeout=120):
83 nonlocal transcript, cursor
84 deadline = time.monotonic() + timeout
85 while True:
86 match = re.search(pattern, transcript[cursor:])
87 if match:
88 cursor += match.end()
89 return match
90 assert time.monotonic() < deadline, transcript[-1800:]
91 if select.select([master], [], [], .2)[0]:
92 try:
93 chunk = os.read(master, 65536)
94 except OSError:
95 chunk = b""
96 assert chunk, transcript[-1800:]
97 transcript += chunk.decode(errors="replace")
98
99 if fresh:
100 expect(r"Machine name \[.*?\]: ")
101 os.write(master, b"Installer fixture\n")
102 expect(r"Project folder: ")
103 os.write(master, (str(projects) + "\n" if fresh else "\n").encode())
104 if fresh:
105 expect(r"Project folder: ")
106 os.write(master, b"\n")
107 expect(r"Enable desktop control\?.*?: ")
108 os.write(master, b"n\n")
109 if fresh:
110 code = expect(r"link this machine with code ([A-Z0-9]+-[A-Z0-9]+)").group(1)
111 machine = http("/api/mcp/relay/pair", {"code": code})
112 expect(r"Installed\. Agent Relay starts at login\.")
113 deadline = time.monotonic() + 20
114 while child.poll() is None and time.monotonic() < deadline:
115 if select.select([master], [], [], .2)[0]:
116 try:
117 transcript += os.read(master, 65536).decode(errors="replace")
118 except OSError:
119 break
120 assert child.poll() is not None, transcript[-1800:]
121 assert child.returncode == 0, transcript[-1800:]
122 os.close(master)
123 master = None
124
125 def online(expected):
126 deadline = time.monotonic() + 20
127 while time.monotonic() < deadline:
128 machines = http("/api/mcp")["machines"]
129 found = next((item for item in machines if item["id"] == machine["id"]), None)
130 if found and found["online"] == expected:
131 return
132 time.sleep(.2)
133 raise AssertionError("agent connection did not change")
134
135 try:
136 install(True)
137 online(True)
138 config = json.loads((data / "agent.json").read_text())
139 assert config["roots"] == [str(projects)] and not config["desktopWrite"]
140 assert (data / "agent.json").stat().st_mode & 0o777 == 0o600
141 key = http("/api/mcp/relay/keys", {"name": "Installer fixture", "resources": [machine["id"]], "write": True})["key"]
142 result = http(f"/api/v1/machines/{machine['id']}/commands", {"method": "list_threads", "params": {"provider": "codex"}}, key)
143 assert any(item["id"] == thread for item in result["result"]["threads"]), result
144 result = http(f"/api/v1/machines/{machine['id']}/commands", {"method": "start_thread", "params": {"provider": "codex", "cwd": str(root), "message": "fixture"}}, key, status=400)
145 assert "outside the roots" in str(result)
146 install(False)
147 online(True)
148 assert json.loads((data / "agent.json").read_text()) == config
149 manage = [str(base / "node"), str(base / "setup.mjs")]
150 subprocess.run([*manage, "stop"], env=env, check=True, capture_output=True)
151 online(False)
152 subprocess.run([*manage, "start"], env=env, check=True, capture_output=True)
153 online(True)
154 subprocess.run([*manage, "uninstall"], env=env, check=True, capture_output=True)
155 online(False)
156 assert (data / "agent.json").exists()
157 report = {"platform": os.uname().sysname, "curl_pipe_prompts": "passed", "pairing": "passed", "native_login_startup": "passed", "private_credentials": "passed", "captured_cli_environment": "passed", "allowed_folders": "passed", "reinstall_keeps_identity": "passed", "stop_start_uninstall": "passed", "spaces_dollars_percent_in_paths": "passed"}
158 if args.output:
159 args.output.write_text(json.dumps(report, indent=2) + "\n")
160 print(json.dumps(report))
161 finally:
162 if (base / "setup.mjs").exists():
163 subprocess.run([str(base / "node"), str(base / "setup.mjs"), "uninstall"], env=env, capture_output=True)
164 if child and child.poll() is None:
165 os.killpg(child.pid, 9)
166 child.wait(timeout=10)
167 if master is not None:
168 os.close(master)
169 if machine:
170 request = urllib.request.Request(origin + "/api/mcp/relay/machines/" + machine["id"], method="DELETE", headers={"Origin": origin})
171 urllib.request.urlopen(request, timeout=10).close()
172
173
174if __name__ == "__main__":
175 main()
tools/dashboard-auth-test.py created+196
...@@ -0,0 +1,196 @@
1#!/usr/bin/env python3
2import argparse
3import base64
4import hashlib
5import http.client
6import json
7import os
8from pathlib import Path
9import socket
10import sqlite3
11import subprocess
12import tempfile
13import time
14import uuid
15from cryptography.hazmat.primitives import hashes
16from cryptography.hazmat.primitives.asymmetric import ec
17from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
18
19
20def b64(value):
21 return base64.urlsafe_b64encode(value).decode().rstrip('=')
22
23
24def cbor(value):
25 def header(kind, size):
26 if size < 24: return bytes([kind * 32 + size])
27 width = 1 if size < 256 else 2 if size < 65536 else 4
28 return bytes([kind * 32 + {1: 24, 2: 25, 4: 26}[width]]) + size.to_bytes(width, 'big')
29 if isinstance(value, int): return header(0 if value >= 0 else 1, value if value >= 0 else -value - 1)
30 if isinstance(value, bytes): return header(2, len(value)) + value
31 if isinstance(value, str): return header(3, len(value.encode())) + value.encode()
32 if isinstance(value, dict): return header(5, len(value)) + b''.join(cbor(k) + cbor(v) for k, v in value.items())
33 raise TypeError(type(value))
34
35
36def main():
37 parser = argparse.ArgumentParser()
38 parser.add_argument('--binary', type=Path, default=Path('dashboard/target/debug/home-dashboard'))
39 args = parser.parse_args()
40 origin, file, rp = 'https://snowglobe.paperclover.net', 'https://file.paperclover.net', 'auth.paperclover.net'
41 name, password, actor = 'auth-test', uuid.uuid4().hex, str(uuid.uuid4())
42 group = str(uuid.uuid4())
43 salt = os.urandom(16)
44 digest = Argon2id(salt=salt, length=32, iterations=5, lanes=1, memory_cost=7168).derive(password.encode())
45 private = ec.generate_private_key(ec.SECP256R1())
46 public = private.public_key().public_numbers()
47 key = cbor({1: 2, 3: -7, -1: 1, -2: public.x.to_bytes(32, 'big'), -3: public.y.to_bytes(32, 'big')})
48 credential_id = os.urandom(32)
49 export = {'rpId': rp, 'roles': [{'id': group, 'name': 'infra-admin'}], 'users': [{
50 'id': actor, 'username': name, 'enabled': True, 'email': 'auth-test@example.invalid', 'emailVerified': True,
51 'firstName': 'Auth', 'lastName': 'Test', 'createdTimestamp': 1, 'requiredActions': [], 'attributes': {}, 'roles': [group],
52 'credentials': [
53 {'id': str(uuid.uuid4()), 'type': 'password', 'createdDate': 1, 'credentialData': {'algorithm': 'argon2', 'hashIterations': 5,
54 'additionalParameters': {'type': ['id'], 'memory': ['7168'], 'parallelism': ['1']}},
55 'secretData': {'salt': base64.b64encode(salt).decode(), 'value': base64.b64encode(digest).decode()}},
56 {'id': str(uuid.uuid4()), 'type': 'webauthn-passwordless', 'userLabel': 'imported', 'createdDate': 1,
57 'credentialData': {'credentialId': base64.b64encode(credential_id).decode(), 'credentialPublicKey': b64(key), 'counter': 0, 'transports': ['internal']}}
58 ]}]}
59 with tempfile.TemporaryDirectory(prefix='dashboard-auth-') as temporary:
60 data = Path(temporary).resolve()
61 proof = uuid.uuid4().hex + uuid.uuid4().hex
62 (data / 'proof').write_text(proof)
63 (data / 'source.json').write_text(json.dumps(export))
64 environment = {**os.environ, 'STUDIO_DOMAIN': 'paperclover.net', 'STUDIO_DATA_DIR': str(data),
65 'STUDIO_PUBLIC_ORIGIN': origin, 'STUDIO_AUTH_RP_ID': rp, 'STUDIO_FILE_ORIGIN': file,
66 'STUDIO_WEB_DIR': str(Path('dashboard/dist').resolve()), 'STUDIO_PROXY_TOKEN_FILE': str(data / 'proof'), 'STUDIO_AUTH_REQUIRED': '1'}
67 binary = str(args.binary.resolve())
68 imported = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True)
69 assert imported.returncode == 0, imported.stderr
70 assert json.loads(imported.stdout) == {'accounts': 1, 'credentials': 2}
71 again = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True)
72 assert again.returncode == 0
73 changed = json.loads(json.dumps(export)); changed['users'][0]['username'] = 'different'
74 (data / 'different.json').write_text(json.dumps(changed))
75 rejected = subprocess.run([binary, '--import-accounts', str(data / 'different.json')],env=environment,capture_output=True)
76 assert rejected.returncode != 0
77 with socket.socket() as available:
78 available.bind(('127.0.0.1', 0)); port = available.getsockname()[1]
79 environment['PORT'] = str(port)
80 log = (data / 'server.log').open('wb')
81 server = None
82
83 def start():
84 nonlocal server
85 server = subprocess.Popen([binary], env=environment, stdout=log, stderr=log)
86 deadline = time.monotonic() + 15
87 while True:
88 try:
89 with socket.create_connection(('127.0.0.1', port), timeout=.1): break
90 except OSError:
91 assert server.poll() is None, (data / 'server.log').read_text()[-2000:]
92 if time.monotonic() > deadline: raise AssertionError('dashboard did not start')
93 time.sleep(.05)
94
95 def stop():
96 server.terminate(); server.wait(timeout=10)
97
98 def request(path, method='GET', body=None, cookies=None, status=200, extra=None, host=origin):
99 headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'}
100 if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'})
101 if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())
102 headers.update(extra or {})
103 connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15)
104 connection.request(method, path, body=json.dumps(body) if body is not None else None, headers=headers)
105 response = connection.getresponse(); content = response.read(); fields = dict(response.getheaders()); connection.close()
106 assert response.status == status, (path, response.status, content[:200], (data / "server.log").read_text()[-1000:])
107 if cookies is not None and 'set-cookie' in fields:
108 cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie
109 key, value = cookie.split(';', 1)[0].split('=', 1); cookies[key] = value
110 return json.loads(content) if fields.get('content-type', '').startswith('application/json') and content else fields
111
112 cookies = {}
113 start()
114 try:
115 request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'})
116 request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'})
117 csrf = request('/auth/status', cookies=cookies)['csrf']
118 login = {'csrf': csrf, 'username': name, 'password': password, 'next': '/users'}
119 request('/auth/password', 'POST', login, cookies, 403, {'Origin': 'https://evil.example'})
120 request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403)
121 request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401)
122 assert request('/auth/password', 'POST', login, cookies)['next'] == '/users'
123 assert 'admin' in request('/api/me', cookies=cookies)['sections']
124 request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'})
125 assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/'
126 # Import's password format must verify with the original Keycloak parameters.
127 with sqlite3.connect(data / 'accounts.sqlite') as db:
128 phc = json.loads(db.execute("SELECT data FROM credentials WHERE kind='password'").fetchone()[0])['phc']
129 assert '$m=7168,t=5,p=1$' in phc
130 other = {}; csrf2 = request('/auth/status', cookies=other)['csrf']
131 begun = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
132 assert begun['options']['publicKey']['rpId'] == rp
133
134 def assertion(begin, origin_value=origin, flags=29, counter=1, handle=actor.encode()):
135 client = json.dumps({'type': 'webauthn.get', 'challenge': begin['options']['publicKey']['challenge'], 'origin': origin_value, 'crossOrigin': False}).encode()
136 authenticator = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + counter.to_bytes(4, 'big')
137 signature = private.sign(authenticator + hashlib.sha256(client).digest(), ec.ECDSA(hashes.SHA256()))
138 return {'id': b64(credential_id), 'rawId': b64(credential_id), 'type': 'public-key',
139 'response': {'authenticatorData': b64(authenticator), 'clientDataJSON': b64(client), 'signature': b64(signature), 'userHandle': b64(handle)}}
140
141 signed = {'csrf': csrf2, 'token': begun['token'], 'credential': assertion(begun)}
142 request('/auth/passkey/finish', 'POST', signed, other)
143 request('/auth/passkey/finish', 'POST', signed, other, 403)
144 for options in [{'origin_value': 'https://evil.example'}, {'flags': 25}, {'flags': 21}, {'counter': 1}, {'handle': uuid.uuid4().bytes}]:
145 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
146 request('/auth/passkey/finish', 'POST', {'csrf': csrf2, 'token': begin['token'], 'credential': assertion(begin, **({'counter': 2} | options))}, other, 401)
147 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
148 tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged')
149 request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':tampered}, other, 401)
150 registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies)
151 new_id = os.urandom(32)
152 client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode()
153 authenticator = hashlib.sha256(rp.encode()).digest() + bytes([93]) + bytes(4) + bytes(16) + len(new_id).to_bytes(2, 'big') + new_id + key
154 credential = {'id': b64(new_id), 'rawId': b64(new_id), 'type': 'public-key', 'response': {
155 'clientDataJSON': b64(client), 'attestationObject': b64(cbor({'fmt': 'none', 'authData': authenticator, 'attStmt': {}})), 'transports': ['internal']}}
156 request('/auth/passkey/save', 'POST', {'csrf': csrf, 'token': registration['token'], 'credential': credential, 'label': 'new passkey'}, cookies, 204)
157 request('/auth/passkey/save', 'POST', {'csrf': csrf, 'token': registration['token'], 'credential': credential}, cookies, 403)
158 file_cookies = {}
159 handoff = request('/auth/file/sign-in?rd=%2Fclover%2FPublic%2F', cookies=file_cookies, status=302, host=file)
160 flow = file_cookies['__Host-snow-flow']
161 callback = request('/auth/continue?flow=' + flow, cookies=cookies, status=302)['location']
162 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies={}, status=403, host=file)
163 finished = request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=302, host=file)
164 assert finished['location'] == file + '/clover/Public/'
165 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=403, host=file)
166 request('/auth/file/check', cookies=file_cookies, status=204, host=file)
167 request('/auth/file/check', cookies=cookies, status=401, host=file)
168 request('/api/me', cookies=file_cookies, status=401)
169 request('/auth/file/sign-in?rd=https://evil.example/', status=400, host=file)
170 invitation = request('/api/users', 'POST', {'profile': {'username': 'invited', 'email': '', 'firstName': 'Invited', 'lastName': 'Person'}, 'groups': [], 'setup': {'kind': 'invite'}}, cookies, 201)
171 setup = invitation['url'].split('setup=', 1)[1]
172 newcomer = {}; new_csrf = request('/auth/status?setup=' + setup, cookies=newcomer)['csrf']
173 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer)
174 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410)
175 request('/api/users', cookies=newcomer, status=403)
176 request('/api/users/' + actor, 'PATCH', {'enabled': False}, cookies, 400)
177 request('/api/users/' + actor + '/groups/' + group, 'DELETE', {}, cookies, 400)
178 replacement = request('/api/users/' + invitation['id'] + '/setup-link', 'POST', {}, cookies)['url']
179 request('/api/users/' + invitation['id'] + '/setup-link', 'DELETE', {}, cookies, 204)
180 request('/auth/status?' + replacement.split('?', 1)[1], cookies={}, status=410)
181 request('/api/users/' + invitation['id'], 'PATCH', {'enabled': False}, cookies, 204)
182 request('/api/me', cookies=newcomer, status=401)
183 stop(); start()
184 request('/api/me', cookies=cookies)
185 request('/auth/file/check', cookies=file_cookies, status=204, host=file)
186 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
187 request('/api/me', cookies=cookies, status=401)
188 request('/auth/file/check', cookies=file_cookies, status=401, host=file)
189 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed'}))
190 finally:
191 if server and server.poll() is None: stop()
192 log.close()
193
194
195if __name__ == '__main__':
196 main()
tools/dashboard-backup-test.py created+71
...@@ -0,0 +1,71 @@
1#!/usr/bin/env python3
2import importlib
3import json
4from pathlib import Path
5import sqlite3
6import tempfile
7
8
9def main():
10 data = importlib.import_module('data')
11 with tempfile.TemporaryDirectory(prefix='dashboard-backup-') as temporary:
12 data.STATE = Path(temporary)
13 data.BACKUPS = data.STATE / 'backups'
14 live = data.STATE / 'dashboard'
15 live.mkdir()
16 connections = []
17 for name in ['accounts.sqlite', 'connections.sqlite']:
18 db = sqlite3.connect(live / name)
19 db.execute('PRAGMA journal_mode=WAL')
20 db.execute('CREATE TABLE durable (value TEXT)')
21 db.execute('INSERT INTO durable VALUES (?)', (name,))
22 db.commit()
23 connections.append(db)
24 (live / 'pictures').mkdir()
25 (live / 'pictures' / 'avatar').write_bytes(b'original picture')
26 backup_id = '20261005T000000Z-abcdef'
27 directory = data.BACKUPS / backup_id
28 directory.mkdir(parents=True)
29 files = data.backup_dashboard(directory / 'dashboard')
30 assert set(files) == {'accounts.sqlite', 'connections.sqlite', 'pictures/avatar'}
31 for name in ['accounts.sqlite', 'connections.sqlite']:
32 with sqlite3.connect(directory / 'dashboard' / name) as db:
33 assert db.execute('SELECT value FROM durable').fetchall() == [(name,)]
34 revision = 'a' * 16
35 (directory / 'manifest.json').write_text(json.dumps({'id': backup_id, 'fromRelease': revision, 'services': {'dashboard': {'files': files}}}))
36 data.current_release = lambda: Path('/releases') / revision
37 lifecycle = []
38 def run(*args, **kwargs):
39 lifecycle.append(args)
40 if args == ('systemctl', 'stop', 'studio-dashboard'):
41 for db in connections:
42 db.close()
43 data.run = run
44 for db in connections:
45 db.execute('DELETE FROM durable')
46 db.execute("INSERT INTO durable VALUES ('later change')")
47 db.commit()
48 (live / 'pictures' / 'avatar').write_bytes(b'later picture')
49 data.restore(backup_id, 'dashboard')
50 assert lifecycle == [('systemctl', 'stop', 'studio-dashboard'), ('systemctl', 'start', 'studio-dashboard'), ('systemctl', 'is-active', '--quiet', 'studio-dashboard')]
51 assert (live / 'pictures' / 'avatar').read_bytes() == b'original picture'
52 for name in ['accounts.sqlite', 'connections.sqlite']:
53 with sqlite3.connect(live / name) as db:
54 assert db.execute('SELECT value FROM durable').fetchall() == [(name,)]
55 safety = next(data.BACKUPS.glob('before-restore-*/dashboard/' + name))
56 with sqlite3.connect(safety) as db:
57 assert db.execute('SELECT value FROM durable').fetchall() == [('later change',)]
58 lifecycle.clear()
59 (directory / 'dashboard' / 'pictures/avatar').write_bytes(b'corrupted backup')
60 try:
61 data.restore(backup_id, 'dashboard')
62 except ValueError:
63 pass
64 else:
65 raise AssertionError('corrupt backup accepted')
66 assert not lifecycle
67 print(json.dumps({'live_wal_backup': 'passed', 'account_and_connection_restore': 'passed', 'safety_copy': 'passed', 'corrupt_backup_refused_before_stop': 'passed'}))
68
69
70if __name__ == '__main__':
71 main()
tools/dashboard-mcp-test.py+1-1
...@@ -67,7 +67,7 @@ def main():...@@ -67,7 +67,7 @@ def main():
67 request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0],67 request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0],
68 "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker}68 "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker}
69 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(request), status=302)69 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(request), status=302)
70 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers["Location"]).query)["request"][0]70 pending = urllib.parse.urlsplit(headers["Location"]).path.removeprefix("/connect/")
71 path = "/api/mcp/consent/" + pending71 path = "/api/mcp/consent/" + pending
72 details, _ = http(path, actor=actor)72 details, _ = http(path, actor=actor)
73 assert details["client"] == client["client_name"]73 assert details["client"] == client["client_name"]
tools/dashboard-relay-test.py+1-1
...@@ -306,7 +306,7 @@ def main():...@@ -306,7 +306,7 @@ def main():
306 fields = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "resource": resource, "scope": "sessions:read sessions:write offline_access",306 fields = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "resource": resource, "scope": "sessions:read sessions:write offline_access",
307 "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")}307 "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")}
308 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(fields), status=302)308 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(fields), status=302)
309 request_id = urllib.parse.parse_qs(urllib.parse.urlsplit(headers["Location"]).query)["request"][0]309 request_id = urllib.parse.urlsplit(headers["Location"]).path.removeprefix("/connect/")
310 consent_path = "/api/mcp/consent/" + request_id310 consent_path = "/api/mcp/consent/" + request_id
311 details, _ = http(consent_path, actor=names[0])311 details, _ = http(consent_path, actor=names[0])
312 assert {r["id"] for r in details["resources"]} == {first["id"], second["id"]}312 assert {r["id"] for r in details["resources"]} == {first["id"], second["id"]}
tools/dashboard-shale-link-test.py+51-5
...@@ -49,6 +49,7 @@ def main():...@@ -49,6 +49,7 @@ def main():
49 marker = 'shale-link-' + uuid.uuid4().hex49 marker = 'shale-link-' + uuid.uuid4().hex
50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')]50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')]
51 ids = []51 ids = []
52 sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts}
5253
53 class TLS(urllib.request.HTTPSHandler):54 class TLS(urllib.request.HTTPSHandler):
54 def https_open(self, request):55 def https_open(self, request):
...@@ -76,6 +77,7 @@ def main():...@@ -76,6 +77,7 @@ def main():
76 _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method,77 _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method,
77 body=json.dumps(body).encode() if body is not None else None,78 body=json.dumps(body).encode() if body is not None else None,
78 headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor,79 headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor,
80 'Cookie': '__Host-snow-session=' + sessions[actor],
79 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status)81 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status)
80 return json.loads(body) if body and status < 400 else body or None82 return json.loads(body) if body and status < 400 else body or None
8183
...@@ -132,7 +134,7 @@ def main():...@@ -132,7 +134,7 @@ def main():
132 assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', []))134 assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', []))
133 assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', []))135 assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', []))
134 if status == 303:136 if status == 303:
135 assert headers['Location'] == origin + '/mcp' + ('?request=' + pending if pending else '')137 assert headers['Location'] == origin + ('/connect/' + pending if pending else '/mcp/settings/shale')
136138
137 def backend_session(value, status):139 def backend_session(value, status):
138 return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status)140 return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status)
...@@ -171,7 +173,7 @@ def main():...@@ -171,7 +173,7 @@ def main():
171 _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code',173 _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code',
172 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256',174 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256',
173 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302)175 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302)
174 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers['Location']).query)['request'][0]176 pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/')
175 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)177 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
176 assert details['client'] == marker178 assert details['client'] == marker
177 api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403)179 api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403)
...@@ -180,13 +182,13 @@ def main():...@@ -180,13 +182,13 @@ def main():
180 def consent(client, index, repository, scope):182 def consent(client, index, repository, scope):
181 pending, verifier, details = pending_request(client, index, scope)183 pending, verifier, details = pending_request(client, index, scope)
182 available = {r['id'] for r in details['resources']}184 available = {r['id'] for r in details['resources']}
183 assert details['linked'] and repository in available, details185 assert details['linked'] and (repository == 'all' or repository in available), details
184 if index == 0:186 if index == 0:
185 assert available == {'alpha', 'beta'}, details187 assert {'alpha', 'beta'} <= available, details
186 path = '/api/mcp/consent/' + pending188 path = '/api/mcp/consent/' + pending
187 api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']})189 api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']})
188 api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]})190 api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]})
189 result = api(accounts[index][0], 'POST', path, body={'resources': [repository]})191 result = api(accounts[index][0], 'POST', path, body={'resources': 'all' if repository == 'all' else [repository]})
190 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query)192 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query)
191 assert query['state'] == [marker]193 assert query['state'] == [marker]
192 tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'],194 tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'],
...@@ -245,6 +247,13 @@ def main():...@@ -245,6 +247,13 @@ def main():
245 found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true')247 found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true')
246 assert len(found) == 1248 assert len(found) == 1
247 ids.append(found[0]['id'])249 ids.append(found[0]['id'])
250 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
251 profile = {key: found[0].get(key) for key in ['username', 'firstName', 'lastName', 'email', 'emailVerified', 'enabled']}
252 profile['requiredActions'] = []
253 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (ids[-1], json.dumps(profile)))
254 stamp = int(time.time())
255 db.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)',
256 (hashlib.sha256(sessions[name].encode()).hexdigest(), ids[-1], 'dashboard', stamp + 3600, '127.0.0.1', stamp, stamp, stamp))
248 assert all(api(name)['shale'] is None for name, _ in accounts)257 assert all(api(name)['shale'] is None for name, _ in accounts)
249 api(accounts[0][0], 'POST', '/api/mcp/shale', status=200)258 api(accounts[0][0], 'POST', '/api/mcp/shale', status=200)
250 old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']259 old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
...@@ -288,7 +297,37 @@ def main():...@@ -288,7 +297,37 @@ def main():
288 'comment_issue', 'set_issue_status', 'set_issue_title'}297 'comment_issue', 'set_issue_status', 'set_issue_title'}
289 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)298 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
290 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}299 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
300 grant = next(value for value in records('grant:').values() if value['scopes'] == ['shale:read', 'offline_access'] and value['user'] == ids[0])
301 endpoint = '/api/mcp/connections/' + grant['id']
302 details = api(accounts[0][0], path=endpoint)
303 assert details['linked'] and details['selected'] == ['alpha']
304 assert {'alpha', 'beta'} <= {resource['id'] for resource in details['resources']}
305 api(accounts[1][0], path=endpoint, status=404)
306 api(accounts[1][0], 'POST', endpoint, 404, {'resources': ['foreign']})
307 api(accounts[0][0], 'POST', endpoint, 403, {'resources': ['foreign']})
308 api(accounts[0][0], 'POST', endpoint, 403, {'resources': ['alpha', 'alpha']})
309 api(accounts[0][0], 'POST', endpoint, 400, {'resources': []})
310 api(accounts[0][0], 'POST', endpoint, 204, {'resources': ['beta']})
311 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'beta'}
312 call(read, 'list_issues', {'repository': 'alpha'}, error=True)
313 call(read, 'create_issue', {'repository': 'beta', 'title': 'REFUSED'}, error=True)
314 api(accounts[0][0], 'POST', endpoint, 204, {'resources': 'all'})
315 assert api(accounts[0][0], path=endpoint)['selected'] == 'all'
316 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha', 'beta'}
317 call(read, 'list_issues', {'repository': 'foreign'}, error=True)
318 call(read, 'create_issue', {'repository': 'beta', 'title': 'REFUSED'}, error=True)
319 nested = 'userscripts/nested-fixture'
320 repository(0, nested)
321 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha', 'beta', nested}
322 assert not call(read, 'list_issues', {'repository': nested})['issues']
323 api(accounts[0][0], 'POST', endpoint, 204, {'resources': [nested]})
324 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {nested}
325 call(read, 'list_issues', {'repository': 'beta'}, error=True)
326 api(accounts[0][0], 'POST', endpoint, 204, {'resources': ['alpha']})
327
291 assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'}328 assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'}
329 all_repositories = consent(oauth_client, 0, 'all', 'shale:read')
330 assert {repo['id'] for repo in call(all_repositories['access_token'], 'list_repositories')['repositories']} == {'alpha', 'beta', nested}
292 assert not call(read, 'list_issues', {'repository': 'alpha'})['issues']331 assert not call(read, 'list_issues', {'repository': 'alpha'})['issues']
293 for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'),332 for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'),
294 (write, 'alpha/../foreign'), (write, 'https://other.invalid')]:333 (write, 'alpha/../foreign'), (write, 'https://other.invalid')]:
...@@ -392,6 +431,7 @@ def main():...@@ -392,6 +431,7 @@ def main():
392 'native_issue_labels_read': True,431 'native_issue_labels_read': True,
393 'committed_write_lost_response_reported_without_replay': True,432 'committed_write_lost_response_reported_without_replay': True,
394 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,433 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
434 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True,
395 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}435 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
396 finally:436 finally:
397 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)437 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
...@@ -408,6 +448,12 @@ def main():...@@ -408,6 +448,12 @@ def main():
408 keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE')448 keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE')
409 except Exception as error:449 except Exception as error:
410 cleanup_errors.append(error)450 cleanup_errors.append(error)
451 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
452 db.execute('PRAGMA foreign_keys=ON')
453 for identity in ids:
454 for table in ['sessions', 'credentials', 'memberships']:
455 db.execute(f'DELETE FROM {table} WHERE user_id=?', (identity,))
456 db.execute('DELETE FROM users WHERE id=?', (identity,))
411 if cleanup_errors:457 if cleanup_errors:
412 raise cleanup_errors[0]458 raise cleanup_errors[0]
413 result['owned_users_and_credentials_removed'] = True459 result['owned_users_and_credentials_removed'] = True
tools/data.py+47-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2import argparse2import argparse
3from contextlib import contextmanager3from contextlib import closing, contextmanager
4from datetime import datetime, timezone4from datetime import datetime, timezone
5import hashlib5import hashlib
6import json6import json
...@@ -9,6 +9,7 @@ from pathlib import Path...@@ -9,6 +9,7 @@ from pathlib import Path
9import re9import re
10import secrets10import secrets
11import shutil11import shutil
12import sqlite3
12import subprocess13import subprocess
13import time14import time
1415
...@@ -140,6 +141,28 @@ def cloned_postgres(snapshot, clone, mountpoint):...@@ -140,6 +141,28 @@ def cloned_postgres(snapshot, clone, mountpoint):
140 run("zfs", "destroy", clone)141 run("zfs", "destroy", clone)
141142
142143
144def backup_dashboard(directory):
145 source = STATE / "dashboard"
146 directory.mkdir(mode=0o700)
147 for path in sorted(source.glob("*.sqlite")):
148 target = directory / path.name
149 with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as live, closing(sqlite3.connect(target)) as copy:
150 live.backup(copy)
151 if copy.execute("PRAGMA quick_check").fetchone() != ("ok",):
152 raise ValueError("Dashboard database backup failed its integrity check")
153 if copy.execute("PRAGMA journal_mode=DELETE").fetchone() != ("delete",):
154 raise ValueError("Dashboard backup could not leave WAL mode")
155 target.chmod(0o600)
156 for suffix in ["-wal", "-shm"]:
157 target.with_name(target.name + suffix).unlink(missing_ok=True)
158 if (source / "pictures").exists():
159 shutil.copytree(source / "pictures", directory / "pictures", symlinks=True)
160 paths = sorted(path for path in directory.rglob("*") if path.is_file())
161 if any(path.is_symlink() for path in directory.rglob("*")):
162 raise ValueError("Dashboard backup contains a symlink")
163 return {str(path.relative_to(directory)): checksum(path) for path in paths}
164
165
143def backup(from_release, to_release):166def backup(from_release, to_release):
144 if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release):167 if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release):
145 raise ValueError("Invalid release ID")168 raise ValueError("Invalid release ID")
...@@ -177,6 +200,8 @@ def backup(from_release, to_release):...@@ -177,6 +200,8 @@ def backup(from_release, to_release):
177 snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)]200 snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)]
178 created = False201 created = False
179 try:202 try:
203 if (STATE / "dashboard").is_dir():
204 manifest["services"]["dashboard"] = {"files": backup_dashboard(directory / "dashboard")}
180 if snapshots:205 if snapshots:
181 run("zfs", "snapshot", *snapshots)206 run("zfs", "snapshot", *snapshots)
182 created = True207 created = True
...@@ -231,6 +256,27 @@ def restore(backup_id, service):...@@ -231,6 +256,27 @@ def restore(backup_id, service):
231 if service == "postgres":256 if service == "postgres":
232 raise ValueError("Postgres serves multiple services; restore a specific database owner")257 raise ValueError("Postgres serves multiple services; restore a specific database owner")
233 entry = manifest["services"][service]258 entry = manifest["services"][service]
259 if service == "dashboard":
260 source = directory / "dashboard"
261 for name, digest in entry["files"].items():
262 path = source / name
263 if not path.resolve().is_relative_to(source.resolve()) or not path.is_file() or checksum(path) != digest:
264 raise ValueError("Dashboard backup is missing or changed")
265 run("systemctl", "stop", "studio-dashboard")
266 safety = BACKUPS / ("before-restore-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3))
267 safety.mkdir(mode=0o700)
268 files = backup_dashboard(safety / "dashboard")
269 (safety / "manifest.json").write_text(json.dumps({"files": files}) + "\n")
270 root = STATE / "dashboard"
271 for path in root.glob("*.sqlite*"):
272 path.unlink()
273 if (root / "pictures").exists():
274 shutil.rmtree(root / "pictures")
275 shutil.copytree(source, root, dirs_exist_ok=True)
276 run("systemctl", "start", "studio-dashboard")
277 run("systemctl", "is-active", "--quiet", "studio-dashboard")
278 print(f"restored=dashboard backup={backup_id} safety={safety.name}")
279 return
234 dataset = entry.get("dataset")280 dataset = entry.get("dataset")
235 if dataset and dataset_for(service) != dataset:281 if dataset and dataset_for(service) != dataset:
236 raise ValueError("Service dataset changed since backup")282 raise ValueError("Service dataset changed since backup")
tools/deploy.py+3
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2import argparse2import argparse
3import json3import json
4import os4import os
5from importlib import import_module
5from pathlib import Path6from pathlib import Path
6import re7import re
7import shlex8import shlex
...@@ -113,6 +114,8 @@ def upload(main=False):...@@ -113,6 +114,8 @@ def upload(main=False):
113 shutil.copytree(origin, destination, symlinks=True)114 shutil.copytree(origin, destination, symlinks=True)
114 else:115 else:
115 shutil.copy2(origin, destination)116 shutil.copy2(origin, destination)
117 if (snapshot / "dashboard/agent/source.json").exists():
118 import_module("build-agent").build(REPO, snapshot / "dashboard/agent/relay.mjs", snapshot / "dashboard/agent/source.json")
116 digest = tree_digest(snapshot)119 digest = tree_digest(snapshot)
117 release = digest[:16]120 release = digest[:16]
118 remote_release = REMOTE / "releases" / release121 remote_release = REMOTE / "releases" / release
tools/import-dashboard-auth.py created+59
...@@ -0,0 +1,59 @@
1#!/usr/bin/env python3
2import argparse
3import hashlib
4import json
5import os
6from pathlib import Path
7import subprocess
8
9
10parser = argparse.ArgumentParser(description="Copy Keycloak accounts into a private dashboard import")
11parser.add_argument("--host", required=True)
12parser.add_argument("--output", required=True, type=Path)
13args = parser.parse_args()
14if args.output.exists():
15 parser.error("output already exists")
16
17remote = r'''
18import json, subprocess, urllib.request
19request = urllib.request.Request("http://127.0.0.1:4646/v1/job/postgres/allocations", headers={"X-Nomad-Token":open("/var/lib/studio/nomad.token").read().strip()})
20allocations = [a["ID"] for a in json.load(urllib.request.urlopen(request)) if a["ClientStatus"] == "running" and a["DesiredStatus"] == "run"]
21assert len(allocations) == 1
22containers = [line.split()[0] for line in subprocess.check_output(["podman", "ps", "--format", "{{.ID}} {{.Names}}"], text=True).splitlines() if line.split()[1].endswith(allocations[0])]
23assert len(containers) == 1
24sql = """
25BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
26SELECT json_build_object(
27 'issuer','https://auth.paperclover.net/realms/master',
28 'rpId','auth.paperclover.net',
29 'roles',(SELECT coalesce(json_agg(json_build_object('id',id,'name',name)), '[]') FROM keycloak_role WHERE realm_id=(SELECT id FROM realm WHERE name='master') AND client_role=false),
30 'users',(SELECT coalesce(json_agg(json_build_object(
31 'id',u.id,'username',u.username,'email',u.email,'firstName',u.first_name,'lastName',u.last_name,
32 'enabled',u.enabled,'emailVerified',u.email_verified,'createdTimestamp',u.created_timestamp,
33 'requiredActions',(SELECT coalesce(json_agg(required_action),'[]') FROM user_required_action WHERE user_id=u.id),
34 'attributes',(SELECT coalesce(json_object_agg(name,vals),'{}') FROM (SELECT name,json_agg(value) AS vals FROM user_attribute WHERE user_id=u.id GROUP BY name)a),
35 'roles',(SELECT coalesce(json_agg(role_id),'[]') FROM user_role_mapping WHERE user_id=u.id),
36 'credentials',(SELECT coalesce(json_agg(json_build_object('id',id,'type',type,'userLabel',user_label,'createdDate',created_date,'credentialData',credential_data::json,'secretData',secret_data::json)),'[]') FROM credential WHERE user_id=u.id)
37 )),'[]') FROM user_entity u WHERE realm_id=(SELECT id FROM realm WHERE name='master'))
38);
39COMMIT;
40"""
41result = subprocess.run(["podman", "exec", "-i", containers[0], "psql", "-U", "postgres", "-d", "keycloak_next", "-tA", "-v", "ON_ERROR_STOP=1"], input=sql, text=True, capture_output=True, check=True)
42print(next(line for line in result.stdout.splitlines() if line.startswith('{')))
43'''
44result = subprocess.run(["ssh", "-o", "BatchMode=yes", args.host, "python3", "-"], input=remote, text=True, capture_output=True, check=True)
45data = json.loads(result.stdout)
46if not data["users"]:
47 raise ValueError("source realm has no accounts")
48content = (json.dumps(data, separators=(",", ":")) + "\n").encode()
49args.output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
50with os.fdopen(os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as output:
51 output.write(content)
52 output.flush()
53 os.fsync(output.fileno())
54counts = {}
55for user in data["users"]:
56 for credential in user["credentials"]:
57 kind = credential["type"]
58 counts[kind] = counts.get(kind, 0) + 1
59print(json.dumps({"accounts": len(data["users"]), "credentials": counts, "sha256": hashlib.sha256(content).hexdigest()}))
tools/router.py+29-28
...@@ -50,6 +50,10 @@ def render(token):...@@ -50,6 +50,10 @@ def render(token):
50 dashboard_proof = file.read().strip()50 dashboard_proof = file.read().strip()
51 if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof):51 if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof):
52 raise ValueError("invalid dashboard proxy token")52 raise ValueError("invalid dashboard proxy token")
53 auth_host = "auth." + os.environ["STUDIO_DOMAIN"]
54 origins = json.dumps({"origins": ["https://snowglobe." + os.environ["STUDIO_DOMAIN"]]}, separators=(",", ":"))
55 webauthn = [" handle /.well-known/webauthn {", ' header Content-Type application/json',
56 f" respond {json.dumps(origins)} 200", " }"]
53 routes = {}57 routes = {}
54 internal_services = {}58 internal_services = {}
55 auth_upstreams = set()59 auth_upstreams = set()
...@@ -130,7 +134,11 @@ def render(token):...@@ -130,7 +134,11 @@ def render(token):
130 service = next(iter(route["services"]))134 service = next(iter(route["services"]))
131 if not re.fullmatch(r"[a-z][a-z0-9-]*", service):135 if not re.fullmatch(r"[a-z][a-z0-9-]*", service):
132 raise ValueError(f"invalid service name: {service}")136 raise ValueError(f"invalid service name: {service}")
137 if service == "keycloak" and host == auth_host:
138 lines += webauthn
133 if service == "shale":139 if service == "shale":
140 lines += [" @userscript path_regexp userscript ^/userscripts/discord-pluralkit-predict(/.*)?$",
141 " redir @userscript /discord-pluralkit-predict{re.userscript.1}?{query} 308"]
134 port = int(os.environ["STUDIO_DASHBOARD_PORT"])142 port = int(os.environ["STUDIO_DASHBOARD_PORT"])
135 if not 1 <= port <= 65535:143 if not 1 <= port <= 65535:
136 raise ValueError("invalid dashboard port for Shale linking")144 raise ValueError("invalid dashboard port for Shale linking")
...@@ -203,11 +211,19 @@ def render(token):...@@ -203,11 +211,19 @@ def render(token):
203 *proxy(upstreams, " "), " }",211 *proxy(upstreams, " "), " }",
204 " handle_response {", " respond 403", " }", " }", " }", "}",212 " handle_response {", " respond 403", " }", " }", " }", "}",
205 ]213 ]
206 elif headers and auth_upstreams:214 elif headers and (auth_upstreams or service == "copyparty"):
207 auth = " ".join(sorted(auth_upstreams))215 native = service == "copyparty"
208 lines += [" handle /snow.oauth2/* {", *proxy(auth, " "), " }", " handle {"]216 auth = f"127.0.0.1:{int(os.environ['STUDIO_DASHBOARD_PORT'])}" if native else " ".join(sorted(auth_upstreams))
217 if native:
218 lines += [" handle /auth/file/* {", " request_header -User-Name", " request_header -User-Groups",
219 f" request_header Studio-Proxy-Token {dashboard_proof}",
220 " request_header X-Studio-Client-IP {remote_host}", *proxy(auth," "), " }"]
221 else:
222 lines += [" handle /snow.oauth2/* {", *proxy(auth," "), " }"]
223 lines += [" handle {"]
209 lines += [f" request_header -{name}" for name in scrub]224 lines += [f" request_header -{name}" for name in scrub]
210 lines += [f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s", " method GET", " rewrite /snow.oauth2/auth",225 lines += [f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s", " method GET", " rewrite " + ("/auth/file/check" if native else "/snow.oauth2/auth"),
226 *([f" header_up Studio-Proxy-Token {dashboard_proof}"] if native else []),
211 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",227 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",
212 " @authenticated status 2xx", " handle_response @authenticated {"]228 " @authenticated status 2xx", " handle_response @authenticated {"]
213 lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()]229 lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()]
...@@ -227,6 +243,11 @@ def render(token):...@@ -227,6 +243,11 @@ def render(token):
227 *proxy(upstreams, " "), " }", "}"]243 *proxy(upstreams, " "), " }", "}"]
228 else:244 else:
229 lines += [f":{listener} {{", *proxy(" ".join(sorted(route["upstreams"])), " "), "}"]245 lines += [f":{listener} {{", *proxy(" ".join(sorted(route["upstreams"])), " "), "}"]
246 if (80, auth_host) not in routes:
247 if not HOST.fullmatch(auth_host):
248 raise ValueError("invalid passkey domain")
249 lines += [f"{auth_host} {{", *([" tls internal"] if auth_host.endswith(".test") else []),
250 *webauthn, " handle {", " respond 503", " }", "}"]
230 traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None)251 traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None)
231 if traces:252 if traces:
232 lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1",253 lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1",
...@@ -241,30 +262,10 @@ def render(token):...@@ -241,30 +262,10 @@ def render(token):
241 if dashboard_host.endswith(".test"):262 if dashboard_host.endswith(".test"):
242 lines.append(" tls internal")263 lines.append(" tls internal")
243 lines += [" encode zstd gzip", " tracing {", " span globe", " span_attributes {", " studio.kind edge", " }", " }"]264 lines += [" encode zstd gzip", " tracing {", " span globe", " span_attributes {", " studio.kind edge", " }", " }"]
244 lines += [" @mcp_public path /oauth/* /mcp/* /.well-known/oauth-* /pairing /agent/connect /api/v1/*",265 lines += [" handle {", " request_header -User-Name", " request_header -User-Groups",
245 " handle @mcp_public {", " request_header -User-Name", " request_header -User-Groups",266 f" request_header Studio-Proxy-Token {dashboard_proof}",
246 " request_header -Studio-Proxy-Token", *proxy(f"127.0.0.1:{dashboard_port}", " "), " }"]267 " request_header X-Studio-Client-IP {remote_host}",
247 if auth_upstreams:268 *proxy(f"127.0.0.1:{dashboard_port}", " "), " }", "}"]
248 auth = " ".join(sorted(auth_upstreams))
249 lines += [
250 " handle /snow.oauth2/* {", *proxy(auth, " "), " }",
251 " handle {", " request_header -User-Name", " request_header -User-Groups", " request_header -Studio-Proxy-Token",
252 f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s",
253 " method GET", " rewrite /snow.oauth2/auth",
254 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",
255 " @unauthorized status 401", " handle_response @unauthorized {",
256 " redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}", " }",
257 " @authenticated status 2xx", " handle_response @authenticated {",
258 " method {method}", " rewrite {uri}",
259 " request_header User-Name {rp.header.X-Auth-Request-Preferred-Username}",
260 " request_header User-Groups {rp.header.X-Auth-Request-Groups}",
261 f" request_header Studio-Proxy-Token {dashboard_proof}",
262 *proxy(f"127.0.0.1:{dashboard_port}", " "),
263 " }", " handle_response {", " respond 403", " }",
264 " }", " }", "}",
265 ]
266 else:
267 lines += [" header Retry-After 5", ' respond "Sign-in is unavailable. Try again in a moment." 503', "}"]
268 internal_port = os.environ.get("STUDIO_INTERNAL_PORT")269 internal_port = os.environ.get("STUDIO_INTERNAL_PORT")
269 if internal_port is not None:270 if internal_port is not None:
270 internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"]271 internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"]