Merge Bifrost Discord DM bridge

This commit is contained in:
Meta Vens 2026-06-09 14:41:36 +01:00
commit dafe0528e6
25 changed files with 1507 additions and 80 deletions

View file

@ -5,9 +5,9 @@
- Bifrost is the GameCult member alpha platform for tasks, governance, contributor accounting, and operational legibility.
- Bifrost also owns GameCult public-protocol transport for governed work crossings: GitHub proposal/review surfaces, Discord dispatch receipts, CultNet/CultCache intake packets, and future collaboration interfaces.
- It sits under the Yggdrasil infrastructure umbrella and should connect GameCult services and workflows without importing crypto ideology or platform theater.
- The transport role follows from Bifrost's Labor Platform role. Do not treat bridge tools as a stray bot utility owned by VoidBot or individual repo Faces.
- The transport role follows from Bifrost's Labor Platform role. Do not treat bridge tools as a stray bot utility owned by VoidBot or individual repo Personas.
- The product center is the governance/labor loop: users, patrons, members, and contributors express policy and work priorities; reward pressure rises around work that matters; contributors claim valuable work; maintainers accept completion artifacts; Bifrost credits the contributor and records the reward allocation.
- Discord should become a native Bifrost interface for Bifrost-scoped work, voting/priority prompts, reward discussion, maintainer notices, and agent swarm routing. This does not make Bifrost responsible for generic room cognition, moderation judgment, archive retrieval, or Face personality.
- Discord should become a native Bifrost interface for Bifrost-scoped work, voting/priority prompts, reward discussion, maintainer notices, and agent swarm routing. This does not make Bifrost responsible for generic room cognition, moderation judgment, archive retrieval, or Persona personality.
## V1 Non-Goals

View file

@ -18,8 +18,8 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
- health and readiness endpoints plus startup config validation
- GitHub App webhook ingestion for issue, pull request, and review sync
- local bridge tooling for agent-owned GitHub draft PRs and Discord posts
- CultCache-backed governance topic threads for feature requests, discussion comments, Face approvals, and dispatch promotion
- CultCache/CultNet-backed agent intake tooling for repo Face update requests
- CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion
- CultCache/CultNet-backed agent intake tooling for repo Persona update requests
- a Verse service contract for CultCache witnesses, CultMesh namespaces, and Eve/CultUI product and operator surfaces
- a read-only Eve provider advertisement export command for Bifrost Verse surface discovery
- Docker image and local Compose stack for containerized smoke testing

View file

@ -1,12 +1,12 @@
# Agent Transport
Bifrost owns the agent transport contract for GameCult work requests because agent intake is part of Bifrost's public-process jurisdiction. The broader ownership rule lives in `docs/jurisdiction-map.md`: Bifrost governs work crossings; CultCache stores typed packets; CultNet moves them; VoidBot observes Discord and packages context; repo Faces claim only work inside their jurisdiction.
Bifrost owns the agent transport contract for GameCult work requests because agent intake is part of Bifrost's public-process jurisdiction. The broader ownership rule lives in `docs/jurisdiction-map.md`: Bifrost governs work crossings; CultCache stores typed packets; CultNet moves them; VoidBot observes Discord and packages context; repo Personas claim only work inside their jurisdiction.
The primary source of truth for feature requests and governance discussion is now the Bifrost typed topic store, not Discord scrollback. Discord can still produce evidence, pressure, and human-readable mirrors, but canonical discussion belongs in Bifrost CultCache documents:
- `bifrost.governance.topic` records the topic, jurisdiction, status, priority, source, Face approval, and dispatch request id.
- `bifrost.governance.topic-comment` records human, agent, Face, and system comments on that topic.
- `bifrost.agent-transport.update-request` is the dispatch packet produced after the topic has enough shape and the owning Face approves it.
- `bifrost.governance.topic` records the topic, jurisdiction, status, priority, source, Persona approval, and dispatch request id.
- `bifrost.governance.topic-comment` records human, agent, Persona, and system comments on that topic.
- `bifrost.agent-transport.update-request` is the dispatch packet produced after the topic has enough shape and the owning Persona approves it.
The broader Verse service contract, including CultMesh namespaces and
Eve/CultUI surface ownership, lives in `docs/verse-service-contract.md`.
@ -25,7 +25,7 @@ That ownership split matters. If the queue lives in VoidBot, Discord becomes the
An update request is not just a message in a queue. It is the governed handoff after a topic has become actionable:
- what changed in the room, repo, motion, or work context
- which repo or Face has jurisdiction
- which repo or Persona has jurisdiction
- why this deserves priority now
- where the claimed work should report back
- which receipt will prove the request was handled
@ -73,7 +73,7 @@ Agents should post opinions, objections, support, questions, approvals, and rece
The planned `#bifrost` Discord channel is a mirror and human interface. Agent chatter mirrored there should not be re-ingested as fresh Discord consensus, because the agent already receives the authoritative Bifrost digest. Human messages in `#bifrost` become Bifrost comments only when Heimdall/Bifrost can link the Discord id to a registered GameCult user, patron, member, contributor, or authorized agent. Unlinked messages are chat fumes: readable context, not governance input.
Mirrored Discord text does not need to be identical to the canonical topic comment. The canonical comment should be clear enough for governance, search, dispatch, and future web UI rendering. The `#bifrost` mirror may be a separate verbal rendering in the Face's own voice, using `--mirror-content` or `--mirror-content-file`; Bifrost posts it through the persona bridge and records a receipt comment back on the topic. Once the hosted Bifrost app is deployed on Yggdrasil, mirror text should include the Bifrost topic URL instead of relying on raw topic ids.
Mirrored Discord text does not need to be identical to the canonical topic comment. The canonical comment should be clear enough for governance, search, dispatch, and future web UI rendering. The `#bifrost` mirror may be a separate verbal rendering in the Persona's own voice, using `--mirror-content` or `--mirror-content-file`; Bifrost posts it through the persona bridge and records a receipt comment back on the topic. Once the hosted Bifrost app is deployed on Yggdrasil, mirror text should include the Bifrost topic URL instead of relying on raw topic ids.
Mirroring is part of accepting Bifrost activity, not a notification garnish. Topic opens, comments, approvals, dispatch promotions, and direct update-request enqueues default to `BIFROST_DISCORD_CHANNEL_ID` / `DISCORD_BIFROST_CHANNEL_ID` and fail closed when no mirror can be posted. The only escape hatch is `--allow-unmirrored true` or `BIFROST_ALLOW_UNMIRRORED_GOVERNANCE=true`, reserved for explicit fixtures and local debugging. Production swarm writes should never use it.
@ -90,7 +90,7 @@ Each request has:
- `id`: stable request id.
- `targetRepoName`: short repository name, such as `AetheriaLore`.
- `targetRepositoryFullName`: optional owner/name form.
- `targetAgentIdentity`: optional Face identity, such as `nibu`.
- `targetAgentIdentity`: optional Persona identity, such as `nibu`.
- `title`: short human label.
- `requestMarkdown`: the actual consensus or task packet.
- `priority`: higher numbers claim first.
@ -120,7 +120,7 @@ node .\tools\agent-transport.mjs snapshot --out E:\tmp\bifrost-agent-transport.m
node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transport.msgpack
```
`enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Face has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests.
`enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Persona has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests.
Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord.

View file

@ -7,7 +7,7 @@ letting the local CLI, Discord text, or web views become the canonical interface
The CultCache witness, CultMesh namespace, Eve/CultUI surface, migration, and
demotion rules are defined in `docs/verse-service-contract.md`.
This is not a new side mission. Bifrost evolved out of the GameCult Labor Platform idea: users and contributors should be able to vote on policy, work priority, and reward allocation; high-demand work should become more valuable until someone picks it up; maintainers should accept completed work; and contributors should receive credit and bounty through an auditable platform. Public-protocol transport belongs here because that workflow crosses GitHub, Discord, and future collaboration interfaces. The bridge is how Bifrost's governance/labor role touches the world without letting VoidBot, repo Faces, or one-off helper scripts become hidden authorities.
This is not a new side mission. Bifrost evolved out of the GameCult Labor Platform idea: users and contributors should be able to vote on policy, work priority, and reward allocation; high-demand work should become more valuable until someone picks it up; maintainers should accept completed work; and contributors should receive credit and bounty through an auditable platform. Public-protocol transport belongs here because that workflow crosses GitHub, Discord, and future collaboration interfaces. The bridge is how Bifrost's governance/labor role touches the world without letting VoidBot, repo Personas, or one-off helper scripts become hidden authorities.
For the full ownership map, read `docs/jurisdiction-map.md`.
@ -27,10 +27,10 @@ It does not mean Bifrost should own OAuth sludge. Heimdall owns OAuth, linked id
- Heimdall owns provider OAuth and account-linked credentials.
- Bifrost owns bridge action policy, request lifecycle, routing, execution receipts, Discord-native Bifrost interface transport, and governance/labor transport.
- VoidBot observes Discord, packages conversation, validates registered Face intent, and speaks through registered personas during the local transition. It should not be the durable authority that mutates GitHub or owns Bifrost-scoped Discord transport.
- VoidBot observes Discord, packages conversation, validates registered Persona intent, and speaks through registered personas during the local transition. It should not be the durable authority that mutates GitHub or owns Bifrost-scoped Discord transport.
- CultCache stores lightweight agent transport packets when the bridge needs file-native state.
- CultNet carries those packets between runtimes.
- Repo Faces generate jurisdictional proposals and authored voice; they may request crossings, but they do not own the bridge.
- Repo Personas generate jurisdictional proposals and authored voice; they may request crossings, but they do not own the bridge.
## Local Bridge CLI
@ -41,7 +41,7 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c
- GitHub actions use the local `gh` authenticated account.
- Discord posts use `BIFROST_DISCORD_BOT_TOKEN` or `DISCORD_BOT_TOKEN`.
The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Faces can stop carrying this machinery themselves.
The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves.
### GitHub Draft PR
@ -78,7 +78,7 @@ node .\tools\bifrost-bridge.mjs github-pr-comment `
--content "This proposal needs a sharper leash before it becomes canon-shaped."
```
The command leaves a signed PR comment through `gh` and prints a JSON receipt. Repo Faces should use this when the argument belongs on the review artifact instead of dissolving into Discord scrollback.
The command leaves a signed PR comment through `gh` and prints a JSON receipt. Repo Personas should use this when the argument belongs on the review artifact instead of dissolving into Discord scrollback.
### Discord Post
@ -90,7 +90,17 @@ node .\tools\bifrost-bridge.mjs discord-post `
--content "Nibu drafted the article and put it in a PR: https://github.com/..."
```
The command posts through Discord's REST API using the configured bot token and prints a JSON receipt. When `--persona-name` is provided, Bifrost uses the shared webhook persona pattern so repo Faces can speak with their own display name and avatar instead of collapsing back into the base bot identity. Configure `BIFROST_DISCORD_PERSONA_WEBHOOK_URL_<channelId>` or `DISCORD_PERSONA_WEBHOOK_URL` when the bot should use an existing webhook; otherwise it creates and caches a `Bifrost Persona Pipe` webhook for the channel when Discord permissions allow it.
The command posts through Discord's REST API using the configured bot token and prints a JSON receipt. When `--persona-name` is provided, Bifrost uses the shared webhook persona pattern so repo Personas can speak with their own display name and avatar instead of collapsing back into the base bot identity. Configure `BIFROST_DISCORD_PERSONA_WEBHOOK_URL_<channelId>` or `DISCORD_PERSONA_WEBHOOK_URL` when the bot should use an existing webhook; otherwise it creates and caches a `Bifrost Persona Pipe` webhook for the channel when Discord permissions allow it.
### Discord DM
```powershell
node .\tools\bifrost-bridge.mjs discord-dm `
--recipient-id 123456789012345678 `
--content "Moderation status update..."
```
The command opens a bot DM with the recipient, posts the message, and prints a JSON receipt. Use this for Bifrost-owned private status crossings such as moderation status notices. Heimdall should eventually provide the actor/grant facts; the CLI is the current local bridge actuator.
## Discord Native Interface Target
@ -100,11 +110,11 @@ The intended shape:
1. Heimdall links a Discord user to a GameCult user/member/patron/contributor account and issues capability claims.
2. Bifrost receives Discord interactions, commands, mentions, or channel events that are relevant to work, policy, priority, reward allocation, maintainer review, or swarm routing.
3. Bifrost records the event against the appropriate work item, motion, request, Face, or receipt.
4. Bifrost routes agent swarm input/output when the event is a Face mention, dispatch acknowledgement, PR/article announcement, or work request.
3. Bifrost records the event against the appropriate work item, motion, request, Persona, or receipt.
4. Bifrost routes agent swarm input/output when the event is a Persona mention, dispatch acknowledgement, PR/article announcement, or work request.
5. Bifrost posts concise Discord receipts that identify the topic, target, status, and next place to continue.
VoidBot still owns room reading, moderation judgment, archive/source retrieval, and Face cognition. Bifrost owns Discord when Discord is being used as the Labor Platform interface or swarm transport.
VoidBot still owns room reading, moderation judgment, archive/source retrieval, and Persona cognition. Bifrost owns Discord when Discord is being used as the Labor Platform interface or swarm transport.
## Heimdall Integration Target

View file

@ -21,9 +21,9 @@ The product center is the Labor Platform loop: eligible participants express pol
- owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation
- Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault
- `E:\Projects\VoidBot`
- observes Discord, packages consensus, and runs repo Face heartbeats
- observes Discord, packages consensus, and runs repo Persona heartbeats
- should call Bifrost for governed GitHub/Discord/intake crossings instead of becoming hidden transport authority
- keeps room cognition, moderation judgment, retrieval, and Face personality separate from Bifrost's Discord-native work interface
- keeps room cognition, moderation judgment, retrieval, and Persona personality separate from Bifrost's Discord-native work interface
- `E:\Projects\CultLib`
- stewards CultCache, CultNet, CultMesh, typed persistence, schemas, and reusable sync/storage libraries
- Bifrost defines the meaning of agent transport packets while CultLib-provided primitives store and move them

View file

@ -20,13 +20,13 @@ The core loop is economic and social, not merely technical:
That loop already has to cross GitHub, Discord, Bifrost's own web UI, and eventually other collaboration surfaces. The bridge role exists so those crossings stay attached to governance, prioritization, reward, and credit instead of becoming stray bot errands.
The agent swarm makes that need louder. Repo Faces can notice work, sharpen consensus, draft proposals, write bylined articles, and ask for priority. They should not each carry their own GitHub, Discord, intake, receipt, and permission machinery. Bifrost owns the public-protocol transport so the swarm can propose work without making transport authority plural.
The agent swarm makes that need louder. Repo Personas can notice work, sharpen consensus, draft proposals, write bylined articles, and ask for priority. They should not each carry their own GitHub, Discord, intake, receipt, and permission machinery. Bifrost owns the public-protocol transport so the swarm can propose work without making transport authority plural.
## Core Invariant
Bifrost owns governed public crossings for GameCult work.
A governed public crossing is any action where an internal GameCult actor, agent, Face, or process causes a durable or visible change on a public or collaboration surface:
A governed public crossing is any action where an internal GameCult actor, agent, Persona, or process causes a durable or visible change on a public or collaboration surface:
- GitHub draft PRs, PR comments, issues, review artifacts, and work links
- Discord-native Bifrost interactions, persona announcements, dispatch receipts, and role-addressed swarm I/O
@ -47,7 +47,7 @@ The bridge role is therefore an extension of Bifrost's Labor Platform role:
- accepted work needs a maintainer-approved completion artifact before credit/reward is assigned
- contributors and agents need one place to see what was requested, who wanted it, what bounty/credit attached, who claimed it, what crossed, and where it landed
If the bridge lives in VoidBot, Discord becomes a hidden governance surface. If it lives separately from Bifrost, transport and governance split into two half-authorities that will eventually disagree. If every repo Face owns its own bridge, the swarm becomes unreviewable noise with adorable avatars. The coherent machine is smaller: Bifrost governs public work transport.
If the bridge lives in VoidBot, Discord becomes a hidden governance surface. If it lives separately from Bifrost, transport and governance split into two half-authorities that will eventually disagree. If every repo Persona owns its own bridge, the swarm becomes unreviewable noise with adorable avatars. The coherent machine is smaller: Bifrost governs public work transport.
## Discord Decision
@ -57,15 +57,15 @@ That means Bifrost should eventually provide Discord access to registered GameCu
It also means Bifrost should become the router for Discord input/output for the agent swarm:
- inbound Discord mentions or channel events that target a Face become Bifrost-routed work, speech, or governance-comment events when authorized
- inbound Discord mentions or channel events that target a Persona become Bifrost-routed work, speech, or governance-comment events when authorized
- outbound agent posts, PR receipts, article announcements, and dispatch acknowledgements go through Bifrost
- Bifrost records which Face spoke, why the message was allowed, what topic, request, or work item it relates to, and where the receipt landed
- Bifrost records which Persona spoke, why the message was allowed, what topic, request, or work item it relates to, and where the receipt landed
- a dedicated `#bifrost` Discord channel can mirror Bifrost topic activity for human readability, but mirrored agent messages there are not re-ingested as new consensus
- mirrored `#bifrost` messages may be rendered as separate in-character verbal comments while the canonical Bifrost topic comment remains structured and sober; Bifrost records the Discord receipt on the topic
- Bifrost-scoped topic activity and direct update-request intake must mirror to `#bifrost` as part of the accepting write; invisible accepted governance activity is a failed write, not a quiet warning
- human `#bifrost` messages become canonical Bifrost comments only after Heimdall/Bifrost links the Discord id to a registered actor; unlinked messages remain non-governance chat context
This does not mean Bifrost owns all Discord behavior. General conversation, room reading, moderation judgment, archive retrieval, and personality cognition remain VoidBot/Face concerns. Bifrost owns Discord when Discord is acting as a GameCult governance/labor/work interface or as the swarm's public transport.
This does not mean Bifrost owns all Discord behavior. General conversation, room reading, moderation judgment, archive retrieval, and personality cognition remain VoidBot/Persona concerns. Bifrost owns Discord when Discord is acting as a GameCult governance/labor/work interface or as the swarm's public transport.
## Ownership Boundaries
@ -74,7 +74,7 @@ This does not mean Bifrost owns all Discord behavior. General conversation, room
- bridge action request shape and lifecycle
- work-priority intake packets and claim semantics
- canonical feature-request and governance topic threads
- topic comments, support, objections, questions, Face approvals, dispatch promotion, and topic receipts
- topic comments, support, objections, questions, Persona approvals, dispatch promotion, and topic receipts
- user, patron, member, contributor, and agent transport events when they affect work, priority, reward, votes, claims, or receipts
- mapping from internal request to public target surface
- GitHub proposal/comment/PR transport for agent and member work
@ -98,8 +98,8 @@ Bifrost consumes Heimdall claims. It does not become the key vault. The split is
- Discord observation and room context
- archival retrieval and source/lore search surfaces
- packaging recent chat consensus into a task packet
- repo Face heartbeat prompts and local personality/state loops
- the worker-side validation that a registered Face is allowed to request a crossing
- repo Persona heartbeat prompts and local personality/state loops
- the worker-side validation that a registered Persona is allowed to request a crossing
VoidBot should not be the durable authority that mutates GitHub or owns Bifrost-scoped Discord transport. It may prepare packets and call Bifrost. It does not get to be a shadow governance platform because it was nearest to the chat log.
@ -112,14 +112,14 @@ VoidBot should not be the durable authority that mutates GitHub or owns Bifrost-
Bifrost defines what an agent transport request means. CultCache stores it. CultNet moves it. CultLib stewards the reusable infrastructure. Storage and sync do not decide governance semantics.
### Repo Faces Own
### Repo Personas Own
- repo-local attention, taste, and proposals
- character/authored voice
- repo-local state, rumination, maps, and bylined essays
- asking for consensus where canon or implementation authority requires it
Repo Faces may generate bridge requests. They do not own the bridge itself.
Repo Personas may generate bridge requests. They do not own the bridge itself.
## Concern Map
@ -132,7 +132,7 @@ Repo Faces may generate bridge requests. They do not own the bridge itself.
| Where is the durable argument surface? | Bifrost | Public process must land on the correct GitHub, Discord, or future collaboration surface. |
| What did the room recently agree or ask for? | VoidBot into Bifrost | Discord observation belongs to the room-aware bot; governed consensus is stored as Bifrost topics/comments. |
| Is Discord acting as a Bifrost interface? | Bifrost | Member access, vote prompts, work requests, priority signals, reward discussion, and swarm routing are native Bifrost transport concerns. |
| What does the repo Face think should happen? | Repo Face | Jurisdictional taste and proposal pressure belong to the Face. |
| What does the repo Persona think should happen? | Repo Persona | Jurisdictional taste and proposal pressure belong to the Persona. |
| Where is the packet stored and synced? | CultCache/CultNet | Transport state should use the shared typed storage/sync substrate. |
| What proves the action happened? | Bifrost | Receipts are part of public-process transport, not local helper trivia. |
@ -140,7 +140,7 @@ Repo Faces may generate bridge requests. They do not own the bridge itself.
Every Bifrost-owned crossing should be able to answer:
- actor: which member, agent, Face, app, or service requested the action
- actor: which member, agent, Persona, app, or service requested the action
- authority: what grant, review state, or policy allowed it
- source: which chat, motion, work item, consensus packet, or repo state produced it
- target: which repo, PR, issue, channel, thread, or interface receives it
@ -159,7 +159,7 @@ The current bridge is deliberately local and boring while Heimdall's managed cre
- `tools/bifrost-bridge.mjs` executes GitHub draft PRs, PR comments, and Discord persona posts with local credentials.
- `tools/agent-transport.mjs` owns CultCache-backed update requests and CultNet snapshot exchange.
- `plugins/bifrost-intake` lets Codex claim matching work packets instantly without requiring MCP tool mounting.
- VoidBot's repo Face worker validates identity and calls Bifrost instead of carrying GitHub or Discord mutation code itself.
- VoidBot's repo Persona worker validates identity and calls Bifrost instead of carrying GitHub or Discord mutation code itself.
This is not the final hosted control plane. It is the current bridge actuator and intake lane under the right ownership.
@ -173,14 +173,14 @@ This is not the final hosted control plane. It is the current bridge actuator an
- Do not expose new durable Bifrost state without a CultCache `.cc` witness or a
migration step that produces one.
- Do not add GitHub mutation machinery to VoidBot when the action is reviewable public work. Call Bifrost.
- Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Face scripts. Call Bifrost.
- Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost.
- Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims.
- Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning.
- Do not let a repo Face turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
- Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
- Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next.
- Do not accept Bifrost topic/comment/approval/dispatch/intake writes without a Discord mirror unless the caller explicitly enables an unmirrored fixture/debug path.
- Do not treat mirrored `#bifrost` agent chatter as fresh Discord consensus. Agents receive Bifrost topic digests directly; the mirror is for humans and linked-user input.
- Do not make Bifrost responsible for generic chat cognition, lore retrieval, moderation taste, or Face personality. Native Discord interface does not mean one app gets to eat every mind in the room.
- Do not make Bifrost responsible for generic chat cognition, lore retrieval, moderation taste, or Persona personality. Native Discord interface does not mean one app gets to eat every mind in the room.
## Future Shape

View file

@ -19,7 +19,7 @@ Bifrost state. They do not own Bifrost's canonical product or operator surface.
| Identity, grants, consent, revocation | Heimdall | OAuth providers, linked accounts, operator grants | signed claims consumed by Bifrost |
| Storage and sync primitives | CultCache/CultNet/CultLib | Bifrost document types and payloads | `.cc` stores, snapshots, raw document updates |
| Service discovery and surface aggregation | Odin | CultMesh namespaces, schema catalogs, service registrations | discoverable Verse routes and surface indexes |
| Room observation and Face cognition | VoidBot/repo Faces | Discord context, repo state, Persona state | proposed Bifrost topics, comments, dispatch requests |
| Room observation and Persona cognition | VoidBot/repo Personas | Discord context, repo state, Persona state | proposed Bifrost topics, comments, dispatch requests |
| Rendering | Eve/browser/native/TUI/Discord | `gamecult.eve.surface.v1` compositions, typed state references | product/operator UI lowerings, not canonical state |
## Durable State And Witnesses
@ -79,15 +79,24 @@ Bifrost has a read-only first-cut provider advertisement command:
node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc
```
It writes a single `gamecult.eve.provider_advertisement.v1` CultCache document
for Odin/Eve discovery experiments. The document names Bifrost's Account,
Patron, Project, Work, Motion, and Operator Verse surfaces; current and planned
schema ids; `.cc` witness/export paths; command authority boundaries; style
capabilities; and demoted presentation/probe surfaces.
It writes Bifrost-owned Eve discovery documents into
`.bifrost/provider-advertisement.cc`:
This export is discovery metadata only. It does not migrate Postgres state,
publish to CultMesh, read secrets, execute bridge actions, or make Razor Pages,
HTTP probes, Discord mirrors, or local dispatch JSON canonical.
- `gamecult.eve.provider_advertisement.v1` names Bifrost's Account, Patron,
Project, Work, Motion, and Operator Verse surfaces; current and planned
schema ids; `.cc` witness/export paths; command authority boundaries; style
capabilities; and demoted presentation/probe surfaces.
- `gamecult.eve.surface_state.v1` publishes the live Bifrost operator dashboard
surface with compact service health, topic/request status, dispatch activity
by source channel, store presence/freshness, and bridge capability status.
- `gamecult.eve.interface_binding.v1` binds that surface to provider id
`bifrost` so Odin can lower it into Nightwing, Eve, browser, or future room
dashboards.
This export does not migrate Postgres state, read secrets, execute bridge
actions, or make Razor Pages, HTTP probes, Discord mirrors, or local dispatch
JSON canonical. The operator surface may display those probes, but the provider
owned `.cc` witness remains the discovery and dashboard source.
For protocol-debug inspection without writing a witness:
@ -109,9 +118,12 @@ Product surfaces are the canonical interface compositions for:
Operator surfaces are the canonical interface compositions for:
- readiness, build/version, config validation, and deploy target;
- CultCache witness freshness and export errors;
- compact service health: daemon readiness, container health, and backing store
presence without making file size or storage mechanics first-rank operator
signal;
- CultMesh publication health and Odin discovery status;
- bridge queues, failed crossings, retry/cancel controls, and receipt gaps;
- topic/request status, dispatch activity by source channel, failed crossings,
retry/cancel controls, and receipt gaps;
- schema versions, migration state, and Postgres-to-`.cc` witness drift;
- Discord mirror health, including fail-closed unmirrored governance writes.
@ -119,6 +131,38 @@ The current Razor Pages app lowers product surfaces into browser UI. Existing
health/readiness endpoints lower operator state into HTTP probes. Both should
be fed by the same typed service state that backs Eve surfaces.
## CultMesh Address Shape
Bifrost publishes semantic CultMesh addresses before transport routes. The
canonical service name survives host moves:
```text
asgard.bifrost
```
The current located instance is:
```text
asgard.starfire.bifrost
```
The planned hosted location is:
```text
asgard.yggdrasil.bifrost
```
Surface resources hang under the located service. TUI and GUI are sibling
lowerings, not one endpoint wearing two costumes:
```text
asgard.starfire.bifrost/eve/tui
asgard.starfire.bifrost/eve/gui
```
CultNet routes are transport metadata for resolving those names. WebSocket and
HTTP URLs are compatibility bridges or probes, not native Bifrost addresses.
## Nested Verses
Bifrost exposes nested Verses where local ownership matters:

View file

@ -33,7 +33,7 @@
"termsOfServiceURL": "https://github.com/GameCult/Bifrost",
"defaultPrompt": [
"Get Bifrost intake context for this repo.",
"Enqueue this consensus packet for a repo Face.",
"Enqueue this consensus packet for a repo Persona.",
"List queued Bifrost agent requests."
],
"brandColor": "#4F46E5",

View file

@ -40,7 +40,7 @@ function formatClaimedRequest(request) {
ID: ${request.id}
Status: ${request.status}
Target repo: ${request.targetRepoName}
Target Face: ${request.targetAgentIdentity ?? "any"}
Target Persona: ${request.targetAgentIdentity ?? "any"}
Priority: ${request.priority}
Title: ${request.title}

View file

@ -1,13 +1,13 @@
---
name: bifrost-intake
description: Use when Codex should instantly check Bifrost intake for repo Face update requests, inject any claimed request into context, or stand down when no request is queued.
description: Use when Codex should instantly check Bifrost intake for repo Persona update requests, inject any claimed request into context, or stand down when no request is queued.
---
# Bifrost Intake
Bifrost owns agent update request semantics. CultCache stores the request documents. CultNet moves raw snapshots and document updates. This plugin exposes the intake hot path to Codex through direct local scripts, not mounted MCP tools.
Use this skill when a user asks to feed a consensus packet to a repo Face, claim work for the current repo, inspect queued update requests, or close a request after work is handled.
Use this skill when a user asks to feed a consensus packet to a repo Persona, claim work for the current repo, inspect queued update requests, or close a request after work is handled.
Do not wait for MCP tool discovery. Do not tell the user the backend is unavailable merely because `tool_search` cannot find Bifrost tools.
@ -17,7 +17,7 @@ Hot path:
node E:\Projects\Bifrost\plugins\bifrost-intake\scripts\intake-context.mjs --repo <RepoName> --agent <face-id>
```
That command claims the highest-priority queued Bifrost request matching the repo and Face, then prints a Codex-ready context packet. If nothing is queued, it prints an explicit no-work message telling the agent to continue the live turn without worrying about intake.
That command claims the highest-priority queued Bifrost request matching the repo and Persona, then prints a Codex-ready context packet. If nothing is queued, it prints an explicit no-work message telling the agent to continue the live turn without worrying about intake.
Use `--claimed-by <identity>` when the claiming identity should differ from `--agent`.

View file

@ -0,0 +1,29 @@
namespace Bifrost.Web.Configuration;
public sealed class HeimdallOptions
{
public const string SectionName = "Heimdall";
public string BaseUrl { get; init; } = "https://heimdall.gamecult.org";
public string AppSlug { get; init; } = "bifrost";
public string DiscordGuildId { get; init; } = string.Empty;
public string[] DiscordAllowedRoleIds { get; init; } = [];
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(BaseUrl) &&
!string.IsNullOrWhiteSpace(AppSlug);
public bool IsDiscordConfigured =>
IsConfigured &&
!string.IsNullOrWhiteSpace(DiscordGuildId) &&
DiscordAllowedRoleIds.Length > 0;
public bool IsPatreonConfigured =>
IsConfigured &&
!string.IsNullOrWhiteSpace(PatreonTierTitle);
}

View file

@ -59,8 +59,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
{
modelBuilder.Entity<UserAccount>(entity =>
{
entity.HasIndex(x => x.GitHubUserId).IsUnique();
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique();
entity.HasIndex(x => x.GitHubUserId).IsUnique().HasFilter("\"GitHubUserId\" IS NOT NULL");
entity.HasIndex(x => x.HeimdallAccountId).IsUnique().HasFilter("\"HeimdallAccountId\" <> ''");
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique().HasFilter("\"NormalizedGitHubLogin\" <> ''");
entity.Property(x => x.HeimdallAccountId).HasMaxLength(160);
entity.Property(x => x.GitHubLogin).HasMaxLength(120);
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
entity.Property(x => x.DisplayName).HasMaxLength(200);

View file

@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddHeimdallUserIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAccounts_GitHubUserId",
table: "UserAccounts");
migrationBuilder.DropIndex(
name: "IX_UserAccounts_NormalizedGitHubLogin",
table: "UserAccounts");
migrationBuilder.AlterColumn<long>(
name: "GitHubUserId",
table: "UserAccounts",
type: "bigint",
nullable: true,
oldClrType: typeof(long),
oldType: "bigint");
migrationBuilder.AddColumn<string>(
name: "HeimdallAccountId",
table: "UserAccounts",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_UserAccounts_GitHubUserId",
table: "UserAccounts",
column: "GitHubUserId",
unique: true,
filter: "\"GitHubUserId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UserAccounts_HeimdallAccountId",
table: "UserAccounts",
column: "HeimdallAccountId",
unique: true,
filter: "\"HeimdallAccountId\" <> ''");
migrationBuilder.CreateIndex(
name: "IX_UserAccounts_NormalizedGitHubLogin",
table: "UserAccounts",
column: "NormalizedGitHubLogin",
unique: true,
filter: "\"NormalizedGitHubLogin\" <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAccounts_GitHubUserId",
table: "UserAccounts");
migrationBuilder.DropIndex(
name: "IX_UserAccounts_HeimdallAccountId",
table: "UserAccounts");
migrationBuilder.DropIndex(
name: "IX_UserAccounts_NormalizedGitHubLogin",
table: "UserAccounts");
migrationBuilder.DropColumn(
name: "HeimdallAccountId",
table: "UserAccounts");
migrationBuilder.AlterColumn<long>(
name: "GitHubUserId",
table: "UserAccounts",
type: "bigint",
nullable: false,
defaultValue: 0L,
oldClrType: typeof(long),
oldType: "bigint",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_UserAccounts_GitHubUserId",
table: "UserAccounts",
column: "GitHubUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserAccounts_NormalizedGitHubLogin",
table: "UserAccounts",
column: "NormalizedGitHubLogin",
unique: true);
}
}
}

View file

@ -825,9 +825,14 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<long>("GitHubUserId")
b.Property<long?>("GitHubUserId")
.HasColumnType("bigint");
b.Property<string>("HeimdallAccountId")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset>("LastSeenAtUtc")
.HasColumnType("timestamp with time zone");
@ -839,10 +844,16 @@ namespace Bifrost.Web.Data.Migrations
b.HasKey("Id");
b.HasIndex("GitHubUserId")
.IsUnique();
.IsUnique()
.HasFilter("\"GitHubUserId\" IS NOT NULL");
b.HasIndex("HeimdallAccountId")
.IsUnique()
.HasFilter("\"HeimdallAccountId\" <> ''");
b.HasIndex("NormalizedGitHubLogin")
.IsUnique();
.IsUnique()
.HasFilter("\"NormalizedGitHubLogin\" <> ''");
b.ToTable("UserAccounts");
});

View file

@ -164,7 +164,9 @@ public sealed class UserAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
public long GitHubUserId { get; set; }
public long? GitHubUserId { get; set; }
public string HeimdallAccountId { get; set; } = string.Empty;
public string GitHubLogin { get; set; } = string.Empty;

View file

@ -0,0 +1,334 @@
using System.Net.Http.Json;
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Text.Json.Serialization;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Heimdall;
public sealed class HeimdallAuthService(
HttpClient httpClient,
HeimdallAuthAttemptStore attemptStore,
BifrostDbContext dbContext,
IOptions<HeimdallOptions> heimdallOptions,
TimeProvider timeProvider)
{
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
{
"discord",
"patreon"
};
public async Task<HeimdallAuthStartResult> StartAsync(
string provider,
string callbackBaseUrl,
string returnUrl,
CancellationToken cancellationToken)
{
if (!AllowedProviders.Contains(provider))
{
throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'.");
}
var options = heimdallOptions.Value;
if (!options.IsConfigured)
{
throw new HeimdallAuthException("Heimdall is not configured.");
}
var entitlementPolicy = provider.Equals("discord", StringComparison.OrdinalIgnoreCase)
? BuildDiscordPolicy(options)
: BuildPatreonPolicy(options);
var attemptId = Guid.NewGuid().ToString("N");
var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback");
var request = new HeimdallStartRequest(
options.AppSlug,
"sign_in",
ToAttemptReturnUrl(callbackBaseUrl, attemptId, returnUrl),
new HeimdallHandoff("backend_callback", attemptId, callbackUrl.ToString()),
entitlementPolicy);
var response = await httpClient.PostAsJsonAsync(
$"/v1/oauth/{provider}/start",
request,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var detail = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HeimdallAuthException($"Heimdall rejected {provider} auth start: {detail}");
}
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
if (payload is null || string.IsNullOrWhiteSpace(payload.AuthorizationUrl))
{
throw new HeimdallAuthException("Heimdall auth start returned no authorization URL.");
}
attemptStore.Create(attemptId, returnUrl);
return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId);
}
public void StoreBackendCallback(HeimdallBackendCallbackPayload payload)
{
if (!string.Equals(payload.AppSlug, heimdallOptions.Value.AppSlug, StringComparison.OrdinalIgnoreCase))
{
throw new HeimdallAuthException("Heimdall callback targeted a different app.");
}
if (string.IsNullOrWhiteSpace(payload.AttemptId))
{
throw new HeimdallAuthException("Heimdall callback omitted attempt id.");
}
attemptStore.Complete(payload.AttemptId, payload);
}
public async Task<HeimdallCompletedSignIn> CompleteAttemptAsync(
string attemptId,
CancellationToken cancellationToken)
{
var attempt = attemptStore.TryTake(attemptId)
?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet.");
var payload = attempt.Payload
?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet.");
if (!payload.IsSuccessful)
{
throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed.");
}
if (!payload.HasMemberAccess)
{
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
}
var userAccount = await UpsertUserAccountAsync(payload, cancellationToken);
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userAccount.GitHubUserId?.ToString() ?? userAccount.HeimdallAccountId),
new(ClaimTypes.Name, userAccount.DisplayName),
new(BifrostClaimTypes.HeimdallAccountId, userAccount.HeimdallAccountId),
new(BifrostClaimTypes.AuthProvider, payload.Provider ?? "heimdall"),
new(BifrostClaimTypes.DisplayName, userAccount.DisplayName),
new(BifrostClaimTypes.AvatarUrl, userAccount.AvatarUrl)
};
if (!string.IsNullOrWhiteSpace(userAccount.GitHubLogin))
{
claims.Add(new Claim(BifrostClaimTypes.GitHubLogin, userAccount.GitHubLogin));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
return new HeimdallCompletedSignIn(
principal,
string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl);
}
private async Task<UserAccount> UpsertUserAccountAsync(
HeimdallBackendCallbackPayload payload,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var accountId = payload.Account?.Id ?? throw new HeimdallAuthException("Heimdall response omitted account id.");
var displayName = payload.Account.DisplayName ?? accountId;
var userAccount = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleOrDefaultAsync(x => x.HeimdallAccountId == accountId, cancellationToken);
if (userAccount is null)
{
userAccount = new UserAccount
{
HeimdallAccountId = accountId,
DisplayName = displayName,
CreatedAtUtc = now,
LastSeenAtUtc = now,
MemberProfile = new MemberProfile
{
Nickname = displayName,
Headline = $"Verified through Heimdall {payload.Provider}",
UpdatedAtUtc = now
},
Membership = new Bifrost.Web.Domain.Membership
{
Status = MembershipStatus.Active,
CreatedAtUtc = now,
ApprovedAtUtc = now,
Notes = "Activated by Heimdall member access claim"
}
};
dbContext.UserAccounts.Add(userAccount);
}
else
{
userAccount.DisplayName = displayName;
userAccount.LastSeenAtUtc = now;
userAccount.MemberProfile ??= new MemberProfile
{
UserAccountId = userAccount.Id,
Nickname = displayName,
Headline = $"Verified through Heimdall {payload.Provider}",
UpdatedAtUtc = now
};
userAccount.Membership ??= new Bifrost.Web.Domain.Membership
{
UserAccountId = userAccount.Id,
CreatedAtUtc = now
};
userAccount.Membership.Status = MembershipStatus.Active;
userAccount.Membership.ApprovedAtUtc ??= now;
}
userAccount.MemberProfile!.Nickname = displayName;
userAccount.MemberProfile.UpdatedAtUtc = now;
ApplicationBootstrapper.EnsureRole(
userAccount.Membership!,
MemberRole.StandardMember,
null,
now,
"Heimdall member access");
await dbContext.SaveChangesAsync(cancellationToken);
return userAccount;
}
private static HeimdallEntitlementPolicy BuildDiscordPolicy(HeimdallOptions options)
{
if (!options.IsDiscordConfigured)
{
throw new HeimdallAuthException("Heimdall Discord access is not configured.");
}
return new HeimdallEntitlementPolicy(
"discord_role_access",
options.DiscordGuildId,
options.DiscordAllowedRoleIds,
null);
}
private static HeimdallEntitlementPolicy BuildPatreonPolicy(HeimdallOptions options)
{
if (!options.IsPatreonConfigured)
{
throw new HeimdallAuthException("Heimdall Patreon access is not configured.");
}
return new HeimdallEntitlementPolicy(
"patreon_membership_access",
null,
null,
options.PatreonTierTitle);
}
private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl)
{
var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
var target = string.IsNullOrWhiteSpace(returnUrl) ? "/App" : returnUrl;
var waitUri = new Uri(baseUri, "auth/heimdall/wait");
return $"{waitUri}?attemptId={Uri.EscapeDataString(attemptId)}&returnTo={Uri.EscapeDataString(target)}";
}
}
public class HeimdallAuthException(string message) : InvalidOperationException(message);
public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message);
public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId);
public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri);
public sealed class HeimdallAuthAttemptStore
{
private readonly ConcurrentDictionary<string, HeimdallAuthAttempt> _attempts = new(StringComparer.Ordinal);
public void Create(string attemptId, string returnUrl)
{
_attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null);
}
public void Complete(string attemptId, HeimdallBackendCallbackPayload payload)
{
_attempts.AddOrUpdate(
attemptId,
_ => new HeimdallAuthAttempt(payload.ReturnTo ?? "/App", payload),
(_, existing) => existing with { Payload = payload });
}
public HeimdallAuthAttempt? TryTake(string attemptId)
{
return _attempts.TryRemove(attemptId, out var attempt) ? attempt : null;
}
}
public sealed record HeimdallAuthAttempt(string ReturnUrl, HeimdallBackendCallbackPayload? Payload);
public sealed record HeimdallStartRequest(
[property: JsonPropertyName("appSlug")] string AppSlug,
[property: JsonPropertyName("mode")] string Mode,
[property: JsonPropertyName("returnTo")] string ReturnTo,
[property: JsonPropertyName("handoff")] HeimdallHandoff Handoff,
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy EntitlementPolicy);
public sealed record HeimdallHandoff(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("attemptId")] string? AttemptId = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
public sealed record HeimdallEntitlementPolicy(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("guildId")] string? GuildId,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("allowedRoleIds")] string[]? AllowedRoleIds,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("requiredTierTitle")] string? RequiredTierTitle);
public sealed record HeimdallStartResponse(
[property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl);
public sealed record HeimdallBackendCallbackPayload(
[property: JsonPropertyName("attemptId")] string AttemptId,
[property: JsonPropertyName("appSlug")] string? AppSlug,
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("provider")] string? Provider,
[property: JsonPropertyName("returnTo")] string? ReturnTo,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("errorDescription")] string? ErrorDescription,
[property: JsonPropertyName("account")] HeimdallAccount? Account,
[property: JsonPropertyName("sharedCapabilities")] string[]? SharedCapabilities,
[property: JsonPropertyName("entitlements")] HeimdallEntitlements? Entitlements)
{
public bool IsSuccessful => string.Equals(Status, "success", StringComparison.OrdinalIgnoreCase);
public bool HasMemberAccess =>
SharedCapabilities?.Any(capability =>
string.Equals(capability, "member_access", StringComparison.OrdinalIgnoreCase) ||
string.Equals(capability, "app_access", StringComparison.OrdinalIgnoreCase)) == true ||
Entitlements?.Facts?.Any(fact =>
string.Equals(fact, "entitlement.app_access", StringComparison.OrdinalIgnoreCase)) == true;
}
public sealed record HeimdallAccount(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("displayName")] string? DisplayName);
public sealed record HeimdallEntitlements(
[property: JsonPropertyName("facts")] string[]? Facts);

View file

@ -15,6 +15,8 @@ public static class GitHubAuthenticationDefaults
public static class BifrostClaimTypes
{
public const string GitHubLogin = "urn:bifrost:github-login";
public const string HeimdallAccountId = "urn:bifrost:heimdall-account-id";
public const string AuthProvider = "urn:bifrost:auth-provider";
public const string DisplayName = "urn:bifrost:display-name";
public const string AvatarUrl = "urn:bifrost:avatar-url";
}

View file

@ -1,5 +1,6 @@
using System.Security.Claims;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Membership;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Shared;
@ -29,8 +30,11 @@ public sealed class CurrentBifrostActorAccessor(
return Cache(httpContext, CurrentBifrostActor.Anonymous);
}
var heimdallAccountId = httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId);
var gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(gitHubId, out var gitHubUserId))
var hasGitHubUserId = long.TryParse(gitHubId, out var gitHubUserId);
if (string.IsNullOrWhiteSpace(heimdallAccountId) && !hasGitHubUserId)
{
return Cache(httpContext, CurrentBifrostActor.Anonymous);
}
@ -42,7 +46,10 @@ public sealed class CurrentBifrostActorAccessor(
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
.SingleOrDefaultAsync(
x => (!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) ||
(hasGitHubUserId && x.GitHubUserId == gitHubUserId),
cancellationToken);
return Cache(httpContext, new CurrentBifrostActor(true, userAccount));
}

View file

@ -20,6 +20,12 @@
<code>GitHubOAuth:ClientSecret</code> before using sign-in.
</div>
}
else if (Model.Auth == "heimdall-error")
{
<div class="callout warning">
Heimdall sign-in did not complete. Check the provider configuration and membership policy.
</div>
}
<div class="hero-actions">
@if (Model.Actor.IsActiveMember)
@ -33,6 +39,8 @@
else
{
<a class="button" href="/auth/sign-in">Sign in with GitHub</a>
<a class="button secondary" href="/auth/heimdall/discord">Sign in with Discord</a>
<a class="button secondary" href="/auth/heimdall/patreon">Sign in with Patreon</a>
}
<a class="button secondary" href="https://github.com/GameCult/Bifrost">View repository</a>

View file

@ -4,6 +4,7 @@ using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
@ -33,6 +34,10 @@ builder.Services
.AddOptions<BifrostHostOptions>()
.BindConfiguration(BifrostHostOptions.SectionName);
builder.Services
.AddOptions<HeimdallOptions>()
.BindConfiguration(HeimdallOptions.SectionName);
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
@ -67,6 +72,12 @@ builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services.AddSingleton<HeimdallAuthAttemptStore>();
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
});
var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
@ -227,6 +238,78 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
new() { RedirectUri = redirectUri });
}).AllowAnonymous();
app.MapGet("/auth/heimdall/{provider}", async (
string provider,
HttpContext httpContext,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
CancellationToken cancellationToken) =>
{
try
{
var returnUrl = httpContext.Request.Query["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(returnUrl))
{
returnUrl = "/App";
}
var start = await heimdallAuthService.StartAsync(
provider,
hostOptions.Value.PublicBaseUrl,
returnUrl,
cancellationToken);
return Results.Redirect(start.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}");
}
}).AllowAnonymous();
app.MapPost("/auth/heimdall/callback", (
HeimdallBackendCallbackPayload payload,
HeimdallAuthService heimdallAuthService) =>
{
try
{
heimdallAuthService.StoreBackendCallback(payload);
return Results.Accepted();
}
catch (HeimdallAuthException error)
{
return Results.BadRequest(error.Message);
}
}).AllowAnonymous();
app.MapGet("/auth/heimdall/wait", async (
HttpContext httpContext,
HeimdallAuthService heimdallAuthService,
CancellationToken cancellationToken) =>
{
var attemptId = httpContext.Request.Query["attemptId"].ToString();
if (string.IsNullOrWhiteSpace(attemptId))
{
return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id");
}
try
{
var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken);
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal);
return Results.Redirect(signIn.RedirectUri);
}
catch (HeimdallAuthPendingException)
{
httpContext.Response.Headers.Append("Refresh", "2");
return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain");
}
catch (HeimdallAuthException error)
{
return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}");
}
}).AllowAnonymous();
app.MapPost("/auth/sign-out", async (HttpContext httpContext) =>
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

View file

@ -24,6 +24,13 @@
"ExpectedHost": "bifrost.gamecult.org",
"RequireStrictHostValidation": true
},
"Heimdall": {
"BaseUrl": "https://heimdall.gamecult.org",
"AppSlug": "bifrost",
"DiscordGuildId": "",
"DiscordAllowedRoleIds": [],
"PatreonTierTitle": "Inner Sanctum"
},
"Logging": {
"LogLevel": {
"Default": "Information",

View file

@ -33,6 +33,9 @@ async function main() {
case "discord-post":
await postDiscordMessage(options);
return;
case "discord-dm":
await sendDiscordDm(options);
return;
default:
throw new Error(`Unknown command "${command}". Run "node tools/bifrost-bridge.mjs help".`);
}
@ -198,6 +201,63 @@ async function postDiscordMessage(options) {
});
}
async function sendDiscordDm(options) {
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
const recipientId = requireOption(options, "recipient-id");
const content = await readOptionText(options, "content", "content-file");
const dryRun = options["dry-run"] === "true";
if (dryRun) {
printJson({
dryRun: true,
action: "discord-dm",
recipientId,
content,
});
return;
}
if (!token) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
}
const channelId = await openDiscordDmChannel(token, recipientId);
const result = await postDiscordBotMessage(token, channelId, content, undefined);
printJson({
action: "discord-dm",
ok: true,
recipientId,
channelId,
messageId: result.id,
transport: result.transport,
url: `https://discord.com/channels/@me/${channelId}/${result.id}`,
});
}
async function openDiscordDmChannel(token, recipientId) {
const response = await fetch("https://discord.com/api/v10/users/@me/channels", {
method: "POST",
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
recipient_id: recipientId,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`Discord DM channel creation failed with ${response.status}: ${text}`);
}
const channel = JSON.parse(text);
if (!channel.id) {
throw new Error("Discord DM channel creation returned no channel id.");
}
return channel.id;
}
async function postDiscordBotMessage(token, channelId, content, replyToMessageId) {
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
@ -609,11 +669,13 @@ Commands:
github-draft-pr Write one file in a target repo and open a draft PR through gh
github-pr-comment Comment on a pull request through gh
discord-post Post a message to Discord through the bot token or persona webhook pipe
discord-dm Send a Discord DM through Bifrost's bridge-owned bot token
Examples:
node tools/bifrost-bridge.mjs github-draft-pr --repo-root E:/Projects/AetheriaLore --identity nibu --title "Nibu: Glitchcraft" --path Aetheria/Articles/Nibu/glitchcraft.md --content-file article.md
node tools/bifrost-bridge.mjs github-pr-comment --repo-root E:/Projects/AetheriaLore --identity nibu --pr 12 --content "This needs a sharper leash."
node tools/bifrost-bridge.mjs discord-post --channel-id 1501196543150264332 --persona-name Nibu --content "Draft PR opened: https://github.com/..."
node tools/bifrost-bridge.mjs discord-dm --recipient-id 123456789 --content "Moderation status update..."
`);
}

View file

@ -220,7 +220,7 @@ async function approveTopic(cache, options) {
const approvalBody = await readOptionalMarkdownOption(options, "body");
if (!topic.jurisdictionAgentIdentity || !equalsIgnoreCase(approvedBy, topic.jurisdictionAgentIdentity)) {
throw new Error(`Topic "${topic.id}" can only be approved by its jurisdiction Face (${topic.jurisdictionAgentIdentity ?? "none"}).`);
throw new Error(`Topic "${topic.id}" can only be approved by its jurisdiction Persona (${topic.jurisdictionAgentIdentity ?? "none"}).`);
}
const now = new Date().toISOString();
@ -766,7 +766,7 @@ function printHelp() {
Commands:
open Create a canonical Bifrost topic for a feature request or discussion
comment Append a proposal, support, objection, question, approval, summary, or receipt
approve Mark a topic approved by its jurisdiction Face and optionally append approval text
approve Mark a topic approved by its jurisdiction Persona and optionally append approval text
promote Convert an approved topic into a Bifrost agent update request
list List topics, optionally filtered by --repo, --agent, --status
show Print one topic with its comments
@ -780,7 +780,7 @@ Examples:
Mirror options:
--mirror-channel-id <id> Mirror this activity to Discord through Bifrost bridge; defaults to BIFROST_DISCORD_CHANNEL_ID
--mirror-persona-name <name> Render the mirror as the Face/persona
--mirror-persona-name <name> Render the mirror as the Persona/persona
--mirror-persona-avatar-url <url> Optional persona avatar for the mirror
--mirror-content <text> Optional more verbal/personality-rich mirror text
--mirror-content-file <path> Read mirror text from a file

View file

@ -0,0 +1,242 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
const repoRoot = resolve(import.meta.dirname, "..");
const projectsRoot = resolve(repoRoot, "..");
const defaultStorePath = resolve(projectsRoot, "VoidBot", ".voidbot", "status", "cultmesh", "voidbot-swarm-state.cc");
const requestDocumentType = "gamecult.operator_dm_request";
const requestSchemaId = "gamecult.operator_dm_request.v1";
async function main() {
const [command, ...rawArgs] = process.argv.slice(2);
const options = parseArgs(rawArgs);
if (!command || command === "help" || command === "--help" || command === "-h") {
printHelp();
return;
}
switch (command) {
case "publish-idunn-alarm":
await publishIdunnAlarm(options);
return;
default:
throw new Error(`Unknown command "${command}". Run "node tools/operator-notification.mjs help".`);
}
}
async function publishIdunnAlarm(options) {
const storePath = resolve(options.store ?? defaultStorePath);
const alarm = {
alarmId: readOptionOrEnv(options, "alarm-id", "IDUNN_ALARM_ID", "unknown-alarm"),
daemonId: readOptionOrEnv(options, "daemon-id", "IDUNN_ALARM_DAEMON_ID", "unknown-daemon"),
severity: readOptionOrEnv(options, "severity", "IDUNN_ALARM_SEVERITY", "operator-action-required"),
reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."),
raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()),
};
const request = buildOperatorDmRequest(alarm);
if (options["dry-run"] === "true") {
printJson({
dryRun: true,
action: "publish-idunn-alarm",
storePath,
documentType: requestDocumentType,
schemaId: requestSchemaId,
request,
});
return;
}
const { CultMesh, defineDocumentType } = loadCultMeshRuntime();
const requestDefinition = defineDocumentType({
type: requestDocumentType,
schemaName: requestDocumentType,
schemaId: requestSchemaId,
schemaVersion: requestSchemaId,
contentHash: requestSchemaId,
global: false,
name: "requestId",
schema: { parse: parseObjectDocument("Operator DM request") },
});
const node = await CultMesh.createNode(storePath, {
documents: [
requestDefinition,
defineDocumentType({
type: "voidbot.swarm_state_snapshot",
schemaName: "voidbot.swarm_state_snapshot",
schemaId: "voidbot.swarm_state_snapshot.v1",
schemaVersion: "voidbot.swarm_state_snapshot.v1",
contentHash: "voidbot.swarm_state_snapshot.v1",
global: false,
schema: { parse: parseObjectDocument("VoidBot swarm snapshot") },
}),
defineDocumentType({
type: "gamecult.eve.provider_advertisement",
schemaName: "gamecult.eve.provider_advertisement",
schemaId: "gamecult.eve.provider_advertisement.v1",
schemaVersion: "gamecult.eve.provider_advertisement.v1",
contentHash: "gamecult.eve.provider_advertisement.v1",
global: false,
name: "providerId",
schema: { parse: parseObjectDocument("Eve provider advertisement") },
}),
defineDocumentType({
type: "gamecult.eve.surface_state",
schemaName: "gamecult.eve.surface_state",
schemaId: "gamecult.eve.surface_state.v1",
schemaVersion: "gamecult.eve.surface_state.v1",
contentHash: "gamecult.eve.surface_state.v1",
global: false,
name: "providerId",
schema: { parse: parseObjectDocument("Eve surface state") },
}),
defineDocumentType({
type: "gamecult.eve.interface_binding",
schemaName: "gamecult.eve.interface_binding",
schemaId: "gamecult.eve.interface_binding.v1",
schemaVersion: "gamecult.eve.interface_binding.v1",
contentHash: "gamecult.eve.interface_binding.v1",
global: false,
name: "bindingId",
schema: { parse: parseObjectDocument("Eve interface binding") },
}),
],
});
await node.put(requestDefinition, request.requestId, request);
await node.flush?.();
printJson({
ok: true,
action: "publish-idunn-alarm",
storePath,
documentType: requestDocumentType,
schemaId: requestSchemaId,
requestId: request.requestId,
});
}
function buildOperatorDmRequest(alarm) {
const requestId = `idunn-alarm-${createHash("sha1")
.update(JSON.stringify(alarm))
.digest("hex")
.slice(0, 16)}`;
const message = [
"Idunn needs operator intervention.",
"",
`Service: ${alarm.daemonId}`,
`Severity: ${alarm.severity}`,
`Reason: ${alarm.reason}`,
`Alarm: ${alarm.alarmId}`,
`Raised: ${alarm.raisedAt}`,
"",
"Automatic recovery either was not authorized or failed.",
].join("\n");
return {
requestId,
command: "owner.dm.send",
status: "pending",
service: alarm.daemonId,
sourceId: alarm.alarmId,
severity: alarm.severity,
reason: alarm.reason,
message,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
requestedBy: "idunn",
transportOwner: "bifrost",
schemaName: requestDocumentType,
schemaVersion: requestSchemaId,
};
}
function loadCultMeshRuntime() {
const candidates = [
resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"),
resolve(projectsRoot, "CultMeshTS", "package.json"),
];
for (const packageJson of candidates) {
if (!existsSync(packageJson)) {
continue;
}
try {
const requireCult = createRequire(packageJson);
const { CultMesh } = requireCult("cultmesh-ts");
const { defineDocumentType } = requireCult("cultcache-ts");
if (CultMesh && defineDocumentType) {
return { CultMesh, defineDocumentType };
}
} catch {
}
}
throw new Error("CultMesh/CultCache TypeScript runtime is unavailable.");
}
function parseObjectDocument(label) {
return {
parse(input) {
if (!input || typeof input !== "object") {
throw new Error(`${label} must be an object.`);
}
return input;
},
};
}
function readOptionOrEnv(options, optionName, envName, fallback) {
return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback;
}
function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument "${token}".`);
}
const key = token.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
options[key] = "true";
continue;
}
options[key] = next;
index += 1;
}
return options;
}
function optionalString(value) {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function printHelp() {
process.stdout.write(`Bifrost operator notification CultMesh publisher
Commands:
publish-idunn-alarm Publish an Idunn alarm as gamecult.operator_dm_request.v1
Examples:
node tools/operator-notification.mjs publish-idunn-alarm --dry-run
node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed"
`);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});

View file

@ -1,6 +1,8 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { existsSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@ -19,6 +21,16 @@ const {
const documentType = "gamecult.eve.provider_advertisement";
const schemaId = "gamecult.eve.provider_advertisement.v1";
const documentId = "bifrost";
const surfaceDocumentType = "gamecult.eve.surface_state";
const surfaceSchemaId = "gamecult.eve.surface_state.v1";
const interfaceBindingDocumentType = "gamecult.eve.interface_binding";
const interfaceBindingSchemaId = "gamecult.eve.interface_binding.v1";
const verseId = "bifrost.local";
const rootVerse = "asgard";
const currentMachine = "starfire";
const canonicalService = `${rootVerse}.bifrost`;
const locatedService = `${rootVerse}.${currentMachine}.bifrost`;
const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`;
const advertisementDefinition = defineDocumentType({
type: documentType,
@ -45,9 +57,40 @@ const advertisementDefinition = defineDocumentType({
{ slot: 10, memberName: "commandBoundaries", typeName: "object", isMany: true },
{ slot: 11, memberName: "styleCapabilities", typeName: "object", isMany: true },
{ slot: 12, memberName: "demotions", typeName: "string", isMany: true },
{ slot: 13, memberName: "rootVerse", typeName: "string" },
{ slot: 14, memberName: "canonicalService", typeName: "string" },
{ slot: 15, memberName: "locatedService", typeName: "string" },
{ slot: 16, memberName: "cultMeshAddress", typeName: "string" },
{ slot: 17, memberName: "endpoints", typeName: "object", isMany: true },
{ slot: 18, memberName: "routes", typeName: "object", isMany: true },
],
});
const surfaceDefinition = defineDocumentType({
type: surfaceDocumentType,
schemaId: surfaceSchemaId,
schemaName: surfaceDocumentType,
schemaVersion: surfaceSchemaId,
schema: { parse: parseObjectDocument("Eve surface state") },
name: "providerId",
members: [
{ slot: 0, memberName: "providerId", typeName: "string", isName: true },
{ slot: 1, memberName: "title", typeName: "string" },
{ slot: 2, memberName: "version", typeName: "long" },
{ slot: 3, memberName: "updatedAt", typeName: "string" },
{ slot: 4, memberName: "surface", typeName: "object" },
],
});
const interfaceBindingDefinition = defineDocumentType({
type: interfaceBindingDocumentType,
schemaId: interfaceBindingSchemaId,
schemaName: interfaceBindingDocumentType,
schemaVersion: interfaceBindingSchemaId,
schema: { parse: parseObjectDocument("Eve interface binding") },
name: "bindingId",
});
async function main() {
const [command, ...rawArgs] = process.argv.slice(2);
const options = parseArgs(rawArgs);
@ -83,12 +126,19 @@ async function exportAdvertisement(options) {
const cache = CultCache.builder()
.withDocumentType(advertisementDefinition)
.withDocumentType(surfaceDefinition)
.withDocumentType(interfaceBindingDefinition)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.pullAllBackingStores();
const advertisement = buildAdvertisement(options);
const stats = await collectStats(options);
const surface = buildOperatorSurface(stats, options);
const binding = buildInterfaceBinding(surface, stats, options);
await cache.put(advertisementDefinition, advertisement.providerId, advertisement);
await cache.put(surfaceDefinition, surface.providerId, surface);
await cache.put(interfaceBindingDefinition, binding.bindingId, binding);
printJson({
ok: true,
@ -98,6 +148,7 @@ async function exportAdvertisement(options) {
out: storePath,
surfaces: advertisement.surfaces.map((surface) => surface.id),
witnesses: advertisement.witnesses.map((witness) => witness.path),
stats: stats.summary,
});
}
@ -107,13 +158,27 @@ function buildAdvertisement(options) {
serviceName: "Bifrost",
contractPath: "docs/verse-service-contract.md",
generatedAt: options["generated-at"] ?? new Date().toISOString(),
rootVerse,
canonicalService,
locatedService,
plannedLocatedService,
cultMeshAddress: locatedService,
endpoints: [
endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]),
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
endpoint("operator-commands", `${locatedService}/commands`, "bifrost.bridge_action.v0", ["command"]),
],
routes: [
route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true),
route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true),
],
authority: {
owner: "Bifrost",
role: "GameCult labor, governance, patron pressure, project work, account membership, and governed-public-crossing provider",
presentationOwner: "Eve/CultUI",
discoveryOwner: "Odin through CultMesh",
stateOwner: "Bifrost typed state with CultCache .cc witnesses or export paths",
runtimeMigration: "not performed by this advertisement",
runtimeMigration: `currently ${locatedService}; planned move target ${plannedLocatedService}`,
},
namespaces: [
namespace("gamecult.bifrost.service", "service registration, build/version, schema catalog, and command discovery"),
@ -141,7 +206,7 @@ function buildAdvertisement(options) {
witnesses: [
witness(".bifrost/provider-advertisement.cc", schemaId, "current", "read-only provider advertisement exported by this tool"),
witness(".bifrost/governance-threads.cc", "bifrost.governance.topic.v0; bifrost.governance.topic_comment.v0", "current", "governance discussion, approvals, and dispatch promotion topics"),
witness(".bifrost/agent-transport.cc", "bifrost.agent-transport.update-request.v0", "current", "repo Face update requests and dispatch queue state"),
witness(".bifrost/agent-transport.cc", "bifrost.agent-transport.update-request.v0", "current", "repo Persona update requests and dispatch queue state"),
witness(".bifrost/work-items.cc", "bifrost.work_item.v0", "planned-export", "work items exported from the alpha transactional store"),
witness(".bifrost/motions.cc", "bifrost.motion.v0; bifrost.vote.v0", "planned-export", "app-native motions and votes exported from the alpha transactional store"),
witness(".bifrost/ledger.cc", "bifrost.ledger_entry.v0", "planned-export", "patron and contributor ledger entries exported from the alpha transactional store"),
@ -150,41 +215,48 @@ function buildAdvertisement(options) {
witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "product and operator Eve/CultUI surface publications"),
],
surfaces: [
surface("bifrost.account", "Account Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [
surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-advertisement.cc", "eve", [
"service health",
"compact service status",
"topic and request status",
"dispatch activity by source channel",
"bridge capability status",
]),
surface("bifrost.account", "Account Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "account/eve", [
"membership status",
"Heimdall-linked account projection",
"grant consumption",
"audit trail lowerings",
]),
surface("bifrost.patron", "Patron Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [
surface("bifrost.patron", "Patron Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "patron/eve", [
"patron standing",
"priority pressure",
"pledge/reward influence",
"receipts",
]),
surface("bifrost.project", "Project Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [
surface("bifrost.project", "Project Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "project/eve", [
"project membership",
"repository links",
"maintainer authority",
"work boards",
]),
surface("bifrost.work", "Work Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [
surface("bifrost.work", "Work Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "work/eve", [
"work items",
"claims",
"review",
"blockers",
"completion artifacts",
]),
surface("bifrost.motion", "Motion Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [
surface("bifrost.motion", "Motion Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "motion/eve", [
"motions",
"topic threads",
"votes",
"approvals",
"objections",
]),
surface("bifrost.operator", "Bifrost Operator Verse", "gamecult.bifrost.surface.operator", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [
surface("bifrost.operator", "Bifrost Operator Verse", "gamecult.bifrost.surface.operator", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "operator/eve", [
"readiness",
"witness freshness",
"store freshness",
"bridge queues",
"schema catalog",
"migration drift",
@ -223,7 +295,7 @@ function buildAdvertisement(options) {
"publish work boards",
"surface maintainer authority",
], [
"does not seize repo Face cognition or project-local ownership",
"does not seize repo Persona cognition or project-local ownership",
]),
boundary("account", "Bifrost consumes Heimdall claims", [
"display membership status",
@ -259,6 +331,373 @@ function buildAdvertisement(options) {
});
}
async function collectStats(options) {
const generatedAt = options["generated-at"] ?? new Date().toISOString();
const health = await fetchProbe("http://127.0.0.1:5080/healthz");
const ready = await fetchProbe("http://127.0.0.1:5080/readyz");
const transport = runJsonTool(["tools/agent-transport.mjs", "list", "--json"]);
const governance = runJsonTool(["tools/governance-threads.mjs", "list", "--json"]);
const docker = dockerContainers();
const witnesses = [
witnessStat(".bifrost/provider-advertisement.cc"),
witnessStat(".bifrost/governance-threads.cc"),
witnessStat(".bifrost/agent-transport.cc"),
witnessStat(".bifrost/discord-webhook-cache.json"),
];
const topics = Array.isArray(governance.value) ? governance.value : [];
const requests = Array.isArray(transport.value) ? transport.value : [];
const topicCounts = countBy(topics, "status");
const requestCounts = countBy(requests, "status");
const recentWindowHours = 24;
const dockerHealthy = docker.items.filter((item) => /healthy/i.test(item.status)).length;
const dockerRunning = docker.items.filter((item) => /^Up\b/i.test(item.status)).length;
return {
generatedAt,
health,
ready,
docker,
governance: {
ok: governance.ok,
error: governance.error,
count: topics.length,
statusCounts: topicCounts,
recentCount: countRecent(topics, recentWindowHours),
channelCounts: countBy(topics, "sourceKind"),
recentChannelCounts: countRecentBy(topics, "sourceKind", recentWindowHours),
latestUpdatedAt: latestTimestamp(topics.map((item) => item.updatedAt)),
},
transport: {
ok: transport.ok,
error: transport.error,
count: requests.length,
statusCounts: requestCounts,
recentCount: countRecent(requests, recentWindowHours),
channelCounts: countBy(requests, "sourceKind"),
recentChannelCounts: countRecentBy(requests, "sourceKind", recentWindowHours),
latestUpdatedAt: latestTimestamp(requests.map((item) => item.updatedAt)),
},
witnesses,
bridge: {
discordPost: true,
discordDm: true,
githubDraftPr: true,
githubPrComment: true,
credentialSource: process.env.BIFROST_DISCORD_BOT_TOKEN ? "BIFROST_DISCORD_BOT_TOKEN" : process.env.DISCORD_BOT_TOKEN ? "DISCORD_BOT_TOKEN-fallback" : "missing",
},
summary: {
status: health.ok && ready.ok ? "ready" : "degraded",
health: health.value ?? health.error,
ready: ready.value ?? ready.error,
dockerRunning,
dockerHealthy,
governanceTopics: topics.length,
transportRequests: requests.length,
recentGovernanceTopics: countRecent(topics, recentWindowHours),
recentTransportRequests: countRecent(requests, recentWindowHours),
openTopics: topicCounts.open ?? 0,
queuedRequests: requestCounts.queued ?? 0,
recentWindowHours,
},
};
}
async function fetchProbe(url) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const text = (await response.text()).trim();
return {
ok: response.ok,
statusCode: response.status,
value: text,
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
function runJsonTool(args) {
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: "utf8",
windowsHide: true,
});
if (result.status !== 0) {
return {
ok: false,
error: (result.stderr || result.stdout || `exit ${result.status}`).trim(),
value: null,
};
}
try {
return {
ok: true,
value: JSON.parse(result.stdout),
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
value: null,
};
}
}
function dockerContainers() {
const result = spawnSync("docker", ["ps", "--filter", "name=bifrost", "--format", "{{json .}}"], {
cwd: repoRoot,
encoding: "utf8",
windowsHide: true,
});
if (result.status !== 0) {
return {
ok: false,
error: (result.stderr || result.stdout || `exit ${result.status}`).trim(),
items: [],
};
}
const items = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
const parsed = JSON.parse(line);
return {
id: parsed.ID,
name: parsed.Names,
image: parsed.Image,
status: parsed.Status,
ports: parsed.Ports,
};
} catch {
return null;
}
})
.filter(Boolean);
return { ok: true, items };
}
function witnessStat(relativePath) {
const fullPath = resolve(repoRoot, relativePath);
if (!existsSync(fullPath)) {
return {
path: relativePath,
exists: false,
updatedAt: null,
};
}
const stat = statSync(fullPath);
return {
path: relativePath,
exists: true,
updatedAt: stat.mtime.toISOString(),
};
}
function buildOperatorSurface(stats) {
const statusTone = stats.summary.status === "ready" ? "ok" : "warn";
const children = [
panelNode("service", "Service", [
metricNode("status", "Status", stats.summary.status, statusTone),
metricNode("daemon", "Daemon", `health ${stats.summary.health} / ready ${stats.summary.ready}`, stats.health.ok && stats.ready.ok ? "ok" : "warn"),
metricNode("containers", "Containers", `${stats.summary.dockerRunning} up / ${stats.summary.dockerHealthy} healthy`, stats.summary.dockerHealthy > 0 ? "ok" : "warn"),
metricNode("stores", "Stores", witnessHealthLine(stats.witnesses), witnessHealthTone(stats.witnesses)),
metricNode("bridge", "Bridge", `Discord post ${yesNo(stats.bridge.discordPost)} / DM ${yesNo(stats.bridge.discordDm)}`, "ok"),
]),
panelNode("activity", "Activity", [
metricNode("topics", "Topics", `${stats.summary.governanceTopics} total / ${stats.summary.openTopics} open / ${stats.summary.recentGovernanceTopics} in ${stats.summary.recentWindowHours}h`, stats.governance.ok ? "ok" : "warn"),
metricNode("requests", "Requests", `${stats.summary.transportRequests} total / ${stats.summary.queuedRequests} queued / ${stats.summary.recentTransportRequests} in ${stats.summary.recentWindowHours}h`, stats.transport.ok ? "ok" : "warn"),
listNode("status", "Status", [
`topics: ${compactCounts(stats.governance.statusCounts)}`,
`requests: ${compactCounts(stats.transport.statusCounts)}`,
]),
listNode("channels", "Dispatch Channels", compactChannelLines(stats.transport.channelCounts, stats.transport.recentChannelCounts, stats.summary.recentWindowHours)),
]),
];
return {
providerId: "bifrost",
title: "Bifrost Operator Dashboard",
version: Date.parse(stats.generatedAt) || Date.now(),
updatedAt: stats.generatedAt,
stats,
surface: {
schema: "gamecult.eve.surface.v1",
id: "bifrost-operator-dashboard",
title: "Bifrost Operator Dashboard",
root: {
id: "bifrost-root",
kind: "dashboard",
props: {
title: "Bifrost",
subtitle: "Governance, labor, and public crossing bridge",
status: stats.summary.status,
generatedAt: stats.generatedAt,
},
children,
},
},
};
}
function buildInterfaceBinding(surface, stats) {
return {
bindingId: "bifrost",
providerId: "bifrost",
title: surface.title,
kind: "operator-dashboard",
updatedAt: surface.updatedAt,
provider: {
id: "bifrost",
title: "Bifrost",
description: "Bifrost-owned operator stats and bridge health surface.",
version: String(surface.version),
endpoint: `${locatedService}/eve/tui`,
cultMeshAddress: `${locatedService}/eve/tui`,
canonicalService,
locatedService,
plannedLocatedService,
endpoints: [
endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]),
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
],
routes: [
route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true),
route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true),
],
capabilities: ["operator-stats", "bridge-health", "governance-counts", "agent-transport-counts"],
usesCultMesh: true,
status: stats.summary.status,
transport: "CultMesh Eve interface binding.",
},
surface: surface.surface,
rendererHints: {
preferredLowerings: ["nightwing-tui", "eve-native", "browser"],
tileId: "bifrost",
minWidth: 80,
minHeight: 18,
preferredWidth: 120,
preferredHeight: 32,
density: "operator",
},
};
}
function panelNode(id, title, children) {
return {
id: `panel-${id}`,
kind: "panel",
props: { title },
children,
};
}
function metricNode(id, label, value, tone) {
return {
id: `metric-${id}`,
kind: "metric",
props: { label, value, tone },
};
}
function listNode(id, title, items) {
return {
id: `list-${id}`,
kind: "list",
props: { title },
children: items.length > 0
? items.map((item, index) => ({
id: `list-${id}-${index}`,
kind: "text",
props: { text: item },
}))
: [{ id: `list-${id}-empty`, kind: "text", props: { text: "none" } }],
};
}
function compactCounts(value) {
const entries = Object.entries(value).sort((left, right) => left[0].localeCompare(right[0]));
return entries.length > 0 ? entries.map(([key, count]) => `${key} ${count}`).join(" / ") : "none";
}
function compactChannelLines(totalCounts, recentCounts, windowHours) {
const keys = new Set([...Object.keys(totalCounts), ...Object.keys(recentCounts)]);
const lines = [...keys]
.sort((left, right) => left.localeCompare(right))
.map((key) => `${key}: ${totalCounts[key] || 0} total / ${recentCounts[key] || 0} in ${windowHours}h`);
return lines.length > 0 ? lines : ["none"];
}
function countBy(items, field) {
const counts = {};
for (const item of items) {
const key = String(item?.[field] || "unknown");
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
function countRecent(items, hours) {
const cutoff = Date.now() - hours * 60 * 60 * 1000;
return items.filter((item) => {
const timestamp = Date.parse(item?.updatedAt || item?.createdAt || "");
return Number.isFinite(timestamp) && timestamp >= cutoff;
}).length;
}
function countRecentBy(items, field, hours) {
const cutoff = Date.now() - hours * 60 * 60 * 1000;
const counts = {};
for (const item of items) {
const timestamp = Date.parse(item?.updatedAt || item?.createdAt || "");
if (!Number.isFinite(timestamp) || timestamp < cutoff) {
continue;
}
const key = String(item?.[field] || "unknown");
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
function latestTimestamp(values) {
return values
.filter((value) => typeof value === "string" && value.trim().length > 0)
.sort()
.at(-1) || null;
}
function witnessHealthLine(witnesses) {
const missing = witnesses.filter((item) => !item.exists).length;
if (missing > 0) {
return `${witnesses.length - missing}/${witnesses.length} present`;
}
const latest = latestTimestamp(witnesses.map((item) => item.updatedAt));
return `all present / latest ${latest ? shortTime(latest) : "unknown"}`;
}
function witnessHealthTone(witnesses) {
return witnesses.every((item) => item.exists) ? "ok" : "warn";
}
function shortTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "unknown";
}
return date.toISOString().slice(5, 16).replace("T", " ");
}
function yesNo(value) {
return value ? "yes" : "no";
}
function namespace(id, purpose) {
return { id, purpose };
}
@ -271,8 +710,21 @@ function witness(path, schemaIds, status, purpose) {
return { path, schemaIds, status, purpose };
}
function surface(id, name, namespace, schemaId, witnessPath, capabilities) {
return { id, name, namespace, schemaId, witnessPath, capabilities };
function surface(id, name, namespace, schemaId, witnessPath, resourceBase, capabilities) {
return {
id,
name,
namespace,
schemaId,
witnessPath,
capabilities,
cultMeshAddress: `${locatedService}/${resourceBase}/tui`,
graphicalAddress: `${locatedService}/${resourceBase}/gui`,
endpoints: [
endpoint("tui", `${locatedService}/${resourceBase}/tui`, schemaId, ["tui"]),
endpoint("gui", `${locatedService}/${resourceBase}/gui`, schemaId, ["gui"]),
],
};
}
function boundary(area, owner, commands, forbiddenAuthority) {
@ -283,6 +735,14 @@ function style(area, capabilities) {
return { area, capabilities };
}
function endpoint(id, address, schemaId, lowerings) {
return { id, address, schemaId, lowerings };
}
function route(id, address, transport, demoted) {
return { id, address, transport, demoted };
}
function parseAdvertisement(input) {
if (!input || typeof input !== "object") {
throw new Error("Provider advertisement must be an object.");
@ -293,6 +753,13 @@ function parseAdvertisement(input) {
serviceName: requireString(input.serviceName, "serviceName"),
contractPath: requireString(input.contractPath, "contractPath"),
generatedAt: requireString(input.generatedAt, "generatedAt"),
rootVerse: optionalString(input.rootVerse, rootVerse),
canonicalService: optionalString(input.canonicalService, canonicalService),
locatedService: optionalString(input.locatedService, locatedService),
plannedLocatedService: typeof input.plannedLocatedService === "string" ? input.plannedLocatedService.trim() : "",
cultMeshAddress: optionalString(input.cultMeshAddress, locatedService),
endpoints: Array.isArray(input.endpoints) ? requireObjectArray(input.endpoints, "endpoints") : [],
routes: Array.isArray(input.routes) ? requireObjectArray(input.routes, "routes") : [],
authority: requireObject(input.authority, "authority"),
namespaces: requireObjectArray(input.namespaces, "namespaces"),
schemas: requireObjectArray(input.schemas, "schemas"),
@ -314,6 +781,15 @@ function parseAdvertisement(input) {
return advertisement;
}
function parseObjectDocument(label) {
return (input) => {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new Error(`${label} must be an object.`);
}
return input;
};
}
function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
@ -344,6 +820,13 @@ function requireString(value, field) {
return value.trim();
}
function optionalString(value, fallback) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
return fallback;
}
function requireObject(value, field) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Provider advertisement field "${field}" must be an object.`);