Lower Ghostlight as one Eve-native surface

This commit is contained in:
Meta Vens 2026-08-22 02:05:01 +02:00
commit 172ac0db55
20 changed files with 711 additions and 517 deletions

View file

@ -35,6 +35,31 @@ multiresolution agency graph for keeping large settings active at finite cost.
- CultMesh publishes typed service and Eve/CultUI state; the browser lowers that
surface and does not become a second state authority.
## Eve-native interface
Ghostlight publishes one stable logical surface, `ghostlight.play`, for
anonymous entry, Session Zero, Contract Review, and campaign play. The browser
contains Eve's canonical provider host and lowering package plus Heimdall's
access-plugin adapter. It does not contain Ghostlight-specific screen renderers
or domain API routing.
Editable Eve bindings are the input model. Text, number, and choice controls
bind either provider-owned typed state or renderer-local draft state; an
operation captures named bindings and submits one canonical Eve command.
Rejected or stale commands preserve local drafts, while an accepted receipt may
clear named bindings before the host refetches the authoritative surface. A
browser may lower this interaction to an HTML form for accessibility, but forms
are not part of Eve's state or command ontology.
Heimdall authentication is composed as the required
`gamecult.heimdall.access` Eve plugin. Heimdall owns OAuth attempts, provider
callbacks, claims, entitlements, and single-use completion redemption.
Ghostlight owns only its hashed HttpOnly app session and derives campaign
authority from canonical membership. The browser sees an opaque attempt handle,
never a claim, refresh credential, account hash, member ID, or actor authority.
See
[`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md).
## Current Status
Ghostlight Dungeon is implemented and deployed as a native Yggdrasil,
@ -101,14 +126,15 @@ The shortest reliable re-entry path is:
- [`docs/architecture/ghostlight-dungeon-mvp.md`](docs/architecture/ghostlight-dungeon-mvp.md): runtime authority, compiler, action loop, persistence, hosting, and security;
- [`docs/architecture/ghostlight-dungeon-session-zero.md`](docs/architecture/ghostlight-dungeon-session-zero.md): campaign negotiation, privacy, publication, membership, and bounded co-op;
- [`docs/architecture/ghostlight-multiresolution-agency.md`](docs/architecture/ghostlight-multiresolution-agency.md): dynamic Gestalt partitioning, cohesive and arena cells, fairness, and atomic strategic waves;
- [`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md): Eve bindings, the stable provider surface, private Heimdall command plane, app-session custody, and browser cut line;
- [`notes/ghostlight-current-system-map.md`](notes/ghostlight-current-system-map.md): current implemented pipeline;
- [`notes/ghostlight-implementation-plan.md`](notes/ghostlight-implementation-plan.md): live sequence and next pressure tests;
- [`state/map.yaml`](state/map.yaml): canonical human-readable project state.
## Repository Shape
- `crates/ghostlight-dungeon/`: Rust daemon, kernel, compiler, persistence,
provider stages, web lowerer, and acceptance harnesses;
- `crates/ghostlight-dungeon/`: Rust daemon, kernels, compiler, persistence,
provider stages, Eve projection/command ingress, and acceptance harnesses;
- `crates/ghostlight-persona-projection/`: generalized projection membrane owned
by Ghostlight and consumed by Epiphany;
- `docs/architecture/`: durable contracts and authority maps;
@ -118,6 +144,8 @@ The shortest reliable re-entry path is:
- `examples/`: earlier fixtures and regression material;
- `state/`: human-readable project memory plus older research-pipeline state;
- `tools/`: state, validation, and fixture helpers.
- `web/`: thin Eve browser host, Ghostlight transport, and Heimdall plugin
adapter; product state and product-specific rendering remain server-owned.
Runtime documents and campaign exports use MessagePack-backed CultCache `.cc`.
JSON exists at schema publication, browser, MCP, model-provider, and diagnostic

View file

@ -0,0 +1,83 @@
# Ghostlight Eve-native interface
## Objective
Ghostlight exposes one actor-filtered logical surface, `ghostlight.play`. Eve
owns its editable bindings, command invocation, receipts, plugin composition,
and browser lowering. Ghostlight continues to own Session Zero and world
admission; Heimdall continues to own authentication and entitlement decisions.
```text
Ghostlight projection
-> EveBrowserProviderHost
-> canonical Eve lowering
-> gamecult.eve.command_invocation.v1
-> Ghostlight ingress or Heimdall private command plane
-> gamecult.eve.command_result.v1
-> authoritative surface refresh
```
## Authority map
- Owner: `SessionZeroKernel` owns draft changes, `WorldKernel` owns campaign
changes, Heimdall owns OAuth attempts and claims, and Ghostlight's app-session
owner binds a verified Heimdall subject to a local cookie.
- Inputs: the provider projector reads only the caller's app session,
membership, actor-filtered campaign or Session Zero slice, and public
Heimdall gate state. Command ingress reads one canonical Eve invocation and
derives member and actor identity server-side.
- Outputs: one `gamecult.eve.surface.v1` document and one persisted
`gamecult.eve.command_receipt.v1` inside a command result. Invitation links
may appear only in a transient projection.
- Derived state: browser drafts, tab selection, focus, command status, the
selected campaign card, and rendered HTML are projections or local
interaction state. None grants access or mutates fiction.
- Forbidden writers: the browser, Eve lowerer, Heimdall plugin, SSE stream,
account preferences, and old auth store cannot choose an actor, establish
membership, approve a Session Zero decision, or commit world state.
- Shared paths: every UI operation resolves the authenticated account and
exact membership, then calls the existing Session Zero or world mailbox.
SSE carries invalidation only and the host refetches the same surface.
- Cut line: the bespoke browser renderers and product-specific API routes are
removed from the public router. Ghostlight's backend callback and local
OAuth-attempt store stop participating. `service/auth.cc` is migration and
rollback evidence only; `service/app-sessions.cc` owns new local sessions and
account preferences.
## Editable values
Eve bindings are the input model. A component binds a typed value by stable
binding name, document identity, field path, value kind, access mode,
authority, and optional write command. Renderer-local composers use
`local-draft`; direct edits name provider-owned state and an advertised write
command. 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.
## Authentication membrane
Anonymous projection contains only `heimdall.access_gate`. Its begin and
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary.
The browser retains only the opaque attempt handle. Discord returns to
Heimdall; Ghostlight redeems the completion, validates the access claim and
`app_access`, and creates a local HttpOnly session.
Routine requests use local verified session state. The cookie hash, stable
account-subject hash, Heimdall session/revision, capabilities, expiries, and
wrapped refresh claim persist in `app-sessions.cc`. Campaign authorization is
always derived from `campaign_membership.v1`; account preferences contain only
the selected campaign.
## Public boundary
- `GET /api/eve/provider`
- `GET /api/eve/surfaces/ghostlight.play`
- `POST /api/eve/commands`
- `GET /api/eve/events`
`/health` remains a probe of the service's typed CultMesh health. Static assets
contain only the provider host, transport, Heimdall adapter, status mount, and
SSE invalidation. Hermodr is not a runtime dependency.

View file

@ -55,6 +55,14 @@ rewrite.
Arena cells never become synthetic collective actors or union knowledge.
- Heimdall owns account identity. `campaign_membership.v1` binds an authenticated
member to exactly one canonical actor.
- Eve owns editable bindings, command invocation, command receipts, plugin
composition, and lowering. Ghostlight publishes one stable
`ghostlight.play` surface; the browser is a thin provider host and never owns
login, Session Zero, campaign, governance, or receipt semantics.
- Heimdall's `gamecult.heimdall.access` plugin renders anonymous and identity
state. Sensitive begin, completion, refresh, and logout operations cross its
private CultNet command plane; the browser retains only an opaque attempt
handle and Ghostlight retains only its own hashed app session.
- VoidBot owns Vault retrieval and evidence. Ghostlight stores exact evidence
receipts, not a rival semantic index.
- Idunn owns deployment and same-release daemon continuity. Odin owns discovery.
@ -158,6 +166,7 @@ capacity.
## Essential references
- MVP authority: `docs/architecture/ghostlight-dungeon-mvp.md`
- Interface authority: `docs/architecture/ghostlight-eve-native-interface.md`
- Gestalt authority: `docs/architecture/ghostlight-multiresolution-agency.md`
- Live system map: `notes/ghostlight-current-system-map.md`
- Implementation program: `notes/ghostlight-implementation-plan.md`

View file

@ -389,25 +389,48 @@ advancing world revision or fictional time. Provider concurrency increments only
`provider_configuration_epoch`; it batches the same cover and cannot repartition
the world. See `docs/architecture/ghostlight-multiresolution-agency.md`.
The browser command boundary is deliberately smaller than `WorldCommand`.
Authenticated HTTP admits only player-owned Speak, unfilled Assess, Attempt,
Wait, and active-cell-budget requests. Actor identity must match the campaign player. Compiler
approval has its own route. Strategic ticks, region commits, gestalt presence,
reaction waves, NPC initiative, and campaign creation are internal mailbox
commands and cannot be invoked through `/api/command`.
The public application boundary is one stable Eve provider surface:
`GET /api/eve/provider`, `GET /api/eve/surfaces/ghostlight.play`,
`POST /api/eve/commands`, and revision-only `GET /api/eve/events`. Anonymous
surface requests return an Eve Heimdall access gate without campaign,
membership, actor, account, boundary, or Session Zero state. Authenticated
surface requests derive account, selected campaign, membership, and exact actor
from Ghostlight-owned app-session and campaign records before projection.
Authentication owns entry to the complete `/api` router before request-body
extraction. Axum's protected-route middleware reads only headers and durable
session authority; an invalid session receives `401` before JSON parsing,
schema rejection, campaign lookup, inference, or mutation. Handlers repeat the
session lookup where they need the session identity, so revocation between the
perimeter check and a handler cannot leave stale authority alive. Public
`/health`, the Heimdall login-attempt boundary, and static browser assets remain
outside this middleware. Login attempts carry no campaign authority: only a
JWKS-verified successful Heimdall callback can bind one to an account, and only
one browser adoption can mint its HttpOnly session alias. Regressions prove malformed anonymous JSON is refused
at the perimeter while the same malformed body under a valid session reaches
the ordinary `400` extractor response.
Command ingress accepts only `gamecult.eve.command_invocation.v1`. It validates
provider, logical surface, command boundary, operation schema, source version,
and idempotency before resolving the caller's authority server-side. Browser
payloads containing account, member, or actor authority fields are rejected.
Every admitted operation then uses the existing `SessionZeroKernel` or
`WorldKernel` mailbox and returns a persisted Eve command receipt. Strategic
ticks, region commits, NPC initiative, and raw kernel commands remain outside
the browser capability surface.
The browser is `EveBrowserProviderHost`, Ghostlight transport, Heimdall access
adapter, status mount, and SSE invalidation. Eve owns editable bindings and
operation capture: renderer-local drafts survive authoritative refreshes and
stale refusals, and only an accepted result may request named draft clearance.
HTML forms may appear as an accessibility lowering detail but are absent from
Eve documents and command payload semantics. Session Zero, campaign,
governance, receipt, and login DOM renderers are not product authorities.
Heimdall owns OAuth attempts, Discord callbacks, entitlement evaluation, claim
issuance, and single-use completion. Anonymous Eve operations cross Heimdall's
authenticated CultNet operation boundary on Yggdrasil loopback. Sensitive
completion and refresh fields are message-wrapped and never enter the public
CultMesh catalog, Odin records, Eve surfaces, logs, or browser responses.
Ghostlight validates the exact issuer, audience, app, profile, access revision,
session identity, expiry, and `app_access` before creating a local app session.
`ghostlight.app_session.v1` stores a hash of the random cookie, stable Heimdall
subject hash, exact Heimdall session and access revision, verified capabilities,
expiries, and a locally wrapped refresh claim. Routine requests verify that
state locally. Claim refresh and logout revalidate exact Heimdall session
custody through the private boundary; a stale refresh cannot resurrect a
logged-out session. Heimdall outages leave valid local sessions usable only
until their verified expiry. Campaign access always comes from
`campaign_membership.v1`; account preferences remember only selection. The old
auth store is frozen rollback evidence and does not participate in admission.
That boundary also projects less state outward than the kernel returns inward.
Player HTTP responses contain only assessment, public commit/roll receipts, and
@ -421,7 +444,7 @@ before generation. Only direct-seed source text enters causal world compilation;
background and excluded sources remain coverage provenance and cannot donate
story incidents or cast.
The player Eve surface follows the same membrane. It renders the player-owned
The actor-filtered Eve surface follows the same membrane. It renders the player-owned
ledger—including that actor's own relationships—and already filtered news, but
does not enumerate canonical institution postures or raw world clocks. Those
remain operator state until narration or an exact information channel makes a
@ -432,13 +455,11 @@ It likewise exposes only the number of operator pins and local active
population leaves eligible for a fission preview. Pin bodies, remote population
names, and `ReplaceResolutionPins` remain outside the player boundary.
HTTP operator inspection and provider-parallelism mutation require both an
authenticated campaign session and an actual loopback TCP peer. The daemon uses
Axum connection metadata from its direct listener; forwarded address headers do
not grant authority. LAN and WireGuard peers receive `403` before spoiler state
or controls are read. The browser operator panel is present only when loaded
from `127.0.0.1`, `localhost`, or `::1`; this mirrors the server boundary and is
not itself treated as security.
Exact private per-member and operator projections remain CultMesh documents for
authorized Verse consumers. The public provider advertisement exposes only the
subject-scoped logical surface and Heimdall plugin requirement. Hermodr may
materialize that provider for inspection when its plugin is installed; it is
not a Ghostlight runtime dependency and owns no application state.
Global strategic context has a separate non-causal lane. Two stable broad Vault
queries run alongside local retrieval, and a Flash extraction stage proposes a

View file

@ -31,6 +31,19 @@ persistent pins, approval-gated gestalt fission, graph and cover receipts, and
a separate operator provider-concurrency limit. The
specific authority map is
`docs/architecture/ghostlight-multiresolution-agency.md`.
The current interface cut makes that Eve publication the browser's actual
product boundary. `ghostlight.play` is one stable actor-filtered surface across
anonymous entry, Session Zero, Contract Review, and play. The browser embeds
Eve's canonical provider host and lowering package; text, numeric, and choice
input uses typed Eve bindings and renderer-local drafts, and operations capture
named bindings into canonical command invocations. There is no Eve form
ontology or generic `payload.fields` dialect. Heimdall supplies the required
`gamecult.heimdall.access` plugin and owns authentication attempts, Discord
callbacks, claims, entitlements, and completion redemption through its private
loopback CultNet command plane. Ghostlight owns hashed local sessions and
derives campaign authority only from canonical membership. The exact cut is
`docs/architecture/ghostlight-eve-native-interface.md`.
The accepted implementation is hosted natively on Yggdrasil as immutable
release `10b638e45756e5210aaee1efb5dcf74dbebf83c0`. Exact checkout,
embedded commit, release manifest, binary hash, systemd process, typed health,

View file

@ -90,6 +90,10 @@ live_architecture:
- SessionZeroKernel is the sole pre-world owner of persistent shared/private negotiation, typed contracts, character drafts, boundaries, decisions, invitations, and approvals. The WorldKernel receives only an unanimously approved brief at atomic publication.
- Bounded co-op uses campaign_membership.v1 to bind one to eight authenticated members to exact actors in the existing campaign mailbox. Sequential public actions, actor-filtered surfaces, unanimous time/travel/cell-budget changes, and non-simulatable human actors are implemented; split parties, PvP, private play actions, delegation, late joining, and group lifecycle commands remain unsupported.
- Contract Review reuses Session Zero against an exact world revision. Unanimous publication may amend forward-looking contract and approved actor ledger fields but cannot rewrite custody, location, topology, knowledge, memory, canon, or history. Boundary tightening becomes immediate anonymous aggregate policy; relaxation waits for publication.
- `ghostlight.play` is the one stable Eve provider surface across anonymous entry, Session Zero, Contract Review, and campaign play. The browser embeds Eve's canonical provider host and lowering package; it owns only ephemeral drafts, tabs, focus, and rendering.
- Eve editable bindings are the input model. Operations capture named bindings into `gamecult.eve.command_invocation.v1`; rejected or stale receipts preserve drafts, and accepted receipts may clear exact binding names. HTML forms are lowering detail, not Eve state or payload semantics.
- Heimdall owns OAuth attempts, Discord callbacks, entitlement evaluation, claims, single-use completion, refresh, and logout. Its required Eve access plugin crosses a private loopback CultNet operation boundary; Ghostlight validates the exact claim and owns only its hashed app session before deriving actor authority from campaign membership.
- Public Ghostlight HTTP exposes only the Eve provider, logical surface, command, and invalidation endpoints plus the typed health probe and static assets. Exact private per-member and operator surfaces remain CultMesh documents; Hermodr is an optional generic materializer, not a runtime dependency.
- Yggdrasil release admission resolves the newest executable- or build-affecting commit from the admitted ref. Later documentation, notes, state receipts, and root Markdown do not become runtime authority or cause a redeploy; Idunn proves the selected commit is an ancestor of the admitted ref and verifies the exact witness after activation.
- Idunn owns Ghostlight deployment and same-release continuity, Odin owns discovery, Heimdall owns account identity, and VoidBot owns Vault retrieval. None can write campaign state. Epiphany is an adjacent consumer and future swarm-capacity provider, not a Ghostlight runtime dependency or authority.
persistence_spine:

2
vendor/eve vendored

@ -1 +1 @@
Subproject commit 076d2124ed476cdaeff540c9d24c2d4fb57d04cf
Subproject commit 6766bee7c14a47144191475e2f35b0343b647b45

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

160
web/dist/assets/index-BuuhLtYk.js vendored Normal file

File diff suppressed because one or more lines are too long

47
web/dist/index.html vendored
View file

@ -4,55 +4,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ghostlight Dungeon</title>
<script type="module" crossorigin src="/assets/index-BHkI62J0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CYZ4YyRa.css">
<script type="module" crossorigin src="/assets/index-BuuhLtYk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0xx6l_wS.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to Session Zero</a>
<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="main">
<section id="entry" class="card" hidden>
<h2>Begin Session Zero</h2>
<p>Build the campaign with your DM before anything becomes world truth.</p>
<form id="session-entry-form">
<label for="draft-name">Campaign draft</label><input id="draft-name" name="name" maxlength="80" required>
<label for="display-name">Your table name</label><input id="display-name" name="display_name" maxlength="80" required>
<label for="vault-provider">Lore Vault</label><select id="vault-provider" name="vault_provider"><option value="aetheria">Aetheria</option></select>
<fieldset><legend>Table</legend><label><input type="radio" name="mode" value="solo" checked> Solo</label><label><input type="radio" name="mode" value="group"> Group, up to eight</label></fieldset>
<button type="submit">Begin Session Zero</button>
</form>
<form id="join-form"><h3>Join a Session Zero</h3><label for="join-token">Invitation token</label><input id="join-token" required><label for="join-name">Your table name</label><input id="join-name" maxlength="80" required><button type="submit">Join table</button></form>
</section>
<section id="session-zero" hidden>
<div class="session-heading"><div><p class="eyebrow">SESSION ZERO</p><h2 id="session-title">Campaign draft</h2></div><p id="session-state" class="state-pill"></p></div>
<nav class="channel-tabs" aria-label="Session Zero channels"><button type="button" id="tab-shared" aria-pressed="true">Shared table</button><button type="button" id="tab-private" aria-pressed="false">Private DM</button></nav>
<div class="session-grid">
<div>
<section class="card" aria-labelledby="conversation-title"><h3 id="conversation-title">Conversation</h3><ol id="session-transcript" class="transcript" aria-live="polite"></ol><form id="session-message-form"><label for="session-message">Talk with the DM</label><textarea id="session-message" maxlength="4000" required></textarea><button type="submit">Send</button></form></section>
<section class="card"><h3>Unresolved decisions</h3><div id="decision-list"></div></section>
<section class="card" id="review-card" hidden><h3>Compilation review</h3><div id="review-content"></div></section>
</div>
<aside>
<section class="card"><h3>Roster and readiness</h3><div id="roster"></div><p id="cell-pool" class="quiet"></p><form id="invite-form"><label for="invite-count">Create invitations</label><input id="invite-count" type="number" min="1" max="7" value="1"><button type="submit">Create links</button></form><div id="invite-output" aria-live="polite"></div></section>
<section class="card"><h3>Campaign contract</h3><dl id="contract-ledger"></dl></section>
<section class="card"><h3>Your private character</h3><dl id="character-ledger"></dl></section>
<section class="card"><h3>Content boundaries</h3><p class="quiet">Only you can see attribution. The table receives the strictest combined rule.</p><form id="boundary-form"><label for="boundary-topic">Topic</label><input id="boundary-topic" maxlength="300" required><label for="boundary-level">Treatment</label><select id="boundary-level"><option value="line">Line — exclude</option><option value="veil">Veil — off-screen</option><option value="ask_first">Ask first</option></select><button type="submit">Set private boundary</button></form><ul id="boundary-list"></ul></section>
<section class="card session-actions"><h3>Readiness</h3><button type="button" id="lock-roster">Lock roster</button><button type="button" id="compile-session">Compile review</button><button type="button" id="approve-session">Approve shared contract and my character</button><button type="button" id="publish-session">Publish campaign</button></section>
</aside>
</div>
</section>
<section id="surface"></section>
<section id="play-controls" hidden>
<form id="composer" class="card"><label for="attempt">What does your character do or say?</label><textarea id="attempt" maxlength="4000" required></textarea><label for="intended-effect">Intended uncertain effect</label><input id="intended-effect" maxlength="1000"><p class="quiet">Speak commits the words. Assess previews resistance and stakes before any roll.</p><div class="button-row"><button type="button" id="speak">Speak</button><button type="submit">Assess attempt</button></div></form>
<section id="receipt" class="card" hidden aria-live="polite"></section>
<section class="card governance"><h2>Shared table decisions</h2><button type="button" id="contract-review">Begin Contract Review</button><form id="travel-form"><label for="travel-destination">Travel together</label><select id="travel-destination"></select><button type="submit">Propose travel</button></form><form id="time-form"><label for="time-minutes">Advance time together</label><input id="time-minutes" type="number" min="1" max="1440" value="60"><button type="submit">Propose time</button></form><form id="resolution-form"><label for="active-cell-budget">Active Persona cells: <output id="active-cell-budget-value">8</output></label><input id="active-cell-budget" type="range" min="1" max="128" value="8"><button type="submit">Propose budget</button></form></section>
</section>
</main>
<main id="surface" tabindex="-1"></main>
</body>
</html>

View file

@ -6,52 +6,13 @@
<title>Ghostlight Dungeon</title>
</head>
<body>
<a class="skip-link" href="#main">Skip to Session Zero</a>
<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="main">
<section id="entry" class="card" hidden>
<h2>Begin Session Zero</h2>
<p>Build the campaign with your DM before anything becomes world truth.</p>
<form id="session-entry-form">
<label for="draft-name">Campaign draft</label><input id="draft-name" name="name" maxlength="80" required>
<label for="display-name">Your table name</label><input id="display-name" name="display_name" maxlength="80" required>
<label for="vault-provider">Lore Vault</label><select id="vault-provider" name="vault_provider"><option value="aetheria">Aetheria</option></select>
<fieldset><legend>Table</legend><label><input type="radio" name="mode" value="solo" checked> Solo</label><label><input type="radio" name="mode" value="group"> Group, up to eight</label></fieldset>
<button type="submit">Begin Session Zero</button>
</form>
<form id="join-form"><h3>Join a Session Zero</h3><label for="join-token">Invitation token</label><input id="join-token" required><label for="join-name">Your table name</label><input id="join-name" maxlength="80" required><button type="submit">Join table</button></form>
</section>
<section id="session-zero" hidden>
<div class="session-heading"><div><p class="eyebrow">SESSION ZERO</p><h2 id="session-title">Campaign draft</h2></div><p id="session-state" class="state-pill"></p></div>
<nav class="channel-tabs" aria-label="Session Zero channels"><button type="button" id="tab-shared" aria-pressed="true">Shared table</button><button type="button" id="tab-private" aria-pressed="false">Private DM</button></nav>
<div class="session-grid">
<div>
<section class="card" aria-labelledby="conversation-title"><h3 id="conversation-title">Conversation</h3><ol id="session-transcript" class="transcript" aria-live="polite"></ol><form id="session-message-form"><label for="session-message">Talk with the DM</label><textarea id="session-message" maxlength="4000" required></textarea><button type="submit">Send</button></form></section>
<section class="card"><h3>Unresolved decisions</h3><div id="decision-list"></div></section>
<section class="card" id="review-card" hidden><h3>Compilation review</h3><div id="review-content"></div></section>
</div>
<aside>
<section class="card"><h3>Roster and readiness</h3><div id="roster"></div><p id="cell-pool" class="quiet"></p><form id="invite-form"><label for="invite-count">Create invitations</label><input id="invite-count" type="number" min="1" max="7" value="1"><button type="submit">Create links</button></form><div id="invite-output" aria-live="polite"></div></section>
<section class="card"><h3>Campaign contract</h3><dl id="contract-ledger"></dl></section>
<section class="card"><h3>Your private character</h3><dl id="character-ledger"></dl></section>
<section class="card"><h3>Content boundaries</h3><p class="quiet">Only you can see attribution. The table receives the strictest combined rule.</p><form id="boundary-form"><label for="boundary-topic">Topic</label><input id="boundary-topic" maxlength="300" required><label for="boundary-level">Treatment</label><select id="boundary-level"><option value="line">Line — exclude</option><option value="veil">Veil — off-screen</option><option value="ask_first">Ask first</option></select><button type="submit">Set private boundary</button></form><ul id="boundary-list"></ul></section>
<section class="card session-actions"><h3>Readiness</h3><button type="button" id="lock-roster">Lock roster</button><button type="button" id="compile-session">Compile review</button><button type="button" id="approve-session">Approve shared contract and my character</button><button type="button" id="publish-session">Publish campaign</button></section>
</aside>
</div>
</section>
<section id="surface"></section>
<section id="play-controls" hidden>
<form id="composer" class="card"><label for="attempt">What does your character do or say?</label><textarea id="attempt" maxlength="4000" required></textarea><label for="intended-effect">Intended uncertain effect</label><input id="intended-effect" maxlength="1000"><p class="quiet">Speak commits the words. Assess previews resistance and stakes before any roll.</p><div class="button-row"><button type="button" id="speak">Speak</button><button type="submit">Assess attempt</button></div></form>
<section id="receipt" class="card" hidden aria-live="polite"></section>
<section class="card governance"><h2>Shared table decisions</h2><button type="button" id="contract-review">Begin Contract Review</button><form id="travel-form"><label for="travel-destination">Travel together</label><select id="travel-destination"></select><button type="submit">Propose travel</button></form><form id="time-form"><label for="time-minutes">Advance time together</label><input id="time-minutes" type="number" min="1" max="1440" value="60"><button type="submit">Propose time</button></form><form id="resolution-form"><label for="active-cell-budget">Active Persona cells: <output id="active-cell-budget-value">8</output></label><input id="active-cell-budget" type="range" min="1" max="128" value="8"><button type="submit">Propose budget</button></form></section>
</section>
</main>
<main id="surface" tabindex="-1"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

82
web/package-lock.json generated
View file

@ -6,7 +6,9 @@
"": {
"name": "@gamecult/ghostlight-dungeon-web",
"dependencies": {
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering"
"@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",
@ -15,11 +17,30 @@
},
"../vendor/eve/packages/eve-browser-lowering": {
"name": "@gamecult/eve-browser-lowering",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@gamecult/eve-contracts": "file:../eve-contracts"
},
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",
"typescript": "^5.6.3"
}
},
"../vendor/eve/packages/eve-contracts": {
"name": "@gamecult/eve-contracts",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1"
},
"devDependencies": {
"@msgpack/msgpack": "^3.1.2",
"@types/node": "^24.0.0",
"json-schema-to-typescript": "^15.0.4",
"typescript": "^5.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@ -466,6 +487,10 @@
"resolved": "../vendor/eve/packages/eve-browser-lowering",
"link": true
},
"node_modules/@gamecult/eve-contracts": {
"resolved": "../vendor/eve/packages/eve-contracts",
"link": true
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -882,6 +907,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@ -924,6 +965,28 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -957,6 +1020,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@ -1025,6 +1094,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",

View file

@ -5,8 +5,13 @@
"scripts": {
"build": "npm run typecheck && vite build",
"dev": "vite --host 127.0.0.1 --port 8832",
"test": "node test/static-boundary.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": { "@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering" },
"dependencies": {
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering",
"@gamecult/eve-contracts": "file:../vendor/eve/packages/eve-contracts",
"ajv": "^8.17.1"
},
"devDependencies": { "typescript": "^5.6.3", "vite": "^7.1.2" }
}

View file

@ -0,0 +1,11 @@
# Heimdall access browser adapter provenance
- Owner: `GameCult/Heimdall`
- Plugin: `gamecult.heimdall.access`
- Source commit: `19e5109`
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
- Vendoring mode: exact semantic copy with formatting only
Ghostlight embeds this adapter so immutable releases do not depend on a sibling
checkout at browser-build time. Authentication behavior remains Heimdall-owned;
changes are imported from the owner and this provenance is advanced with them.

View file

@ -0,0 +1,78 @@
// Vendored from GameCult/Heimdall commit 19e5109, 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 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): Promise<boolean> {
const handle = sessionStorage.getItem(ATTEMPT_KEY);
if (!handle) return false;
await target.complete(handle);
return true;
}
function object(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

View file

@ -1,257 +1,121 @@
import { renderEveSurface } from "@gamecult/eve-browser-lowering";
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";
const $ = <T extends Element>(selector: string) => document.querySelector<T>(selector)!;
const status = $("#status") as HTMLElement;
const entry = $("#entry") as HTMLElement;
const sessionRoot = $("#session-zero") as HTMLElement;
const surfaceHost = $("#surface") as HTMLElement;
const playControls = $("#play-controls") as HTMLElement;
const receipt = $("#receipt") as HTMLElement;
let revision = 0;
let resolutionEpoch = 0;
let viewerActorId = "";
let sessionId = "";
let viewerMemberId = "";
let activeChannel = "shared:table";
let currentSurface: any = null;
let requestActive = false;
class GhostlightEveTransport implements EveBrowserProviderTransport {
private provider: EveProviderAdvertisement | undefined;
private sourceVersion = 0;
class ServerFailure extends Error {}
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string) {
const value = document.createElement(tag);
if (text !== undefined) value.textContent = text;
if (className) value.className = className;
return value;
}
async function decode(response: Response) {
if (response.status === 204) return null;
return (response.headers.get("content-type") ?? "").includes("json") ? response.json() : response.text();
}
function errorText(body: any, fallback: string) {
if (typeof body === "string" && body.trim()) return body.trim();
return typeof body?.error === "string" ? body.error : fallback;
}
async function post(path: string, body: unknown) {
if (requestActive) throw new Error("Another table command is still being admitted.");
requestActive = true;
document.body.setAttribute("aria-busy", "true");
try {
const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const value = await decode(response);
if (!response.ok) throw new ServerFailure(errorText(value, "The server refused the command."));
return value;
} finally {
requestActive = false;
document.body.removeAttribute("aria-busy");
async providerAdvertisement(): Promise<EveProviderAdvertisement> {
this.provider = await fetchJson<EveProviderAdvertisement>("api/eve/provider");
return this.provider;
}
}
function showError(reason: unknown) {
const message = reason instanceof Error ? reason.message : "The connection failed.";
status.textContent = message;
receipt.hidden = false;
receipt.replaceChildren(el("h2", reason instanceof ServerFailure ? "Request refused" : "Request interrupted"), el("p", message), el("p", "The server remains authoritative. Refresh before retrying.", "quiet"));
}
window.addEventListener("unhandledrejection", event => { event.preventDefault(); showError(event.reason); });
function showAuthenticationGate() {
entry.hidden = true; sessionRoot.hidden = true; playControls.hidden = true; surfaceHost.hidden = false;
const card = el("article", undefined, "card");
card.append(el("h2", "Ghostlight Dungeon playtest"), el("p", "Sign in through Heimdall with an eligible Discord account."));
const button = el("button", "Continue with Discord");
button.addEventListener("click", () => void startLogin().catch(showError));
card.append(button);
surfaceHost.replaceChildren(card);
}
async function startLogin() {
const response = await fetch("/api/auth/heimdall/start", { method: "POST" });
const body = await decode(response);
if (!response.ok) throw new ServerFailure(errorText(body, "Heimdall could not begin sign-in."));
sessionStorage.setItem("ghostlight_heimdall_attempt", body.attempt_id);
window.location.assign(body.authorization_url);
}
async function resumeLogin() {
const attempt = sessionStorage.getItem("ghostlight_heimdall_attempt");
if (!attempt) return;
const deadline = Date.now() + 600_000;
while (Date.now() < deadline) {
await new Promise(resolve => window.setTimeout(resolve, 900));
const response = await fetch(`/api/auth/heimdall/attempt/${encodeURIComponent(attempt)}`, { cache: "no-store" });
const body = await decode(response);
if (!response.ok) throw new ServerFailure(errorText(body, "Heimdall sign-in expired."));
if (body.status === "pending") continue;
if (body.status !== "succeeded") throw new ServerFailure(body.error ?? "Discord access was not admitted.");
const adopted = await fetch(`/api/auth/heimdall/attempt/${encodeURIComponent(attempt)}/adopt`, { method: "POST" });
if (!adopted.ok) throw new ServerFailure(errorText(await decode(adopted), "Heimdall session adoption failed."));
sessionStorage.removeItem("ghostlight_heimdall_attempt");
return;
async surface(surface: EveProviderSurfaceAdvertisement): Promise<EveSurfaceDocument> {
const surfaceId = surface.surfaceId || "ghostlight.play";
const pendingInvite = sessionStorage.getItem("ghostlight.pendingInvite");
const query = pendingInvite ? `?invite=${encodeURIComponent(pendingInvite)}` : "";
const document = await fetchJson<EveSurfaceDocument>(`api/eve/surfaces/${encodeURIComponent(surfaceId)}${query}`);
this.sourceVersion = Math.max(0, Math.trunc(document.version || 0));
return document;
}
throw new Error("Heimdall sign-in timed out.");
}
async function refresh() {
const response = await fetch("/api/surface", { cache: "no-store" });
if (response.status === 401) return showAuthenticationGate();
const surface = await decode(response);
if (!response.ok) throw new ServerFailure(errorText(surface, "The table surface is unavailable."));
currentSurface = surface;
if (surface.surface_id === "ghostlight.session-zero-entry") return renderEntry();
if (surface.session_zero) return renderSession(surface.session_zero);
renderCampaign(surface);
}
function renderEntry() {
status.textContent = "No campaign is selected. Begin or join a Session Zero.";
entry.hidden = false; sessionRoot.hidden = true; surfaceHost.hidden = true; playControls.hidden = true;
const invite = new URLSearchParams(location.search).get("invite");
if (invite) ($("#join-token") as HTMLInputElement).value = invite;
}
function pairs(target: HTMLElement, fields: Record<string, unknown>) {
target.replaceChildren();
for (const [label, raw] of Object.entries(fields)) {
const value = Array.isArray(raw) ? raw.join(" · ") : String(raw || "Not decided yet");
target.append(el("dt", label.replaceAll("_", " ")), el("dd", value));
}
}
function renderSession(session: any) {
entry.hidden = true; sessionRoot.hidden = false; surfaceHost.hidden = true; playControls.hidden = true;
sessionId = session.id; revision = Number(session.revision); viewerMemberId = session.viewer_member_id;
if (!session.channels.some((channel: any) => channel.id === activeChannel)) activeChannel = "shared:table";
$("#session-title").textContent = currentSurface.title;
$("#session-state").textContent = `${String(session.status).replaceAll("_", " ")} · revision ${revision}`;
status.textContent = session.status === "compiling" ? "The DM is compiling the agreed brief. You can leave this page." : "Session Zero is persistent. Conversation is not world truth until unanimous publication.";
const shared = activeChannel === "shared:table";
$("#tab-shared").setAttribute("aria-pressed", String(shared));
$("#tab-private").setAttribute("aria-pressed", String(!shared));
const channel = session.channels.find((item: any) => item.id === activeChannel) ?? session.channels[0];
const transcript = $("#session-transcript"); transcript.replaceChildren();
for (const message of channel?.messages ?? []) {
const item = el("li", undefined, `message ${message.speaker}`);
item.append(el("strong", message.speaker === "dm" ? "DM" : message.speaker === "system" ? "Table" : "Player"), el("p", message.text));
transcript.append(item);
}
const roster = $("#roster"); roster.replaceChildren();
for (const member of session.public_party) {
const row = el("p", `${member.approved ? "Ready" : "Not ready"} · ${member.display_name} as ${member.name || "undecided"}${member.is_host ? " · host" : ""}`);
if (session.viewer_is_host && !session.roster_locked && !member.is_host) {
const remove = el("button", "Remove");
remove.addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/remove-member`, { expected_revision: revision, member_id: member.member_id }); await refresh(); });
row.append(" ", remove);
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),
});
if (intent.operation.operationId === "session_zero.join" && result.receipt?.state === "accepted") {
sessionStorage.removeItem("ghostlight.pendingInvite");
}
roster.append(row);
return result;
}
$("#cell-pool").textContent = `${session.pooled_cell_allowance} pooled active Persona cells · ${session.active_members}/8 players`;
pairs($("#contract-ledger"), session.contract);
pairs($("#character-ledger"), session.private_character);
const boundaryList = $("#boundary-list"); boundaryList.replaceChildren(...session.private_boundaries.map((boundary: any) => {
const item = el("li", `${boundary.level.replaceAll("_", " ")} · ${boundary.topic} `);
const remove = el("button", "Remove"); remove.addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/boundary/${encodeURIComponent(boundary.id)}/remove`, { expected_revision: revision }); await refresh(); }); item.append(remove); return item;
}));
const decisions = $("#decision-list"); decisions.replaceChildren();
for (const decision of session.decisions.filter((item: any) => !item.resolved)) {
const card = el("article", undefined, "decision");
card.append(el("h4", decision.prompt), el("p", decision.proposed_resolution));
const accept = el("button", "Accept"); accept.addEventListener("click", () => void decide(decision.id, true));
const counter = el("button", "Counter"); counter.addEventListener("click", async () => { const text = window.prompt("What should the DM propose instead?", decision.proposed_resolution); if (!text?.trim()) return; await post(`/api/session-zero/${sessionId}/decision`, { expected_revision: revision, decision_id: decision.id, accept: false, counter: text.trim() }); await refresh(); });
const discuss = el("button", "Discuss"); discuss.addEventListener("click", () => { ($("#session-message") as HTMLTextAreaElement).value = `I'd like to discuss: ${decision.prompt}`; ($("#session-message") as HTMLTextAreaElement).focus(); });
card.append(accept, counter, discuss); decisions.append(card);
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(),
routeHint: { sourceVersion: this.sourceVersion },
},
payload: { handle },
issuedAt: new Date().toISOString(),
clientId: "ghostlight.browser",
commandBoundary: "ghostlight.eve.commands",
receiptSchema: "gamecult.eve.command_result.v1",
});
}
const review = $("#review-card") as HTMLElement; review.hidden = !session.preview;
if (session.preview) {
const content = $("#review-content"); content.replaceChildren(el("p", session.preview.title));
if (session.preview.branch_assumptions?.length) content.append(el("p", `Branch-local assumptions: ${session.preview.branch_assumptions.join("; ")}`));
content.append(el("p", `${Object.keys(session.preview.topology ?? {}).length} locations · ${Object.keys(session.preview.institutions ?? {}).length} institutions · ${Object.keys(session.preview.clocks ?? {}).length} clocks`));
}
const linkedInvite = new URL(window.location.href).searchParams.get("invite");
if (linkedInvite) {
sessionStorage.setItem("ghostlight.pendingInvite", linkedInvite);
history.replaceState(null, "", `${window.location.pathname}${window.location.hash}`);
}
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);
}
const host = Boolean(session.viewer_is_host);
($("#invite-form") as HTMLFormElement).hidden = !host || session.roster_locked;
($("#lock-roster") as HTMLButtonElement).disabled = !host || session.roster_locked;
($("#compile-session") as HTMLButtonElement).disabled = !host || !session.roster_locked || ["compiling", "review", "published"].includes(session.status);
($("#approve-session") as HTMLButtonElement).disabled = session.status !== "review" || session.approved;
($("#publish-session") as HTMLButtonElement).disabled = !host || !session.publish_ready;
return body as T;
}
function renderCampaign(surface: any) {
entry.hidden = true; sessionRoot.hidden = true; surfaceHost.hidden = false; playControls.hidden = false;
revision = Number(surface.world_revision); resolutionEpoch = Number(surface.resolution?.policy?.resolution_epoch ?? 0); viewerActorId = surface.viewer_actor_id;
if (!viewerActorId) throw new Error("Actor-filtered campaign surface omitted the viewer actor binding.");
renderEveSurface(surface, surfaceHost, { body: document.body, clientId: "ghostlight.browser", statusElement: status });
status.textContent = `World revision ${revision}. Your surface contains only ${viewerActorId}'s permitted state.`;
const budget = Number(surface.resolution?.policy?.active_cell_budget ?? 8);
($("#active-cell-budget") as HTMLInputElement).value = String(budget); ($("#active-cell-budget-value") as HTMLOutputElement).value = String(budget);
const travelSelect = $("#travel-destination") as HTMLSelectElement;
travelSelect.replaceChildren(...(surface.reachable_destinations ?? []).map((destination: any) => {
const option = document.createElement("option"); option.value = destination.id; option.textContent = `${destination.name} · ${destination.travel_minutes} minutes`; return option;
}));
renderGovernance(surface.governance ?? {});
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;
}
function renderGovernance(governance: any) {
const timeForm = $("#time-form") as HTMLFormElement;
const travelForm = $("#travel-form") as HTMLFormElement;
const budgetForm = $("#resolution-form") as HTMLFormElement;
const pendingTime = governance.time_proposals?.[0];
const pendingTravel = governance.travel_proposals?.[0];
const pendingBudget = governance.cell_budget_proposals?.[0];
const timeButton = timeForm.querySelector("button")!;
const travelButton = travelForm.querySelector("button")!;
const budgetButton = budgetForm.querySelector("button")!;
timeButton.textContent = pendingTime ? `Approve ${pendingTime.minutes} minute advance (${pendingTime.approvals.length}/${governance.active_member_count})` : "Propose time";
travelButton.textContent = pendingTravel ? `Approve group travel (${pendingTravel.approvals.length}/${governance.active_member_count})` : "Propose travel";
budgetButton.textContent = pendingBudget ? `Approve ${pendingBudget.active_cell_budget} cells (${pendingBudget.approvals.length}/${governance.active_member_count})` : "Propose budget";
timeForm.dataset.proposalId = pendingTime?.id ?? ""; travelForm.dataset.proposalId = pendingTravel?.id ?? ""; budgetForm.dataset.proposalId = pendingBudget?.id ?? "";
}
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",
pollMs: 0,
requestedSurfaceId: "ghostlight.play",
source: "Ghostlight",
statusElement: status,
pluginAdapters: [heimdallAccessBrowserAdapter],
});
async function decide(decisionId: string, accept: boolean) {
await post(`/api/session-zero/${sessionId}/decision`, { expected_revision: revision, decision_id: decisionId, accept, counter: null }); await refresh();
}
window.addEventListener("unhandledrejection", event => {
event.preventDefault();
status.textContent = event.reason instanceof Error ? event.reason.message : String(event.reason);
});
function showCommand(body: any) {
receipt.hidden = false;
if (body.kind === "assessed") {
const a = body.assessment; receipt.replaceChildren(el("h2", a.admissible ? `Assessment · DC ${a.dc}` : "Attempt not admitted"), el("p", a.admissible ? `Success: ${a.success_stake}` : a.missing_permission));
if (a.admissible) { const confirm = el("button", "Confirm server roll"); confirm.addEventListener("click", () => void sendWorld({ type: "attempt", actor_id: viewerActorId, assessment_digest: a.digest })); receipt.append(confirm); }
} else if (body.kind?.includes("pending")) receipt.replaceChildren(el("h2", "Waiting for the table"), el("p", "The proposal is revision-bound and will commit only after every active member approves."));
else receipt.replaceChildren(el("h2", "World committed"), el("p", body.receipt?.roll ? `d20 ${body.receipt.roll.d20} · ${String(body.receipt.roll.outcome).replaceAll("_", " ")}` : `Revision ${body.revision ?? revision}`));
}
await host.start();
await resumeHeimdallAccess({
complete: async handle => {
const result = await api.completeAuthentication(handle);
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
}
await host.refresh();
},
});
async function sendWorld(command: unknown) { const body = await post("/api/command", command); showCommand(body); await refresh(); return body; }
$("#session-entry-form").addEventListener("submit", async event => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement)); delete data.mode; await post("/api/session-zero", data); await refresh(); });
$("#join-form").addEventListener("submit", async event => { event.preventDefault(); const token = ($("#join-token") as HTMLInputElement).value.trim(); await post(`/api/session-zero/join/${encodeURIComponent(token)}`, { display_name: ($("#join-name") as HTMLInputElement).value.trim() }); history.replaceState({}, "", location.pathname); await refresh(); });
$("#tab-shared").addEventListener("click", () => { activeChannel = "shared:table"; renderSession(currentSurface.session_zero); });
$("#tab-private").addEventListener("click", () => { activeChannel = `private:${viewerMemberId}`; renderSession(currentSurface.session_zero); });
$("#session-message-form").addEventListener("submit", async event => { event.preventDefault(); const input = $("#session-message") as HTMLTextAreaElement; await post(`/api/session-zero/${sessionId}/message`, { expected_revision: revision, channel_id: activeChannel, text: input.value.trim() }); input.value = ""; status.textContent = "Your message is durable. The DM is considering it."; await refresh(); });
$("#invite-form").addEventListener("submit", async event => { event.preventDefault(); const body = await post(`/api/session-zero/${sessionId}/invites`, { count: Number(($(
"#invite-count") as HTMLInputElement).value) }); const output = $("#invite-output"); output.replaceChildren(); for (const token of body.invite_tokens) { const url = `${location.origin}${location.pathname}?invite=${encodeURIComponent(token)}`; const p = el("p"); const link = el("a", url); link.href = url; p.append(link); output.append(p); } await refresh(); });
$("#boundary-form").addEventListener("submit", async event => { event.preventDefault(); await post(`/api/session-zero/${sessionId}/boundary`, { expected_revision: revision, boundary_id: null, topic: ($("#boundary-topic") as HTMLInputElement).value.trim(), level: ($("#boundary-level") as HTMLSelectElement).value }); ($("#boundary-topic") as HTMLInputElement).value = ""; await refresh(); });
$("#lock-roster").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/lock`, { expected_revision: revision }); await refresh(); });
$("#compile-session").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/compile`, { expected_revision: revision }); await refresh(); });
$("#approve-session").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/approve`, { expected_revision: revision }); await refresh(); });
$("#publish-session").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/publish`, { expected_revision: revision }); await refresh(); });
$("#composer").addEventListener("submit", async event => { event.preventDefault(); const description = ($("#attempt") as HTMLTextAreaElement).value.trim(); const intended = ($("#intended-effect") as HTMLInputElement).value.trim(); if (!intended) return void (status.textContent = "Name the uncertain effect, or use Speak."); await sendWorld({ type: "assess", expected_revision: revision, intent: { actor_id: viewerActorId, description, intended_effect: intended } }); });
$("#speak").addEventListener("click", async () => { const input = $("#attempt") as HTMLTextAreaElement; await sendWorld({ type: "speak", expected_revision: revision, actor_id: viewerActorId, text: input.value.trim(), intended_effect: null }); input.value = ""; });
$("#contract-review").addEventListener("click", async () => { await post("/api/campaigns/contract-review", {}); await refresh(); });
$("#active-cell-budget").addEventListener("input", event => { ($("#active-cell-budget-value") as HTMLOutputElement).value = (event.currentTarget as HTMLInputElement).value; });
$("#time-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const id = form.dataset.proposalId; const body = id ? await post(`/api/governance/time/${encodeURIComponent(id)}/approve`, { expected_revision: revision }) : await post("/api/governance/time", { expected_revision: revision, minutes: Number(($(
"#time-minutes") as HTMLInputElement).value) }); showCommand(body); await refresh(); });
$("#travel-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const id = form.dataset.proposalId; const destination = ($("#travel-destination") as HTMLSelectElement).value; if (!id && !destination) return void (status.textContent = "No reachable destination is compiled from this scene."); const body = id ? await post(`/api/governance/travel/${encodeURIComponent(id)}/approve`, { expected_revision: revision }) : await post("/api/governance/travel", { expected_revision: revision, destination_location_id: destination }); showCommand(body); await refresh(); });
$("#resolution-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const id = form.dataset.proposalId; const body = id ? await post(`/api/governance/cell-budget/${encodeURIComponent(id)}/approve`, { expected_revision: revision }) : await post("/api/governance/cell-budget", { expected_revision: revision, expected_resolution_epoch: resolutionEpoch, active_cell_budget: Number(($(
"#active-cell-budget") as HTMLInputElement).value) }); showCommand(body); await refresh(); });
const events = new EventSource("/api/events");
let refreshQueued = false;
events.addEventListener("revision", () => { if (refreshQueued) return; refreshQueued = true; window.setTimeout(() => { refreshQueued = false; void refresh().catch(showError); }, 100); });
events.onerror = () => { status.textContent = "Realtime revision notices are reconnecting; the server remains authoritative."; };
void resumeLogin().then(refresh).catch(showError);
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.";
});

View file

@ -1,5 +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,main{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,label,button,pre,.state-pill,dt{font-family:"IBM Plex Mono",monospace}.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}#status{color:#9aa69f;font-size:.78rem}main{padding:1.5rem 0 5rem}#surface{min-height:8rem}form,.card{margin-top:1rem;padding:1rem;border:1px solid #66543d;background:#17201ddd}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}input[type="radio"]{width:auto}input[type="range"]{padding:.25rem;accent-color:#d49b58}textarea{min-height:7rem}button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}button+button,.channel-tabs button[aria-pressed="false"]{background:transparent;color:#d49b58}button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}pre{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font-size:.72rem}.card h3{margin:.1rem 0}.card p{color:#b9c0b7}.warning{color:#e4a86a!important}code{font-family:"IBM Plex Mono",monospace;color:#d49b58}.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}.session-heading{display:flex;justify-content:space-between;align-items:end;gap:1rem}.state-pill{padding:.4rem .65rem;border:1px solid #66543d;color:#d49b58;font-size:.72rem}.channel-tabs{position:sticky;top:0;z-index:2;padding:.35rem 0;background:#0c1110e8}.session-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(18rem,1fr);gap:1rem}.session-grid aside{min-width:0}.transcript{list-style:none;padding:0;max-height:34rem;overflow:auto}.message{border-left:2px solid #59675e;padding:.6rem .8rem;margin:.65rem 0;background:#0b0f0d}.message.dm{border-color:#d49b58}.message p{white-space:pre-wrap;margin:.25rem 0}.message strong,dt{color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}.decision{border-top:1px solid #354039;padding:.8rem 0}dl{display:grid;grid-template-columns:minmax(7rem,.7fr) minmax(0,1.3fr);gap:.45rem .75rem}dt,dd{margin:0;overflow-wrap:anywhere}fieldset{border:1px solid #4a5148;margin:1rem 0}fieldset label{display:inline-block;margin-right:1rem}.button-row{display:flex;flex-wrap:wrap}.governance{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.governance h2{grid-column:1/-1}.quiet{color:#9aa69f!important}.session-actions button{display:block;width:100%}.card a{color:#d49b58;overflow-wrap:anywhere}
@media(max-width:820px){.session-grid,.governance{grid-template-columns:1fr}.session-grid aside{display:contents}.channel-tabs{display:flex}.channel-tabs button{flex:1}.session-heading{align-items:start;flex-direction:column}dl{grid-template-columns:1fr}.message{padding:.5rem}}
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,main{width:min(100% - 1rem,1180px)}form,.card{padding:.75rem}.state-pill{margin:0}}
.form-heading{font:600 .72rem "IBM Plex Mono",monospace;text-transform:uppercase;letter-spacing:.12em;color:#d49b58}.card details{margin:.65rem 0;border-top:1px solid #354039;padding-top:.65rem}.card summary{cursor:pointer;font:600 .76rem "IBM Plex Mono",monospace;color:#d49b58}.card ul{margin:.6rem 0;padding-left:1.25rem}.card li{margin:.25rem 0;color:#c8d0c8}#receipt h3{margin:.1rem 0 .75rem}
:root{color-scheme:dark;font-family:"Crimson Pro",serif;background:#0c1110;color:#e8e1cf}
body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% 0,#253229 0,transparent 38rem),linear-gradient(145deg,#0c1110,#111513)}
header,#surface{width:min(1180px,calc(100% - 2rem));margin:auto}
header{padding:3rem 0 1.25rem;border-bottom:1px solid #66543d}
h1{font-size:clamp(2.6rem,8vw,5.5rem);font-weight:400;letter-spacing:-.045em;margin:.1rem 0}
.eyebrow,#status,.field-label,button,pre{font-family:"IBM Plex Mono",monospace}
.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}
#status{color:#9aa69f;font-size:.78rem}
#surface{padding:1.5rem 0 5rem;min-height:12rem}
.eve-surface{display:grid;gap:1rem}
.card,.pane,.heimdall-access{padding:1rem;border:1px solid #66543d;background:#17201ddd}
.cultui-field{display:block;margin:.75rem 0}
.field-label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;margin:.65rem 0 .25rem}
textarea,input,select{box-sizing:border-box;width:100%;resize:vertical;background:#0b0f0d;color:#f2e7d0;border:1px solid #4a5148;padding:.8rem;margin:.15rem 0;font:1.05rem/1.4 "Crimson Pro",serif}
textarea{min-height:7rem}
button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}
button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}
pre,.cultui-code{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font:.72rem/1.5 "IBM Plex Mono",monospace}
.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}
.cultui-list-item{border-top:1px solid #354039;padding:.8rem 0}
.cultui-list-item-label,.cultui-list-item-status{font-family:"IBM Plex Mono",monospace;color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}
.cultui-tabs [role="tablist"]{display:flex;gap:.4rem;position:sticky;top:0;background:#0c1110e8;z-index:2}
.eve-command-result{margin:1rem 0;padding:1rem;border:1px solid #66543d;background:#17201d}
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,#surface{width:min(100% - 1rem,1180px)}.card,.pane,.heimdall-access{padding:.75rem}}

View file

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

15
web/vite.config.mjs Normal file
View 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),
),
},
},
});