feat(admin): serve Operator Console SPA at /admin (rust-embed)

Preact + Vite standalone admin-ui bundled into the server binary via
rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin
challenge); tabs cover instance status, admin keys, invite codes, and
the effective-config editor (live vs restart-required per field).

admin-ui/dist is committed because rust-embed reads it at compile time;
node_modules is gitignored (npm ci reproduces it). A lib test asserts
index.html and the /admin/ asset base are embedded so an unbuilt
console fails the build instead of an operator.

The console is served on the admin plane only (loopback bind), behind
the existing NIP-98 admin_guard + MAC allowlist.
This commit is contained in:
jwinterm
2026-07-18 16:03:03 -04:00
parent 8b408a063a
commit 35447868d4
16 changed files with 1912 additions and 34 deletions
Generated
+177 -33
View File
@@ -14,7 +14,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"generic-array", "generic-array",
] ]
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher",
"cpufeatures", "cpufeatures 0.2.17",
] ]
[[package]] [[package]]
@@ -77,14 +77,14 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e7e126d41c3e0a6c85db7ba216bce5e41d53c156191c67d993613685fcc9410" checksum = "5e7e126d41c3e0a6c85db7ba216bce5e41d53c156191c67d993613685fcc9410"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"hex", "hex",
"hmac", "hmac",
"pbkdf2", "pbkdf2",
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"subtle", "subtle",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -355,6 +355,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "bs58" name = "bs58"
version = "0.5.1" version = "0.5.1"
@@ -418,7 +427,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"inout", "inout",
] ]
@@ -445,6 +454,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -480,6 +495,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc" name = "crc"
version = "3.4.0" version = "3.4.0"
@@ -548,6 +572,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ctr" name = "ctr"
version = "0.9.2" version = "0.9.2"
@@ -564,9 +597,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest", "digest 0.10.7",
"fiat-crypto", "fiat-crypto",
"rustc_version", "rustc_version",
"subtle", "subtle",
@@ -609,7 +642,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
@@ -626,12 +659,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"const-oid", "const-oid 0.9.6",
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@@ -662,7 +706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [ dependencies = [
"der", "der",
"digest", "digest 0.10.7",
"elliptic-curve", "elliptic-curve",
"rfc6979", "rfc6979",
"signature", "signature",
@@ -688,7 +732,7 @@ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"serde", "serde",
"sha2", "sha2 0.10.9",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -710,7 +754,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [ dependencies = [
"base16ct", "base16ct",
"crypto-bigint", "crypto-bigint",
"digest", "digest 0.10.7",
"ff", "ff",
"generic-array", "generic-array",
"group", "group",
@@ -1202,7 +1246,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -1281,6 +1325,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "0.14.32" version = "0.14.32"
@@ -1627,7 +1680,7 @@ dependencies = [
"ecdsa", "ecdsa",
"elliptic-curve", "elliptic-curve",
"once_cell", "once_cell",
"sha2", "sha2 0.10.9",
"signature", "signature",
] ]
@@ -1724,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -1739,6 +1792,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -2038,7 +2101,7 @@ version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"hmac", "hmac",
] ]
@@ -2143,7 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"opaque-debug", "opaque-debug",
"universal-hash", "universal-hash",
] ]
@@ -2580,8 +2643,8 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"digest", "digest 0.10.7",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@@ -2594,6 +2657,41 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.118",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"sha2 0.11.0",
"walkdir",
]
[[package]] [[package]]
name = "rustc-hex" name = "rustc-hex"
version = "2.1.0" version = "2.1.0"
@@ -2699,6 +2797,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@@ -2852,8 +2959,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -2863,8 +2970,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
] ]
[[package]] [[package]]
@@ -2898,7 +3016,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"rand_core", "rand_core",
] ]
@@ -2962,11 +3080,12 @@ dependencies = [
"rand", "rand",
"reqwest", "reqwest",
"rmp-serde", "rmp-serde",
"rust-embed",
"rustls 0.22.4", "rustls 0.22.4",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx", "sqlx",
"subtle", "subtle",
"tokio", "tokio",
@@ -3093,7 +3212,7 @@ dependencies = [
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlformat", "sqlformat",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -3133,7 +3252,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx-core", "sqlx-core",
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
@@ -3157,7 +3276,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
"digest", "digest 0.10.7",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@@ -3178,7 +3297,7 @@ dependencies = [
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@@ -3219,7 +3338,7 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@@ -3822,6 +3941,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -3867,7 +3992,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@@ -4003,6 +4128,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -4161,6 +4296,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
+1
View File
@@ -32,6 +32,7 @@ tower_governor = "0.4"
governor = "0.6" governor = "0.6"
# Database # Database
rust-embed = { version = "8", features = ["debug-embed"] }
arc-swap = "1" arc-swap = "1"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] } sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] }
ipnetwork = "0.20" ipnetwork = "0.20"
+6
View File
@@ -0,0 +1,6 @@
# Node deps — reproducible from package-lock.json (`npm ci`).
node_modules/
# NOTE: dist/ is intentionally NOT ignored. rust-embed bakes admin-ui/dist/ into
# the server binary at compile time, so the built console is committed. Rebuild it
# with `npm run build` in this directory whenever src/ changes (CI checks freshness).
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
:root{--smirk-bg: #2c1e12;--smirk-bg-elevated: #3a2a1b;--smirk-bg-sunken: #241810;--smirk-fg: #f3e9dd;--smirk-fg-muted: #b9a891;--smirk-accent: #f5c542;--smirk-accent-fg: #2c1e12;--smirk-border: rgba(255, 255, 255, .12);--smirk-border-strong: rgba(255, 255, 255, .22);--smirk-danger: #ef4444;--smirk-ok: #22c55e;--smirk-warn: #f59e0b;--smirk-radius: 10px;--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--smirk-bg);color:var(--smirk-fg);font-family:var(--smirk-font);font-size:14px;line-height:1.5}h1,h2,h3{font-weight:700}button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--smirk-border);background:var(--smirk-bg-elevated);color:inherit;padding:8px 14px}button.primary{background:var(--smirk-accent);color:var(--smirk-accent-fg);border:none;font-weight:700}button.danger{color:var(--smirk-danger);border-color:var(--smirk-danger)}button:disabled{opacity:.5;cursor:default}input,textarea,select{font:inherit;color:inherit;background:var(--smirk-bg-sunken);border:1px solid var(--smirk-border);border-radius:8px;padding:8px 10px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}a{color:var(--smirk-accent)}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Smirk Operator Console</title>
<script type="module" crossorigin src="/admin/assets/index-DLOlKNyc.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Smirk Operator Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1023
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "smirk-admin-ui",
"private": true,
"version": "0.3.0",
"type": "module",
"description": "Operator console SPA, embedded into smirk-backend-core and served at /admin.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"preact": "^10.24.3"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+78
View File
@@ -0,0 +1,78 @@
// Fetch layer for the /admin JSON API. Same-origin (the SPA is served from the admin
// plane), bearer access token held in memory only (never localStorage). Admin routes
// are outside the public OpenAPI, so this is hand-written.
let accessToken: string | null = null;
export function setToken(t: string | null): void {
accessToken = t;
}
export function hasToken(): boolean {
return accessToken !== null;
}
async function req(method: string, path: string, body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {};
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const data: unknown = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg =
(data as { error?: string; message?: string })?.error ??
(data as { message?: string })?.message ??
`${res.status} ${res.statusText}`;
throw new Error(msg);
}
return data;
}
export interface ChallengeResp {
challenge: string;
url: string;
instance_id: string;
expires_in: number;
}
export interface TokenResp {
access_token: string;
refresh_token?: string;
expires_in: number;
}
export interface KeyRow {
id: string;
pubkey: string;
status: string;
}
export interface InviteView {
code_prefix: string;
label: string | null;
created_at: string;
status: string;
}
export const api = {
challenge: () => req('GET', '/admin/auth/challenge') as Promise<ChallengeResp>,
verify: (admin_token: string, challenge: string) =>
req('POST', '/admin/auth/verify', { admin_token, challenge }) as Promise<TokenResp>,
logout: () => req('POST', '/admin/auth/logout', {}),
me: () => req('GET', '/admin/me'),
features: () => req('GET', '/admin/features'),
keys: () => req('GET', '/admin/keys') as Promise<KeyRow[]>,
addKey: (pubkey: string, label?: string) =>
req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }),
revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`),
config: () => req('GET', '/admin/config'),
putConfig: (patch: unknown, expected_versions: Record<string, number>) =>
req('PUT', '/admin/config', { patch, expected_versions }),
invites: () =>
req('GET', '/admin/invites') as Promise<{ invites: InviteView[]; unused_count: number }>,
mintInvites: (count: number, label?: string) =>
req('POST', '/admin/invites', label ? { count, label } : { count }) as Promise<{
codes: string[];
}>,
};
+363
View File
@@ -0,0 +1,363 @@
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import './tokens.css';
import { api, setToken, type KeyRow, type InviteView } from './api';
import { buildAdminToken, nip07 } from './nip98';
// ── login ─────────────────────────────────────────────────────────────────────
function Login({ onDone }: { onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const signIn = async () => {
setBusy(true);
setErr(null);
try {
const signer = nip07();
if (!signer) {
throw new Error(
'No NIP-07 signer found. Enable your Smirk wallet (or nos2x/Alby) on this origin, then reload.',
);
}
const c = await api.challenge();
const token = await buildAdminToken(c, signer);
const { access_token } = await api.verify(token, c.challenge);
setToken(access_token);
onDone();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Sign-in failed');
} finally {
setBusy(false);
}
};
return (
<div style={{ maxWidth: 420, margin: '12vh auto', padding: 24, textAlign: 'center' }}>
<div style={{ fontSize: 40 }}>🐸</div>
<h1>Smirk Operator Console</h1>
<p style={{ color: 'var(--smirk-fg-muted)' }}>
Sign in with an admin Nostr key on your instance's allowlist.
</p>
<button class="primary" style={{ padding: '12px 20px', fontSize: 16 }} disabled={busy} onClick={signIn}>
{busy ? 'Signing in…' : 'Sign in with Smirk'}
</button>
{err && <p style={{ color: 'var(--smirk-danger)', marginTop: 16 }}>{err}</p>}
</div>
);
}
// ── shared ────────────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: unknown }) {
return (
<div
style={{
background: 'var(--smirk-bg-elevated)',
border: '1px solid var(--smirk-border)',
borderRadius: 'var(--smirk-radius)',
padding: 16,
marginBottom: 16,
}}
>
<h3 style={{ marginTop: 0 }}>{title}</h3>
{children}
</div>
);
}
function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null);
const [err, setErr] = useState<string | null>(null);
const reload = () => {
setErr(null);
fn()
.then(setData)
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, deps);
return { data, err, reload };
}
// ── status ────────────────────────────────────────────────────────────────────
function StatusTab() {
const { data, err } = useAsync(() => api.features());
return (
<Section title="Instance features">
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<pre style={{ overflow: 'auto', fontSize: 12 }}>{JSON.stringify(data, null, 2)}</pre>
</Section>
);
}
// ── admin keys ────────────────────────────────────────────────────────────────
function KeysTab() {
const { data, err, reload } = useAsync(() => api.keys());
const [pubkey, setPubkey] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const add = async () => {
setBusy(true);
setMsg(null);
try {
await api.addKey(pubkey.trim().toLowerCase());
setPubkey('');
reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const revoke = async (id: string) => {
if (!confirm('Revoke this admin key?')) return;
await api.revokeKey(id);
reload();
};
return (
<Section title="Admin keys">
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
{(data ?? []).map((k: KeyRow) => (
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{k.pubkey.slice(0, 16)}…</td>
<td style={{ padding: '6px 4px' }}>{k.status}</td>
<td style={{ padding: '6px 4px', textAlign: 'right' }}>
{k.status !== 'revoked' && (
<button class="danger" onClick={() => revoke(k.id)}>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input
style={{ flex: 1 }}
placeholder="new admin pubkey (64 hex)"
value={pubkey}
onInput={(e) => setPubkey((e.target as HTMLInputElement).value)}
/>
<button class="primary" disabled={busy || pubkey.trim().length !== 64} onClick={add}>
Add key
</button>
</div>
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
</Section>
);
}
// ── invites ───────────────────────────────────────────────────────────────────
function InvitesTab() {
const { data, err, reload } = useAsync(() => api.invites());
const [count, setCount] = useState(5);
const [label, setLabel] = useState('');
const [minted, setMinted] = useState<string[] | null>(null);
const [busy, setBusy] = useState(false);
const mint = async () => {
setBusy(true);
try {
const r = await api.mintInvites(count, label.trim() || undefined);
setMinted(r.codes);
reload();
} finally {
setBusy(false);
}
};
return (
<Section title={`Invite codes${data ? ` — ${data.unused_count} unused` : ''}`}>
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<input
type="number"
min={1}
max={1000}
style={{ width: 80 }}
value={count}
onInput={(e) => setCount(Number((e.target as HTMLInputElement).value))}
/>
<input
style={{ flex: 1 }}
placeholder="label (optional)"
value={label}
onInput={(e) => setLabel((e.target as HTMLInputElement).value)}
/>
<button class="primary" disabled={busy} onClick={mint}>
Mint
</button>
</div>
{minted && (
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
<b>New codes — copy now, they are shown once:</b>
<pre style={{ margin: '6px 0 0', whiteSpace: 'pre-wrap' }}>{minted.join('\n')}</pre>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
{(data?.invites ?? []).map((i: InviteView) => (
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{i.code_prefix}…</td>
<td style={{ padding: '6px 4px' }}>{i.label ?? ''}</td>
<td style={{ padding: '6px 4px', color: 'var(--smirk-fg-muted)' }}>{i.created_at.slice(0, 10)}</td>
<td style={{ padding: '6px 4px' }}>{i.status}</td>
</tr>
))}
</tbody>
</table>
</Section>
);
}
// ── config ────────────────────────────────────────────────────────────────────
type Overlay = Record<string, Record<string, unknown>>;
interface ConfigResp {
effective: Overlay;
overlay: Overlay;
versions: Record<string, number>;
runtime_class: Record<string, string>;
restart_pending: boolean;
}
function ConfigTab() {
const { data, err, reload } = useAsync(() => api.config() as Promise<ConfigResp>);
const [edit, setEdit] = useState<Overlay>({});
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => {
if (data) setEdit(structuredClone(data.effective));
}, [data]);
if (err) return <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>;
if (!data) return <Section title="Config">Loading…</Section>;
const setField = (section: string, field: string, value: unknown) =>
setEdit((e) => ({ ...e, [section]: { ...(e[section] ?? {}), [field]: value } }));
const save = async (section: string) => {
setMsg(null);
try {
await api.putConfig(
{ [section]: edit[section] },
{ [section]: data.versions[section] ?? 0 },
);
setMsg(`Saved ${section}.`);
reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
};
return (
<div>
{data.restart_pending && (
<div
style={{
background: 'rgba(245,158,11,0.12)',
border: '1px solid var(--smirk-warn)',
borderRadius: 8,
padding: 10,
marginBottom: 16,
}}
>
⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode).
</div>
)}
{msg && <p style={{ color: 'var(--smirk-accent)' }}>{msg}</p>}
{Object.keys(edit)
.sort()
.map((section) => (
<Section title={section}>
{Object.entries(edit[section] ?? {}).map(([field, value]) => {
const cls = data.runtime_class[`${section}.${field}`] ?? 'restart-required';
const overridden = data.overlay[section]?.[field] !== undefined;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<label style={{ flex: 1, minWidth: 0 }}>
{field}{' '}
<span style={{ fontSize: 11, color: 'var(--smirk-fg-muted)' }}>
· {cls === 'restart-required' ? 'restart' : 'live'}
{overridden ? ' · db' : ''}
</span>
</label>
{typeof value === 'boolean' ? (
<input
type="checkbox"
checked={value}
onChange={(e) => setField(section, field, (e.target as HTMLInputElement).checked)}
/>
) : (
<input
style={{ width: 220 }}
value={String(value ?? '')}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setField(section, field, typeof value === 'number' ? Number(v) : v);
}}
/>
)}
</div>
);
})}
<button class="primary" style={{ marginTop: 8 }} onClick={() => save(section)}>
Save {section}
</button>
</Section>
))}
</div>
);
}
// ── shell ─────────────────────────────────────────────────────────────────────
const TABS = ['Status', 'Keys', 'Invites', 'Config'] as const;
type Tab = (typeof TABS)[number];
function Console({ onLogout }: { onLogout: () => void }) {
const [tab, setTab] = useState<Tab>('Status');
return (
<div style={{ maxWidth: 760, margin: '0 auto', padding: 20 }}>
<header style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, flex: 1 }}>🐸 Smirk Operator Console</h2>
<button
onClick={() => {
api.logout().catch(() => {});
setToken(null);
onLogout();
}}
>
Sign out
</button>
</header>
<nav style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
{TABS.map((t) => (
<button class={t === tab ? 'primary' : ''} onClick={() => setTab(t)}>
{t}
</button>
))}
</nav>
{tab === 'Status' && <StatusTab />}
{tab === 'Keys' && <KeysTab />}
{tab === 'Invites' && <InvitesTab />}
{tab === 'Config' && <ConfigTab />}
</div>
);
}
function App() {
const [authed, setAuthed] = useState(false);
return authed ? <Console onLogout={() => setAuthed(false)} /> : <Login onDone={() => setAuthed(true)} />;
}
render(<App />, document.getElementById('app')!);
+60
View File
@@ -0,0 +1,60 @@
// Admin login = a NIP-98 kind-27235 "signed action" over a server-issued nonce.
// The backend (admin_verify) binds SIX tags exactly once and a request-descriptor
// hash over an EMPTY body. Get any of this wrong and verify returns an opaque 401,
// so this mirrors src/core/crypto/nip98.rs precisely.
async function sha256Hex(input: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export interface Challenge {
challenge: string;
url: string;
instance_id: string;
expires_in: number;
}
/** A NIP-07 signer: `window.nostr` (Smirk / Alby / nos2x). */
export interface Nip07 {
getPublicKey(): Promise<string>;
signEvent(event: {
kind: number;
created_at: number;
content: string;
tags: string[][];
}): Promise<{ id: string; pubkey: string; sig: string; [k: string]: unknown }>;
}
/**
* Build + sign the admin_login event for a challenge and return the
* `Nostr <base64(event)>` token the backend's `admin_token` field expects.
*/
export async function buildAdminToken(c: Challenge, signer: Nip07): Promise<string> {
// request_descriptor("POST", "/admin/auth/verify", "", b"") then its sha256.
const emptyBodyHash = await sha256Hex('');
const descriptor = `POST\n/admin/auth/verify\n\n${emptyBodyHash}`;
const payload = await sha256Hex(descriptor);
const signed = await signer.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', c.url],
['method', 'POST'],
['purpose', 'admin_login'],
['challenge', c.challenge],
['payload', payload],
['instance_id', c.instance_id],
],
});
return `Nostr ${btoa(JSON.stringify(signed))}`;
}
/** The page-injected NIP-07 provider, or null. Smirk installs `window.nostr`. */
export function nip07(): Nip07 | null {
const n = (window as unknown as { nostr?: Nip07 }).nostr;
return n && typeof n.signEvent === 'function' ? n : null;
}
+50
View File
@@ -0,0 +1,50 @@
/* Smirk brand tokens (vendored — the admin-ui is standalone, no @smirk/ui dep). */
:root {
--smirk-bg: #2c1e12;
--smirk-bg-elevated: #3a2a1b;
--smirk-bg-sunken: #241810;
--smirk-fg: #f3e9dd;
--smirk-fg-muted: #b9a891;
--smirk-accent: #f5c542;
--smirk-accent-fg: #2c1e12;
--smirk-border: rgba(255, 255, 255, 0.12);
--smirk-border-strong: rgba(255, 255, 255, 0.22);
--smirk-danger: #ef4444;
--smirk-ok: #22c55e;
--smirk-warn: #f59e0b;
--smirk-radius: 10px;
--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--smirk-bg);
color: var(--smirk-fg);
font-family: var(--smirk-font);
font-size: 14px;
line-height: 1.5;
}
h1, h2, h3 { font-weight: 700; }
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--smirk-border);
background: var(--smirk-bg-elevated);
color: inherit;
padding: 8px 14px;
}
button.primary { background: var(--smirk-accent); color: var(--smirk-accent-fg); border: none; font-weight: 700; }
button.danger { color: var(--smirk-danger); border-color: var(--smirk-danger); }
button:disabled { opacity: 0.5; cursor: default; }
input, textarea, select {
font: inherit;
color: inherit;
background: var(--smirk-bg-sunken);
border: 1px solid var(--smirk-border);
border-radius: 8px;
padding: 8px 10px;
}
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
a { color: var(--smirk-accent); }
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
// Served by the Rust backend at /admin (rust-embed). `base: '/admin/'` makes the
// built asset URLs absolute under /admin/assets/ so they resolve regardless of the
// trailing slash. Preact JSX via esbuild (no plugin, keeps the toolchain tiny).
export default defineConfig({
base: '/admin/',
build: {
outDir: 'dist',
target: 'es2022',
emptyOutDir: true,
assetsDir: 'assets',
},
esbuild: {
jsx: 'automatic',
jsxImportSource: 'preact',
},
});
+70 -1
View File
@@ -1032,8 +1032,61 @@ pub async fn admin_invites_list(
})) }))
} }
// ── Operator console SPA (embedded, served at /admin) ─────────────────────────
// The Preact console built from admin-ui/ and embedded via rust-embed (single-binary
// self-host, no separate deploy). Served UNAUTHENTICATED (no admin_guard) — the shell
// loads without a session and runs the NIP-98 login client-side against the guarded
// JSON routes. Only exact /admin, /admin/, /admin/assets/*path are added, so the JSON
// routes (/admin/keys, /admin/config, ...) are never shadowed.
#[derive(rust_embed::RustEmbed)]
#[folder = "admin-ui/dist/"]
struct AdminAssets;
pub async fn admin_index() -> Response {
match AdminAssets::get("index.html") {
Some(f) => (
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
f.data.into_owned(),
)
.into_response(),
None => (
StatusCode::NOT_FOUND,
"operator console not built (run: npm --prefix admin-ui run build)",
)
.into_response(),
}
}
pub async fn admin_asset(Path(path): Path<String>) -> Response {
match AdminAssets::get(&format!("assets/{path}")) {
Some(f) => {
let mime = if path.ends_with(".js") || path.ends_with(".mjs") {
"application/javascript; charset=utf-8"
} else if path.ends_with(".css") {
"text/css; charset=utf-8"
} else if path.ends_with(".svg") {
"image/svg+xml"
} else if path.ends_with(".woff2") {
"font/woff2"
} else if path.ends_with(".png") {
"image/png"
} else if path.ends_with(".json") {
"application/json"
} else {
"application/octet-stream"
};
([(axum::http::header::CONTENT_TYPE, mime)], f.data.into_owned()).into_response()
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
pub fn routes() -> Router<Arc<AppState>> { pub fn routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/admin", get(admin_index))
.route("/admin/", get(admin_index))
.route("/admin/assets/*path", get(admin_asset))
.route("/admin/auth/challenge", post(admin_challenge)) .route("/admin/auth/challenge", post(admin_challenge))
.route("/admin/auth/verify", post(admin_verify)) .route("/admin/auth/verify", post(admin_verify))
.route("/admin/auth/refresh", post(admin_refresh)) .route("/admin/auth/refresh", post(admin_refresh))
@@ -1049,7 +1102,23 @@ pub fn routes() -> Router<Arc<AppState>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::host_allowed; use super::{host_allowed, AdminAssets};
// The Operator Console SPA must be built into admin-ui/dist/ and embedded at
// compile time (rust-embed). If admin-ui was never built, index.html is absent
// and /admin serves nothing — this catches that before it reaches an operator.
#[test]
fn admin_console_is_embedded() {
let index = AdminAssets::get("index.html").expect("admin-ui/dist/index.html embedded");
let html = std::str::from_utf8(&index.data).expect("index.html is utf-8");
assert!(html.contains("Smirk Operator Console"), "console title missing");
// Vite rewrites asset URLs to the /admin/ base; the served route matches.
assert!(html.contains("/admin/assets/"), "asset base not rewritten to /admin/");
assert!(
AdminAssets::iter().any(|p| p.starts_with("assets/")),
"no built JS/CSS assets embedded",
);
}
#[test] #[test]
fn host_allowlist() { fn host_allowlist() {