Merge ghostlight-verse/dungeon-eve-quarantine: the Dungeon's Eve surface, web host and daemon are parked at parked/ghostlight-eve-surface until Thing 2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 00:11:58 +02:00
commit c43eb54962
44 changed files with 86 additions and 28666 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

View file

@ -5,7 +5,8 @@
Ghostlight is a Rust world kernel and the play daemon built on it. Models
propose; the kernel admits or refuses. The same kernel is meant to run the
simulation behind Aetheria's public story, a persistent cast and storylets, as
well as Ghostlight Dungeon, the single-player DM-led client. `README.md` says
well as Ghostlight Dungeon, the single-player DM-led client (parked at
tag `parked/ghostlight-eve-surface`). `README.md` says
what exists today and what does not.
## Where State Lives
@ -55,28 +56,21 @@ what exists today and what does not.
- Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism:
`docs/architecture/ghostlight-world-ontology.md`. The consumer contract:
`docs/architecture/ghostlight-world-consumer-api.md`.
- Dungeon daemon: `crates/ghostlight-dungeon/src/` (`runtime.rs` for routes and
commands, `play.rs` for the play table, `eve.rs` for the surface).
Interface authority: `docs/architecture/ghostlight-eve-native-interface.md`.
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
tag `parked/ghostlight-eve-surface`; not on main.
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale: `docs/architecture/ghostlight-dungeon-mvp.md`.
- Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
`notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09
rebuild, not the current plan.
## Build, Verify, Deploy
Ghostlight Dungeon ships to Linux on Yggdrasil. Idunn freezes an exact commit,
runs the locked native Linux tests, builds the daemon and web projection in
pinned containers, and seals the release. The steps are
`deployment/idunn/recipe.toml`; the owning runbook is
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
You are probably reading this on a Windows workstation. That is where the shell
is, not where the artifact runs. A Windows compile is not evidence about the
Linux release; build for the target, or let the deploy path's builder do it, and
say which one you used. Heavy builds and test runs go to Yggdrasil, not the
workstation. Deploy only through Idunn (`idunn up ghostlight`).
Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and
lifecycle brakes engaged, unit stopped; retirement waits on
`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the
`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence
about Linux, so say which host you used. Do not run `idunn up ghostlight`.
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
`F:\Projects\gamecult-ops` before acting.

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

@ -2,7 +2,7 @@
**Ghostlight is a world kernel: typed world state that a language model can
propose changes to and can never write.** The valuable part is
`crates/ghostlight`. The dungeon game in this repo is one client of it.
`crates/ghostlight`. The Dungeon game, one client of it, is parked (below).
**The thesis.** World state is typed documents. Every change to it, and every
narrative dependency on it, is an operation in a closed, predefined algebra.
@ -13,7 +13,7 @@ committed. A model-as-game-master holds the world in a context window and
drifts; here the world is a digest-chained journal that survives restarts and
cannot be talked into contradicting itself.
**Use it as a structured state backend.** Do not judge it by the dungeon's
**Use it as a structured state backend.** Do not judge it by the parked Dungeon's
prose. The kernel is a library; prose comes from whichever model you plug into
its ports.
@ -30,7 +30,8 @@ its ports.
algebra, and what the reducer checks.
- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a
program that reads a world and proposes changes to the characters it controls
(`POST /cultnet/world-patch`, served by the Dungeon daemon).
(`POST /cultnet/world-patch`; no process on main serves that door, a host serves
`ghostlight::admit_document`).
Start with those two documents, then `cargo doc -p ghostlight --open`.
@ -70,18 +71,12 @@ advances it a number of ticks under a token cap, reporting per tick what each
faction did and what it cost. Nothing loads AetheriaLore prose, models
storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`.
## The dungeon harness
## Ghostlight Dungeon (parked)
`crates/ghostlight-dungeon` is a single-player, DM-led game on one world: free
text in, a Persona's prose out, an Interpreter lowering that prose to proposals
the kernel decides. Status: it is deployed on Yggdrasil under Idunn, but it is a
product under repair, not a demo to judge the kernel by. Its play-session
defects are open (`notes/ghostlight-verse-map.md`, probed 2026-10-09), its
storyteller is a small local model by default, and a live run with a hosted model
waits on an operator key. An agent that runs it as a game master and finds the
narration incoherent has most likely measured an empty affordance catalog or the model,
not the kernel. Sign-in is
Heimdall's, so it does not serve an anonymous visitor.
The single-player, DM-led client and its daemon, Eve play surface and browser
host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and
nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI
(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag.
## Repo map
@ -89,14 +84,10 @@ Heimdall's, so it does not serve an anonymous visitor.
|---|---|
| `crates/ghostlight/` | The sealed kernel library. |
| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. |
| `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: HTTP and Eve routes, play table, CultMesh publication, Idunn health. |
| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). |
| `web/` | Thin Eve browser host and Heimdall access adapter; owns no product state. |
| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. |
| `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build and seal a release. |
| `vendor/eve/` | Git submodule supplying the Eve browser lowering. |
| `AGENTS.md` | Invariants and where things are. |
| `docs/architecture/ghostlight-dungeon-mvp.md` | The authority architecture. |
| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). |
| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. |
`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design
@ -106,32 +97,25 @@ running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history
## Build and test
The release target is Linux on Yggdrasil; a Windows build says nothing about it.
The recipe's steps are the verification:
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
workstation:
```sh
git submodule update --init vendor/eve
cargo build --locked --workspace
cargo test --locked -p ghostlight-persona-projection
cargo test --locked -p ghostlight
cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon
cargo build --locked --release -p ghostlight-dungeon --bin ghostlight-dungeon
npm --prefix web ci && npm --prefix web test && npm --prefix web run build
cargo test --locked -p ghostlight-verse
```
The kernel alone needs only `cargo test -p ghostlight`. The sidecar has
`npm run sdk:build` and `npm run sdk:test`. Four Dungeon tests are `#[ignore]`d
because they drive the vendored browser lowering through
`tools/eve_client_bridge.mjs` and need Node beside cargo; the release acceptance
test `controllers::tests::real_local_model_cognition_modes_commit_speech` is
ignored by default and needs a real model.
`npm run sdk:build` and `npm run sdk:test`. The acceptance test
`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored
by default and needs a real model.
Run the Dungeon locally with `npm run dungeon:run` or
`cargo run -p ghostlight-dungeon -- --state-root PATH`; configuration is by
`GHOSTLIGHT_*` environment variables (the recipe's `required_environment` lists
the deployment ones). Inference is routed by model name: `local/` goes to an
OpenAI-compatible loopback endpoint, `claude` to the SDK sidecar, and the verse
runner's `hosted/` to an https endpoint. Ghostlight never reads, stores or
forwards a model credential beyond a key file named at run time.
Inference is routed by model name: `local/` goes to an OpenAI-compatible
loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/`
to an https endpoint. Ghostlight never reads, stores or forwards a model
credential beyond a key file named at run time.
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
publication, browser, model-provider and diagnostic boundaries.
publication, model-provider and diagnostic boundaries.

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

@ -80,7 +80,7 @@ const HANDLE_SEPARATOR: &str = "__";
const PERSONA_WORD_BUDGET: usize = 180;
const CONTROLLER_WORK_ROW: &str = "controller_work.v18";
/// The `controller-work` state slot's schema. `pub` so a deploy recipe (or a
/// test pinning one, e.g. `ghostlight-dungeon`'s recipe-drift test) can check
/// test pinning one) can check
/// itself against this constant instead of hand-copying the version number.
pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v18";

View file

@ -7916,7 +7916,8 @@ mod tests {
};
/// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer:
/// `ghostlight-dungeon`'s play agent (PA.f61), which refuses a
/// `ghostlight-dungeon`'s play agent (PA.f61, parked at
/// `parked/ghostlight-eve-surface`), which refuses a
/// `set_persona_material` run whose patch answers true here, since
/// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse
/// its JSON to decide the question itself.

View file

@ -1606,7 +1606,8 @@ mod tests {
// PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it
// from its own list too. This is now the complete 24-tool list.
// PA.f88: this is the library's own test fixture, kept in sync by hand.
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at
// `parked/ghostlight-eve-surface`) is the
// single policy owner of what the play agent may actually call; this
// copy pins nothing there and is not read by production code.
const PLAY_TOOLS: &[&str] = &[

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,5 +1,7 @@
# Ghostlight authority architecture
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
## Status
This document is the adopted rebuild target for the Ghostlight runtime. It is

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

@ -1,5 +1,7 @@
# Ghostlight Dungeon play agent (plan step 16)
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
## Status
Target, adopted 2026-09-22. Not implemented. The means will be in

View file

@ -1,5 +1,7 @@
# Ghostlight Dungeon Session Zero
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
Status: adopted direction, 2026-09-15. Not implemented. Its role as the
playtest gate is superseded (operator, 2026-09-22) by the play agent,
`ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes

View file

@ -63,6 +63,11 @@ attributed proposal — is not part of this pass; see "Not in this pass" below.
## Transport
Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is
parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves
it. `ghostlight::admit_document` and `encode_receipt` are the contract a host
serves.
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
`api_router` (`runtime.rs`). The handler gates loopback and content type
(`application/msgpack`) exactly as the snapshot route does, then hands the

View file

@ -2,25 +2,21 @@
This is a pointer. It does not carry open work; the Eureka mind does.
## What runs
## What exists
Ghostlight Dungeon runs on Yggdrasil under Idunn at
`https://yggdrasil.gamecult.org/ghostlight/` (runtime `ghostlight-yggdrasil`,
target `ghostlight`, ref `main`, state root `/var/lib/gamecult/ghostlight-world-v2`).
`/health` names the commit it serves (`crates/ghostlight-dungeon/src/runtime.rs`).
The binary is `ghostlight-dungeon`; its inference reaches Bonsai on Raven
through a loopback tunnel, and sign-in goes through Heimdall, discovered via
Odin.
Nothing is deployed. The Dungeon (daemon, play table, Eve surface, browser host,
`vendor/eve`, Idunn recipe) is parked at the tag `parked/ghostlight-eve-surface`
and is not on main. A Thing 2 rebuild starts from that tag.
## How it deploys
What is in the tree is the kernel (`crates/ghostlight`), the persona projection
(`crates/ghostlight-persona-projection`) and the verse runner
(`crates/ghostlight-verse`). `cargo test` runs them on Linux; use the
`ygg-verify` stopgap on Yggdrasil, not the workstation, and say which host
you used.
Idunn is the only deploy path (`idunn up ghostlight`). The recipe is
`deployment/idunn/recipe.toml`; the runbook is
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. The Ghostlight daemon
cannot start without Odin: its Warming presence and process write lease go
through Odin. Preflight Odin's route health before a deploy. Do not invoke the
retired v1 actuator (`gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`), its
wiring test, or `ghostlight-dungeon.service`.
The Idunn target `ghostlight` is braked and stopped, not retired. Its state is in
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. Do not run
`idunn up ghostlight`, the v1 actuator, or `ghostlight-dungeon.service`.
## Where open work is
@ -35,9 +31,10 @@ target and probed body facts are `notes/ghostlight-verse-target.md` and
1. `README.md`: what exists and what does not.
2. `AGENTS.md`: invariants and where things are.
3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture.
4. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
3. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
current mechanism.
4. `docs/architecture/ghostlight-dungeon-mvp.md`: the parked Dungeon's authority
architecture. History until Thing 2 revives it.
5. `docs/architecture/ghostlight-play-agent-cut.md`: the dated record of the play
agent and of the first deploy on 2026-09-23 (findings PA.f198 to PA.f216).
It is history; the mind says which findings are still open.

View file

@ -1,5 +1,7 @@
# Ghostlight Verse: Map
> Status: Dungeon, eve.rs and the bridge-test references below are parked at tag `parked/ghostlight-eve-surface`; line refs are to that tag.
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r3`
(`notes/ghostlight-verse-target.md`). This page holds body facts, the model
page and rationale only. Cut specs, questions, rulings and follow-ups live in
@ -66,7 +68,7 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec
### Does main build and pass?
Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised:
Two jobs through `ygg-verify.sh` at `4fee97f`, with the Eve submodule initialised:
- Rust (image `eureka-verify-rust`), the recipe's own steps:
`ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed,

View file

@ -5,8 +5,14 @@ No live smoke exists in this tree. The ignored test this file once documented,
ran the production tick driver and the Active elaboration sweep against a real
CodexConnector; the play agent's Cut 1 (`d69e9d4`) deleted both, and the test
with them. A command that names it now matches nothing and exits 0. No
replacement exists. The real-model gate that does exist is the Idunn recipe's
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`).
replacement exists. The recipe that once gated a deploy on a real model is
gone from main, and so is its `accept-real-persona-membrane` step. The one real-model test
left is the ignored `controllers::tests::real_local_model_cognition_modes_commit_speech`
(`crates/ghostlight/src/controllers.rs`). It has no automated run: it needs a model server
on `GHOSTLIGHT_LOCAL_ENDPOINT` plus the `GHOSTLIGHT_CONTROLLER_*_MODEL`,
`GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID` and `GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG`
variables, and a person runs it by hand with `cargo test -p ghostlight -- --ignored
real_local_model_cognition_modes_commit_speech`.
What remains true and stays here: bringing up a real CodexConnector and a
built Claude SDK sidecar. The seed lane (`world.seed`) still runs real

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