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:
Meta Vens 2026-10-22 23:52:51 +02:00
commit f40bbaac13
32 changed files with 7 additions and 28583 deletions

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
node_modules/
web/dist/
sidecar/claude-sdk/dist/
target/

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "vendor/eve"]
path = vendor/eve
url = https://github.com/GameCult/Eve.git

680
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"] }

View file

@ -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())
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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"
);
}

View file

@ -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"

View file

@ -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.

View file

@ -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"
},

View file

@ -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 +0,0 @@
Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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" }
}

View file

@ -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.

View file

@ -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> : {};
}

View file

@ -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);

View file

@ -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}}

View file

@ -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); },
};
}

View file

@ -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.");

View file

@ -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"]
}

View file

@ -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),
),
},
},
});