Compare commits
47 changed files with 28863 additions and 656 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules/
|
||||
web/dist/
|
||||
sidecar/claude-sdk/dist/
|
||||
target/
|
||||
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "vendor/eve"]
|
||||
path = vendor/eve
|
||||
url = https://github.com/GameCult/Eve.git
|
||||
28
AGENTS.md
28
AGENTS.md
|
|
@ -5,8 +5,7 @@
|
|||
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 (parked at
|
||||
tag `parked/ghostlight-eve-surface`). `README.md` says
|
||||
well as Ghostlight Dungeon, the single-player DM-led client. `README.md` says
|
||||
what exists today and what does not.
|
||||
|
||||
## Where State Lives
|
||||
|
|
@ -56,21 +55,28 @@ 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`.
|
||||
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
|
||||
tag `parked/ghostlight-eve-surface`; not on main.
|
||||
- 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`.
|
||||
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
|
||||
- Claude SDK sidecar: `sidecar/claude-sdk/`.
|
||||
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
|
||||
- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`.
|
||||
- Architecture rationale: `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
|
||||
|
||||
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`.
|
||||
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`).
|
||||
|
||||
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
|
||||
`F:\Projects\gamecult-ops` before acting.
|
||||
|
|
|
|||
680
Cargo.lock
generated
680
Cargo.lock
generated
|
|
@ -37,6 +37,15 @@ 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"
|
||||
|
|
@ -52,6 +61,28 @@ 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"
|
||||
|
|
@ -75,6 +106,70 @@ 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"
|
||||
|
|
@ -87,6 +182,12 @@ 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"
|
||||
|
|
@ -187,6 +288,12 @@ 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"
|
||||
|
|
@ -259,6 +366,85 @@ 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"
|
||||
|
|
@ -267,6 +453,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -280,6 +467,30 @@ 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"
|
||||
|
|
@ -296,6 +507,12 @@ 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"
|
||||
|
|
@ -404,6 +621,18 @@ 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"
|
||||
|
|
@ -413,7 +642,7 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
|
@ -454,6 +683,44 @@ 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"
|
||||
|
|
@ -475,6 +742,15 @@ 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"
|
||||
|
|
@ -508,12 +784,24 @@ 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"
|
||||
|
|
@ -527,6 +815,7 @@ dependencies = [
|
|||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
|
|
@ -567,7 +856,7 @@ dependencies = [
|
|||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -733,6 +1022,27 @@ 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"
|
||||
|
|
@ -772,12 +1082,43 @@ 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"
|
||||
|
|
@ -789,6 +1130,40 @@ 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"
|
||||
|
|
@ -833,6 +1208,16 @@ 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"
|
||||
|
|
@ -845,6 +1230,16 @@ 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"
|
||||
|
|
@ -866,6 +1261,21 @@ 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"
|
||||
|
|
@ -888,7 +1298,7 @@ dependencies = [
|
|||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -904,7 +1314,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
|
|
@ -926,7 +1336,7 @@ dependencies = [
|
|||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
|
@ -940,12 +1350,28 @@ 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"
|
||||
|
|
@ -957,6 +1383,16 @@ 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"
|
||||
|
|
@ -966,6 +1402,15 @@ 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"
|
||||
|
|
@ -999,6 +1444,23 @@ 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"
|
||||
|
|
@ -1080,6 +1542,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1088,6 +1552,15 @@ 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"
|
||||
|
|
@ -1154,6 +1627,12 @@ 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"
|
||||
|
|
@ -1207,6 +1686,17 @@ 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"
|
||||
|
|
@ -1230,6 +1720,15 @@ 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"
|
||||
|
|
@ -1246,6 +1745,27 @@ 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"
|
||||
|
|
@ -1258,6 +1778,16 @@ 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"
|
||||
|
|
@ -1268,6 +1798,16 @@ 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"
|
||||
|
|
@ -1355,6 +1895,45 @@ 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"
|
||||
|
|
@ -1392,7 +1971,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
|
@ -1444,6 +2023,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1454,13 +2034,23 @@ 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",
|
||||
]
|
||||
|
||||
|
|
@ -1482,6 +2072,7 @@ 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",
|
||||
|
|
@ -1505,6 +2096,36 @@ 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]]
|
||||
|
|
@ -1519,6 +2140,12 @@ 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"
|
||||
|
|
@ -1592,6 +2219,12 @@ 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"
|
||||
|
|
@ -1613,6 +2246,15 @@ 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"
|
||||
|
|
@ -1882,6 +2524,12 @@ 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"
|
||||
|
|
@ -1911,6 +2559,26 @@ dependencies = [
|
|||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
|
|
|||
12
Cargo.toml
12
Cargo.toml
|
|
@ -1,6 +1,7 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/ghostlight",
|
||||
"crates/ghostlight-dungeon",
|
||||
"crates/ghostlight-persona-projection",
|
||||
"crates/ghostlight-verse",
|
||||
]
|
||||
|
|
@ -13,10 +14,19 @@ 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"] }
|
||||
|
|
@ -24,7 +34,9 @@ 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"
|
||||
|
|
|
|||
220
README.md
220
README.md
|
|
@ -1,121 +1,149 @@
|
|||
# Ghostlight
|
||||
|
||||
**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, one client of it, is parked (below).
|
||||
Ghostlight is a Rust library that keeps a simulated world honest, and a play
|
||||
daemon built on it. A language model proposes; the library's kernel checks each
|
||||
proposal against typed world state and admits or refuses it. Places, people,
|
||||
knowledge, commitments, and time live in the kernel's journal, not in a model's
|
||||
context window, so they survive restarts and long stories.
|
||||
|
||||
**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.
|
||||
Models propose operations; the kernel checks each against the state (references,
|
||||
preconditions, authority, conservation, who knows what) and admits it or refuses
|
||||
it with the complete list of mismatches. Narration describes only what was
|
||||
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.
|
||||
## What is here
|
||||
|
||||
**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.
|
||||
| Part | Path | What it is |
|
||||
|---|---|---|
|
||||
| World kernel | `crates/ghostlight/` | The sealed `ghostlight` library: world state, the closed operation set and reducer, the CultCache journal, the mailbox that is its only door, controllers, elaboration, lenses, the consumer ingress, and the inference ports. |
|
||||
| Persona projection | `crates/ghostlight-persona-projection/` | The membrane between structured state and a prose-only Persona: projection, Persona turn receipts, and the controller descriptors the kernel consumes. |
|
||||
| Dungeon | `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: single-player, DM-led play over one world. HTTP and Eve routes, Heimdall sign-in, app sessions, the play table, CultMesh publication, and Idunn health. |
|
||||
| Browser | `web/` | A thin Eve browser host and the Heimdall access adapter. It renders the server's surface and owns no product state. |
|
||||
| Claude SDK sidecar | `sidecar/claude-sdk/` | A Node process that serves `claude`-prefixed models to the kernel through the Claude Agent SDK. It is a stopgap for a direct Messages API port. |
|
||||
| Deploy recipe | `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build, and seal a release. |
|
||||
| Eve | `vendor/eve/` | A git submodule (`.gitmodules`) supplying the Eve browser lowering and contracts. |
|
||||
|
||||
- `crates/ghostlight`: the sealed library. A consumer gets create/open
|
||||
(`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command
|
||||
submission through role-scoped ports (`PlayPort`, `ConsumerPort`,
|
||||
`ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port
|
||||
entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`,
|
||||
`InferencePort`). Mutable state, ID issuers, the reducer and the journal
|
||||
writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
|
||||
and `crates/ghostlight/tests/external_admission.rs` prove the seal.
|
||||
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects,
|
||||
places, routes, holdings, knowledge, commitments, pressures), the operation
|
||||
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`; no process on main serves that door, a host serves
|
||||
`ghostlight::admit_document`).
|
||||
## How it works
|
||||
|
||||
Start with those two documents, then `cargo doc -p ghostlight --open`.
|
||||
- The kernel is sealed. A consumer reaches it through create/open, immutable
|
||||
snapshots, command submission, typed receipts, and the runner and port entry
|
||||
points. There is no public mutable state, ID issuer, reducer, or journal
|
||||
writer, and the crate declares no cargo features. The seal is proven by
|
||||
`compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by
|
||||
`crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests,
|
||||
and opportunities and asserts that each is refused with nothing committed.
|
||||
- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a
|
||||
command, is decided by one reducer, and commits atomically to a
|
||||
digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb).
|
||||
- Models author through typed tools and a repair loop
|
||||
(`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A
|
||||
refused proposal returns its mismatches to the model; it never edits state.
|
||||
- A Persona speaks only prose. A Projector builds its view, and an Interpreter
|
||||
lowers its words into typed proposals with exact source quotes
|
||||
(`crates/ghostlight/src/controllers.rs`).
|
||||
- Vault evidence for seeding comes from a read-only markdown directory reader
|
||||
(`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the
|
||||
directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`, read once at startup, so
|
||||
changing it needs a restart
|
||||
(`crates/ghostlight-dungeon/src/runtime.rs`).
|
||||
- Other programs can propose changes to the characters they control through
|
||||
`POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`,
|
||||
`crates/ghostlight/src/consumer.rs`). The contract is
|
||||
`docs/architecture/ghostlight-world-consumer-api.md`.
|
||||
|
||||
**Populate the algebra first.** The kernel owns the component operations; a world owns its
|
||||
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
|
||||
Speak, which records an utterance and changes nothing. Until your world declares affordances
|
||||
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
|
||||
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
|
||||
will be thin no matter how good the model is. The Aetheria verse genesis
|
||||
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
|
||||
only record the act; their effects are the next phase.
|
||||
## Ghostlight Dungeon
|
||||
|
||||
## Elaborators
|
||||
Dungeon is the single-player, DM-led client. A player signs in with Heimdall,
|
||||
creates a world, seeds it from a vault, approves and activates it, and plays in
|
||||
free text. The play table (`crates/ghostlight-dungeon/src/play.rs`) owns the
|
||||
turn lifecycle; the interface is one Eve surface, `ghostlight.play`
|
||||
(`crates/ghostlight-dungeon/src/eve.rs`, described in
|
||||
`docs/architecture/ghostlight-eve-native-interface.md`). Because its player is
|
||||
not a developer, Dungeon is judged by whether someone can sit down and play.
|
||||
|
||||
An elaborator is a model session, confined to one jurisdiction of the world, that
|
||||
adds structure where the world is thin: it answers a derived causal boundary
|
||||
(a destination nobody has described yet) or a scale deficit with typed
|
||||
declarations, and the kernel verifies the result like any other patch. Sessions
|
||||
are resumable and checkpointed against committed history. Eight stock lenses
|
||||
(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil,
|
||||
ember, numen) give each session a role-specific emphasis, for example customs and
|
||||
local jokes, or money and ledgers. A lens changes the prompt only, never what the
|
||||
kernel admits. Because boundaries are derived from state, elaborators can be
|
||||
dispatched as the player approaches a place or a social structure, instead of
|
||||
authoring the world up front.
|
||||
Where it runs:
|
||||
|
||||
## Direction (planned, not built)
|
||||
- **Yggdrasil**, under Idunn, built from `main` by `deployment/idunn/recipe.toml`.
|
||||
Idunn owns the daemon's lifecycle and the write lease. Odin supplies discovery
|
||||
and Heimdall's command-boundary record. The operating runbook is
|
||||
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
|
||||
- **Inference** is routed by model name: a `local/` prefix goes to an
|
||||
OpenAI-compatible loopback endpoint
|
||||
(`GHOSTLIGHT_LOCAL_ENDPOINT`, `crates/ghostlight/src/local_inference.rs`),
|
||||
which in production is Bonsai on Raven's GPU reached through a loopback
|
||||
tunnel; a `claude` prefix goes to the SDK sidecar
|
||||
(`GHOSTLIGHT_SDK_SIDECAR`, `crates/ghostlight/src/sdk_inference.rs`).
|
||||
Ghostlight never reads, stores, or forwards a model credential.
|
||||
- **Sign-in** is Heimdall's. Ghostlight keeps a hashed HttpOnly session and
|
||||
derives authority from the world's owner and approvers.
|
||||
|
||||
Use the kernel to simulate a whole world with many kinds of actor: Aetheria's
|
||||
factions and a persistent cast whose members keep identity, memory and
|
||||
commitments across sessions. Newspaper-staff simulations before the 2026-08
|
||||
teardown are the earlier proof of the idea; that code is in git history, not in
|
||||
the tree. Running this at scale is gated by inference cost, not by the design.
|
||||
`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates
|
||||
an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run`
|
||||
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`.
|
||||
## Direction
|
||||
|
||||
## Ghostlight Dungeon (parked)
|
||||
The next work makes Ghostlight the simulation behind Aetheria's public story.
|
||||
The operator's rulings (campaign `ghostlight-verse` in the Eureka mind) ask for:
|
||||
|
||||
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.
|
||||
- a world loaded from the Aetheria verse, with factions and a cast;
|
||||
- storylets for Aetheria;
|
||||
- a persistent cast whose characters keep identity, memory, and commitments
|
||||
across sessions, so serialized episodes can follow them;
|
||||
- episodes a cheap pipeline can turn into a video stream, and channel
|
||||
characters who later appear in the game.
|
||||
|
||||
## Repo map
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `crates/ghostlight/` | The sealed kernel library. |
|
||||
| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. |
|
||||
| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). |
|
||||
| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. |
|
||||
| `AGENTS.md` | Invariants and where things are. |
|
||||
| `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
|
||||
essays and proposals from before the rebuild. They describe organs (campaigns,
|
||||
co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the
|
||||
running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history.
|
||||
None of this exists yet. Nothing under `crates/` loads Aetheria lore, models
|
||||
storylets, or produces an episode. Every event the kernel admits is meant to
|
||||
have exactly one render path: ship action filmed in Aetheria, or a social verb
|
||||
staged as an Aetheria conversation. Today a world can declare any affordance
|
||||
kind, and the kernel does not enforce that restriction.
|
||||
|
||||
## Build and test
|
||||
|
||||
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
|
||||
workstation:
|
||||
The release target is Linux on Yggdrasil; a Windows build says nothing about
|
||||
it. Idunn runs the steps in `deployment/idunn/recipe.toml`, which are the
|
||||
verification:
|
||||
|
||||
```sh
|
||||
cargo build --locked --workspace
|
||||
git submodule update --init vendor/eve
|
||||
cargo test --locked -p ghostlight-persona-projection
|
||||
cargo test --locked -p ghostlight
|
||||
cargo test --locked -p ghostlight-verse
|
||||
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
|
||||
```
|
||||
|
||||
The kernel alone needs only `cargo test -p ghostlight`. The sidecar has
|
||||
`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.
|
||||
The sidecar has its own `npm --prefix sidecar/claude-sdk run build` and `test`
|
||||
(also the root scripts `sdk:build` and `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 step runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
(ignored by default) against a real model.
|
||||
|
||||
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.
|
||||
To run Dungeon locally: `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 ones a deployment sets.
|
||||
|
||||
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
|
||||
publication, model-provider and diagnostic boundaries.
|
||||
## Documents
|
||||
|
||||
- `AGENTS.md`: repository doctrine for agents.
|
||||
- `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture
|
||||
of the world machine.
|
||||
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary and the
|
||||
kernel's current mechanism.
|
||||
- `docs/architecture/ghostlight-world-consumer-api.md`: the consumer contract.
|
||||
- `docs/architecture/ghostlight-eve-native-interface.md`: the Eve surface.
|
||||
- `docs/architecture/ghostlight-library-extraction.md`,
|
||||
`ghostlight-stock-lenses.md`: closed records of landed work. Pages ending in
|
||||
`-cut.md` and `-postmortem.md` are dated history.
|
||||
- `docs/architecture/ghostlight-play-agent.md`: historical. Its status line
|
||||
predates the play-agent cuts; read it with `-cut.md` beside it, not as the
|
||||
current play surface (`crates/ghostlight-dungeon/src/play.rs` is).
|
||||
- `docs/architecture/ghostlight-session-zero.md`: unbuilt design, superseded by
|
||||
the play agent. Nothing in `crates/` implements it.
|
||||
- `notes/fresh-workspace-handoff.md`: where things run and where open work is
|
||||
recorded.
|
||||
- `docs/public-architecture/`, `docs/articles/`: unbuilt design and essays
|
||||
written before the rebuild. They describe campaigns, co-op, Nemesis
|
||||
and strategic resolvers as organs; none of that exists in `crates/`. They do not
|
||||
describe the running machine.
|
||||
- `docs/product/lore-vault-entitlements.md`: a product proposal for hosted
|
||||
vaults; nothing in `crates/` implements it.
|
||||
|
||||
Runtime documents use MessagePack-backed CultCache. JSON appears only at schema
|
||||
publication, browser, model-provider, and diagnostic boundaries.
|
||||
|
|
|
|||
48
crates/ghostlight-dungeon/Cargo.toml
Normal file
48
crates/ghostlight-dungeon/Cargo.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
[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"] }
|
||||
65
crates/ghostlight-dungeon/build.rs
Normal file
65
crates/ghostlight-dungeon/build.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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())
|
||||
}
|
||||
939
crates/ghostlight-dungeon/src/app_session.rs
Normal file
939
crates/ghostlight-dungeon/src/app_session.rs
Normal file
|
|
@ -0,0 +1,939 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
1007
crates/ghostlight-dungeon/src/eve.rs
Normal file
1007
crates/ghostlight-dungeon/src/eve.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,107 @@
|
|||
{
|
||||
"schema": "ghostlight.eve_client_bridge_fixture.v1",
|
||||
"steps": [
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-10-09T23:58:34.345Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528",
|
||||
"operationId": "world.create",
|
||||
"routeHint": {
|
||||
"sourceVersion": 0,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_create.v4"
|
||||
},
|
||||
"payload": {
|
||||
"bindings": {
|
||||
"brief": "A world captured for the committed Eve client bridge fixture.",
|
||||
"jurisdictions": "[]",
|
||||
"lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}",
|
||||
"subject_label": "The Fixture Operator",
|
||||
"targets": "{}",
|
||||
"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.create"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-10-09T23:58:35.759Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b",
|
||||
"operationId": "world.approve",
|
||||
"routeHint": {
|
||||
"sourceVersion": 1,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_approve.v0"
|
||||
},
|
||||
"payload": {},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.approve"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-10-09T23:58:37.240Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2",
|
||||
"operationId": "world.activate",
|
||||
"routeHint": {
|
||||
"sourceVersion": 2,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_activate.v0"
|
||||
},
|
||||
"payload": {},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.activate"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-10-09T23:58:38.611Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39",
|
||||
"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"
|
||||
}
|
||||
1678
crates/ghostlight-dungeon/src/heimdall.rs
Normal file
1678
crates/ghostlight-dungeon/src/heimdall.rs
Normal file
File diff suppressed because it is too large
Load diff
2001
crates/ghostlight-dungeon/src/idunn_health.rs
Normal file
2001
crates/ghostlight-dungeon/src/idunn_health.rs
Normal file
File diff suppressed because it is too large
Load diff
21
crates/ghostlight-dungeon/src/main.rs
Normal file
21
crates/ghostlight-dungeon/src/main.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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
|
||||
}
|
||||
464
crates/ghostlight-dungeon/src/mesh.rs
Normal file
464
crates/ghostlight-dungeon/src/mesh.rs
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
11900
crates/ghostlight-dungeon/src/play.rs
Normal file
11900
crates/ghostlight-dungeon/src/play.rs
Normal file
File diff suppressed because it is too large
Load diff
7366
crates/ghostlight-dungeon/src/runtime.rs
Normal file
7366
crates/ghostlight-dungeon/src/runtime.rs
Normal file
File diff suppressed because it is too large
Load diff
27
crates/ghostlight-dungeon/tests/build_provenance.rs
Normal file
27
crates/ghostlight-dungeon/tests/build_provenance.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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) can check
|
||||
/// test pinning one, e.g. `ghostlight-dungeon`'s recipe-drift test) can check
|
||||
/// itself against this constant instead of hand-copying the version number.
|
||||
pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v18";
|
||||
|
||||
|
|
@ -924,6 +924,11 @@ impl ControllerWork {
|
|||
}
|
||||
|
||||
impl NarrativeCheckpoint {
|
||||
/// The row holds its submission body: the next step is the world's answer.
|
||||
fn awaits_world(&self) -> bool {
|
||||
matches!(self, Self::ReadyToSubmit { .. } | Self::NoProposal { .. })
|
||||
}
|
||||
|
||||
fn command_id(&self) -> CommandId {
|
||||
match self {
|
||||
Self::Projector { command_id, .. }
|
||||
|
|
@ -1100,6 +1105,11 @@ impl OperationalCheckpoint {
|
|||
}
|
||||
}
|
||||
|
||||
/// The row holds its submission body: the next step is the world's answer.
|
||||
fn awaits_world(&self) -> bool {
|
||||
matches!(self, Self::ReadyToSubmit { .. } | Self::NoProposal { .. })
|
||||
}
|
||||
|
||||
fn opportunity(&self) -> &DecisionOpportunity {
|
||||
match self {
|
||||
Self::AgentInFlight { opportunity, .. }
|
||||
|
|
@ -1541,20 +1551,10 @@ fn select_scope(
|
|||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let [opportunity] = matches.as_slice() else {
|
||||
return if !matches.is_empty() {
|
||||
Err(ControllerError::AmbiguousOpportunity)
|
||||
} else if require_digest
|
||||
&& snapshot
|
||||
.opportunities
|
||||
.iter()
|
||||
.filter(|opportunity| opportunity.scope == exact_opportunity.scope)
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
// The scope is still offered, at a digest the bound run did not see.
|
||||
Err(ControllerError::ScopeMoved)
|
||||
} else {
|
||||
return if matches.is_empty() {
|
||||
Err(ControllerError::NoOpportunity { expected })
|
||||
} else {
|
||||
Err(ControllerError::AmbiguousOpportunity)
|
||||
};
|
||||
};
|
||||
let granted: Vec<AffordanceSnapshot> = snapshot
|
||||
|
|
@ -1623,16 +1623,27 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit
|
|||
}
|
||||
|
||||
/// Two opportunity values that name the same decision: the same world and the
|
||||
/// same decision scope. This is the one owner of which decision a persisted
|
||||
/// row answers, for both lanes. The caller's opportunity only names the
|
||||
/// decision; it never decides staleness. The world does: the operational lane
|
||||
/// checks `ensure_scope_unchanged` before each round, and the narrative lane
|
||||
/// runs to the kernel's `ScopeChanged` at submit, where `interrupted`
|
||||
/// re-lowers the same prose once.
|
||||
/// same decision scope. The scope digest is not part of the name. It says what
|
||||
/// the scope looked like when a proposal was reasoned, so it gates work that is
|
||||
/// still reasoning, never a row whose submission body the world answers by
|
||||
/// command id.
|
||||
fn same_decision(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool {
|
||||
left.world_id == right.world_id && left.scope == right.scope
|
||||
}
|
||||
|
||||
/// Whether a persisted row may be resumed against the caller's opportunity.
|
||||
/// A row that awaits the world carries its submission body, and the world
|
||||
/// answers it by command id (its receipt, or a `ScopeChanged` refusal), so the
|
||||
/// caller's digest cannot change what that command is. A row still reasoning
|
||||
/// keeps the digest check, so no tokens are spent on a scope that moved.
|
||||
fn row_answers_caller(
|
||||
row: &DecisionOpportunity,
|
||||
caller: &DecisionOpportunity,
|
||||
awaits_world: bool,
|
||||
) -> bool {
|
||||
same_decision(row, caller) && (awaits_world || row.scope_digest == caller.scope_digest)
|
||||
}
|
||||
|
||||
fn derive_narrative_capture(
|
||||
turn: &PersonaTurn,
|
||||
interpreter_prompt: &str,
|
||||
|
|
@ -2892,12 +2903,11 @@ pub struct ConstituentRefusal {
|
|||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ConstituentRefusalReason {
|
||||
/// The subject holds no single decidable opportunity on its scope any more,
|
||||
/// so it was not selected and nothing was submitted for it.
|
||||
/// The subject's opportunity moved between the cover and the cell, so it was
|
||||
/// not selected and nothing was submitted for it.
|
||||
LeftBeforeSelection,
|
||||
/// The scope the member was bound to no longer held: found moved at
|
||||
/// selection (still offered at a new digest, nothing submitted), or refused
|
||||
/// by the kernel at admission (a neighbour's act moved it).
|
||||
/// The kernel refused its proposal because the scope it was reasoned from
|
||||
/// no longer held (a neighbour's act moved it).
|
||||
ScopeChanged,
|
||||
/// The kernel refused its command for any other reason.
|
||||
WorldRefused,
|
||||
|
|
@ -2914,16 +2924,6 @@ pub enum ConstituentRefusalReason {
|
|||
CallUnattributed,
|
||||
}
|
||||
|
||||
/// The label for a member whose selection failed: only a moved scope is
|
||||
/// `ScopeChanged`; every other error means the subject holds no single
|
||||
/// decidable opportunity.
|
||||
fn selection_refusal(error: &ControllerError) -> ConstituentRefusalReason {
|
||||
match error {
|
||||
ControllerError::ScopeMoved => ConstituentRefusalReason::ScopeChanged,
|
||||
_ => ConstituentRefusalReason::LeftBeforeSelection,
|
||||
}
|
||||
}
|
||||
|
||||
/// One coarse cell's outcome: one inference, N ordinary one-opportunity
|
||||
/// submissions, and whatever the decode could not attribute.
|
||||
#[derive(Debug)]
|
||||
|
|
@ -2971,8 +2971,6 @@ pub enum ControllerError {
|
|||
AmbiguousOpportunity,
|
||||
#[error("opportunity does not match this exact controller command")]
|
||||
OpportunityMismatch,
|
||||
#[error("the subject's opportunity on this scope moved to a new digest")]
|
||||
ScopeMoved,
|
||||
#[error("controller opportunity has no Speak affordance")]
|
||||
SpeakUnavailable,
|
||||
#[error("controller opportunity grants no affordance")]
|
||||
|
|
@ -3126,9 +3124,7 @@ impl ControllerRunner {
|
|||
}
|
||||
match self.work.lookup(command_id).await? {
|
||||
ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => {
|
||||
// A moved scope is the kernel's to refuse at submit, and
|
||||
// `interrupted` re-lowers once (ruling narrative-resume-relowers).
|
||||
if !same_decision(checkpoint.opportunity(), opportunity) {
|
||||
if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) {
|
||||
return Err(ControllerError::OpportunityMismatch);
|
||||
}
|
||||
return self
|
||||
|
|
@ -3136,7 +3132,7 @@ impl ControllerRunner {
|
|||
.await;
|
||||
}
|
||||
ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => {
|
||||
if !same_decision(checkpoint.opportunity(), opportunity) {
|
||||
if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) {
|
||||
return Err(ControllerError::OpportunityMismatch);
|
||||
}
|
||||
return Ok(narrative_pending(
|
||||
|
|
@ -3208,7 +3204,7 @@ impl ControllerRunner {
|
|||
}
|
||||
match self.work.lookup(command_id).await? {
|
||||
ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => {
|
||||
if !same_decision(checkpoint.opportunity(), opportunity) {
|
||||
if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) {
|
||||
return Err(ControllerError::OpportunityMismatch);
|
||||
}
|
||||
return self
|
||||
|
|
@ -3216,7 +3212,7 @@ impl ControllerRunner {
|
|||
.await;
|
||||
}
|
||||
ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => {
|
||||
if !same_decision(checkpoint.opportunity(), opportunity) {
|
||||
if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) {
|
||||
return Err(ControllerError::OpportunityMismatch);
|
||||
}
|
||||
return Ok(operational_pending(
|
||||
|
|
@ -3524,11 +3520,9 @@ impl ControllerRunner {
|
|||
// cell was not active when the cell ran. It is dropped rather
|
||||
// than declined: declining a stale opportunity would be refused
|
||||
// anyway, and the drop is recorded where a reader can see it.
|
||||
// Still offered at a new digest is a moved scope; anything else
|
||||
// means the subject holds no single decidable opportunity.
|
||||
Err(error) => refused.push(ConstituentRefusal {
|
||||
Err(_) => refused.push(ConstituentRefusal {
|
||||
subject: member.subject,
|
||||
reason: selection_refusal(&error),
|
||||
reason: ConstituentRefusalReason::LeftBeforeSelection,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -3911,13 +3905,10 @@ impl ControllerRunner {
|
|||
select_one(&snapshot, exact_opportunity)
|
||||
}
|
||||
|
||||
/// The one owner of "an operational in-flight row whose scope moved spends
|
||||
/// nothing": the scope this run bound still derives the same digest, checked
|
||||
/// against the world before every round, fresh or resumed. The caller's
|
||||
/// opportunity only names the decision and never decides staleness; a moved
|
||||
/// digest is `ScopeMoved`. The operational lane's early abort, and only its:
|
||||
/// the narrative lane runs to the kernel's refusal so its prose and receipts
|
||||
/// survive to be lowered again.
|
||||
/// The scope this run bound still derives the same digest. The operational
|
||||
/// lane's early abort, and only its: a lane holding nothing to preserve
|
||||
/// stops as soon as the scope moves, while the narrative lane runs to the
|
||||
/// kernel's refusal so its prose and receipts survive to be lowered again.
|
||||
async fn ensure_scope_unchanged(
|
||||
&self,
|
||||
opportunity: &DecisionOpportunity,
|
||||
|
|
@ -4325,9 +4316,16 @@ impl ControllerRunner {
|
|||
.snapshot()
|
||||
.await
|
||||
.map_err(ControllerError::Snapshot)?;
|
||||
// The kernel's `ScopeChanged` already decided that the scope moved;
|
||||
// nothing here compares digests to decide it again.
|
||||
let fresh = select_fresh(&snapshot, &opportunity)?;
|
||||
if fresh.opportunity.scope_digest == opportunity.scope_digest {
|
||||
// The refusal was not this scope's move, and the runner does not
|
||||
// invent a second explanation for it.
|
||||
return Err(ControllerError::World(KernelError::ScopeChanged {
|
||||
scope: opportunity.scope,
|
||||
expected: opportunity.scope_digest.clone(),
|
||||
actual: fresh.opportunity.scope_digest,
|
||||
}));
|
||||
}
|
||||
// No branch here checks the fresh grant set for `SPEAK_KIND`: grants
|
||||
// are recorded in an insert-only ledger (`affordance_grants` never
|
||||
// removes an entry), and this checkpoint could only reach
|
||||
|
|
@ -8432,7 +8430,12 @@ mod tests {
|
|||
.turn(CommandId::new(), &stale_opportunity)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}");
|
||||
assert!(matches!(
|
||||
error,
|
||||
ControllerError::NoOpportunity {
|
||||
expected: ControllerMode::NarrativePersona
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
capturing.prepared.lock().unwrap().is_empty(),
|
||||
"a stale scope digest reached inference"
|
||||
|
|
@ -11348,13 +11351,14 @@ mod tests {
|
|||
let refreshed = after.opportunities[0].clone();
|
||||
assert_ne!(refreshed, opportunity);
|
||||
assert_eq!(refreshed.scope_digest, opportunity.scope_digest);
|
||||
// A digest the world does not derive selects nothing: the scope is
|
||||
// still offered, at a digest this caller did not see.
|
||||
// A digest the world does not derive selects nothing.
|
||||
let mut forged = opportunity.clone();
|
||||
forged.scope_digest = ScopeDigest::fixture("sha256:not-this-scope");
|
||||
assert!(matches!(
|
||||
runner.run_narrative(CommandId::new(), &forged).await,
|
||||
Err(ControllerError::ScopeMoved)
|
||||
Err(ControllerError::NoOpportunity {
|
||||
expected: ControllerMode::NarrativePersona
|
||||
})
|
||||
));
|
||||
let NarrativeRun::Completed(resumed) =
|
||||
runner.run_narrative(command_id, &refreshed).await.unwrap()
|
||||
|
|
@ -19531,7 +19535,7 @@ mod tests {
|
|||
.run_operational(operational_command, &operational)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(refused, Err(ControllerError::ScopeMoved)),
|
||||
matches!(refused, Err(ControllerError::NoOpportunity { .. })),
|
||||
"the operational lane stopped holding its early abort: {refused:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -19800,13 +19804,7 @@ mod tests {
|
|||
.run_operational(first_command, &fresh)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}");
|
||||
// The row's own stale opportunity reads the same: the world decides.
|
||||
let error = runner
|
||||
.run_operational(first_command, &first)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}");
|
||||
assert!(matches!(error, ControllerError::OpportunityMismatch));
|
||||
assert_eq!(mailbox.operator_log().await.unwrap().len(), 1);
|
||||
|
||||
drop(runner);
|
||||
|
|
@ -19891,7 +19889,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_grouped_member_whose_scope_moved_is_named_in_refused_as_scope_changed() {
|
||||
async fn a_grouped_member_that_left_the_cell_is_named_in_refused() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::OperationalAgent,
|
||||
NewController::OperationalAgent,
|
||||
|
|
@ -19932,84 +19930,6 @@ mod tests {
|
|||
let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else {
|
||||
panic!("a coarse cell did not run the grouped lane")
|
||||
};
|
||||
assert_eq!(
|
||||
run.refused,
|
||||
vec![ConstituentRefusal {
|
||||
subject: leaver,
|
||||
reason: ConstituentRefusalReason::ScopeChanged,
|
||||
}]
|
||||
);
|
||||
assert!(run.needs.is_empty(), "{:?}", run.needs);
|
||||
|
||||
drop(runner);
|
||||
drop(mailbox);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
/// `AmbiguousOpportunity` cannot be built through a live world (one
|
||||
/// opportunity per scope), so the mapping is pinned directly.
|
||||
#[test]
|
||||
fn selection_refusal_names_only_a_moved_scope_scope_changed() {
|
||||
assert_eq!(
|
||||
selection_refusal(&ControllerError::ScopeMoved),
|
||||
ConstituentRefusalReason::ScopeChanged
|
||||
);
|
||||
for left in [
|
||||
ControllerError::NoOpportunity {
|
||||
expected: ControllerMode::NarrativePersona,
|
||||
},
|
||||
ControllerError::AmbiguousOpportunity,
|
||||
ControllerError::OpportunityMismatch,
|
||||
] {
|
||||
assert_eq!(
|
||||
selection_refusal(&left),
|
||||
ConstituentRefusalReason::LeftBeforeSelection
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_grouped_member_that_truly_left_is_named_in_refused_as_left_before_selection() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::OperationalAgent,
|
||||
NewController::OperationalAgent,
|
||||
])
|
||||
.await;
|
||||
let cell = one_group(&mailbox).await;
|
||||
let Cell::Group { id, tick, members } = cell else {
|
||||
panic!("the cell is not coarse")
|
||||
};
|
||||
// One member's subject is no longer in the world at all; the other is
|
||||
// bound to the world as it is.
|
||||
let world = mailbox.snapshot().await.unwrap();
|
||||
let leaver = SubjectId::issue();
|
||||
let mut gone = members[1].opportunity.clone();
|
||||
gone.scope.subject_id = leaver;
|
||||
let bound = vec![
|
||||
Constituent {
|
||||
subject: members[0].subject,
|
||||
opportunity: fresh_opportunity(&world, members[0].subject),
|
||||
},
|
||||
Constituent {
|
||||
subject: leaver,
|
||||
opportunity: gone,
|
||||
},
|
||||
];
|
||||
let (runner, _store) = grouped_runner(
|
||||
&mailbox,
|
||||
vec![output(
|
||||
vec![speak_call("c0", "c0__speak", "The hinge is flooding.")],
|
||||
"lone-act",
|
||||
)],
|
||||
);
|
||||
let cell = Cell::Group {
|
||||
id,
|
||||
tick,
|
||||
members: bound,
|
||||
};
|
||||
let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else {
|
||||
panic!("a coarse cell did not run the grouped lane")
|
||||
};
|
||||
assert_eq!(
|
||||
run.refused,
|
||||
vec![ConstituentRefusal {
|
||||
|
|
@ -20225,17 +20145,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_narrative_row_in_flight_whose_scope_moved_runs_to_the_kernel_and_re_lowers_once() {
|
||||
async fn a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::NarrativePersona,
|
||||
NewController::NarrativePersona,
|
||||
])
|
||||
.await;
|
||||
// The first turn's projector call is pending; the second subject's turn
|
||||
// commits and moves the first's scope.
|
||||
// commits; a further call would panic the port.
|
||||
let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))];
|
||||
outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span."));
|
||||
let (runner, store) = grouped_runner(&mailbox, outputs);
|
||||
let (runner, _store) = grouped_runner(&mailbox, outputs);
|
||||
let snapshot = mailbox.snapshot().await.unwrap();
|
||||
let first = snapshot.opportunities[0].clone();
|
||||
let second = snapshot.opportunities[1].clone();
|
||||
|
|
@ -20251,95 +20171,9 @@ mod tests {
|
|||
let moved = mailbox.snapshot().await.unwrap();
|
||||
let fresh = fresh_opportunity(&moved, first.scope.subject_id);
|
||||
assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move");
|
||||
let log_before = mailbox.operator_log().await.unwrap().len();
|
||||
assert_eq!(log_before, 1);
|
||||
|
||||
// The fresh caller names the row's decision, so the row runs on: the
|
||||
// three turn calls, the kernel's refusal, then one re-lowering. A fifth
|
||||
// answer is left over to show that no further call was made.
|
||||
let relowering = || narrative_turn_outputs("Mara", "The rain has teeth tonight.").remove(2);
|
||||
let mut resumed = narrative_turn_outputs("Mara", "The rain has teeth tonight.");
|
||||
resumed.push(relowering());
|
||||
resumed.push(relowering());
|
||||
let (resume, port) = runner_over(&mailbox, resumed, store.clone());
|
||||
let NarrativeRun::Completed(decision) =
|
||||
resume.run_narrative(command, &fresh).await.unwrap()
|
||||
else {
|
||||
panic!("the resumed narrative row did not re-lower to a commit")
|
||||
};
|
||||
assert!(
|
||||
decision.persona_turn().binding().interrupted_from.is_some(),
|
||||
"the turn committed without recording the interruption"
|
||||
);
|
||||
assert_eq!(
|
||||
port.outputs.lock().unwrap().len(),
|
||||
1,
|
||||
"the resume did not spend exactly four provider calls"
|
||||
);
|
||||
assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before + 1);
|
||||
|
||||
drop((runner, resume));
|
||||
drop(mailbox);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_narrative_row_resumed_for_another_decision_is_refused_without_spend() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::NarrativePersona,
|
||||
NewController::NarrativePersona,
|
||||
])
|
||||
.await;
|
||||
let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))];
|
||||
outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span."));
|
||||
let (runner, store) = grouped_runner(&mailbox, outputs);
|
||||
let snapshot = mailbox.snapshot().await.unwrap();
|
||||
let first = snapshot.opportunities[0].clone();
|
||||
let second = snapshot.opportunities[1].clone();
|
||||
let command = CommandId::new();
|
||||
assert!(matches!(
|
||||
runner.run_narrative(command, &first).await.unwrap(),
|
||||
NarrativeRun::Pending(_)
|
||||
));
|
||||
let (resume, port) =
|
||||
runner_over(&mailbox, narrative_turn_outputs("Mara", "Rain."), store.clone());
|
||||
assert!(matches!(
|
||||
resume.run_narrative(command, &second).await,
|
||||
Err(ControllerError::OpportunityMismatch)
|
||||
));
|
||||
assert_eq!(port.outputs.lock().unwrap().len(), 3, "the refused resume spent a call");
|
||||
// The row's own decision still resumes: the refusal was identity alone.
|
||||
assert!(matches!(
|
||||
resume.run_narrative(command, &first).await.unwrap(),
|
||||
NarrativeRun::Completed(_)
|
||||
));
|
||||
|
||||
drop((runner, resume));
|
||||
drop(mailbox);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_narrative_row_whose_scope_did_not_move_commits_without_re_lowering() {
|
||||
let (_directory, mailbox, task) =
|
||||
active_cell_mailbox(vec![NewController::NarrativePersona]).await;
|
||||
let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))];
|
||||
outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight."));
|
||||
let (runner, _store) = grouped_runner(&mailbox, outputs);
|
||||
let opportunity = mailbox.snapshot().await.unwrap().opportunities[0].clone();
|
||||
let command = CommandId::new();
|
||||
assert!(matches!(
|
||||
runner.run_narrative(command, &opportunity).await.unwrap(),
|
||||
NarrativeRun::Pending(_)
|
||||
));
|
||||
// The kernel accepts the unmoved scope, so nothing is refused and
|
||||
// `interrupted` is never reached: the move is the kernel's to decide.
|
||||
let NarrativeRun::Completed(decision) =
|
||||
runner.run_narrative(command, &opportunity).await.unwrap()
|
||||
else {
|
||||
panic!("an unmoved scope did not commit")
|
||||
};
|
||||
assert!(decision.persona_turn().binding().interrupted_from.is_none());
|
||||
let error = runner.run_narrative(command, &fresh).await.unwrap_err();
|
||||
assert!(matches!(error, ControllerError::OpportunityMismatch));
|
||||
assert_eq!(mailbox.operator_log().await.unwrap().len(), 1);
|
||||
|
||||
drop(runner);
|
||||
drop(mailbox);
|
||||
|
|
@ -20524,99 +20358,11 @@ mod tests {
|
|||
task.await.unwrap();
|
||||
}
|
||||
|
||||
/// An in-flight operational row read in uncertain custody after its scope
|
||||
/// moved is refused as a mismatch, not reported pending; read against its
|
||||
/// own scope it is pending.
|
||||
#[tokio::test]
|
||||
async fn select_scope_tells_a_moved_digest_from_a_gone_scope() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::OperationalAgent,
|
||||
NewController::OperationalAgent,
|
||||
])
|
||||
.await;
|
||||
let snapshot = mailbox.snapshot().await.unwrap();
|
||||
let first = snapshot.opportunities[0].clone();
|
||||
let mut moved = snapshot.clone();
|
||||
moved.opportunities[0].scope_digest = snapshot.opportunities[1].scope_digest.clone();
|
||||
assert_ne!(first.scope_digest, moved.opportunities[0].scope_digest);
|
||||
assert!(matches!(
|
||||
select_scope(&moved, &first, true),
|
||||
Err(ControllerError::ScopeMoved)
|
||||
));
|
||||
assert!(select_scope(&moved, &first, false).is_ok());
|
||||
let mut gone = snapshot.clone();
|
||||
gone.opportunities.remove(0);
|
||||
assert!(matches!(
|
||||
select_scope(&gone, &first, true),
|
||||
Err(ControllerError::NoOpportunity { .. })
|
||||
));
|
||||
let mut doubled = snapshot.clone();
|
||||
doubled.opportunities.push(first.clone());
|
||||
assert!(matches!(
|
||||
select_scope(&doubled, &first, true),
|
||||
Err(ControllerError::AmbiguousOpportunity)
|
||||
));
|
||||
drop(mailbox);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
/// The probe's first case: the world moves under a run between its rounds.
|
||||
/// The check at the top of the next round refuses it, one provider call in
|
||||
/// total, and the driver would label it `ScopeMoved`.
|
||||
#[tokio::test]
|
||||
async fn an_operational_scope_move_between_rounds_is_refused_before_the_next_round() {
|
||||
let (_directory, mailbox, task, world) = interruption_world().await;
|
||||
let operational = mailbox
|
||||
.snapshot()
|
||||
.await
|
||||
.unwrap()
|
||||
.opportunities
|
||||
.iter()
|
||||
.find(|entry| entry.scope.subject_id == world.neighbour)
|
||||
.expect("the neighbour has a live opportunity")
|
||||
.clone();
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let runner = ControllerRunner::open(
|
||||
mailbox.clone(),
|
||||
Arc::new(InterruptingPort {
|
||||
mailbox: mailbox.clone(),
|
||||
outputs: Mutex::new(vec![output(
|
||||
vec![InferenceEvent::ToolCall {
|
||||
call_id: "need".into(),
|
||||
name: RECORD_NEED_TOOL.into(),
|
||||
arguments: json!({ "detail": "no route" }).to_string(),
|
||||
}],
|
||||
"operational-need",
|
||||
)]),
|
||||
calls: calls.clone(),
|
||||
commits: vec![(
|
||||
0,
|
||||
MidTurnCommit::Ops(vec![crate::patch::ComponentOp::CloseRoute {
|
||||
route: Ref::Existing(world.ramp),
|
||||
}]),
|
||||
)],
|
||||
seen: Arc::new(Mutex::new(Vec::new())),
|
||||
}),
|
||||
Arc::new(RecordingWorkStore {
|
||||
persisted: Arc::new(AtomicBool::new(true)),
|
||||
work: Mutex::new(BTreeMap::new()),
|
||||
}),
|
||||
models(),
|
||||
)
|
||||
.expect("the fixture ports open");
|
||||
let result = runner.run_operational(CommandId::new(), &operational).await;
|
||||
assert!(
|
||||
matches!(result, Err(ControllerError::ScopeMoved)),
|
||||
"{result:?}"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "a doomed round spent inference");
|
||||
drop(runner);
|
||||
drop(mailbox);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
/// An in-flight operational row read in uncertain custody is pending for
|
||||
/// its own decision whatever digest the caller holds: nothing is spent
|
||||
/// there, and the world check runs at the point of spend after reopen.
|
||||
#[tokio::test]
|
||||
async fn an_operational_row_in_flight_in_uncertain_custody_is_pending_for_its_own_decision() {
|
||||
async fn an_operational_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::OperationalAgent,
|
||||
NewController::OperationalAgent,
|
||||
|
|
@ -20647,14 +20393,16 @@ mod tests {
|
|||
let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id);
|
||||
assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move");
|
||||
let uncertain = uncertain_runner(&mailbox, &store);
|
||||
for caller in [&fresh, &first] {
|
||||
let OperationalRun::Pending(pending) =
|
||||
uncertain.run_operational(command, caller).await.unwrap()
|
||||
else {
|
||||
panic!("a row in uncertain custody was not reported pending")
|
||||
};
|
||||
assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired);
|
||||
}
|
||||
assert!(matches!(
|
||||
uncertain.run_operational(command, &fresh).await,
|
||||
Err(ControllerError::OpportunityMismatch)
|
||||
));
|
||||
let OperationalRun::Pending(pending) =
|
||||
uncertain.run_operational(command, &first).await.unwrap()
|
||||
else {
|
||||
panic!("a row bound to the caller's own scope was not reported pending")
|
||||
};
|
||||
assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired);
|
||||
|
||||
drop((runner, uncertain));
|
||||
drop(mailbox);
|
||||
|
|
@ -20662,7 +20410,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_narrative_row_in_flight_in_uncertain_custody_is_pending_for_its_own_decision() {
|
||||
async fn a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() {
|
||||
let (_directory, mailbox, task) = active_cell_mailbox(vec![
|
||||
NewController::NarrativePersona,
|
||||
NewController::NarrativePersona,
|
||||
|
|
@ -20689,14 +20437,16 @@ mod tests {
|
|||
let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id);
|
||||
assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move");
|
||||
let uncertain = uncertain_runner(&mailbox, &store);
|
||||
for caller in [&fresh, &first] {
|
||||
let NarrativeRun::Pending(pending) =
|
||||
uncertain.run_narrative(command, caller).await.unwrap()
|
||||
else {
|
||||
panic!("a row of the caller's own decision was not reported pending")
|
||||
};
|
||||
assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired);
|
||||
}
|
||||
assert!(matches!(
|
||||
uncertain.run_narrative(command, &fresh).await,
|
||||
Err(ControllerError::OpportunityMismatch)
|
||||
));
|
||||
let NarrativeRun::Pending(pending) =
|
||||
uncertain.run_narrative(command, &first).await.unwrap()
|
||||
else {
|
||||
panic!("a row bound to the caller's own scope was not reported pending")
|
||||
};
|
||||
assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired);
|
||||
|
||||
drop((runner, uncertain));
|
||||
drop(mailbox);
|
||||
|
|
@ -20797,14 +20547,8 @@ mod tests {
|
|||
) -> Result<ControllerWorkWrite, ControllerWorkStoreError> {
|
||||
let written = self.inner.persist(work).await?;
|
||||
let awaiting = match work {
|
||||
ControllerWork::Narrative(row) => matches!(
|
||||
row,
|
||||
NarrativeCheckpoint::ReadyToSubmit { .. } | NarrativeCheckpoint::NoProposal { .. }
|
||||
),
|
||||
ControllerWork::Operational(row) => matches!(
|
||||
row,
|
||||
OperationalCheckpoint::ReadyToSubmit { .. } | OperationalCheckpoint::NoProposal { .. }
|
||||
),
|
||||
ControllerWork::Narrative(row) => row.awaits_world(),
|
||||
ControllerWork::Operational(row) => row.awaits_world(),
|
||||
_ => false,
|
||||
};
|
||||
if awaiting && self.armed.load(Ordering::SeqCst) {
|
||||
|
|
|
|||
|
|
@ -7916,8 +7916,7 @@ mod tests {
|
|||
};
|
||||
|
||||
/// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer:
|
||||
/// `ghostlight-dungeon`'s play agent (PA.f61, parked at
|
||||
/// `parked/ghostlight-eve-surface`), which refuses a
|
||||
/// `ghostlight-dungeon`'s play agent (PA.f61), 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.
|
||||
|
|
|
|||
|
|
@ -1606,8 +1606,7 @@ 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` (parked at
|
||||
// `parked/ghostlight-eve-surface`) is the
|
||||
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` 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] = &[
|
||||
|
|
|
|||
|
|
@ -414,8 +414,8 @@ async fn genesis_under_another_command_id_finds_the_world_occupied() {
|
|||
assert_eq!(after.state_digest, before.state_digest);
|
||||
}
|
||||
|
||||
/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`):
|
||||
/// the lowering admits a ship verb only as an utterance,
|
||||
/// Evidence for the open question `ship-acts-are-not-speech`, pinning what the
|
||||
/// phase A lowering does today: a ship verb is only admissible as an utterance,
|
||||
/// and committing it asserts a claim fact the room hears.
|
||||
#[test]
|
||||
fn phase_a_ship_verbs_are_spoken_acts_in_the_room() {
|
||||
|
|
|
|||
|
|
@ -98,13 +98,10 @@ pub enum RefusalReason {
|
|||
/// The cell's subject no longer holds an opportunity.
|
||||
OpportunityGone,
|
||||
/// The cell's work was bound to a scope that has since moved. Work still
|
||||
/// reasoning is refused by the operational lane's world check before each
|
||||
/// inference round. Work whose submission body
|
||||
/// reasoning is refused before any inference. Work whose submission body
|
||||
/// was persisted but never sent is submitted and refused by the world with
|
||||
/// `ScopeChanged`. Narrative work is never refused for a moved scope at
|
||||
/// resume: the kernel refuses it at submit and the same prose is
|
||||
/// re-lowered once, and only a second move ends it overtaken. A cell whose
|
||||
/// command already committed does not end this way: the controller answers it with its receipt whatever the scope
|
||||
/// `ScopeChanged`. A cell whose command already committed does not end
|
||||
/// this way: the controller answers it with its receipt whatever the scope
|
||||
/// has done since.
|
||||
ScopeMoved,
|
||||
/// A grouped member's own tool call could not be decoded and it proposed
|
||||
|
|
@ -648,10 +645,10 @@ fn classify(error: &ControllerError) -> CellEnd {
|
|||
CellEnd::Refused(RefusalReason::ScopeMoved)
|
||||
}
|
||||
ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected),
|
||||
ControllerError::NoOpportunity { .. }
|
||||
| ControllerError::AmbiguousOpportunity
|
||||
| ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone),
|
||||
ControllerError::ScopeMoved => CellEnd::Refused(RefusalReason::ScopeMoved),
|
||||
ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => {
|
||||
CellEnd::Refused(RefusalReason::OpportunityGone)
|
||||
}
|
||||
ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::ScopeMoved),
|
||||
_ => CellEnd::Fatal("controller_error"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -982,7 +982,6 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() {
|
|||
expected: ControllerMode::OperationalAgent,
|
||||
},
|
||||
ControllerError::AmbiguousOpportunity,
|
||||
ControllerError::OpportunityMismatch,
|
||||
] {
|
||||
assert!(matches!(
|
||||
classify(&gone),
|
||||
|
|
@ -990,7 +989,7 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() {
|
|||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
classify(&ControllerError::ScopeMoved),
|
||||
classify(&ControllerError::OpportunityMismatch),
|
||||
CellEnd::Refused(RefusalReason::ScopeMoved)
|
||||
));
|
||||
assert!(matches!(
|
||||
|
|
@ -2367,30 +2366,6 @@ fn grouped_refusing(
|
|||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_moved_scope_member_in_a_coarse_cell_is_recorded_as_scope_moved_at_its_position() {
|
||||
let (_dir, verse) = Harness::fresh().await;
|
||||
let group = three_member_group(&verse).await;
|
||||
let Cell::Group { members, .. } = &group else {
|
||||
panic!("a group")
|
||||
};
|
||||
let reasons = [
|
||||
ConstituentRefusalReason::LeftBeforeSelection,
|
||||
ConstituentRefusalReason::ScopeChanged,
|
||||
ConstituentRefusalReason::LeftBeforeSelection,
|
||||
];
|
||||
let (status, committed, refusals) = fold(grouped_refusing(&group, &reasons), &group, 3);
|
||||
assert!(status.is_none() && committed.is_empty());
|
||||
assert_eq!(
|
||||
refusals,
|
||||
vec![
|
||||
Refusal { cell: 3, subject: members[0].subject, reason: RefusalReason::OpportunityGone },
|
||||
Refusal { cell: 3, subject: members[1].subject, reason: RefusalReason::ScopeMoved },
|
||||
Refusal { cell: 3, subject: members[2].subject, reason: RefusalReason::OpportunityGone },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cell_the_driver_folds_records_each_refused_member_at_its_own_position() {
|
||||
let (_dir, verse) = Harness::fresh().await;
|
||||
|
|
|
|||
188
deployment/idunn/recipe.toml
Normal file
188
deployment/idunn/recipe.toml
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
schema = "gamecult.idunn.target_declaration.v1"
|
||||
target = "ghostlight"
|
||||
source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT"
|
||||
required_gitlinks = ["vendor/eve"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-persona-boundary"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-world-library"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-dungeon"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-daemon"
|
||||
phase = "build"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "install-web-dependencies"
|
||||
phase = "prepare"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "ci"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-web"
|
||||
phase = "test"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "test"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-web"
|
||||
phase = "build"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "run", "build"]
|
||||
|
||||
[[steps]]
|
||||
id = "accept-real-persona-membrane"
|
||||
phase = "acceptance"
|
||||
runner = "rust-acceptance"
|
||||
argv = [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"-p",
|
||||
"ghostlight",
|
||||
"--lib",
|
||||
"controllers::tests::real_local_model_cognition_modes_commit_speech",
|
||||
"--",
|
||||
"--ignored",
|
||||
"--exact",
|
||||
]
|
||||
required_environment = [
|
||||
"CARGO_HOME",
|
||||
"GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG",
|
||||
"GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"HOME",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
]
|
||||
|
||||
[[artifacts]]
|
||||
id = "ghostlight-dungeon"
|
||||
source_kind = "runner-output"
|
||||
runner = "rust-build"
|
||||
source = "target/release/ghostlight-dungeon"
|
||||
destination = "ghostlight-dungeon"
|
||||
executable = true
|
||||
|
||||
[[artifacts]]
|
||||
id = "web"
|
||||
source_kind = "runner-output"
|
||||
runner = "web-build"
|
||||
source = "web/dist"
|
||||
destination = "web"
|
||||
|
||||
[service]
|
||||
executable_artifact = "ghostlight-dungeon"
|
||||
required_adjacent_artifacts = ["web"]
|
||||
arguments = [
|
||||
{ kind = "literal", value = "--state-root" },
|
||||
{ kind = "binding", name = "state_root" },
|
||||
]
|
||||
transport = "http"
|
||||
route_required = true
|
||||
required_environment = [
|
||||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||||
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
|
||||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"GHOSTLIGHT_DISCORD_GUILD_ID",
|
||||
"GHOSTLIGHT_DISCORD_ROLE_ID",
|
||||
"GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE",
|
||||
"GHOSTLIGHT_HEIMDALL_BASE_URL",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
"GHOSTLIGHT_LOCATED_SERVICE",
|
||||
"GHOSTLIGHT_ODIN_RUDP",
|
||||
"GHOSTLIGHT_PLAY_MODEL",
|
||||
"GHOSTLIGHT_PUBLIC_APP_URL",
|
||||
"GHOSTLIGHT_SERVICE_ID",
|
||||
"GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE",
|
||||
]
|
||||
optional_environment = ["RUST_LOG"]
|
||||
|
||||
[service.health]
|
||||
contract = "ghostlight.cultnet-rudp-service-health"
|
||||
|
||||
[state]
|
||||
schema_generation = "world-v3"
|
||||
|
||||
[[state.slots]]
|
||||
id = "world"
|
||||
relative_path = "world.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "app-sessions"
|
||||
relative_path = "service/app-sessions-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.app_session_store.v2"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "controller-work"
|
||||
relative_path = "service/controller-work.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.controller_work.v18"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "mesh-projection"
|
||||
relative_path = "service/mesh-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "gamecult.eve.surface.v1"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "rebuildable"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[provides]]
|
||||
capability = "ghostlight.world-service"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
compatibility = "consumer-v6"
|
||||
|
||||
[[provides]]
|
||||
capability = "gamecult.eve.surface-provider"
|
||||
schema = "gamecult.eve.surface.v1"
|
||||
compatibility = "v1"
|
||||
|
||||
[[dependencies]]
|
||||
kind = "shared-infrastructure"
|
||||
capability = "odin.verse-rendezvous"
|
||||
schema = "odin.verse-topology.v1"
|
||||
compatibility = "v1"
|
||||
startup = "before-promotion"
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# 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
|
||||
|
|
@ -626,11 +624,9 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's
|
|||
runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
against a real model; exercising a whole play turn against one is not gated
|
||||
by this contract.
|
||||
`world.begin` is the one route that makes a world. Its payload is
|
||||
`world_begin.v1`: title, brief and subject_label, nothing else; the route
|
||||
decides everything else a world is born with (no targets, no roots, no persona
|
||||
or agent label, uniform lens weights) and `begin_plan` decides how far Begin
|
||||
goes. `world.seed` runs one
|
||||
`world.create`'s current payload is `world_create.v4`: title, brief, targets,
|
||||
jurisdiction roots and lens weights are required, and Dungeon's create surface
|
||||
offers uniform weights as its editable default. `world.seed` runs one
|
||||
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
|
||||
|
||||
Focused tests must prove:
|
||||
|
|
|
|||
100
docs/architecture/ghostlight-eve-native-interface.md
Normal file
100
docs/architecture/ghostlight-eve-native-interface.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# 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 | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) |
|
||||
| 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.
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -63,11 +63,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,21 +2,25 @@
|
|||
|
||||
This is a pointer. It does not carry open work; the Eureka mind does.
|
||||
|
||||
## What exists
|
||||
## What runs
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## How it deploys
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
## Where open work is
|
||||
|
||||
|
|
@ -31,10 +35,9 @@ 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-world-ontology.md`: the kernel's vocabulary and
|
||||
3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture.
|
||||
4. `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.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# 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`
|
||||
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2`
|
||||
(`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
|
||||
the Eureka mind; query them there, never copy them here.
|
||||
|
|
@ -11,15 +9,6 @@ Phase 1 map: Imagination, session `self-2026-10-09-morning`, 2026-10-09.
|
|||
Base for every phase-1 anchor: Ghostlight `origin/main` `4fee97f`.
|
||||
Times are UTC unless marked local (UTC+2).
|
||||
|
||||
## Cut sequence
|
||||
|
||||
Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it.
|
||||
|
||||
- model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key).
|
||||
- raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key).
|
||||
- deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain).
|
||||
- v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run).
|
||||
|
||||
## Body facts (probed 2026-10-09)
|
||||
|
||||
### Does main run on Yggdrasil?
|
||||
|
|
@ -68,7 +57,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 the Eve submodule initialised:
|
||||
Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised:
|
||||
|
||||
- Rust (image `eureka-verify-rust`), the recipe's own steps:
|
||||
`ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed,
|
||||
|
|
@ -213,67 +202,6 @@ from 21:04 the denial forever. Mechanism:
|
|||
code is flat under `crates/ghostlight/src/`. Dated cut documents and
|
||||
postmortems keep their paths: they are history.
|
||||
|
||||
### Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, `4741d50`)
|
||||
|
||||
- Three paths answer the same fact (the subject still holds one opportunity on
|
||||
the bound scope, at a new digest), with two errors. A probe test run on
|
||||
Yggdrasil returned these, with one provider call in total:
|
||||
- The scope moves during round 0 of a fresh run. Round 1's
|
||||
`ensure_scope_unchanged` returns `NoOpportunity`.
|
||||
- A resume passing the row's own stale opportunity returns `NoOpportunity`
|
||||
(same gate).
|
||||
- A resume passing the fresh opportunity returns `OpportunityMismatch`, from
|
||||
the digest clause of `row_answers_caller` at lookup.
|
||||
- The driver's `classify` (`verse/run.rs` 641-651) labels the first two
|
||||
`OpportunityGone` and the third `ScopeMoved`. That makes the first two
|
||||
mislabels: the label's doc says `OpportunityGone` means the subject holds no
|
||||
opportunity. Cause: when `select_scope`'s digest clause empties the match
|
||||
list, it answers `NoOpportunity` (`controllers.rs` ~1552).
|
||||
- In the narrative lane, the lookup digest clause is the only gate. A driver
|
||||
resume with the fresh opportunity is refused there. Fork A's kernel-refusal
|
||||
re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity.
|
||||
- Grouped lane (probed 2026-10-17, `771a79d`, by reading; `select_one` now
|
||||
returns `ScopeMoved`): `run_group` maps every `select_one` error to
|
||||
`LeftBeforeSelection`, which the driver records as `OpportunityGone`. A member
|
||||
whose scope moved is dropped before any constituent, inference or submission
|
||||
exists, so only the label is wrong; spend is identical and nothing branches on
|
||||
the reason (`grouped-record-and-carry` keys on membership in `refused`). Own
|
||||
cut: `cut-grouped-scope-moved-label.r1`.
|
||||
|
||||
### Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, `73f26a5`)
|
||||
|
||||
- **Today, at lookup, by the caller's digest.** `run_narrative`'s Confirmed
|
||||
and CustodyUncertain arms (`controllers.rs` 3144, 3152) call
|
||||
`row_answers_caller`, the only callers it has since `in-flight-gate`
|
||||
(`771a79d`). A fresh caller is refused `OpportunityMismatch`; a stale caller
|
||||
passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads
|
||||
the world (source read: `self.select` is called only at fresh run, round
|
||||
checks of the operational lane, and the other lanes' fresh paths).
|
||||
- **The kernel path is already whole.** `submit_narrative`'s `ScopeChanged`
|
||||
arm calls `interrupted`, which re-lowers once. A second move reads
|
||||
`interrupted_from.is_some()` and ends the turn `overtaken`, with nothing
|
||||
submitted and no further inference. The progression arm (~2375) refuses to
|
||||
persist a second re-lowering even if the runner tried. Pinned by
|
||||
`a_second_scope_change_after_the_re_lowering_spends_nothing`.
|
||||
- **Probe** (Yggdrasil, ygg-verify rust, clone at `73f26a5`, sed in the job;
|
||||
script `probe1.sh` in the imagination-ag5-narrative scratchpad): both lookup
|
||||
arms put on `same_decision`, and `OpportunityMismatch` moved from `ScopeMoved`
|
||||
to `OpportunityGone` in `classify`. `cargo test -p ghostlight --lib`: 803
|
||||
passed, 3 failed. The failures were
|
||||
`a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference`
|
||||
(the resumed row now called the provider and ran the test port dry),
|
||||
`a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate`, and
|
||||
`an_error_ends_a_cell_as_the_run_must_treat_it` (the classify table). No
|
||||
verse driver test reached `ScopeMoved` through `OpportunityMismatch`.
|
||||
- **`OpportunityMismatch` is no longer a moved scope anywhere.** After the cut
|
||||
it arises from `same_decision` (another decision), `select_scope`'s
|
||||
controller-id check, and the Persona lane's forged-field refusals. The driver
|
||||
cannot reach a `same_decision` mismatch, because `run_cell` derives the
|
||||
command id from world, cell, subject and tick.
|
||||
- **Dead code.** Both `awaits_world` methods have no production caller once
|
||||
`row_answers_caller` goes. The operational one has been test-only since
|
||||
`771a79d`. The only other reader is the `NeverSentStore` test fixture.
|
||||
|
||||
## Model page
|
||||
|
||||
One row per persistent kind phase 1 touches. No cell may be empty.
|
||||
|
|
@ -288,7 +216,6 @@ One row per persistent kind phase 1 touches. No cell may be empty.
|
|||
| v1 body (`ghostlight-dungeon.service`, `/srv/ghostlight`, v1 state) | unit name; paths | retired in fact 2026-09-23; unit still enabled | nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling |
|
||||
| Bonsai lifecycle (Raven containers) | container `bonsai`; scripts under `/opt/gamecult/bonsai` | started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper | gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question |
|
||||
| Repository docs (README, AGENTS, live architecture pages, handoff) | path on `main` | rewritten to the live machine; pre-rebuild material leaves `main` by operator ruling | the Ghostlight repo; history in git, dated cut documents and postmortems |
|
||||
| Controller-work row (`controller-work.cc`, narrative and operational checkpoints) | `CommandId` (driver: derived from world, cell, subject, tick) | persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (`interrupted_from`), then commits, declines or ends overtaken | which decision a row answers: `same_decision`, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: `ensure_scope_unchanged` before each round; narrative: the kernel's `ScopeChanged` at submit, answered by `interrupted`). Labels: `classify` alone |
|
||||
|
||||
### Where phase 2's verb sets plug in
|
||||
|
||||
|
|
@ -337,16 +264,3 @@ verbs as the target.
|
|||
Dungeon tests that drive the real client, and nothing runs them. Fixing the
|
||||
gate means a runner with both Rust and Node, which is Idunn's recipe and
|
||||
runner surface: a follow-up, with each phase-1 Hands run doing them by hand.
|
||||
- **A narrative resume has one detector, and it is the kernel** (ruling
|
||||
`narrative-resume-relowers`). The caller's opportunity names the decision
|
||||
and nothing more, in both lanes, through the one `same_decision`. The lanes
|
||||
differ only in when the world is asked. The operational lane asks before each
|
||||
round, because it has nothing to preserve. The narrative lane asks at submit,
|
||||
because its prose and receipts survive to be lowered again. The price is the
|
||||
remaining rounds plus at most one Interpreter call. `OpportunityMismatch`
|
||||
then stops meaning a moved scope, so `classify` labels it `OpportunityGone`,
|
||||
as the grouped lane already does. Keeping it on `ScopeMoved` would leave a
|
||||
second, wrong answer to "did the scope move". The grouped selection label
|
||||
becomes a pure function, because `AmbiguousOpportunity` cannot be built
|
||||
through a live world, which offers one opportunity per scope. Pinning the
|
||||
function is what closes follow-up `grouped-left-arms-pin`.
|
||||
|
|
|
|||
|
|
@ -5,14 +5,8 @@ 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 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`.
|
||||
replacement exists. The real-model gate that does exist is the Idunn recipe's
|
||||
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`).
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
"name": "ghostlight",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Ghostlight: the Rust kernel for socially situated agents.",
|
||||
"description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.",
|
||||
"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"
|
||||
},
|
||||
|
|
|
|||
117
tools/eve_client_bridge.mjs
Normal file
117
tools/eve_client_bridge.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// 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
Submodule
1
vendor/eve
vendored
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0
|
||||
18
web/index.html
Normal file
18
web/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!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
Normal file
1269
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
web/package.json
Normal file
17
web/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"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" }
|
||||
}
|
||||
11
web/src/heimdall-access-adapter.provenance.md
Normal file
11
web/src/heimdall-access-adapter.provenance.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# 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.
|
||||
139
web/src/heimdall-access-adapter.ts
Normal file
139
web/src/heimdall-access-adapter.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Vendored from GameCult/Heimdall commit e8e2832, plugin
|
||||
// gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
|
||||
// adapter because its release must remain self-contained.
|
||||
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
|
||||
|
||||
export interface HeimdallAccessResumeTarget {
|
||||
complete(handle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HeimdallAccessResumeOptions {
|
||||
appSlug: string;
|
||||
}
|
||||
|
||||
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
|
||||
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
|
||||
return {
|
||||
pluginId: "gamecult.heimdall.access",
|
||||
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
|
||||
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
|
||||
schemas: ["heimdall.access_gate_state.v1"],
|
||||
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
},
|
||||
renderComponent(
|
||||
component: { id?: string },
|
||||
props: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const view = document.createElement("section");
|
||||
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
|
||||
if (component.id) view.id = component.id;
|
||||
view.setAttribute("role", "status");
|
||||
view.setAttribute("aria-live", "polite");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = String(props.title || "Access");
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = String(props.detail || "");
|
||||
view.append(title, detail);
|
||||
const displayName = String(props.displayName || "");
|
||||
if (displayName) {
|
||||
const identity = document.createElement("p");
|
||||
identity.className = "heimdall-identity";
|
||||
identity.textContent = displayName;
|
||||
view.append(identity);
|
||||
}
|
||||
return view;
|
||||
},
|
||||
async consumeCommandResult(
|
||||
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
|
||||
): Promise<void> {
|
||||
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
|
||||
const handle = String(pluginPayload.payload.handle || "");
|
||||
const navigation = object(pluginPayload.payload.navigation);
|
||||
const url = new URL(String(navigation.url || ""));
|
||||
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
|
||||
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
|
||||
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, handle);
|
||||
window.location.assign(url.toString());
|
||||
return;
|
||||
}
|
||||
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
|
||||
const status = String(pluginPayload.payload.status || "");
|
||||
if (status === "authenticated" || status === "denied" || status === "expired") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
|
||||
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
|
||||
});
|
||||
|
||||
export async function resumeHeimdallAccess(
|
||||
target: HeimdallAccessResumeTarget,
|
||||
options: HeimdallAccessResumeOptions,
|
||||
): Promise<boolean> {
|
||||
const returned = readHeimdallBrowserReturn(window.location.href, options.appSlug);
|
||||
const storedHandle = sessionStorage.getItem(ATTEMPT_KEY);
|
||||
if (returned) {
|
||||
clearHeimdallBrowserReturn();
|
||||
if (returned.status === "error") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
throw new Error(returned.message);
|
||||
}
|
||||
if (storedHandle && storedHandle !== returned.handle) {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
throw new Error("Heimdall returned an authentication attempt other than the one this browser started.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, returned.handle);
|
||||
}
|
||||
const handle = returned?.status === "success" ? returned.handle : storedHandle;
|
||||
if (!handle) return false;
|
||||
await target.complete(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readHeimdallBrowserReturn(
|
||||
href: string,
|
||||
appSlug: string,
|
||||
): { status: "success"; handle: string } | { status: "error"; message: string } | undefined {
|
||||
const url = new URL(href);
|
||||
const parameters = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
||||
const status = parameters.get("heimdall_status");
|
||||
if (!status) return undefined;
|
||||
if (parameters.get("heimdall_handoff_kind") !== "browser_completion"
|
||||
|| parameters.get("heimdall_app_slug") !== appSlug) {
|
||||
return { status: "error", message: "Heimdall returned an authentication result for another application or handoff mode." };
|
||||
}
|
||||
if (status !== "success") {
|
||||
return {
|
||||
status: "error",
|
||||
message: parameters.get("heimdall_error_description")
|
||||
|| parameters.get("heimdall_error")
|
||||
|| "Heimdall authentication failed.",
|
||||
};
|
||||
}
|
||||
// The attempt id is the handle the app's backend completes over Heimdall's
|
||||
// private command plane. The completion code in the same fragment is a
|
||||
// distinct random secret for the HTTP redeem route; this adapter neither
|
||||
// needs nor forwards it.
|
||||
const attemptId = parameters.get("heimdall_attempt_id") || "";
|
||||
if (!attemptId) {
|
||||
return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
|
||||
}
|
||||
return { status: "success", handle: attemptId };
|
||||
}
|
||||
|
||||
function clearHeimdallBrowserReturn(): void {
|
||||
history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
197
web/src/main.ts
Normal file
197
web/src/main.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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);
|
||||
25
web/src/style.css
Normal file
25
web/src/style.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
@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}}
|
||||
106
web/test/heimdall-adapter.test.mjs
Normal file
106
web/test/heimdall-adapter.test.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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); },
|
||||
};
|
||||
}
|
||||
36
web/test/static-boundary.mjs
Normal file
36
web/test/static-boundary.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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.");
|
||||
12
web/tsconfig.json
Normal file
12
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
15
web/vite.config.mjs
Normal file
15
web/vite.config.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@gamecult/eve-contracts": fileURLToPath(
|
||||
new URL("../vendor/eve/packages/eve-contracts/dist/index.js", import.meta.url),
|
||||
),
|
||||
"ajv/dist/2020.js": fileURLToPath(
|
||||
new URL("./node_modules/ajv/dist/2020.js", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue