Park the Dungeon: delete ghostlight-dungeon, web/, Eve bridge, vendor/eve, recipe and Eve interface doc (tag parked/ghostlight-eve-surface)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ee62fec98a
commit
f40bbaac13
32 changed files with 7 additions and 28583 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
|||
node_modules/
|
||||
web/dist/
|
||||
sidecar/claude-sdk/dist/
|
||||
target/
|
||||
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "vendor/eve"]
|
||||
path = vendor/eve
|
||||
url = https://github.com/GameCult/Eve.git
|
||||
680
Cargo.lock
generated
680
Cargo.lock
generated
|
|
@ -37,15 +37,6 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
|
|
@ -61,28 +52,6 @@ version = "1.0.104"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
|
|
@ -106,70 +75,6 @@ version = "1.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -182,12 +87,6 @@ version = "0.23.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
|
|
@ -288,12 +187,6 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -366,85 +259,6 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultmesh-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultcache-rs",
|
||||
"cultnet-rs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultnet-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cultcache-rs",
|
||||
"ed25519-dalek",
|
||||
"hmac",
|
||||
"rand 0.9.5",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -453,7 +267,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -467,30 +280,6 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -507,12 +296,6 @@ version = "2.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
|
|
@ -621,18 +404,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
|
|
@ -642,7 +413,7 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
|
@ -683,44 +454,6 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghostlight-dungeon"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-connector",
|
||||
"cultcache-rs",
|
||||
"cultmesh-rs",
|
||||
"cultnet-rs",
|
||||
"ghostlight",
|
||||
"ghostlight-persona-projection",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"rand 0.9.5",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghostlight-persona-projection"
|
||||
version = "0.1.0"
|
||||
|
|
@ -742,15 +475,6 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
|
|
@ -784,24 +508,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.11.0"
|
||||
|
|
@ -815,7 +527,6 @@ dependencies = [
|
|||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
|
|
@ -856,7 +567,7 @@ dependencies = [
|
|||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -1022,27 +733,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
|
|
@ -1082,43 +772,12 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
|
|
@ -1130,40 +789,6 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -1208,16 +833,6 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -1230,16 +845,6 @@ version = "0.2.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
|
|
@ -1261,21 +866,6 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
|
@ -1298,7 +888,7 @@ dependencies = [
|
|||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -1314,7 +904,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
|
|
@ -1336,7 +926,7 @@ dependencies = [
|
|||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.5",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
|
@ -1350,28 +940,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
|
|
@ -1383,16 +957,6 @@ dependencies = [
|
|||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
|
|
@ -1402,15 +966,6 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
|
|
@ -1444,23 +999,6 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
|
|
@ -1542,8 +1080,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1552,15 +1088,6 @@ version = "2.1.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
|
|
@ -1627,12 +1154,6 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
|
|
@ -1686,17 +1207,6 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
|
|
@ -1720,15 +1230,6 @@ dependencies = [
|
|||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
|
|
@ -1745,27 +1246,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -1778,16 +1258,6 @@ version = "1.15.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
|
|
@ -1798,16 +1268,6 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1895,45 +1355,6 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
|
|
@ -1971,7 +1392,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.5",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
|
@ -2023,7 +1444,6 @@ dependencies = [
|
|||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2034,23 +1454,13 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
|||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
|
@ -2072,7 +1482,6 @@ version = "0.1.44"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
|
@ -2096,36 +1505,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2140,12 +1519,6 @@ version = "1.20.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -2219,12 +1592,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
|
@ -2246,15 +1613,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
|
|
@ -2524,12 +1882,6 @@ version = "0.52.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
|
@ -2559,26 +1911,6 @@ dependencies = [
|
|||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
|
|
|||
12
Cargo.toml
12
Cargo.toml
|
|
@ -1,7 +1,6 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/ghostlight",
|
||||
"crates/ghostlight-dungeon",
|
||||
"crates/ghostlight-persona-projection",
|
||||
"crates/ghostlight-verse",
|
||||
]
|
||||
|
|
@ -14,19 +13,10 @@ version = "0.1.0"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
async-stream = "0.3"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" }
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" }
|
||||
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" }
|
||||
jsonwebtoken = "9"
|
||||
hmac = "0.12"
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -34,9 +24,7 @@ serde_json = "1"
|
|||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-segmentation = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
zeroize = "1.8"
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
[package]
|
||||
name = "ghostlight-dungeon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
|
||||
[[bin]]
|
||||
name = "ghostlight-dungeon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
async-trait.workspace = true
|
||||
async-stream.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
base64.workspace = true
|
||||
codex-connector.workspace = true
|
||||
cultcache-rs.workspace = true
|
||||
cultnet-rs.workspace = true
|
||||
cultmesh-rs.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
libc = "0.2"
|
||||
hmac.workspace = true
|
||||
ghostlight = { path = "../ghostlight" }
|
||||
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ghostlight = { path = "../ghostlight", features = ["test-support"] }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
use std::{env, path::Path, process::Command};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT");
|
||||
let manifest_dir =
|
||||
env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR");
|
||||
let repo_root = Path::new(&manifest_dir).join("../..");
|
||||
emit_git_rerun_paths(&repo_root);
|
||||
// Release tooling injects the clean-tree commit. Git discovery is only a
|
||||
// convenience for local builds. The exact ref and reflog dependencies above
|
||||
// keep that convenience from silently publishing stale provenance.
|
||||
let commit = env::var("GHOSTLIGHT_BUILD_COMMIT")
|
||||
.ok()
|
||||
.filter(|value| is_commit(value))
|
||||
.or_else(|| git_commit(&repo_root))
|
||||
.expect("GhostlightDungeon builds require an exact 40-hex source commit");
|
||||
println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}");
|
||||
}
|
||||
|
||||
fn emit_git_rerun_paths(repo_root: &Path) {
|
||||
for git_path in ["HEAD", "logs/HEAD", "packed-refs"] {
|
||||
if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) {
|
||||
emit_rerun_path(repo_root, &path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"])
|
||||
&& let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference])
|
||||
{
|
||||
emit_rerun_path(repo_root, &path);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_rerun_path(repo_root: &Path, git_path: &str) {
|
||||
let path = Path::new(git_path);
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
repo_root.join(path)
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", resolved.display());
|
||||
}
|
||||
|
||||
fn git_commit(repo_root: &Path) -> Option<String> {
|
||||
git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value))
|
||||
}
|
||||
|
||||
fn git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo_root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_commit(value: &str) -> bool {
|
||||
value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
|
@ -1,939 +0,0 @@
|
|||
//! Local custody for authenticated Heimdall sessions.
|
||||
//!
|
||||
//! This store owns only browser-session continuity. It does not own world
|
||||
//! selection, commands, idempotency, exports, or fictional state.
|
||||
|
||||
use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh};
|
||||
use ghostlight::VerifiedPrincipalEvidence;
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
|
||||
use anyhow::{Context, bail};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
const STORE_TYPE: &str = "ghostlight.app_session_store.v2";
|
||||
/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the
|
||||
/// `app-sessions` state slot's schema against this constant.
|
||||
pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2";
|
||||
const STORE_KEY: &str = "primary";
|
||||
const MAX_SESSION_RECORDS: usize = 1_024;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AppSessionState {
|
||||
schema: String,
|
||||
sessions: BTreeMap<String, AppSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AppSession {
|
||||
schema: String,
|
||||
cookie_hash: String,
|
||||
account_subject_hash: String,
|
||||
heimdall_session_id: String,
|
||||
access_revision: u64,
|
||||
verified_capabilities: Vec<String>,
|
||||
access_expires_at: DateTime<Utc>,
|
||||
refresh_expires_at: DateTime<Utc>,
|
||||
encrypted_refresh_claim: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_refresh_at: DateTime<Utc>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// The clause by which the local commit refuses a refresh Heimdall executed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub(crate) enum RefreshRefusal {
|
||||
#[error("local session was revoked during refresh")]
|
||||
Revoked,
|
||||
#[error("local session moved to another revision since the refresh candidate was taken")]
|
||||
MovedSinceCandidate,
|
||||
#[error("refresh receipt revision went backwards")]
|
||||
RevisionWentBackwards,
|
||||
#[error("refresh receipt names a different Heimdall session")]
|
||||
SessionId,
|
||||
#[error("refresh receipt names a different account")]
|
||||
Account,
|
||||
}
|
||||
|
||||
pub(crate) struct RefreshCandidate {
|
||||
pub(crate) cookie_hash: String,
|
||||
pub(crate) account_subject_hash: String,
|
||||
pub(crate) heimdall_session_id: String,
|
||||
pub(crate) access_revision: u64,
|
||||
pub(crate) refresh_claim: String,
|
||||
}
|
||||
|
||||
pub(crate) struct AppSessionOwner {
|
||||
store: OwnedRedbMessagePackBackingStore,
|
||||
row: CultCacheEnvelope,
|
||||
state: AppSessionState,
|
||||
wrapping_key: [u8; 32],
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
impl AppSessionOwner {
|
||||
pub(crate) fn open(
|
||||
store_path: impl AsRef<Path>,
|
||||
wrapping_key_path: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let wrapping_key = read_wrapping_key(wrapping_key_path.as_ref())?;
|
||||
let store = OwnedRedbMessagePackBackingStore::new(store_path.as_ref())?;
|
||||
store
|
||||
.validate_path_identity()
|
||||
.context("app-session store path identity is invalid")?;
|
||||
let rows = store.pull_all()?;
|
||||
let (row, state) = match rows.as_slice() {
|
||||
[] => {
|
||||
let state = AppSessionState {
|
||||
schema: STORE_SCHEMA.into(),
|
||||
sessions: BTreeMap::new(),
|
||||
};
|
||||
let row = envelope(&state)?;
|
||||
if !store.compare_and_swap_batch(&[], vec![row.clone()])? {
|
||||
bail!("app-session store changed during initialization");
|
||||
}
|
||||
(row, state)
|
||||
}
|
||||
[row]
|
||||
if row.r#type == STORE_TYPE
|
||||
&& row.key == STORE_KEY
|
||||
&& row.schema_id.as_deref() == Some(STORE_SCHEMA) =>
|
||||
{
|
||||
let state: AppSessionState =
|
||||
rmp_serde::from_slice(&row.payload).context("app-session state is corrupt")?;
|
||||
if rmp_serde::to_vec_named(&state)? != row.payload {
|
||||
bail!("app-session state is not canonical");
|
||||
}
|
||||
if state.schema != STORE_SCHEMA {
|
||||
bail!("app-session state schema is invalid");
|
||||
}
|
||||
validate_state(&state, &wrapping_key)?;
|
||||
(row.clone(), state)
|
||||
}
|
||||
_ => bail!(
|
||||
"app-session v2 store contains foreign or legacy records; start with a fresh store"
|
||||
),
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
row,
|
||||
state,
|
||||
wrapping_key,
|
||||
healthy: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create_session(
|
||||
&mut self,
|
||||
input: VerifiedSessionAdmission,
|
||||
) -> anyhow::Result<String> {
|
||||
if !input
|
||||
.capabilities()
|
||||
.iter()
|
||||
.any(|value| value == "app_access")
|
||||
{
|
||||
bail!("Heimdall session lacks app_access");
|
||||
}
|
||||
if input.account_id().trim().is_empty()
|
||||
|| input.heimdall_session_id().trim().is_empty()
|
||||
|| input.refresh_claim().is_empty()
|
||||
|| input.refresh_expires_at() <= input.access_expires_at()
|
||||
{
|
||||
bail!("Heimdall session material is invalid");
|
||||
}
|
||||
let mut cookie = [0_u8; 32];
|
||||
rand::rng().fill_bytes(&mut cookie);
|
||||
let raw_cookie = URL_SAFE_NO_PAD.encode(cookie);
|
||||
let cookie_hash = secret_hash(&raw_cookie);
|
||||
let account_subject_hash = secret_hash(&format!("heimdall-account:{}", input.account_id()));
|
||||
let now = Utc::now();
|
||||
let session = AppSession {
|
||||
schema: "ghostlight.app_session.v2".into(),
|
||||
cookie_hash: cookie_hash.clone(),
|
||||
account_subject_hash: account_subject_hash.clone(),
|
||||
heimdall_session_id: input.heimdall_session_id().to_owned(),
|
||||
access_revision: input.access_revision(),
|
||||
verified_capabilities: input.capabilities().to_vec(),
|
||||
access_expires_at: input.access_expires_at(),
|
||||
refresh_expires_at: input.refresh_expires_at(),
|
||||
encrypted_refresh_claim: wrap_refresh(
|
||||
&self.wrapping_key,
|
||||
&account_subject_hash,
|
||||
input.heimdall_session_id(),
|
||||
input.refresh_claim(),
|
||||
)?,
|
||||
created_at: now,
|
||||
last_refresh_at: now,
|
||||
revoked_at: None,
|
||||
};
|
||||
let mut next = self.state.clone();
|
||||
for previous in next.sessions.values_mut().filter(|previous| {
|
||||
previous.revoked_at.is_none()
|
||||
&& previous.heimdall_session_id == input.heimdall_session_id()
|
||||
}) {
|
||||
previous.revoked_at = Some(now);
|
||||
}
|
||||
next.sessions.insert(cookie_hash, session);
|
||||
self.commit(next)?;
|
||||
Ok(raw_cookie)
|
||||
}
|
||||
|
||||
pub(crate) fn is_healthy(&self) -> bool {
|
||||
self.healthy
|
||||
}
|
||||
|
||||
pub(crate) fn validate_custody(&mut self) -> anyhow::Result<()> {
|
||||
self.ensure_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn account_for_cookie(
|
||||
&mut self,
|
||||
raw_cookie: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> anyhow::Result<Option<VerifiedPrincipalEvidence>> {
|
||||
self.ensure_owned()?;
|
||||
let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok((session.revoked_at.is_none()
|
||||
&& session.access_expires_at > now
|
||||
&& session.refresh_expires_at > now
|
||||
&& session
|
||||
.verified_capabilities
|
||||
.iter()
|
||||
.any(|value| value == "app_access"))
|
||||
.then(|| {
|
||||
VerifiedPrincipalEvidence::new(
|
||||
session.account_subject_hash.clone(),
|
||||
session.access_expires_at,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn revoke_cookie(&mut self, raw_cookie: &str) -> anyhow::Result<bool> {
|
||||
self.ensure_owned()?;
|
||||
let cookie_hash = secret_hash(raw_cookie);
|
||||
let mut next = self.state.clone();
|
||||
let Some(session) = next.sessions.get_mut(&cookie_hash) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
session.revoked_at = Some(Utc::now());
|
||||
self.commit(next)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn revoke_cookie_hash(&mut self, cookie_hash: &str) -> anyhow::Result<bool> {
|
||||
self.ensure_owned()?;
|
||||
let mut next = self.state.clone();
|
||||
let Some(session) = next.sessions.get_mut(cookie_hash) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
session.revoked_at = Some(Utc::now());
|
||||
self.commit(next)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn session_for_logout(
|
||||
&mut self,
|
||||
raw_cookie: &str,
|
||||
) -> anyhow::Result<Option<RefreshCandidate>> {
|
||||
self.ensure_owned()?;
|
||||
let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match self.refresh_candidate(session) {
|
||||
Ok(candidate) => Ok(Some(candidate)),
|
||||
Err(error) => self.poison(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sessions_due_for_refresh(
|
||||
&mut self,
|
||||
now: DateTime<Utc>,
|
||||
refresh_before: chrono::Duration,
|
||||
) -> anyhow::Result<Vec<RefreshCandidate>> {
|
||||
self.ensure_owned()?;
|
||||
let candidates = self
|
||||
.state
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|session| {
|
||||
session.revoked_at.is_none()
|
||||
&& session.refresh_expires_at > now
|
||||
&& session.access_expires_at <= now + refresh_before
|
||||
})
|
||||
.map(|session| self.refresh_candidate(session))
|
||||
.collect::<anyhow::Result<Vec<_>>>();
|
||||
match candidates {
|
||||
Ok(candidates) => Ok(candidates),
|
||||
Err(error) => self.poison(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_refresh(
|
||||
&mut self,
|
||||
cookie_hash: &str,
|
||||
expected_access_revision: u64,
|
||||
input: VerifiedSessionRefresh,
|
||||
) -> anyhow::Result<()> {
|
||||
if !input
|
||||
.capabilities()
|
||||
.iter()
|
||||
.any(|value| value == "app_access")
|
||||
{
|
||||
bail!("refreshed Heimdall session lacks app_access");
|
||||
}
|
||||
if input.refresh_claim().is_empty()
|
||||
|| input.refresh_expires_at() <= input.access_expires_at()
|
||||
{
|
||||
bail!("refreshed Heimdall session material is invalid");
|
||||
}
|
||||
let mut next = self.state.clone();
|
||||
let session = next
|
||||
.sessions
|
||||
.get_mut(cookie_hash)
|
||||
.context("local session vanished during refresh")?;
|
||||
if session.revoked_at.is_some() {
|
||||
return Err(RefreshRefusal::Revoked.into());
|
||||
}
|
||||
if session.access_revision != expected_access_revision {
|
||||
return Err(RefreshRefusal::MovedSinceCandidate.into());
|
||||
}
|
||||
// Heimdall's access_revision is a revocation epoch: refresh rotates
|
||||
// the token at the same revision, so equal is the normal receipt and
|
||||
// only a lower one is a refusal.
|
||||
if input.access_revision() < expected_access_revision {
|
||||
return Err(RefreshRefusal::RevisionWentBackwards.into());
|
||||
}
|
||||
if session.heimdall_session_id != input.heimdall_session_id() {
|
||||
return Err(RefreshRefusal::SessionId.into());
|
||||
}
|
||||
if session.account_subject_hash
|
||||
!= secret_hash(&format!("heimdall-account:{}", input.account_id()))
|
||||
{
|
||||
return Err(RefreshRefusal::Account.into());
|
||||
}
|
||||
session.access_revision = input.access_revision();
|
||||
session.verified_capabilities = input.capabilities().to_vec();
|
||||
session.access_expires_at = input.access_expires_at();
|
||||
session.refresh_expires_at = input.refresh_expires_at();
|
||||
session.encrypted_refresh_claim = wrap_refresh(
|
||||
&self.wrapping_key,
|
||||
&session.account_subject_hash,
|
||||
&session.heimdall_session_id,
|
||||
input.refresh_claim(),
|
||||
)?;
|
||||
session.last_refresh_at = Utc::now();
|
||||
self.commit(next)
|
||||
}
|
||||
|
||||
fn refresh_candidate(&self, session: &AppSession) -> anyhow::Result<RefreshCandidate> {
|
||||
Ok(RefreshCandidate {
|
||||
cookie_hash: session.cookie_hash.clone(),
|
||||
account_subject_hash: session.account_subject_hash.clone(),
|
||||
heimdall_session_id: session.heimdall_session_id.clone(),
|
||||
access_revision: session.access_revision,
|
||||
refresh_claim: unwrap_refresh(
|
||||
&self.wrapping_key,
|
||||
&session.account_subject_hash,
|
||||
&session.heimdall_session_id,
|
||||
&session.encrypted_refresh_claim,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn commit(&mut self, mut next: AppSessionState) -> anyhow::Result<()> {
|
||||
self.ensure_owned()?;
|
||||
prune_sessions(&mut next, Utc::now());
|
||||
let next_row = envelope(&next)?;
|
||||
let swapped = self
|
||||
.store
|
||||
.compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]);
|
||||
if !matches!(swapped, Ok(true)) {
|
||||
return self.poison(anyhow::anyhow!(
|
||||
"app-session commit outcome is uncertain; sole ownership was lost"
|
||||
));
|
||||
}
|
||||
if self.store.validate_path_identity().is_err() {
|
||||
return self.poison(anyhow::anyhow!(
|
||||
"app-session store path changed after durable commit"
|
||||
));
|
||||
}
|
||||
self.row = next_row;
|
||||
self.state = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_owned(&mut self) -> anyhow::Result<()> {
|
||||
if !self.healthy {
|
||||
bail!("app-session custody is poisoned; reopen the owner");
|
||||
}
|
||||
if self.store.validate_path_identity().is_err() {
|
||||
self.healthy = false;
|
||||
bail!("app-session store ownership was lost");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poison<T>(&mut self, error: anyhow::Error) -> anyhow::Result<T> {
|
||||
self.healthy = false;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// The idempotency key of the refresh that spends `refresh_claim`: a retry of
|
||||
/// that refresh derives the same key, the next refresh (a new claim) a new one,
|
||||
/// and the claim itself never leaves the process in it.
|
||||
pub(crate) fn refresh_idempotency_key(refresh_claim: &str) -> String {
|
||||
format!(
|
||||
"refresh:{}",
|
||||
secret_hash(&format!("heimdall-refresh-key:{refresh_claim}"))
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn secret_hash(value: &str) -> String {
|
||||
format!("sha256:{:x}", Sha256::digest(value.as_bytes()))
|
||||
}
|
||||
|
||||
fn envelope(state: &AppSessionState) -> anyhow::Result<CultCacheEnvelope> {
|
||||
Ok(CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec_named(state)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(STORE_SCHEMA.into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_state(state: &AppSessionState, wrapping_key: &[u8; 32]) -> anyhow::Result<()> {
|
||||
if state.schema != STORE_SCHEMA {
|
||||
bail!("app-session state schema is invalid");
|
||||
}
|
||||
for (key, session) in &state.sessions {
|
||||
if key != &session.cookie_hash
|
||||
|| !is_secret_hash(key)
|
||||
|| !is_secret_hash(&session.account_subject_hash)
|
||||
|| session.schema != "ghostlight.app_session.v2"
|
||||
|| session.heimdall_session_id.trim().is_empty()
|
||||
|| session.heimdall_session_id.trim() != session.heimdall_session_id
|
||||
|| !session
|
||||
.verified_capabilities
|
||||
.iter()
|
||||
.any(|value| value == "app_access")
|
||||
|| session.refresh_expires_at <= session.access_expires_at
|
||||
|| session.last_refresh_at < session.created_at
|
||||
|| session
|
||||
.revoked_at
|
||||
.is_some_and(|revoked_at| revoked_at < session.created_at)
|
||||
{
|
||||
bail!("app-session state contains a noncanonical session");
|
||||
}
|
||||
unwrap_refresh(
|
||||
wrapping_key,
|
||||
&session.account_subject_hash,
|
||||
&session.heimdall_session_id,
|
||||
&session.encrypted_refresh_claim,
|
||||
)
|
||||
.context("app-session state contains an unreadable refresh claim")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_secret_hash(value: &str) -> bool {
|
||||
value.len() == 71
|
||||
&& value.strip_prefix("sha256:").is_some_and(|hex| {
|
||||
hex.bytes()
|
||||
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
|
||||
})
|
||||
}
|
||||
|
||||
fn prune_sessions(state: &mut AppSessionState, now: DateTime<Utc>) {
|
||||
let retention_floor = now - chrono::Duration::days(7);
|
||||
state.sessions.retain(|_, session| {
|
||||
session.refresh_expires_at > retention_floor
|
||||
&& session
|
||||
.revoked_at
|
||||
.is_none_or(|revoked_at| revoked_at > retention_floor)
|
||||
});
|
||||
if state.sessions.len() <= MAX_SESSION_RECORDS {
|
||||
return;
|
||||
}
|
||||
let mut oldest = state
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|(key, session)| {
|
||||
let live = session.revoked_at.is_none() && session.refresh_expires_at > now;
|
||||
(live, session.last_refresh_at, key.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
oldest.sort();
|
||||
for (_, _, key) in oldest
|
||||
.into_iter()
|
||||
.take(state.sessions.len() - MAX_SESSION_RECORDS)
|
||||
{
|
||||
state.sessions.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_wrapping_key(path: &Path) -> anyhow::Result<[u8; 32]> {
|
||||
let source = std::fs::read(path)
|
||||
.with_context(|| format!("failed to read session wrapping key {}", path.display()))?;
|
||||
let decoded = if source.len() == 32 {
|
||||
source
|
||||
} else {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(std::str::from_utf8(&source)?.trim())
|
||||
.context("session wrapping key must be 32 raw bytes or base64url")?
|
||||
};
|
||||
decoded
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("session wrapping key must be exactly 32 bytes"))
|
||||
}
|
||||
|
||||
fn wrap_refresh(
|
||||
key: &[u8; 32],
|
||||
account_hash: &str,
|
||||
session_id: &str,
|
||||
claim: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut iv = [0_u8; 12];
|
||||
rand::rng().fill_bytes(&mut iv);
|
||||
let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}");
|
||||
let ciphertext = Aes256Gcm::new_from_slice(key)?
|
||||
.encrypt(
|
||||
(&iv).into(),
|
||||
aes_gcm::aead::Payload {
|
||||
msg: claim.as_bytes(),
|
||||
aad: aad.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("failed to wrap Heimdall refresh claim"))?;
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(iv),
|
||||
URL_SAFE_NO_PAD.encode(ciphertext)
|
||||
))
|
||||
}
|
||||
|
||||
fn unwrap_refresh(
|
||||
key: &[u8; 32],
|
||||
account_hash: &str,
|
||||
session_id: &str,
|
||||
wrapped: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let (iv, ciphertext) = wrapped
|
||||
.split_once('.')
|
||||
.context("wrapped refresh claim is malformed")?;
|
||||
let iv: [u8; 12] = URL_SAFE_NO_PAD
|
||||
.decode(iv)?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("wrapped refresh IV is invalid"))?;
|
||||
let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext)?;
|
||||
let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}");
|
||||
let plaintext = Aes256Gcm::new_from_slice(key)?
|
||||
.decrypt(
|
||||
(&iv).into(),
|
||||
aes_gcm::aead::Payload {
|
||||
msg: &ciphertext,
|
||||
aad: aad.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("failed to unwrap Heimdall refresh claim"))?;
|
||||
String::from_utf8(plaintext).context("Heimdall refresh claim is not UTF-8")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every `.rs` file under `src`, subdirectories included, with everything
|
||||
/// a `#[cfg(test)]` attribute guards removed — whatever that item is, and
|
||||
/// whatever a test module is called.
|
||||
///
|
||||
/// The item's extent is read from the layout: an attribute at indent `N`
|
||||
/// guards lines until the first later line at indent `N` that closes a
|
||||
/// block, or, for an item with no body, until the first line at indent `N`
|
||||
/// that ends in `;` or `,`. This is a claim about rustfmt's output, not
|
||||
/// about Rust's grammar, and it is the reason the scan does not try to
|
||||
/// balance braces: braces inside string and character literals are not
|
||||
/// block structure, and a scanner that counts them reads the end of a test
|
||||
/// module in the wrong place.
|
||||
fn production_sources() -> Vec<(std::path::PathBuf, String)> {
|
||||
fn indent(line: &str) -> usize {
|
||||
line.len() - line.trim_start().len()
|
||||
}
|
||||
|
||||
fn strip_cfg_test(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut kept: Vec<&str> = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < lines.len() {
|
||||
let line = lines[index];
|
||||
if line.trim() != "#[cfg(test)]" {
|
||||
kept.push(line);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let guard = indent(line);
|
||||
index += 1;
|
||||
// A body opens on the attribute's own item; without one, the
|
||||
// item ends at its first terminator.
|
||||
let mut opened = lines
|
||||
.get(index)
|
||||
.is_some_and(|next| next.trim_end().ends_with('{'));
|
||||
while index < lines.len() {
|
||||
let body = lines[index];
|
||||
index += 1;
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if opened {
|
||||
if indent(body) == guard
|
||||
&& (trimmed == "}" || trimmed == "};" || trimmed == "},")
|
||||
{
|
||||
break;
|
||||
}
|
||||
} else if indent(body) == guard
|
||||
&& (trimmed.ends_with(';') || trimmed.ends_with(','))
|
||||
{
|
||||
break;
|
||||
} else if indent(body) == guard && trimmed.ends_with('{') {
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
kept.join("\n")
|
||||
}
|
||||
|
||||
let mut sources = Vec::new();
|
||||
let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
||||
while let Some(directory) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&directory).expect("the source tree reads") {
|
||||
let path = entry.expect("a readable entry").path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.expect("the source reads")
|
||||
.replace("\r\n", "\n");
|
||||
sources.push((path, strip_cfg_test(&text)));
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
/// True when the text immediately following the type's name calls its
|
||||
/// `new`. Written as a scan rather than one literal so the qualified
|
||||
/// `<Type>::new(` form and any whitespace the formatter may introduce
|
||||
/// count the same as the plain path.
|
||||
fn calls_new(tail: &str) -> bool {
|
||||
let tail = tail.trim_start();
|
||||
let tail = tail.strip_prefix('>').unwrap_or(tail).trim_start();
|
||||
let Some(tail) = tail.strip_prefix("::") else {
|
||||
return false;
|
||||
};
|
||||
let Some(tail) = tail.trim_start().strip_prefix("new") else {
|
||||
return false;
|
||||
};
|
||||
tail.trim_start().starts_with('(')
|
||||
}
|
||||
|
||||
/// The library owns `VerifiedPrincipalEvidence` and gives it a public
|
||||
/// constructor, so the single-minter rule its private fields used to
|
||||
/// enforce is this one: production mints it in `account_for_cookie` and
|
||||
/// nowhere else, from a live cookie this owner holds custody of. A second
|
||||
/// minter anywhere in Dungeon's production source is a second authority
|
||||
/// over who the kernel believes is speaking.
|
||||
///
|
||||
/// The type is followed by name, so production may not give it a second
|
||||
/// name: an `as` import or a type alias is refused outright rather than
|
||||
/// counted, because a renamed minter is one this test cannot follow. What
|
||||
/// it still cannot see is a call that never spells the type — a macro
|
||||
/// expansion, or a generic instantiated elsewhere.
|
||||
#[test]
|
||||
fn soul_exactly_one_production_site_mints_verified_principal_evidence() {
|
||||
// Assembled from halves so this test's own source is never a match,
|
||||
// whatever the scan above decides about this module's extent.
|
||||
let name = format!("VerifiedPrincipal{}", "Evidence");
|
||||
let mut sites = Vec::new();
|
||||
for (path, production) in production_sources() {
|
||||
for (at, _) in production.match_indices(name.as_str()) {
|
||||
let tail = &production[at + name.len()..];
|
||||
assert!(
|
||||
!tail.trim_start().starts_with("as "),
|
||||
"{} renames {name} on import",
|
||||
path.display()
|
||||
);
|
||||
if calls_new(tail) {
|
||||
sites.push(path.clone());
|
||||
}
|
||||
}
|
||||
for line in production.lines() {
|
||||
assert!(
|
||||
!(line.trim_start().starts_with("type ") && line.contains(name.as_str())),
|
||||
"{} aliases {name} to another name: {line}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
sites.len(),
|
||||
1,
|
||||
"production mints {name} in exactly one place: {sites:?}"
|
||||
);
|
||||
assert!(
|
||||
sites[0].ends_with("app_session.rs"),
|
||||
"the one minter is not this owner: {:?}",
|
||||
sites[0]
|
||||
);
|
||||
}
|
||||
|
||||
fn session(claim: &str) -> VerifiedSessionAdmission {
|
||||
VerifiedSessionAdmission::fixture(
|
||||
"account-1",
|
||||
"heimdall-session-1",
|
||||
3,
|
||||
Utc::now() + chrono::Duration::minutes(5),
|
||||
Utc::now() + chrono::Duration::days(7),
|
||||
claim,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_rejects_an_alternate_messagepack_shape() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [5_u8; 32]).unwrap();
|
||||
let store_path = directory.path().join("app-sessions-v2.cc");
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
let state = AppSessionState {
|
||||
schema: STORE_SCHEMA.into(),
|
||||
sessions: BTreeMap::new(),
|
||||
};
|
||||
let row = CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec(&state).unwrap(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(STORE_SCHEMA.into()),
|
||||
};
|
||||
assert!(store.compare_and_swap_batch(&[], vec![row]).unwrap());
|
||||
drop(store);
|
||||
assert!(AppSessionOwner::open(&store_path, &key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_only_session_custody_and_reopens() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [7_u8; 32]).unwrap();
|
||||
let store = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(session("private-refresh-claim"))
|
||||
.unwrap();
|
||||
let account = owner
|
||||
.account_for_cookie(&cookie, Utc::now())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let account_hash = account.account_subject_hash().to_owned();
|
||||
drop(owner);
|
||||
|
||||
let mut reopened = AppSessionOwner::open(&store, &key).unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.account_for_cookie(&cookie, Utc::now())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.account_subject_hash(),
|
||||
account_hash
|
||||
);
|
||||
assert!(reopened.revoke_cookie(&cookie).unwrap());
|
||||
assert_eq!(
|
||||
reopened.account_for_cookie(&cookie, Utc::now()).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_heimdall_adoption_retry_rotates_the_only_live_cookie() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [9_u8; 32]).unwrap();
|
||||
let store = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
|
||||
let first = owner
|
||||
.create_session(session("same-refresh-receipt"))
|
||||
.unwrap();
|
||||
let second = owner
|
||||
.create_session(session("same-refresh-receipt"))
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(owner.account_for_cookie(&first, Utc::now()).unwrap(), None);
|
||||
assert!(
|
||||
owner
|
||||
.account_for_cookie(&second, Utc::now())
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
owner
|
||||
.state
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|session| session.revoked_at.is_none())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_legacy_or_foreign_store_instead_of_migrating_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [3_u8; 32]).unwrap();
|
||||
let store_path = directory.path().join("app-sessions-v2.cc");
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
let foreign = CultCacheEnvelope {
|
||||
key: "primary".into(),
|
||||
r#type: "app_session_store.v1".into(),
|
||||
payload: vec![],
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some("ghostlight.app_session_store.v1".into()),
|
||||
};
|
||||
assert!(store.compare_and_swap_batch(&[], vec![foreign]).unwrap());
|
||||
assert!(AppSessionOwner::open(&store_path, &key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_for_a_session_that_moved_is_refused_by_name() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [5_u8; 32]).unwrap();
|
||||
let mut owner =
|
||||
AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(VerifiedSessionAdmission::fixture(
|
||||
"account",
|
||||
"session",
|
||||
1,
|
||||
Utc::now() + chrono::Duration::minutes(1),
|
||||
Utc::now() + chrono::Duration::days(1),
|
||||
"claim-0",
|
||||
))
|
||||
.unwrap();
|
||||
let receipt = VerifiedSessionRefresh::fixture(
|
||||
"account",
|
||||
"session",
|
||||
1,
|
||||
Utc::now() + chrono::Duration::minutes(1),
|
||||
Utc::now() + chrono::Duration::days(1),
|
||||
"claim-1",
|
||||
);
|
||||
let error = owner
|
||||
.apply_refresh(&secret_hash(&cookie), 0, receipt)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<RefreshRefusal>(),
|
||||
Some(&RefreshRefusal::MovedSinceCandidate)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_claim_is_encrypted_and_exactly_bound() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [11_u8; 32]).unwrap();
|
||||
let store = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(session("needle-private-refresh"))
|
||||
.unwrap();
|
||||
let candidate = owner.session_for_logout(&cookie).unwrap().unwrap();
|
||||
assert_eq!(candidate.refresh_claim, "needle-private-refresh");
|
||||
drop(owner);
|
||||
let bytes = std::fs::read(store).unwrap();
|
||||
assert!(
|
||||
!bytes
|
||||
.windows("needle-private-refresh".len())
|
||||
.any(|window| window == b"needle-private-refresh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_store_path_revokes_session_authentication_until_reopen() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [19_u8; 32]).unwrap();
|
||||
let path = directory.path().join("app-sessions-v2.cc");
|
||||
let displaced = directory.path().join("displaced.cc");
|
||||
let mut owner = AppSessionOwner::open(&path, &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(session("private-refresh-claim"))
|
||||
.unwrap();
|
||||
|
||||
std::fs::rename(&path, &displaced).unwrap();
|
||||
std::fs::File::create(&path).unwrap();
|
||||
assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err());
|
||||
assert!(!owner.is_healthy());
|
||||
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::rename(&displaced, &path).unwrap();
|
||||
assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err());
|
||||
drop(owner);
|
||||
|
||||
let mut reopened = AppSessionOwner::open(&path, &key).unwrap();
|
||||
assert!(
|
||||
reopened
|
||||
.account_for_cookie(&cookie, Utc::now())
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_rejects_corrupt_inner_session_custody() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [23_u8; 32]).unwrap();
|
||||
let path = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&path, &key).unwrap();
|
||||
owner
|
||||
.create_session(session("private-refresh-claim"))
|
||||
.unwrap();
|
||||
drop(owner);
|
||||
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&path).unwrap();
|
||||
let rows = store.pull_all().unwrap();
|
||||
let mut state: AppSessionState = rmp_serde::from_slice(&rows[0].payload).unwrap();
|
||||
state
|
||||
.sessions
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.encrypted_refresh_claim = "not-an-encrypted-claim".into();
|
||||
let corrupt = envelope(&state).unwrap();
|
||||
assert!(store.compare_and_swap_batch(&rows, vec![corrupt]).unwrap());
|
||||
drop(store);
|
||||
|
||||
assert!(AppSessionOwner::open(&path, &key).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,60 +0,0 @@
|
|||
{
|
||||
"schema": "ghostlight.eve_client_bridge_fixture.v1",
|
||||
"steps": [
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-10-10T00:38:56.201Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "25d921aa-f228-425c-92e3-08c5dd9af508",
|
||||
"operationId": "world.begin",
|
||||
"routeHint": {
|
||||
"sourceVersion": 0,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_begin.v1"
|
||||
},
|
||||
"payload": {
|
||||
"bindings": {
|
||||
"brief": "A world captured for the committed Eve client bridge fixture.",
|
||||
"subject_label": "The Fixture Operator",
|
||||
"title": "Bridge Fixture World"
|
||||
}
|
||||
},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.begin"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-10-10T00:38:57.625Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "fc391d0d-d107-4a66-b5c5-b754ffa814fa",
|
||||
"operationId": "world.play",
|
||||
"routeHint": {
|
||||
"sourceVersion": 3,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_play.v0"
|
||||
},
|
||||
"payload": {
|
||||
"bindings": {
|
||||
"text": "The new owner looks around."
|
||||
}
|
||||
},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.play"
|
||||
}
|
||||
],
|
||||
"vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +0,0 @@
|
|||
mod app_session;
|
||||
mod eve;
|
||||
mod heimdall;
|
||||
mod idunn_health;
|
||||
mod mesh;
|
||||
mod play;
|
||||
mod runtime;
|
||||
|
||||
use anyhow::bail;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
let state_root = match args.as_slice() {
|
||||
[flag, path] if flag == "--state-root" => Some(PathBuf::from(path)),
|
||||
[] => None,
|
||||
_ => bail!("usage: ghostlight-dungeon [--state-root PATH]"),
|
||||
};
|
||||
runtime::run(state_root).await
|
||||
}
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
//! Derived CultMesh and Eve projection.
|
||||
//!
|
||||
//! The mesh publishes the world owner's current surface. It never pulls or
|
||||
//! repairs world state and it has no command authority.
|
||||
|
||||
use crate::eve;
|
||||
use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultmesh_rs::{
|
||||
CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions,
|
||||
publish_cultnet_messages_to_rudp_catalog,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon";
|
||||
pub(crate) const SURFACE_ID: &str = "ghostlight.play";
|
||||
pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands";
|
||||
pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1";
|
||||
pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1";
|
||||
/// The repository that owns this surface, as the command receipt contract
|
||||
/// requires of every result.
|
||||
pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight";
|
||||
|
||||
const HEALTH_KEY: &str = "ghostlight:dungeon:health";
|
||||
const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon";
|
||||
const SURFACE_KEY: &str = "eve:surface:ghostlight.play";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")]
|
||||
struct EveSurfaceRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.eve.provider_advertisement",
|
||||
schema = "gamecult.eve.provider_advertisement.v1"
|
||||
)]
|
||||
struct EveProviderAdvertisementRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "ghostlight.service_health",
|
||||
schema = "ghostlight.service_health.v2"
|
||||
)]
|
||||
struct ServiceHealthRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
cultmesh_rs::cultmesh_documents!(GhostlightDocuments {
|
||||
EveSurfaceRecord => "gamecult.eve.surface.v1",
|
||||
EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1",
|
||||
ServiceHealthRecord => "ghostlight.service_health.v2",
|
||||
});
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MeshRuntimeIdentity {
|
||||
pub(crate) runtime_id: String,
|
||||
pub(crate) service_id: String,
|
||||
pub(crate) located_service: String,
|
||||
}
|
||||
|
||||
impl Default for MeshRuntimeIdentity {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
runtime_id: "ghostlight-dungeon".into(),
|
||||
service_id: "ghostlight-dungeon".into(),
|
||||
located_service: "local".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MeshPublisher {
|
||||
node: Arc<Mutex<CultMeshNode>>,
|
||||
remote: Option<RemoteReplication>,
|
||||
identity: MeshRuntimeIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RemoteReplication {
|
||||
target: SocketAddr,
|
||||
runtime_id: String,
|
||||
pending: Arc<(Mutex<Option<Vec<cultnet_rs::CultNetMessage>>>, Condvar)>,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MeshPublisher {
|
||||
pub(crate) fn open(
|
||||
store_path: impl AsRef<Path>,
|
||||
rudp_target: Option<SocketAddr>,
|
||||
identity: MeshRuntimeIdentity,
|
||||
) -> Result<Self> {
|
||||
if let Some(parent) = store_path.as_ref().parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let node = CultMesh::create_node(
|
||||
store_path,
|
||||
GhostlightDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: identity.runtime_id.clone(),
|
||||
// Ghostlight derives its own interface from the local owner.
|
||||
// Stale remote surfaces must never become startup authority.
|
||||
pull_on_start: false,
|
||||
},
|
||||
)?;
|
||||
let remote = rudp_target
|
||||
.map(|target| RemoteReplication::start(target, identity.runtime_id.clone()))
|
||||
.transpose()?;
|
||||
Ok(Self {
|
||||
node: Arc::new(Mutex::new(node)),
|
||||
remote,
|
||||
identity,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn provider_advertisement(&self, updated_at: &str) -> Value {
|
||||
provider_advertisement(&self.identity, updated_at)
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, snapshot: Option<&WorldSnapshot>) -> Result<Value> {
|
||||
let surface = eve::mesh_surface(snapshot);
|
||||
let world_state = eve::world_state(snapshot);
|
||||
let version = eve::surface_version(snapshot);
|
||||
let updated_at = Utc::now().to_rfc3339();
|
||||
let health = json!({
|
||||
"schema":"ghostlight.service_health.v2",
|
||||
"status":"ok",
|
||||
"worldState":world_state,
|
||||
"surfaceVersion":version,
|
||||
"updatedAtUtc":updated_at,
|
||||
"runtime":self.identity.runtime_id,
|
||||
"commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
|
||||
});
|
||||
let advertisement = provider_advertisement(&self.identity, &updated_at);
|
||||
let mut node = self
|
||||
.node
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("mesh lock poisoned"))?;
|
||||
let mut remote_messages = Vec::new();
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
ADVERTISEMENT_KEY,
|
||||
&EveProviderAdvertisementRecord {
|
||||
value: advertisement,
|
||||
},
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
SURFACE_KEY,
|
||||
&EveSurfaceRecord { value: surface },
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
// Health is the completion marker for this derived projection. A
|
||||
// failed advertisement or surface write must leave the prior version
|
||||
// visibly stale rather than publishing green first.
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
HEALTH_KEY,
|
||||
&ServiceHealthRecord {
|
||||
value: health.clone(),
|
||||
},
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
drop(node);
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.enqueue(remote_messages);
|
||||
}
|
||||
Ok(health)
|
||||
}
|
||||
|
||||
pub(crate) fn health(&self) -> Result<Value> {
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| !remote.is_alive())
|
||||
{
|
||||
anyhow::bail!("Odin projection worker stopped");
|
||||
}
|
||||
self.node
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("mesh projection is busy or its lock is poisoned"))?
|
||||
.get_required::<ServiceHealthRecord>(HEALTH_KEY)
|
||||
.map(|record| record.value)
|
||||
}
|
||||
|
||||
fn put_and_stage<T>(
|
||||
&self,
|
||||
node: &mut CultMeshNode,
|
||||
key: impl Into<String>,
|
||||
value: &T,
|
||||
remote_messages: &mut Vec<cultnet_rs::CultNetMessage>,
|
||||
) -> Result<()>
|
||||
where
|
||||
T: DatabaseEntry + serde::Serialize,
|
||||
{
|
||||
let key = key.into();
|
||||
node.put(&key, value)?;
|
||||
if let Some(remote) = &self.remote {
|
||||
remote_messages.push(node.create_rudp_document_message(
|
||||
&key,
|
||||
value,
|
||||
&remote.options(),
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: &str) -> Value {
|
||||
json!({
|
||||
"schema":"gamecult.eve.provider_advertisement.v1",
|
||||
"providerId":PROVIDER_ID,
|
||||
"serviceId":identity.service_id,
|
||||
"verseId":"gamecult.private",
|
||||
"rootVerse":"gamecult",
|
||||
"canonicalService":"ghostlight-dungeon",
|
||||
"locatedService":identity.located_service,
|
||||
"cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon",
|
||||
"title":"Ghostlight Dungeon",
|
||||
"kind":"narrative.simulation",
|
||||
"updatedAtUtc":updated_at,
|
||||
"freshness":{"state":"live","lastSeenAtUtc":updated_at,"maxAgeMs":360000},
|
||||
"schemas":[
|
||||
"gamecult.eve.surface.v1",
|
||||
"gamecult.eve.command_invocation.v1",
|
||||
COMMAND_RESULT_SCHEMA,
|
||||
"heimdall.access_gate_state.v1",
|
||||
STATE_SCHEMA,
|
||||
CONSUMER_PATCH_SCHEMA,
|
||||
CONSUMER_RECEIPT_SCHEMA
|
||||
],
|
||||
"witnesses":[{
|
||||
"kind":"source-commit",
|
||||
"ref":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
|
||||
}],
|
||||
"surfaces":[{
|
||||
"surfaceId":SURFACE_ID,
|
||||
"schema":"gamecult.eve.surface.v1",
|
||||
"url":"/api/eve/surfaces/ghostlight.play",
|
||||
"transport":"https-json",
|
||||
"status":"available",
|
||||
"surfaceKind":"subject-scoped-narrative",
|
||||
"interactionModel":"typed-command-receipts",
|
||||
"worldInteraction":{
|
||||
"projectionKind":"owner-derived",
|
||||
"stateSchemas":[STATE_SCHEMA],
|
||||
"commandBoundary":COMMAND_BOUNDARY,
|
||||
"receiptSchema":COMMAND_RESULT_SCHEMA,
|
||||
"loweringTargets":["browser","eve-native","tui"],
|
||||
"ownership":"One Ghostlight WorldMailbox owns state; Eve lowers derived projections and commands."
|
||||
},
|
||||
"requiresPlugins":[{
|
||||
"pluginId":"gamecult.heimdall.access",
|
||||
"versionRange":"^1.0.0",
|
||||
"availability":"required",
|
||||
"requiredCapabilities":["auth.gate","auth.begin","auth.complete","auth.logout"]
|
||||
}]
|
||||
}],
|
||||
"commands":[{
|
||||
"command":COMMAND_BOUNDARY,
|
||||
"transport":"https-json",
|
||||
"summary":"Canonical Ghostlight Eve command boundary."
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
impl RemoteReplication {
|
||||
fn start(target: SocketAddr, runtime_id: String) -> Result<Self> {
|
||||
let pending = Arc::new((
|
||||
Mutex::new(None::<Vec<cultnet_rs::CultNetMessage>>),
|
||||
Condvar::new(),
|
||||
));
|
||||
let worker_pending = pending.clone();
|
||||
let worker_runtime_id = runtime_id.clone();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let worker_alive = alive.clone();
|
||||
thread::Builder::new()
|
||||
.name("ghostlight-odin-rudp".into())
|
||||
.spawn(move || {
|
||||
struct Liveness(Arc<AtomicBool>);
|
||||
impl Drop for Liveness {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let _liveness = Liveness(worker_alive);
|
||||
loop {
|
||||
let messages = {
|
||||
let (lock, ready) = &*worker_pending;
|
||||
let mut pending = lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
while pending.is_none() {
|
||||
pending = ready
|
||||
.wait(pending)
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
pending.take().unwrap_or_default()
|
||||
};
|
||||
if messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let options = remote_options(target, &worker_runtime_id);
|
||||
if let Err(error) =
|
||||
publish_cultnet_messages_to_rudp_catalog(&messages, options)
|
||||
{
|
||||
tracing::warn!(
|
||||
document_count = messages.len(),
|
||||
%target,
|
||||
%error,
|
||||
"Odin publication failed; retaining the newest derived projection for retry"
|
||||
);
|
||||
let (lock, ready) = &*worker_pending;
|
||||
let mut pending = lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if pending.is_none() {
|
||||
*pending = Some(messages);
|
||||
}
|
||||
let _ = ready
|
||||
.wait_timeout(pending, std::time::Duration::from_secs(2))
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
target,
|
||||
runtime_id,
|
||||
pending,
|
||||
alive,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn options(&self) -> CultMeshRudpDocumentPublishOptions {
|
||||
remote_options(self.target, &self.runtime_id)
|
||||
}
|
||||
|
||||
fn enqueue(&self, messages: Vec<cultnet_rs::CultNetMessage>) {
|
||||
let (lock, ready) = &*self.pending;
|
||||
let mut pending = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*pending = Some(messages);
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions {
|
||||
let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id);
|
||||
options.source_agent_id = Some(PROVIDER_ID.into());
|
||||
options.source_role = Some("narrative-simulation".into());
|
||||
options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()];
|
||||
options
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn publishes_only_owner_derived_surface_and_health() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
None,
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let health = publisher.publish(None).unwrap();
|
||||
assert_eq!(health["worldState"], "empty");
|
||||
assert_eq!(health["surfaceVersion"], 0);
|
||||
assert_eq!(publisher.health().unwrap(), health);
|
||||
let node = publisher.node.lock().unwrap();
|
||||
let surface = node
|
||||
.get_required::<EveSurfaceRecord>(SURFACE_KEY)
|
||||
.unwrap()
|
||||
.value;
|
||||
assert_eq!(surface["type"], "surface-state");
|
||||
assert_eq!(surface["schema"], "gamecult.eve.surface.v1");
|
||||
assert_eq!(surface["providerId"], PROVIDER_ID);
|
||||
assert_eq!(surface["version"], 0);
|
||||
assert!(
|
||||
surface["updatedAtUtc"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.parse::<DateTime<Utc>>().is_ok())
|
||||
);
|
||||
assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]);
|
||||
assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]);
|
||||
assert!(
|
||||
node.get_required::<EveProviderAdvertisementRecord>(ADVERTISEMENT_KEY)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(node.get_required::<ServiceHealthRecord>(HEALTH_KEY).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_odin_cannot_block_local_publication() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
publisher.publish(None).unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(500));
|
||||
assert_eq!(publisher.health().unwrap()["worldState"], "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_odin_worker_makes_runtime_health_non_green() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
publisher.publish(None).unwrap();
|
||||
publisher
|
||||
.remote
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.alive
|
||||
.store(false, Ordering::Release);
|
||||
assert!(publisher.health().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertisement_has_one_surface_and_one_command_boundary() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
None,
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let advertisement = publisher.provider_advertisement("2026-09-01T00:00:00Z");
|
||||
assert_eq!(advertisement["surfaces"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(advertisement["commands"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(advertisement["surfaces"][0]["surfaceId"], SURFACE_ID);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
|||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
#[test]
|
||||
fn embedded_commit_matches_checkout_head() {
|
||||
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(&repo_root)
|
||||
.output()
|
||||
.expect("git must be available for the provenance regression test");
|
||||
let checkout_head = if output.status.success() {
|
||||
String::from_utf8(output.stdout)
|
||||
.expect("git commit must be UTF-8")
|
||||
.trim()
|
||||
.to_owned()
|
||||
} else {
|
||||
fs::read_to_string(repo_root.join(".ghostlight-source-commit"))
|
||||
.expect("frozen source archive must carry its exact commit witness")
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
assert_eq!(
|
||||
env!("GHOSTLIGHT_BUILD_COMMIT"),
|
||||
checkout_head,
|
||||
"the binary embedded stale Git provenance"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
schema = "gamecult.idunn.target_declaration.v1"
|
||||
target = "ghostlight"
|
||||
source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT"
|
||||
required_gitlinks = ["vendor/eve"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-persona-boundary"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-world-library"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-dungeon"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-daemon"
|
||||
phase = "build"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "install-web-dependencies"
|
||||
phase = "prepare"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "ci"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-web"
|
||||
phase = "test"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "test"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-web"
|
||||
phase = "build"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "run", "build"]
|
||||
|
||||
[[steps]]
|
||||
id = "accept-real-persona-membrane"
|
||||
phase = "acceptance"
|
||||
runner = "rust-acceptance"
|
||||
argv = [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"-p",
|
||||
"ghostlight",
|
||||
"--lib",
|
||||
"controllers::tests::real_local_model_cognition_modes_commit_speech",
|
||||
"--",
|
||||
"--ignored",
|
||||
"--exact",
|
||||
]
|
||||
required_environment = [
|
||||
"CARGO_HOME",
|
||||
"GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG",
|
||||
"GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"HOME",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
]
|
||||
|
||||
[[artifacts]]
|
||||
id = "ghostlight-dungeon"
|
||||
source_kind = "runner-output"
|
||||
runner = "rust-build"
|
||||
source = "target/release/ghostlight-dungeon"
|
||||
destination = "ghostlight-dungeon"
|
||||
executable = true
|
||||
|
||||
[[artifacts]]
|
||||
id = "web"
|
||||
source_kind = "runner-output"
|
||||
runner = "web-build"
|
||||
source = "web/dist"
|
||||
destination = "web"
|
||||
|
||||
[service]
|
||||
executable_artifact = "ghostlight-dungeon"
|
||||
required_adjacent_artifacts = ["web"]
|
||||
arguments = [
|
||||
{ kind = "literal", value = "--state-root" },
|
||||
{ kind = "binding", name = "state_root" },
|
||||
]
|
||||
transport = "http"
|
||||
route_required = true
|
||||
required_environment = [
|
||||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||||
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
|
||||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"GHOSTLIGHT_DISCORD_GUILD_ID",
|
||||
"GHOSTLIGHT_DISCORD_ROLE_ID",
|
||||
"GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE",
|
||||
"GHOSTLIGHT_HEIMDALL_BASE_URL",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
"GHOSTLIGHT_LOCATED_SERVICE",
|
||||
"GHOSTLIGHT_ODIN_RUDP",
|
||||
"GHOSTLIGHT_PLAY_MODEL",
|
||||
"GHOSTLIGHT_PUBLIC_APP_URL",
|
||||
"GHOSTLIGHT_SERVICE_ID",
|
||||
"GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE",
|
||||
]
|
||||
optional_environment = ["RUST_LOG"]
|
||||
|
||||
[service.health]
|
||||
contract = "ghostlight.cultnet-rudp-service-health"
|
||||
|
||||
[state]
|
||||
schema_generation = "world-v3"
|
||||
|
||||
[[state.slots]]
|
||||
id = "world"
|
||||
relative_path = "world.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "app-sessions"
|
||||
relative_path = "service/app-sessions-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.app_session_store.v2"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "controller-work"
|
||||
relative_path = "service/controller-work.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.controller_work.v18"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "mesh-projection"
|
||||
relative_path = "service/mesh-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "gamecult.eve.surface.v1"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "rebuildable"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[provides]]
|
||||
capability = "ghostlight.world-service"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
compatibility = "consumer-v6"
|
||||
|
||||
[[provides]]
|
||||
capability = "gamecult.eve.surface-provider"
|
||||
schema = "gamecult.eve.surface.v1"
|
||||
compatibility = "v1"
|
||||
|
||||
[[dependencies]]
|
||||
kind = "shared-infrastructure"
|
||||
capability = "odin.verse-rendezvous"
|
||||
schema = "odin.verse-topology.v1"
|
||||
compatibility = "v1"
|
||||
startup = "before-promotion"
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
# Ghostlight Eve-native interface
|
||||
|
||||
## Objective
|
||||
|
||||
Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the
|
||||
provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`).
|
||||
Eve owns editable bindings, command invocation, receipts, plugin composition,
|
||||
and browser lowering. Ghostlight owns world creation, seeding, activation, play,
|
||||
and world admission; Heimdall owns authentication and entitlement decisions.
|
||||
|
||||
```text
|
||||
Ghostlight projection (eve.rs)
|
||||
-> EveBrowserProviderHost
|
||||
-> canonical Eve lowering
|
||||
-> gamecult.eve.command_invocation.v1
|
||||
-> Ghostlight command ingress (runtime.rs) or Heimdall private command plane
|
||||
-> gamecult.eve.command_result.v1
|
||||
-> authoritative surface refresh
|
||||
```
|
||||
|
||||
## Authority map
|
||||
|
||||
- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort`
|
||||
and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's
|
||||
app-session owner (`app_session.rs`) binds a verified Heimdall subject to a
|
||||
local cookie.
|
||||
- Inputs: the projector reads only the caller's app session, the world
|
||||
snapshot, and the caller's play-turn view. Command ingress reads one canonical
|
||||
Eve invocation and derives the principal server-side.
|
||||
- Outputs: one `gamecult.eve.surface.v1` document and one
|
||||
`gamecult.eve.command_receipt.v1` inside a command result.
|
||||
- Derived state: browser drafts, focus, command status, and rendered HTML are
|
||||
projections or local interaction state. None grants access or mutates
|
||||
fiction.
|
||||
- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the
|
||||
SSE stream cannot choose a principal or commit world state.
|
||||
- Shared path: every operation resolves the authenticated account, then calls
|
||||
the world mailbox, the seed port, or the play port.
|
||||
SSE carries invalidation only and the host refetches the same surface.
|
||||
|
||||
## The surface
|
||||
|
||||
`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller
|
||||
and world phase (`anonymous_surface`, `authenticated_surface` in
|
||||
`crates/ghostlight-dungeon/src/eve.rs`):
|
||||
|
||||
| Caller and world | Content | Operations |
|
||||
|---|---|---|
|
||||
| Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` |
|
||||
| Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) |
|
||||
| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_begin.v1`: title, brief, subject_label): create, then approve and activate as separate kernel commands, stopping at the first refusal; where a seed vault is configured it stops at the approved Draft so the owner can seed before Activate |
|
||||
| Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) |
|
||||
| Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) |
|
||||
|
||||
`world.play` is the only human play action. The open question's identity rides
|
||||
the play button's own action as an opaque token, never as an editable field.
|
||||
`operation_schema` in `eve.rs` is the single table from operation id to schema.
|
||||
|
||||
## Editable values
|
||||
|
||||
Eve bindings are the input model. A component binds a typed value by stable
|
||||
binding name, document identity, field path, value kind, and access mode.
|
||||
Renderer-local composers use `local-draft`. An operation captures one or more
|
||||
named binding values atomically.
|
||||
|
||||
The browser lowerer may use an HTML form for keyboard and accessibility
|
||||
behavior. HTML form structure never enters Eve state or command payload
|
||||
semantics. Accepted receipts may clear named drafts; rejection and stale
|
||||
conflict preserve them. An omitted clear-binding list means clear the surface's
|
||||
drafts; an explicit empty list means clear nothing. Ghostlight validates every
|
||||
payload at ingress regardless of what the control advertises.
|
||||
|
||||
## Authentication membrane
|
||||
|
||||
Anonymous projection contains only `heimdall.access_gate`. Its begin and
|
||||
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary
|
||||
(`heimdall.rs`). Ghostlight reads the redacted `heimdall:command-boundary`
|
||||
record from Odin, validates it, and invokes the discovered route. Odin does not
|
||||
proxy the command and never receives claims or completion payloads. The browser
|
||||
retains only the opaque attempt handle (`web/src/heimdall-access-adapter.ts`).
|
||||
Ghostlight redeems the completion, validates the access claim, and creates a
|
||||
local HttpOnly session cookie (`ghostlight_session`).
|
||||
|
||||
Routine requests use local verified session state, persisted in
|
||||
`app-sessions-v2.cc` (`app_session.rs`): the cookie hash, the Heimdall
|
||||
session and `access_revision`, expiries, and a wrapped refresh claim.
|
||||
`access_revision` is Heimdall's revocation epoch.
|
||||
|
||||
## Public boundary
|
||||
|
||||
- `GET /api/eve/provider`
|
||||
- `GET /api/eve/surfaces/{surface_id}`
|
||||
- `POST /api/eve/commands`
|
||||
- `GET /api/eve/events`
|
||||
- `GET /health`, a probe of the service's typed CultMesh health
|
||||
- `POST /cultnet/snapshot` and `POST /cultnet/world-patch`, the loopback
|
||||
CultNet doors (see `ghostlight-world-consumer-api.md` for the second)
|
||||
|
||||
Static assets in `web/` contain only the Eve provider host, the transport, the
|
||||
Heimdall adapter, and the SSE invalidation hook.
|
||||
|
|
@ -2,11 +2,8 @@
|
|||
"name": "ghostlight",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.",
|
||||
"description": "Ghostlight: the Rust kernel for socially situated agents.",
|
||||
"scripts": {
|
||||
"dungeon:build": "cargo build --workspace && npm --prefix web run build",
|
||||
"dungeon:test": "cargo test --workspace && npm --prefix web run build",
|
||||
"dungeon:run": "cargo run -p ghostlight-dungeon",
|
||||
"sdk:build": "npm --prefix sidecar/claude-sdk run build",
|
||||
"sdk:test": "npm --prefix sidecar/claude-sdk test"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
// Cut 10 (PA.f149): drives the real vendored Eve browser lowering
|
||||
// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a
|
||||
// surface document Dungeon actually served, so the command intent this
|
||||
// prints is what a real client sends -- never a hand-built payload asserting
|
||||
// its own shape is correct.
|
||||
//
|
||||
// stdin: {"surface": <the surface document>, "steps": [{"set": {node_id: text}}, {"click": node_id}]}
|
||||
// stdout: one JSON line per intent the click(s) produced, in order.
|
||||
//
|
||||
// `set` types into a control by its Eve node id (the same id
|
||||
// `eve.rs`'s own `json!({"id": ...})` calls carry), by finding the rendered
|
||||
// element via `data-eve-node-id` (assignId) and dispatching a real `input`
|
||||
// event, exactly as a user's keystroke would. `click` clicks a
|
||||
// `control.button` node the same way. jsdom is resolved from the vendored
|
||||
// package's own devDependencies (its own test suite uses the identical
|
||||
// pattern in `test/host-isolation.test.mjs`) rather than adding a second copy
|
||||
// to this repo.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const loweringRoot = join(here, "..", "vendor", "eve", "packages", "eve-browser-lowering");
|
||||
const vendorRequire = createRequire(join(loweringRoot, "package.json"));
|
||||
const { JSDOM } = vendorRequire("jsdom");
|
||||
const { EveBrowserDraftStore, renderEveSurface } = await import(
|
||||
"file://" + join(loweringRoot, "dist", "index.js").replace(/\\/g, "/")
|
||||
);
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='host'></div></body></html>", {
|
||||
url: "https://eve.bridge.test/",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const names = [
|
||||
"document", "window", "Element", "HTMLElement", "HTMLButtonElement",
|
||||
"HTMLInputElement", "HTMLSelectElement", "HTMLOptionElement",
|
||||
"HTMLTextAreaElement", "HTMLImageElement", "Event", "CustomEvent",
|
||||
];
|
||||
for (const name of names) globalThis[name] = dom.window[name];
|
||||
return dom;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, char => `\\${char}`);
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", chunk => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { surface, steps, provider } = JSON.parse(await readStdin());
|
||||
|
||||
const dom = installDom();
|
||||
const host = dom.window.document.getElementById("host");
|
||||
const intents = [];
|
||||
const draftStore = new EveBrowserDraftStore();
|
||||
const options = {
|
||||
draftStore,
|
||||
clientId: "eve.bridge.test",
|
||||
commandSink: intent => { intents.push(intent); },
|
||||
// `commandBoundary`/`receiptSchema` are not carried on the surface
|
||||
// document itself -- a real client learns them from the provider's own
|
||||
// CultMesh advertisement (`mesh.rs::provider_advertisement`,
|
||||
// `surfaces[].worldInteraction`), fetched separately. The caller passes
|
||||
// that same shape through here.
|
||||
provider,
|
||||
};
|
||||
renderEveSurface(surface, host, options);
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.set) {
|
||||
for (const [nodeId, value] of Object.entries(step.set)) {
|
||||
const wrapper = host.querySelector(`[data-eve-node-id="${cssEscape(nodeId)}"]`);
|
||||
if (!wrapper) throw new Error(`bridge: no rendered control for node id ${nodeId}`);
|
||||
// `assignId` stamps the Eve node id onto the field's own wrapper
|
||||
// (`renderEditableControl`'s `<label>`), not the `<input>`/
|
||||
// `<textarea>` inside it, so the real editable element is a
|
||||
// descendant when the wrapper itself is not one.
|
||||
const element = /^(INPUT|TEXTAREA|SELECT)$/.test(wrapper.tagName)
|
||||
? wrapper
|
||||
: wrapper.querySelector("input, textarea, select");
|
||||
if (!element) throw new Error(`bridge: node id ${nodeId} has no editable control`);
|
||||
// PA.f174: a real browser refuses keystrokes into a disabled
|
||||
// control outright -- typing into it here without this check would
|
||||
// silently capture a value no real client could ever have entered,
|
||||
// hiding exactly the gap PA.f167 named untestable.
|
||||
if (element.disabled) {
|
||||
throw new Error(`bridge: node id ${nodeId}'s own control is disabled; a real client cannot type into it`);
|
||||
}
|
||||
element.value = value;
|
||||
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
if (step.click) {
|
||||
const element = host.querySelector(`[data-eve-node-id="${cssEscape(step.click)}"]`);
|
||||
if (!element) throw new Error(`bridge: no rendered control for node id ${step.click}`);
|
||||
element.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
dom.window.close();
|
||||
process.stdout.write(intents.map(intent => JSON.stringify(intent)).join("\n") + "\n");
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
process.stderr.write(`${error.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
1
vendor/eve
vendored
1
vendor/eve
vendored
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ghostlight Dungeon</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#surface">Skip to Ghostlight</a>
|
||||
<header>
|
||||
<p class="eyebrow">GHOSTLIGHT / LIVING WORLDS</p>
|
||||
<h1>Ghostlight Dungeon</h1>
|
||||
<p id="status" role="status" aria-live="polite">Reading the table…</p>
|
||||
</header>
|
||||
<main id="surface" tabindex="-1"></main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1269
web/package-lock.json
generated
1269
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@gamecult/ghostlight-dungeon-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run typecheck && vite build",
|
||||
"dev": "vite --host 127.0.0.1 --port 8832",
|
||||
"test": "node test/static-boundary.mjs && node --experimental-strip-types --test test/heimdall-adapter.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering",
|
||||
"@gamecult/eve-contracts": "file:../vendor/eve/packages/eve-contracts",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": { "typescript": "^5.6.3", "vite": "^7.1.2" }
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Heimdall access browser adapter provenance
|
||||
|
||||
- Owner: `GameCult/Heimdall`
|
||||
- Plugin: `gamecult.heimdall.access`
|
||||
- Source commit: `e8e2832`
|
||||
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
|
||||
- Vendoring mode: exact semantic copy with formatting only
|
||||
|
||||
Ghostlight embeds this adapter so immutable releases do not depend on a sibling
|
||||
checkout at browser-build time. Authentication behavior remains Heimdall-owned;
|
||||
changes are imported from the owner and this provenance is advanced with them.
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
// Vendored from GameCult/Heimdall commit e8e2832, plugin
|
||||
// gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
|
||||
// adapter because its release must remain self-contained.
|
||||
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
|
||||
|
||||
export interface HeimdallAccessResumeTarget {
|
||||
complete(handle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HeimdallAccessResumeOptions {
|
||||
appSlug: string;
|
||||
}
|
||||
|
||||
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
|
||||
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
|
||||
return {
|
||||
pluginId: "gamecult.heimdall.access",
|
||||
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
|
||||
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
|
||||
schemas: ["heimdall.access_gate_state.v1"],
|
||||
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
},
|
||||
renderComponent(
|
||||
component: { id?: string },
|
||||
props: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const view = document.createElement("section");
|
||||
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
|
||||
if (component.id) view.id = component.id;
|
||||
view.setAttribute("role", "status");
|
||||
view.setAttribute("aria-live", "polite");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = String(props.title || "Access");
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = String(props.detail || "");
|
||||
view.append(title, detail);
|
||||
const displayName = String(props.displayName || "");
|
||||
if (displayName) {
|
||||
const identity = document.createElement("p");
|
||||
identity.className = "heimdall-identity";
|
||||
identity.textContent = displayName;
|
||||
view.append(identity);
|
||||
}
|
||||
return view;
|
||||
},
|
||||
async consumeCommandResult(
|
||||
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
|
||||
): Promise<void> {
|
||||
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
|
||||
const handle = String(pluginPayload.payload.handle || "");
|
||||
const navigation = object(pluginPayload.payload.navigation);
|
||||
const url = new URL(String(navigation.url || ""));
|
||||
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
|
||||
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
|
||||
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, handle);
|
||||
window.location.assign(url.toString());
|
||||
return;
|
||||
}
|
||||
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
|
||||
const status = String(pluginPayload.payload.status || "");
|
||||
if (status === "authenticated" || status === "denied" || status === "expired") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
|
||||
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
|
||||
});
|
||||
|
||||
export async function resumeHeimdallAccess(
|
||||
target: HeimdallAccessResumeTarget,
|
||||
options: HeimdallAccessResumeOptions,
|
||||
): Promise<boolean> {
|
||||
const returned = readHeimdallBrowserReturn(window.location.href, options.appSlug);
|
||||
const storedHandle = sessionStorage.getItem(ATTEMPT_KEY);
|
||||
if (returned) {
|
||||
clearHeimdallBrowserReturn();
|
||||
if (returned.status === "error") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
throw new Error(returned.message);
|
||||
}
|
||||
if (storedHandle && storedHandle !== returned.handle) {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
throw new Error("Heimdall returned an authentication attempt other than the one this browser started.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, returned.handle);
|
||||
}
|
||||
const handle = returned?.status === "success" ? returned.handle : storedHandle;
|
||||
if (!handle) return false;
|
||||
await target.complete(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readHeimdallBrowserReturn(
|
||||
href: string,
|
||||
appSlug: string,
|
||||
): { status: "success"; handle: string } | { status: "error"; message: string } | undefined {
|
||||
const url = new URL(href);
|
||||
const parameters = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
||||
const status = parameters.get("heimdall_status");
|
||||
if (!status) return undefined;
|
||||
if (parameters.get("heimdall_handoff_kind") !== "browser_completion"
|
||||
|| parameters.get("heimdall_app_slug") !== appSlug) {
|
||||
return { status: "error", message: "Heimdall returned an authentication result for another application or handoff mode." };
|
||||
}
|
||||
if (status !== "success") {
|
||||
return {
|
||||
status: "error",
|
||||
message: parameters.get("heimdall_error_description")
|
||||
|| parameters.get("heimdall_error")
|
||||
|| "Heimdall authentication failed.",
|
||||
};
|
||||
}
|
||||
// The attempt id is the handle the app's backend completes over Heimdall's
|
||||
// private command plane. The completion code in the same fragment is a
|
||||
// distinct random secret for the HTTP redeem route; this adapter neither
|
||||
// needs nor forwards it.
|
||||
const attemptId = parameters.get("heimdall_attempt_id") || "";
|
||||
if (!attemptId) {
|
||||
return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
|
||||
}
|
||||
return { status: "success", handle: attemptId };
|
||||
}
|
||||
|
||||
function clearHeimdallBrowserReturn(): void {
|
||||
history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
197
web/src/main.ts
197
web/src/main.ts
|
|
@ -1,197 +0,0 @@
|
|||
import {
|
||||
EveBrowserProviderHost,
|
||||
type EveBrowserProviderTransport,
|
||||
type EveCommandIntent,
|
||||
type EveProviderAdvertisement,
|
||||
type EveProviderSurfaceAdvertisement,
|
||||
type EveSurfaceDocument,
|
||||
} from "@gamecult/eve-browser-lowering";
|
||||
import { heimdallAccessBrowserAdapter, resumeHeimdallAccess } from "./heimdall-access-adapter";
|
||||
import "@gamecult/eve-browser-lowering/styles.css";
|
||||
import "./style.css";
|
||||
|
||||
class GhostlightEveTransport implements EveBrowserProviderTransport {
|
||||
private provider: EveProviderAdvertisement | undefined;
|
||||
private sourceVersion = 0;
|
||||
private authenticatedSurface = false;
|
||||
|
||||
async providerAdvertisement(): Promise<EveProviderAdvertisement> {
|
||||
this.provider = await fetchJson<EveProviderAdvertisement>("api/eve/provider");
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
async surface(surface: EveProviderSurfaceAdvertisement): Promise<EveSurfaceDocument> {
|
||||
const surfaceId = surface.surfaceId || "ghostlight.play";
|
||||
const document = await fetchJson<EveSurfaceDocument>(`api/eve/surfaces/${encodeURIComponent(surfaceId)}`);
|
||||
this.sourceVersion = Math.max(0, Math.trunc(document.version || 0));
|
||||
this.authenticatedSurface = !containsAnonymousAccessGate(document);
|
||||
return document;
|
||||
}
|
||||
|
||||
hasAuthenticatedSurface(): boolean {
|
||||
return this.authenticatedSurface;
|
||||
}
|
||||
|
||||
async submitCommand(intent: EveCommandIntent): Promise<unknown> {
|
||||
const result = await fetchJson<any>("api/eve/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(intent),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private advertisedTransport(): { transport?: string } {
|
||||
const commands = (this.provider as { commands?: Array<{ command?: string; transport?: string }> } | undefined)?.commands;
|
||||
const transport = commands?.find(command => command.command === "ghostlight.eve.commands")?.transport;
|
||||
return transport ? { transport } : {};
|
||||
}
|
||||
|
||||
async completeAuthentication(handle: string): Promise<any> {
|
||||
if (!this.provider) await this.providerAdvertisement();
|
||||
return await this.submitCommand({
|
||||
schema: "gamecult.eve.command_invocation.v1",
|
||||
providerId: this.provider?.providerId || "gamecult.ghostlight.dungeon",
|
||||
surfaceId: "ghostlight.play",
|
||||
operation: {
|
||||
operationId: "heimdall.auth.complete",
|
||||
schemaId: "heimdall.auth_complete_command.v1",
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
// The route transport is the one the provider advertises for its
|
||||
// command boundary, as the vendored client takes it from a command
|
||||
// descriptor; Ghostlight refuses an invocation without it.
|
||||
routeHint: { sourceVersion: this.sourceVersion, ...this.advertisedTransport() },
|
||||
},
|
||||
payload: { handle },
|
||||
issuedAt: new Date().toISOString(),
|
||||
clientId: "ghostlight.browser",
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T = any>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, { cache: "no-store", credentials: "same-origin", ...init });
|
||||
const body = await response.json().catch(() => undefined);
|
||||
if (!response.ok) {
|
||||
const message = body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `Ghostlight refused the request (${response.status}).`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function containsAnonymousAccessGate(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some(containsAnonymousAccessGate);
|
||||
const node = value as Record<string, unknown>;
|
||||
if (node.kind === "heimdall.access_gate") {
|
||||
const props = node.props && typeof node.props === "object" && !Array.isArray(node.props)
|
||||
? node.props as Record<string, unknown>
|
||||
: {};
|
||||
return props.state === "anonymous";
|
||||
}
|
||||
return Object.values(node).some(containsAnonymousAccessGate);
|
||||
}
|
||||
|
||||
function requiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) throw new Error(`Missing browser host #${id}.`);
|
||||
return element as T;
|
||||
}
|
||||
|
||||
const surfaceHost = requiredElement<HTMLElement>("surface");
|
||||
const status = requiredElement<HTMLElement>("status");
|
||||
const api = new GhostlightEveTransport();
|
||||
const host = new EveBrowserProviderHost(surfaceHost, api, {
|
||||
body: document.body,
|
||||
clientId: "ghostlight.browser",
|
||||
// PA.f191: this used to be 0 (no poll at all), relying entirely on the
|
||||
// `revision` SSE subscription below for every live update. A dropped
|
||||
// stream — closed by an intermediary, or never subscribed at all (see
|
||||
// `trySubscribeToRevisions` below) — then froze the card with no recovery
|
||||
// short of a manual reload. This poll is deliberately low-frequency: it is
|
||||
// a fallback net, not the primary channel, and `host.refresh()` is already
|
||||
// a no-op when the surface version has not moved.
|
||||
pollMs: 5_000,
|
||||
requestedSurfaceId: "ghostlight.play",
|
||||
source: "Ghostlight",
|
||||
statusElement: status,
|
||||
pluginAdapters: [heimdallAccessBrowserAdapter],
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", event => {
|
||||
event.preventDefault();
|
||||
status.textContent = event.reason instanceof Error ? event.reason.message : String(event.reason);
|
||||
});
|
||||
|
||||
// PA.f191: `api.hasAuthenticatedSurface()` reflects whatever the *last*
|
||||
// `surface()` fetch saw, and that flag can flip from false to true well
|
||||
// after this module's own startup sequence finishes — a player who signs in
|
||||
// through the in-page gate (`heimdall.auth_completion_status.v1`, resolved
|
||||
// entirely inside the vendored host's own `submit()`/`refresh()` cycle,
|
||||
// never observed by this file directly) authenticates on a timer this file
|
||||
// has no direct hook into. A one-shot check right after startup only ever
|
||||
// caught the redirect flow (`resumeHeimdallAccess`'s own `complete`
|
||||
// callback, below); the in-page flow left the tab with no `revision`
|
||||
// subscription for the rest of the session, so every answer after the first
|
||||
// was refused as stale and the card looked frozen. `trySubscribeToRevisions`
|
||||
// is idempotent and cheap to call speculatively — `hasAuthenticatedSurface()`
|
||||
// is a field read — so it is called at every point this file can observe an
|
||||
// authentication transition, plus on a short recheck interval to catch the
|
||||
// in-page flow it cannot observe directly any other way.
|
||||
let revisionsSubscribed = false;
|
||||
function trySubscribeToRevisions(): void {
|
||||
if (revisionsSubscribed || !api.hasAuthenticatedSurface()) return;
|
||||
revisionsSubscribed = true;
|
||||
const events = new EventSource("api/eve/events");
|
||||
events.addEventListener("revision", () => void host.refresh());
|
||||
events.addEventListener("error", () => {
|
||||
status.textContent = "Live revision notices are unavailable; your authoritative surface remains safe to refresh.";
|
||||
});
|
||||
}
|
||||
|
||||
await host.start();
|
||||
trySubscribeToRevisions();
|
||||
const resumeTarget = {
|
||||
complete: async (handle: string) => {
|
||||
const result = await api.completeAuthentication(handle);
|
||||
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
|
||||
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
|
||||
}
|
||||
await host.refresh();
|
||||
trySubscribeToRevisions();
|
||||
},
|
||||
};
|
||||
await resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" });
|
||||
trySubscribeToRevisions();
|
||||
|
||||
// A player who finishes Discord in another tab or app and switches back to
|
||||
// this one, rather than following Heimdall's "Return to the app" link, never
|
||||
// reloads this page, so the load-time resume above never sees the result.
|
||||
// Re-running the same resume when the tab becomes visible asks Heimdall about
|
||||
// the attempt this tab started; it answers pending or authenticated, and the
|
||||
// adapter drops the stored attempt once the answer is final.
|
||||
let resuming = false;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible" || resuming || api.hasAuthenticatedSurface()) return;
|
||||
resuming = true;
|
||||
resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" })
|
||||
.catch(error => {
|
||||
status.textContent = error instanceof Error ? error.message : String(error);
|
||||
})
|
||||
.finally(() => {
|
||||
resuming = false;
|
||||
});
|
||||
});
|
||||
|
||||
// The in-page gate's own completion never calls back into this file: it
|
||||
// resolves inside the vendored host's `submit()` (a command dispatch) and
|
||||
// its own `window.setTimeout(() => void host.refresh(), 100)`, which is the
|
||||
// one place `api.hasAuthenticatedSurface()` gets re-evaluated for that flow.
|
||||
// This is the only vantage point this file has on that transition; the
|
||||
// pollMs fallback above shares the same "cheap, idempotent, safe to run
|
||||
// speculatively" reasoning.
|
||||
window.setInterval(trySubscribeToRevisions, 2_000);
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:opsz,wght@8..144,400;8..144,600&family=IBM+Plex+Mono:wght@400;600&display=swap');
|
||||
:root{color-scheme:dark;font-family:"Crimson Pro",serif;background:#0c1110;color:#e8e1cf}
|
||||
body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% 0,#253229 0,transparent 38rem),linear-gradient(145deg,#0c1110,#111513)}
|
||||
header,#surface{width:min(1180px,calc(100% - 2rem));margin:auto}
|
||||
header{padding:3rem 0 1.25rem;border-bottom:1px solid #66543d}
|
||||
h1{font-size:clamp(2.6rem,8vw,5.5rem);font-weight:400;letter-spacing:-.045em;margin:.1rem 0}
|
||||
.eyebrow,#status,.field-label,button,pre{font-family:"IBM Plex Mono",monospace}
|
||||
.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}
|
||||
#status{color:#9aa69f;font-size:.78rem}
|
||||
#surface{padding:1.5rem 0 5rem;min-height:12rem}
|
||||
.eve-surface{display:grid;gap:1rem}
|
||||
.card,.pane,.heimdall-access{padding:1rem;border:1px solid #66543d;background:#17201ddd}
|
||||
.cultui-field{display:block;margin:.75rem 0}
|
||||
.field-label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;margin:.65rem 0 .25rem}
|
||||
textarea,input,select{box-sizing:border-box;width:100%;resize:vertical;background:#0b0f0d;color:#f2e7d0;border:1px solid #4a5148;padding:.8rem;margin:.15rem 0;font:1.05rem/1.4 "Crimson Pro",serif}
|
||||
textarea{min-height:7rem}
|
||||
button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}
|
||||
button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}
|
||||
pre,.cultui-code{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font:.72rem/1.5 "IBM Plex Mono",monospace}
|
||||
.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}
|
||||
.cultui-list-item{border-top:1px solid #354039;padding:.8rem 0}
|
||||
.cultui-list-item-label,.cultui-list-item-status{font-family:"IBM Plex Mono",monospace;color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}
|
||||
.cultui-tabs [role="tablist"]{display:flex;gap:.4rem;position:sticky;top:0;background:#0c1110e8;z-index:2}
|
||||
.eve-command-result{margin:1rem 0;padding:1rem;border:1px solid #66543d;background:#17201d}
|
||||
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,#surface{width:min(100% - 1rem,1180px)}.card,.pane,.heimdall-access{padding:.75rem}}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import {
|
||||
heimdallAccessBrowserAdapter,
|
||||
readHeimdallBrowserReturn,
|
||||
resumeHeimdallAccess,
|
||||
} from "../src/heimdall-access-adapter.ts";
|
||||
|
||||
const attemptKey = "gamecult.heimdall.access.attempt";
|
||||
const originalGlobals = {
|
||||
history: globalThis.history,
|
||||
sessionStorage: globalThis.sessionStorage,
|
||||
window: globalThis.window,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobal("history", originalGlobals.history);
|
||||
restoreGlobal("sessionStorage", originalGlobals.sessionStorage);
|
||||
restoreGlobal("window", originalGlobals.window);
|
||||
});
|
||||
|
||||
test("browser return parser completes by attempt id and never reads the completion code", () => {
|
||||
const handle = "d0a9c1e7-89ac-46f9-8c85-c2484ed18f76";
|
||||
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle });
|
||||
assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined);
|
||||
assert.equal(readHeimdallBrowserReturn(returnUrl(handle), "another-app")?.status, "error");
|
||||
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight"), { status: "success", handle });
|
||||
assert.ok(!JSON.stringify(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight")).includes("secret-redeem-code"));
|
||||
assert.equal(readHeimdallBrowserReturn(returnUrl("", "secret-redeem-code"), "ghostlight")?.status, "error");
|
||||
});
|
||||
|
||||
test("fresh browser context redeems the URL witness and scrubs it", async () => {
|
||||
const handle = "0be30b8c-b599-4920-b8b8-733f5e7bc275";
|
||||
const storage = memoryStorage();
|
||||
const replaced = [];
|
||||
installBrowserGlobals(returnUrl(handle), storage, replaced);
|
||||
const completed = [];
|
||||
|
||||
assert.equal(await resumeHeimdallAccess({ complete: async value => completed.push(value) }, { appSlug: "ghostlight" }), true);
|
||||
assert.deepEqual(completed, [handle]);
|
||||
assert.equal(storage.getItem(attemptKey), handle);
|
||||
assert.deepEqual(replaced, ["/ghostlight/"]);
|
||||
|
||||
await heimdallAccessBrowserAdapter.consumeCommandResult({
|
||||
schemaId: "heimdall.auth_completion_status.v1",
|
||||
payload: { status: "authenticated" },
|
||||
});
|
||||
assert.equal(storage.getItem(attemptKey), null);
|
||||
});
|
||||
|
||||
test("conflicting stored and returned attempts never reach completion", async () => {
|
||||
const storage = memoryStorage([[attemptKey, "stored-handle"]]);
|
||||
installBrowserGlobals(returnUrl("returned-handle"), storage, []);
|
||||
let completed = false;
|
||||
|
||||
await assert.rejects(
|
||||
resumeHeimdallAccess({ complete: async () => { completed = true; } }, { appSlug: "ghostlight" }),
|
||||
/other than the one this browser started/,
|
||||
);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(storage.getItem(attemptKey), null);
|
||||
});
|
||||
|
||||
function installBrowserGlobals(href, storage, replaced) {
|
||||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(globalThis, "history", {
|
||||
configurable: true,
|
||||
value: { replaceState: (_state, _title, url) => replaced.push(url) },
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { location: { href, pathname: "/ghostlight/", search: "" } },
|
||||
});
|
||||
}
|
||||
|
||||
function restoreGlobal(name, value) {
|
||||
if (value === undefined) delete globalThis[name];
|
||||
else Object.defineProperty(globalThis, name, { configurable: true, value });
|
||||
}
|
||||
|
||||
// Heimdall issues a random completion code distinct from the attempt id, as
|
||||
// the real callback does; the fixture must not restate the old equality.
|
||||
function returnUrl(attemptId, completionCode = `code-for-${attemptId}`) {
|
||||
const parameters = new URLSearchParams({
|
||||
heimdall_status: "success",
|
||||
heimdall_provider: "discord",
|
||||
heimdall_handoff_kind: "browser_completion",
|
||||
heimdall_app_slug: "ghostlight",
|
||||
heimdall_mode: "sign_in",
|
||||
heimdall_attempt_id: attemptId,
|
||||
heimdall_completion_code: completionCode,
|
||||
});
|
||||
return `https://yggdrasil.gamecult.org/ghostlight/#${parameters}`;
|
||||
}
|
||||
|
||||
function memoryStorage(initial = []) {
|
||||
const values = new Map(initial);
|
||||
return {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: key => values.get(key) ?? null,
|
||||
key: index => [...values.keys()][index] ?? null,
|
||||
removeItem: key => { values.delete(key); },
|
||||
setItem: (key, value) => { values.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const forbidden = [
|
||||
"/api/session-zero",
|
||||
"/api/governance",
|
||||
"/api/command",
|
||||
"/api/auth/heimdall",
|
||||
"renderSession",
|
||||
"renderCampaign",
|
||||
"payload.fields",
|
||||
'kind: "form"',
|
||||
'"kind":"form"',
|
||||
];
|
||||
|
||||
function sourceFiles(directory) {
|
||||
return readdirSync(directory)
|
||||
.flatMap(name => {
|
||||
const path = join(directory, name);
|
||||
return statSync(path).isDirectory() ? sourceFiles(path) : [path];
|
||||
})
|
||||
.filter(path => /\.(ts|tsx|js|mjs)$/.test(path));
|
||||
}
|
||||
|
||||
const violations = sourceFiles(fileURLToPath(new URL("../src", import.meta.url)))
|
||||
.flatMap(path => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return forbidden.filter(token => source.includes(token)).map(token => `${path}: ${token}`);
|
||||
});
|
||||
|
||||
if (violations.length) {
|
||||
throw new Error(`Ghostlight browser crossed its Eve boundary:\n${violations.join("\n")}`);
|
||||
}
|
||||
|
||||
console.log("Ghostlight browser contains only the Eve provider boundary.");
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@gamecult/eve-contracts": fileURLToPath(
|
||||
new URL("../vendor/eve/packages/eve-contracts/dist/index.js", import.meta.url),
|
||||
),
|
||||
"ajv/dist/2020.js": fileURLToPath(
|
||||
new URL("./node_modules/ajv/dist/2020.js", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue