Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
9a716cfd7e Merge pull request 'Feedback receiver design (records, session check, delivery, erase redaction)' (#10) from heimdall/feedback-receiver into main 2026-11-13 03:28:42 +00:00
ae0037c159 Merge pull request 'Guest accounts design (role field, invite, grants, leave and erase)' (#9) from heimdall/bifrost-guest-accounts into main 2026-11-13 03:28:41 +00:00
Heimdall
96665a778b docs(feedback-receiver): drop stale draft marker; steward merges the design
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:28:33 +00:00
Heimdall
3b0a614ee9 docs(feedback-receiver): steward rulings: Ratatoskr client (2a, shared client, own audience) and explicit short token lifetimes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 22:47:05 +00:00
Heimdall
d29f8014f9 docs(guest-accounts): steward rulings: stored Role, form-posted invite code (no token in a URL), provider lists narrowed to the intersection, no collector member_id for guests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 22:46:53 +00:00
Heimdall
25ce117f16 docs(feedback-receiver): re-read references at main e58e953
Docs only. Mouth line numbers (shifted by about 100 lines of mouth code), bridge.md, packages.lock.json refs refreshed; Heimdall PR #6 is merged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 22:44:54 +00:00
Heimdall
f04b20feed docs(guest-accounts): re-read references at main e58e953; OIDC chat tier is already any non-Suspended account, owed change is Left/Erased
Docs only. Refreshes stale file and line references, drops the claim that OIDC must move to Status == Active (main already admits any non-Suspended account with a live training-notice receipt), names the new IsActiveMember callers, and updates the provider-list and Heimdall PR #6 notes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 22:44:53 +00:00
e58e9531df Merge pull request 'Refuse production startup when the Heimdall app secret is blank' (#83) from heimdall/appsecret-empty-refusal into main 2026-11-12 19:27:58 +00:00
Heimdall
1771f15789 Soul fixes: deploy doc says production refuses a blank secret; name the echo test for what it checks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 19:27:57 +00:00
Heimdall
1739d5552a Refuse production startup when the Heimdall app secret is blank
A missing secret silently switched Discord and Patreon member sign-in off.
Outside Development and Testing the validator now fails naming
Heimdall:AppSecretFile and Heimdall:AppSecret, never a value.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 19:23:43 +00:00
920ecb1fbe Merge pull request 'Google link: pin the four test gaps, one slot owner, drop IsSignInProvider' (#82) from heimdall/google-link-test-gaps into main 2026-11-12 17:02:18 +00:00
Heimdall
1298e7db1e Pin the Google link guards: own slot beside a parallel attempt, undefined purpose, empty pending link id, non-linked 201
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:26:25 +00:00
Heimdall
f9d0c24f66 Take the link's cookie slot from the started attempt; drop unused IsSignInProvider
The await step passed replaceSlot to StartAsync but named the slot from its own cookieName, so replaceSlot decided nothing. The step now returns the slot the started attempt chose, one owner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:26:25 +00:00
dc151e6cac Merge pull request 'Link a Google login to a signed-in member, explicit link only; Bifrost stores no Heimdall token' (#81) from heimdall/google-link into main 2026-11-12 16:20:50 +00:00
Heimdall
1fbe16eb73 Complete page pin: the form also carries pendingLinkId
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 15:46:34 +00:00
Heimdall
4c9937f881 Bifrost: explicit Google link by fresh Heimdall sign-in, no stored token
The account page's POST starts an ordinary sign-in whose attempt (a purpose, the expected
account id, a pending link id; never a token) is only the proof for a link. Each Heimdall
call that needs a bearer follows its own fresh sign-in; the bearer lives in that request.
Google stays out of AllowedProviders and member access. Authorization is redacted from
HttpClient logging. Outcomes redirect to a fixed closed set of wording.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 15:21:40 +00:00
Heimdall
95416653ba Google link: tests and fake Heimdall link routes (red until the flow lands)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 15:21:40 +00:00
d1d13992aa Merge pull request 'Start refusal: read headers only so the 4 KiB body bound limits the read' (#80) from heimdall/start-refusal-headers-only into main 2026-11-12 14:54:29 +00:00
Heimdall
1998f0e6ae Start refusal: a client timeout in the body read still redirects; user cancellation propagates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:54:19 +00:00
Heimdall
31ced94f2a Start refusal: read headers only so the 4 KiB bound limits the read; narrow the swallow to non-cancellation
Soul re-pass on 87a1bb6b: PostAsJsonAsync buffered the whole body before the cap applied. Test renamed to say what it pins.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:48:35 +00:00
b687da954b Merge pull request 'Heimdall start refusal: log the HTTP status and Heimdall's error code' (#79) from heimdall/start-refusal-status into main 2026-11-12 14:48:31 +00:00
Heimdall
87a1bb6b3b Start refusal: bound the body read, survive undecodable bodies, pin the code length cap
Soul pass on c085cead: a refusal with an unsupported charset escaped as a 500, the body was read without a bound, and the 64-character cap was untested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:41:14 +00:00
Heimdall
c085cead9d Heimdall start refusal: log the HTTP status and Heimdall's error code
The journal said only 'Heimdall rejected the auth start', which cannot tell a wrong app secret (401 app_auth_required) from a fault. The message now carries the status and, when the body has one, an error code that is a short snake_case token; no other body text is echoed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:26:14 +00:00
fe0ff40864 Merge pull request 'Heimdall__AppSecretFile: keep the Heimdall app secret out of the process environment' (#78) from heimdall/appsecret-file into main 2026-11-12 13:46:25 +00:00
Heimdall
280d843aeb AppSecretFile: pin the deprecation at Warning level, say trailing newlines
Soul pass on 25b9cc16: the warning level was unpinned (a Trace demotion passed) and the docs said one newline where the code trims all.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 13:46:24 +00:00
Heimdall
25b9cc1683 Document Heimdall__AppSecretFile and the AppSecret deprecation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 13:24:59 +00:00
Heimdall
95d2e8d262 Read the Heimdall app secret from Heimdall__AppSecretFile; env value deprecated
The file wins, is read once when the options are built, and a set-but-missing,
unreadable or empty file stops startup in every environment without naming the
secret. Heimdall__AppSecret still works and logs a deprecation warning.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 13:24:59 +00:00
b80b27da0f Linux path fixes: containment by path.relative, hostile-cursor test, verify prerequisites (#77) 2026-11-12 12:55:35 +00:00
420a4dbf85 Mouth: follow upgrades only a plain basis; @room keeps room (#76) 2026-11-12 12:28:39 +00:00
a1b173079d tests: dispatch-gate tests run on Linux; classify the remaining Yggdrasil failures (#75) 2026-11-12 12:24:51 +00:00
afafff18bd Mouth: basis "follow" for engagement peers (CHAT_FOLLOW, default off) (#74) 2026-11-12 12:21:31 +00:00
c2de290628 Desk: UnixSocketDeskDoor (Huginn desk door client), fixtures at 79d954c, card re-pin to CultLib 3a51bbab, card opaque (#70) 2026-11-12 11:44:45 +00:00
Heimdall
e639e13a8b docs: feedback receiver: C# CultCache runtime fit, two-lock restore rule, gaps owed to Libby
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:55:12 +00:00
Heimdall
46cf7d4492 docs: feedback receiver: store path /var/lib/gamecult/bifrost/feedback-receiver, C# in the Bifrost app, HTTPS probe result
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:52:09 +00:00
Heimdall
a3bab9b8e5 docs: feedback receiver: fold Eureka's forks 2-5 decisions (client_credentials, HTTPS carrier, withhold edges, 56-day retention); private_key_jwt/Ed25519 finding
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:48:47 +00:00
Heimdall
beafbe18e2 docs: feedback receiver: Meta's rulings of 2026-11-12 (erase redaction, training-notice receipt, host withhold, Mind OIDC audience); token section and mouth citations re-read against main b1725a1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:42:21 +00:00
Heimdall
8a3262b3e8 docs: feedback receiver: re-verify the mouth facts against main 55b74275
The mouth moved since bf8ff62: the pending answer is chat_pending.v2 with a
twelve-field delivery, a chat ack now posts a public m.read receipt and
releases stored images, and presence follows the brake. Update the line
references and the gaps, and record why a feedback ack must be its own kind.
No design change; the OIDC and account files are unchanged on main.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:39:30 +00:00
Heimdall
ee88bc709d docs: feedback receiver: cite Ratatoskr PR #24 at b146000d, route 2 claims check, CultCache file, erase and backup rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:39:30 +00:00
Heimdall
abeb8a1d5f docs: feedback receiver: correct mouth line references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:39:30 +00:00
Heimdall
6c4c9c00f9 docs: feedback receiver design (draft, do not merge)
Design for the receiver half of Ratatoskr's Mind feedback pages: records, session
check, account-kind lookup, delivery as quoted data, erase redaction and gaps.
Design only; nothing implemented.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 10:39:30 +00:00
b1725a106f docs: sealed-motion Mind key path is /var/lib/mind-<persona>; register only after a Persona-shell self-test (#32) 2026-11-12 10:31:05 +00:00
d26aa50041 Docs: desk feed visibility is Huginn's door policy; cite the written door spec (#69) 2026-11-12 10:17:33 +00:00
4cb83971f9 Desk: bearer credential (OpenIddict validation, desk.read/desk.answer, audience bifrost-desk) and answerer masking (#71) 2026-11-12 10:17:29 +00:00
d4365a8364 Mouth: fetch one named event's image for its Persona, store it, post an inert receipt (CHAT_FETCH, off by default) (#73)
A Persona's Mind shell holds no Matrix token, so the mouth now keeps one named image for her: it reads her live membership and the room's full state, takes content.url from the fetched event only, checks info.sha256 against the hash it computed, stores the bytes in her image store and posts the inert org.gamecult.file_received receipt as her own user. Behind CHAT_FETCH (exactly 1), off by default; an off mouth answers the kind as UnknownKind. Not deployed by this merge.

Shipper: Eureka. Reviewed by Heimdall against cut-mouth-fetch-attachment.r7 (bundle 3a6b45d, then delta d4bccd4: one log line per fetch with a fixed cause=, event-id percent-encoding pinned, doc-only change to docs/bridge.md).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 00:59:28 +00:00
0d820fdc00 Mouth: id shape on every delivery field, mentions bounded in bytes, round cap counts every target GET (#72) 2026-11-11 21:06:13 +00:00
7231cdb977 Mouth: reply_to lows after #64 (round cap defers, 60 s excerpt, wider scrub, id shape) (#68) 2026-11-11 20:05:46 +00:00
f6a826b6e7 Desk feed design: per-Mind open forks, read-only (decisions in PR #37 section 14) (#67) 2026-11-11 17:06:45 +00:00
6acb4980b8 docs: how a Ratatoskr voting module authenticates to Bifrost (design) (#37) 2026-11-11 17:06:31 +00:00
e8e8fcc178 Docs: no refresh token, MAS skips userinfo; withdrawal stops new tokens, open sessions run to expiry (#66) 2026-11-11 16:48:27 +00:00
Heimdall
2aabaabc53 docs(guest-accounts): fold in Eureka's review: matrix_id lookup, unknown/status/freshness contract, closed-room grants with steward grantor, guest DM conflict
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 06:13:28 +00:00
Heimdall
99128e9f90 Merge remote-tracking branch 'origin/main' into heimdall/bifrost-guest-accounts 2026-11-09 14:39:22 +00:00
Heimdall
8abd912917 docs: guest accounts design (role field, invite, grants, leave and erase)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 21:26:13 +00:00
191 changed files with 13585 additions and 1733 deletions

View file

@ -89,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
- default connection string points at local PostgreSQL
- build with `dotnet build Bifrost.slnx`
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT`
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`

File diff suppressed because one or more lines are too long

View file

@ -41,7 +41,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only
### Data Protection on the live host
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
19. The service's working directory resolves to the release directory: `readlink /proc/<MainPID>/cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty).
### Proxy and client address

View file

@ -54,9 +54,16 @@ permits, windows and `MaxTrackedKeys`.
New since the live release, and not read by the validator:
- **`Heimdall__AppSecret`**: required for Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and
`IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail,
so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost`
- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one
trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never
rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file
stops startup in every environment, with a message that names the option and path and never the value. Ops steps:
`docs/heimdall-app-secret-file.md`.
- **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for
Discord and Patreon sign-in. Outside Development and Testing startup fails when neither this nor
`Heimdall__AppSecretFile` supplies a non-blank secret (the message names both options, never a value), because
`HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` would otherwise be false and those sign-ins silently
stop working (GitHub sign-in is unaffected). The value is Heimdall's app secret for slug `bifrost`
(`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
from logs.
- **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be
@ -129,7 +136,7 @@ All must hold before step 1. A missing one means the window is not used.
not a workstation.
6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret`
included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env`
(mode 600, owner `bifrost`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
(mode 600, owner `bifrost`; a build with the empty-secret refusal does not start without it or `Heimdall__AppSecretFile`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that
everyone is signed out once (see "What moves").
8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run

117
docs/desk-feed-design.md Normal file
View file

@ -0,0 +1,117 @@
# Desk feed: per-Mind open forks for a signed-in member (design, no code)
Status: proposal, 2026-11-11, Heimdall (Imagination pass) against `origin/main` e8e8fcc. Docs only; nothing here is built, ruled or deployed.
Labels: **FACT** = read at the cited place. **INFERENCE** = my reading. **UNREACHED** = I could not read it.
Reads: `src/Bifrost.Web/Features/Desk/*` (main), `docs/ratatoskr-voting-auth.md` (PR #37, e172869), `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, Ratatoskr `fbec032` (`src/app/features/desk/*`), `tools/persona-matrix-mouth.mjs`, `docs/bridge.md`. UNREACHED: CultLib `fork-card.md` (no checkout here; I rely on the Desk code that pins it at de8a062a), Huginn's `desk_wire.rs` (PR #31 is merged; the list/read half of the door wire is not defined in anything I read).
## 1. What is asked, and the finding that shapes it
Ratatoskr's Desk row per Mind is empty "until Bifrost feeds it" (FACT `src/app/features/desk/source.ts`: `deskFor(mind): ForkList | undefined`, slice 2 "replaces this with Bifrost's list reader"). The page already decodes `gamecult.fork.list.v0` (`forkContract.ts`).
**Finding: the feed route is already built.** `GET /api/desk/{mind}/forks?status=open&limit=N` is on main (PR #40, hardened #42-#45): list, per-Mind socket door, `open_count`, `stamp`, notes stripped for non-deciders. It serves nothing today because its two ports have no production implementation (`DeskPorts.cs`: `IDeskCredential`, `IDeskDoor`; `MapDesk` fails startup if either is missing). So the smallest honest feed is **not a new endpoint. It is the existing list route, read-only, with its two ports filled and a token that carries only `desk.read`.** A second route would need a second contract, vectors and owner for no gain.
## 2. The endpoint
- `GET https://<bifrost>/api/desk/{mind}/forks?status=open&limit=50[&cursor=c<N>]`, one call per Mind (the client already asks per Mind). `limit=0` returns counts and stamp only (FACT binding file, route table), usable for a badge.
- Response: `gamecult.fork.list.v0` (`ForkList`: `mind`, `stamp`, `open_count`, `recorded_count`, `forks[]` of `ForkEntry`, `next_cursor`). **JSON, and why:** Ratatoskr is a browser client, the xenos boundary; the contract (CultLib `fork-card.md`, Transport) fixes JSON at the HTTP edge with every key written, and the documents are typed C# records (`DeskDocuments.cs`) mirroring CultLib's. Bifrost to Huginn is the typed door, not JSON.
- The row the UI needs is derived by the client from each entry: `card.fork_id`, `card.title`, age (section 5), and the count is `open_count`. The page "derives nothing" (FACT `forkContract.ts` header), so it takes `open_count` as the one count.
- Not offered: any POST (`/api/desk/{mind}/answers` stays unreachable to this client because its token has no `desk.answer`, so it is refused 403 `insufficient_scope` at step 4 of the pipeline, before the body), any vote, any write.
## 3. Auth: what a signed-in member can call today, and what the feed reuses
FACT today: a signed-in member holds only the cookie `__Host-bifrost_session`; the default scheme is Cookie (`Program.cs:189-200`); no `AddValidation` exists, so no access token authenticates any `/api/*` route; the desk handler is `.AllowAnonymous()` and judges its own bearer (`DeskEndpoints.cs`, `MapDesk`), and the cookie scheme cannot authenticate `/api/desk/*`. **A signed-in member can call nothing on the desk today.** Chat.gamecult.org never holds the `__Host-` cookie, so "the Heimdall session" cannot be sent by the browser to Bifrost from chat.
So the session is used as PR #37 uses it: the **sign-in leg of an OIDC code+PKCE flow** (`/connect/authorize` resumes on the Heimdall-backed session, FACT `OidcEndpoints.cs:44-70`, which admits any account that is not Suspended and has an identity: membership is neither required nor written, so the sign-in leg does not prove Active. It works for `desk.read` only because the desk re-checks Active on every request) mints a short access token. Reuse, exactly:
1. Token: PR #37's second public PKCE client (Ratatoskr) and OpenIddict validation as a non-default scheme (built as `OidcDeskCredential` on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; the label `BifrostBearer` in PR #37 section 5 names the role, not the scheme string; handler `IDeskCredential` production impl, the credential port; H4 in the desk cut map, a label used only there). Request scope `desk.read` only, audience `bifrost-desk`. No `desk.answer`, no `votes.*`.
2. Judgment: unchanged `DeskEndpoints.HandleAsync`: audience, scope, then `OidcEndpoints.ActiveMemberIdAsync(db, sub)` re-read every request (non-Active gets 403 `forbidden`).
3. CORS: existing `DeskCors` (exactly `Desk:AllowedOrigin`, GET and POST, no credentials).
Rejected: a cookie-authenticated same-origin JSON route (works for a Bifrost page, not for the chat origin; a second auth path on the same data), and letting chat present its Matrix/MAS token (Bifrost does not validate it; INFERENCE per PR #37 section 1).
## 4. Where the data comes from (compare, recommend one)
Huginn is the truth. A fork is a question record with options, a derived status, deciders and a server-computed `options_hash` (FACT binding file, "Cards are a read-only projection"). Bifrost holds no fork store, no cache, no hash (FACT cut map authority map). Everything else is a cache or an unrelated plane. Options:
- **(a) A small Huginn endpoint Bifrost calls.** This is already the plan of record, not a new thing: `IDeskDoor.ListAsync(socket, context, request)` over the per-Mind peer-checked unix socket, `context` = {Mind, authority, subject, deciders} built by Bifrost alone (`DeskPorts.cs`). Huginn returns the list "as it projects it for this viewer".
- **(b) A file the persona mouth tails or writes.** FACT the mouth holds one `cursor.cc` per Persona and it stores only the Matrix `/sync` position `next_batch` (`persona-matrix-mouth.mjs` ~:491, :906-912). `chat_pending` is Matrix deliveries to a Persona over loopback RUDP (credential-derived connection id), served *to* Huginn, which is the client; the mouth reads Matrix, not Huginn. It holds no fork, no question and no Huginn client. A file route means a new writer inside the Matrix actuator that copies private records out of the mind into the chat plane, a second owner of "what a member may see", a Node process rewriting Rust's projection, and staleness equal to its write period. The mouth would gain a job it was split away from.
- **(c) Bifrost reads Huginn directly with a CultNet selection** (what `huginn open <campaign>` / `huginn blocked <spec>` do). Rejected: those return every in-force question and follow-up of a campaign (`epiphany.pipeline.question.v2`, `follow_up.v2`), most of which are not forks. Bifrost would need a general read of the mind and would decide fork-hood and visibility itself. Also Bifrost has no Huginn client at all (FACT: no CultNet/RUDP/unix-socket client in C#; `UnixDomainSocketEndPoint` appears nowhere).
**Recommend (a), as the existing desk door, list request only.** Reasons:
- **Auth:** the door is peer-credential checked (the receiver's uid); Bifrost passes the verified subject; the member's token never reaches Huginn.
- **Staleness:** none added. A live read, `Cache-Control: no-store` (FACT), with Huginn's `stamp` (`s<N>`, same stamp same state) as the change signal; a client polls, or `limit=0` for the cheap probe. Bifrost may cache by stamp later; it need not.
- **Failure:** per Mind and per call. A dead socket, timeout or unknown outcome is 503 `unavailable` with `Retry-After` (FACT `WriteUnavailableAsync`); the other Minds' rows are unaffected because the client asks per Mind. The mouth being down is irrelevant. Nothing is served from a stale copy that could mislead.
- **Mouth overlap:** none, which is the point. Chat plane (mouth) and fork plane (door) stay two owners.
- **Not yet existing:** `UnixSocketDeskDoor` (the door port's production impl; H5 in the desk cut map) and the Huginn side of the list wire. Eureka owns that wire (binding file section 5, "The door wire is Eureka's"). I found no definition of the list/read request bytes; this note does not invent one.
## 5. Meaning of "open" and "age"
- **open** = the card's `status` is `open` as Huginn derives it (`ForkCard.status`; `?status=open` filter). Bifrost does not decide it. The count is `open_count` of the same response, so the number and the rows come from one stamp. `recorded_count` (closed with a ruling) is not part of the feed.
- **age** = now minus when the question was raised. **The contract cannot give this today.** FACT `ForkCard` has `order` (a position), `raised_in` (a reference) and no timestamp; answers carry `answered_at`/`received_at` but an unanswered fork has none. Ruled 2026-11-11 by Libby (CultLib steward): the card gains `raised_at`, a REQUIRED key whose value is an RFC3339 string or nil (the card goes from 16 to 17 keys; its hex changes). Huginn emits the question's admission time, or nil when it has none. Bifrost's record gains one field that must accept string or nil. Age derives from `raised_at` and is computed by the client; Bifrost computes nothing; nil means age unknown and the row shows no age. Until the contract lands the feed ships without age (the fallback loses nothing else).
## 6. D1 (options_hash) and the feed
Two different "D1"s exist: the ruling cited for this feed is the fork-desk answer-binding D1 (2026-11-10: `options_hash` covers title, context, options with ids, recommendation and depends; `fork_id` is bound beside it). PR #37 also has a D1 (consent). This section is the first; section 7 the second.
- Feed rows carry the card as Huginn returns it, `options_hash` included. Bifrost never computes or corrects a hash (FACT binding file). A row's identity is `(fork_id, options_hash)`: an edit to a shown title changes the hash, so the row changes with it.
- The feed shows title, which the hash covers, so feed and detail cannot disagree about it.
- **Age is not decision content.** Ruled by Libby (X2, my earlier "inside the hash" option is withdrawn): `raised_at` stays OUT of `options_hash`, which keeps exactly five members (title, context, options, recommendation, depends), so the hash digests and Void's `optionsHash.ts` vectors do not change. Only the card's own bytes change, by the added key.
## 7. PR #37 decisions, what this feed needs
| id | decision (brief) | for the feed |
|---|---|---|
| D1 | explicit consent for `votes.cast`, or first-party implicit | **not needed**: a `desk.read` grant casts and changes nothing; implicit read consent (PR #37 section 7) |
| D2 | re-authentication at cast for high-stakes motions | **not needed**: no cast |
| D3 | token lifetime | **decided** (PR #37 section 14 D3): no refresh token; `desk.read`-only requests get a 60-minute access token, so a polling feed needs no redirect per 10 minutes. Per-audience lifetime is not built; probe 7 is owed |
| D4 | where a member lists and revokes grants | **not needed** while the grant is read-only; revisit with `desk.answer` |
| D5 | runtime brake | **decided** (section 14 D5): the existing marker-file brake, held by the steward. It is NOT per-client and does not cover `/api/desk` until the guard's matching is extended; that and the desk-only deployment are the code PR's to settle |
| D6 | rate limits | **decided** (section 14 D6): 120 requests/min per account for `desk.read`, in configuration. A 429 has no refusal code in the contract (Q3 of the desk cut map): not decided there |
| D7 | order against the MAS rollout | **decided** (section 14 D7): independent; the Ratatoskr client is seeded only when configured. The feed still needs Bifrost OIDC live, which it is not (PR #37 section 1) |
Already ruled: the answer-binding D1 above, visibility ruling c (any `desk.read` holder sees cards and outcomes; notes only for deciders), read with Bifrost's requirement that the door returns only cards this member may see, D2/answer-binding (decider list authored in Bifrost admin, Mind read-only).
## 8. Privacy: what a member may see per Mind
- **Filter owner: Huginn.** The list and read calls carry the viewer's `subject` and that Mind's `deciders` (no `authority`; an answer carries it); Huginn projects "for this viewer". The policy is in Huginn's `docs/desk-feed-door.md` ("Listing", Huginn main 79d954c or later) and is not copied here: not listed unless a listing record is in force, deciders see every showable fork, a hidden fork reads as `fork_unknown`. That gate is not live on Huginn main yet (`cut-desk-feed-listing`), so no Mind that holds private questions goes behind a live receiver before it lands. Bifrost holds NO audience rule: no filter on which forks appear, no per-card visibility rule, no copy of the policy (two copies would drift). Bifrost's two new acts are the per-request Active check and the `desk.read` bearer (below), beside two already built: it nils every `note` for a non-decider (`DeskNotes.Strip`), and it resolves `{mind}` only against the configured list (unknown Mind and unknown fork return the same 404 bytes, so Minds are not probeable).
- **Fields safe for a member:** `fork_id`, `title`, `options`, `recommendation`, `depends`, `status`, `order`, `options_hash`, counts, `stamp`, and `raised_at` (string or nil). **Not safe, and not in the feed:** `note` (stripped), the decider list beyond ids already on the card, anything from the mind that is not a fork card (follow-ups, findings, research, persona state, room text).
- **Answerer ids (X3, closed by the steward in PR #37 section 14):** `ForkEntry.record.answers[]` carries each answerer's account id (a Bifrost `UserAccount.Id`) to any `desk.read` holder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not need `record` at all. SUPERSEDED by Eureka's ruling `eureka-substrate:ruling:desk-read-visibility-withheld-ids` (Huginn #56, 79d954c): the door, not Bifrost, withholds decider ids and answerer ids from a non-decider and returns counts. Bifrost masks nothing it cannot read (the card is opaque to it) and keeps no answerer mask; it still nils `note` for a non-decider. Until Huginn's cut and the CultLib change land, the door sends ids whole, so no receiver goes live on a Mind before then.
- **Content privacy:** a fork's `context` and `title` are Mind-authored prose and may name people or private matter. The door is the only gate: Bifrost requires the list wire to carry only cards the member may see, passes them through; the door withholds answerer and decider ids. The audience policy is Huginn's door spec's, not here. The feed ships no free text Bifrost wrote.
- **Consent and receipts:** reading creates no member record, so no receipt and no consent object; the member's act is the token grant (read-only, implicit). Answer receipts and the append-only record belong to the answer path and stay out. Audit: log client id and `sub`, not the response. The training-notice receipt gates the chat token only (Meta's ruling, 2026-11-11): a desk token issues for a member with no receipt.
## 9. Failure modes
Bearer unjudgeable: 503 `unavailable` (not a refusal of the caller). Not Active: 403. Unknown Mind: 404 `fork_unknown`. Oversize fork: 422 `fork_too_large` with a cursor to continue (FACT #42/#44; the client must handle it, not render nothing). Token expired: 401; the client restarts the redirect (D3). Door down: 503; the Desk row shows "unavailable, retry", never an empty list (an empty list must mean zero open forks). Huginn emits a decider list outside 1..64 for a configured Mind: 503 (config fault, FACT). Ratatoskr's source is synchronous; it needs a fetch-and-hold layer that keeps the last good `stamp` and marks it stale, which is Void's.
## 10. Tests that would prove it (consumer side)
1. Ratatoskr, against a stubbed list: a `ForkList` fixture from the vendored vectors renders the right rows and count; a 503 renders "unavailable", not an empty desk; `open_count` 0 with an empty page renders "none open".
2. Bifrost, `DeskWebApplicationFactory` + `FakeDeskDoor`: a token with `desk.read` lists (200); no token 401; wrong audience 401; a `desk.read`-only token POSTing an answer is 403 `insufficient_scope` with the door never called; a non-Active subject is 403 with the door never called; a cookie alone gets 401.
3. Privacy: a non-decider's response has every `note` nil (key present); Bifrost masks no `answerer` (Huginn's door withholds ids); a decider keeps notes; an unknown Mind and an unknown fork return identical bytes; Bifrost relays exactly the cards the door returns (a door double returning N cards yields N rows, none dropped or added) and applies no audience filter of its own.
4. Hash: a row's `options_hash` equals the vector value, and Ratatoskr's `optionsHash.ts` recomputed over the rendered title/context/options matches it (pins D1 end to end).
5. Probes owed by PR #37 section 11 (items 1, 3, 5): public client exchange, audience+scope enforced, bearer on `/App` still refused.
6. Door contract: a `UnixSocketDeskDoor` against a Huginn double returns `Listed`, `NotListed`, `ForkTooLarge`, and an exception maps to 503.
## 11. Size estimate (lines, include tests)
| file | lines |
|---|---|
| Bifrost `Features/Desk/UnixSocketDeskDoor.cs` (list only; read/answer added by the answer cut) | ~180 |
| Bifrost `Features/Oidc/*` bearer validation, `desk.read` scope, audience, second client seed (shared with PR #37) | ~150 |
| Bifrost `IDeskCredential` production impl (validates the bearer, returns `DeskToken`) + wiring in `Program.cs`; no audience or visibility logic | ~60 |
| Bifrost sweep revoking the Ratatoskr/desk client's authorizations for subjects failing the chat-tier test (PR #37 section 8; derived from membership, no outbound POST) | ~80 |
| Bifrost `OidcGuard` match extended to `/api/desk` (D5 brake) and the desk-only deployment settlement | ~30 |
| Bifrost per-audience token lifetime at authorize (D3: 60 minutes for `desk.read`-only; probe 7 first) | ~30 |
| Bifrost `DeskNotes` extension: mask `answerer` for non-deciders (dropped: the door withholds ids) | 0 |
| Bifrost re-vendor `tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json` (17-key card, new CultLib commit) + `DeskVectors.cs:9,11` pin + `ForkCard` 17th member `RaisedAt`, string or nil (`DeskDocuments.cs:64-80`) + `DeskDocumentTests`/`DeskScenarioTests` updates | ~60 |
| CultLib `fork-card.md` + schema + card vectors for `raised_at` (17-key card; `options_hash` vectors unchanged) | ~40 |
| Huginn: list wire handler and `raised_at` (Eureka's) | not estimated |
| Ratatoskr list reader + token redirect + stale holder (Void's) | ~250 |
| Bifrost tests (items 2, 3, 6) | ~250 |
| This doc and `docs/oidc-issuer.md` note | ~40 |
Subtraction: no new route, no store, no migration, no DTO type beyond one field. The two shared ports (door, bearer) are the cost, and the answer path needs them anyway.
## 12. Open decisions
- **X1** (CultLib, ruled 2026-11-11, Libby): `raised_at` is a required card key, RFC3339 string or nil; 16 to 17 keys. Huginn's emission of it is Eureka's, still to build.
- **X2** (withdrawn): `raised_at` is out of `options_hash`, digests unchanged (Libby).
- **Nothing in this note is open for Bifrost's own design.** Decided and recorded elsewhere: `raised_at` (Libby, CultLib ruling 2026-11-11, X1/X2 closed); answerer ids (X3: withheld by Huginn's door, ruling `desk-read-visibility-withheld-ids`, supersedes the Bifrost mask); PR #37 D1-D7 (section 14); card visibility (the Huginn door's policy, `docs/desk-feed-door.md` in Huginn; Bifrost holds no audience rule).
- Owed by others: Eureka owns Huginn's emission of `raised_at`, the listing gate (`cut-desk-feed-listing`) and the ops root-run that binds the sockets; the door wire is on Huginn main and its golden fixtures are the contract for `UnixSocketDeskDoor`.

370
docs/feedback-receiver.md Normal file
View file

@ -0,0 +1,370 @@
# Feedback receiver
Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:chat-s3533-3533`, updated for
`chat-s3549-3549`. It is the receiver half of Ratatoskr's Mind feedback pages. The page spec is Ratatoskr PR #24,
`docs/mind-feedback-pages.md` at b146000d, which I take as final here; its section 7a names this doc. The rulings it
builds on are Heimdall's drafts on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f).
Read at Bifrost main `b1725a1` (2026-11-12), line references re-read at `e58e953` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`,
`src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*`, `Domain/BifrostModels.cs`, `Program.cs`, `docs/oidc-issuer.md`,
`docs/ratatoskr-voting-auth.md`; the deployed trees under `/srv/bifrost` were last read at an earlier main (section 9). I could not read Eureka's receiver
notes in #minds, and `huginn-feedback-kinds-proposal.md` was not in `/opt/commons/handoff` when I looked. Where
PR #24 names a kind or a field it says the proposal wins, so I cite PR #24 and name the proposal's kinds only as PR #24 does.
Where source disagrees with either, the section says so.
Rulings of Meta, 2026-11-12, now written into this doc: erase redacts the answer text in the receiver's store and keeps a text-free record
(section 7a); the training notice covers any text entered into the app, bar a short exception list Eureka drafts (section 4a); the host may
withhold an answer from its Mind in an emergency, with a logged receipt (section 6a); Minds authenticate to the receiver through an OIDC
client with an audience, as the desk does (section 4b). Eureka's decisions on forks 2 to 5 of 2026-11-12 (`/opt/commons/handoff/bifrost-10-forks-2-5-recs-20261112.md`)
are folded into sections 2, 4b, 6a, 7a and 9 and close those forks, except where section 9 says a point is still open.
## Rulings of the steward (Heimdall, 2026-11-12)
Two of this doc's forks are the Bifrost steward's. Meta's, Eureka's and Libby's items are not ruled here.
1. **Ratatoskr's client for people: reading 2a, with no third client.** The one public PKCE client the desk-credential cut seeds for Ratatoskr (only when `Bifrost:Oidc:Clients:Ratatoskr` is configured, so absent config changes nothing) also gets a scope `feedback.answer` that puts audience `bifrost-feedback-page` on the token. Authorization code with PKCE only, exact redirect URIs, no refresh token, the token only in an `Authorization` header and never in a URL, a query or a log. The audience is distinct from the Mind's `bifrost-feedback`, so a person's token never opens a Mind endpoint and a Mind's never opens the page (least privilege). Reading 2b, a Matrix token checked at the homeserver, is refused: it is a bearer for the person's whole Matrix account. The receiver writes a text-free receipt per accepted submission (receipts).
2. **Token lifetimes are explicit, short, and set in the issuer change.** `feedback.answer` access token: 15 minutes, because the Bifrost session cookie re-issues it silently. The Mind's `client_credentials` token: 10 minutes, one call to renew. No refresh token for either. The desk's lifetime is its own decision and is not touched here. Until the issuer change lands the OpenIddict default applies; this doc no longer relies on it.
## 1. What the receiver owns
| Concern | Owner | Receiver's part |
|---|---|---|
| A question, its listing, status, hides, rulings | the owning Mind, in her own mind (Huginn) | reads them to build the projection; **never writes to Huginn** (an admission for another instance is refused `ForeignInstance`, PR #24 section 3) |
| An answer's **text** | the receiver | stores it; it is never admitted into a mind |
| The answer's **record** in the Mind | the owning Mind's Persona | she admits a text-free `feedback_answer` from the delivery, and acks after she has |
| What `kind` an account is | Bifrost (`Membership`, later `Role`) | reads it at answer time and stamps it |
| Delivery to the Mind | the receiver, at least once | keeps the record until the Persona acks |
| Hiding, closing, ruling | the owning Mind | **never**; a hide only changes what the projection serves |
| Erase | Bifrost's erase, executed here | redacts the text and name in the store, keeps a text-free record (section 7a) |
| Withholding an answer from its Mind | the host (emergency only) | logs a text-free withhold record and skips delivery; changes nothing stored or ruled (section 6a) |
| Who may answer | Bifrost: `Active`, a live training-notice receipt | checks both on every submission with text (section 4a) |
The page and the client write no record that carries identity (PR #24, section 3). A Matrix sender is a different identity
authority and never becomes `answerer`.
## 2. The mouth's pending/ack path, read from source
Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs`; line numbers re-read at main `e58e953` (2026-11-12), where the mouth has grown by about 100 lines since `b1725a1` and nothing else in this section changed:
- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.156) is filled in one place, `syncOnce`
(l.543; `pending.push(...made)` l.602), from Matrix `/sync` events. No function accepts a delivery from elsewhere.
- **A request is non-consuming; an ack consumes.** The pending requests carry `afterSeq`; the mouth answers
`pendingAfter(afterSeq)` (l.720) paged to 256 KiB (`pageOf`, l.1055-1084; `limits.replyBytes`, documents l.53). `chat_ack` carries `throughSeq`; `ack`
(l.662-665) drops every delivery at or below it, releases stored images (`images?.acked`) and advances the Matrix cursor. An ack posts nothing to
Matrix: her public `m.read` receipt follows a separate `chat_read` document (header l.10-13, `chat_read` branch l.1138). The endpoint serves a closed list of kinds
(`served`, l.1103) and answers any other loudly with `chat_refusal` `UnknownKind` (l.1112); a request for another Persona gets `UnknownPersona`.
- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.619-627), so a
restart before the ack re-delivers from Matrix. `seqCounter` (l.155) is persisted whenever a round made deliveries (l.614), so
sequence numbers never repeat across a restart.
- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.63) `syncOnce` stops reading (l.550). A truncated round (pending cap l.578, reply-read cap l.579)
leaves the cursor where it was (l.607).
- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.1060-1062); the caller's
next ack covers its seq, so it is dropped. Harmless for a chat line, wrong for a durable record.
- **The endpoint is loopback RUDP.** `startChatEndpoint` (l.1091) refuses a non-loopback host (l.1092). It is another process
from Bifrost.Web, which has no way to hand the mouth anything.
- **The delivery tuple is closed and versioned.** `deliveryTuple` is twelve fields (the twelfth is an optional stored-image reference), the pending answer
`gamecult.persona.chat_pending.v2` (`persona-chat-documents.mjs` l.216-224). v3 adds `basis` and `form` (14 fields) and a room tail (`chat_pending.v3`,
asked for by `chat_pending_request.v2`); v4 adds `reply_to` (15 fields, `chat_pending_request.v3`, served only with `CHAT_PENDING_V4=1`, `replyLinkFromEnv` l.1185). No version has a source or
kind field. A delivery cannot say it is not a Matrix message.
- **A fetch kind exists, off by default.** `chat_fetch_request` is served only with `CHAT_FETCH=1` (`served`, l.1103; `fetchFromEnv`, l.1187): an optional served kind is a precedent for adding kinds
to `served`, and the way a kind is withheld until its consumer is ready.
- **The consumer is not in this repo.** The mouth's header (l.10-11) says the heartbeat polls with a non-consuming
request and decides who runs. I could not confirm that from source.
What follows from it:
1. **Feedback cannot ride `pending` as it is.** There is no inlet, and the cursor logic ties acks to Matrix batches.
2. **A framed text delivery is forgeable.** Any Matrix member can write a line that looks like a frame, and no tuple has a
field to tell the Mind which one is real. The frame must be a typed difference in the wire, not a convention in `text`.
3. **A record that is not in Matrix is not re-delivered by a restart.** Feedback needs its own durable store and its own
delivery cursor.
PR #24 section 3 names the carrier: a new delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost, through
the mouth's pending/ack path. Source adds one constraint on how it rides:
- **It cannot be a variant inside `chat_pending`.** Each version is a closed positional tuple (twelve, fourteen or fifteen fields) encoded by
`pageOf` (l.1060), so a different tuple in that page breaks every current consumer, and a text frame in `text` is forgeable by any Matrix member.
- **Option B, kept in shape:** the delivery document has its own request, page and ack kinds (`feedback_pending_request`, `feedback_pending`, `feedback_ack`), its own seq space and
a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor and `MAX_PENDING` are untouched. Per the decision below the kinds are served by the receiver over
HTTPS, not added to the mouth's `served` list (l.1103). Confidence medium: the Persona-side consumer, which I cannot see, must learn them.
- **Why main's changes reinforce B.** A chat `ack` releases stored images and advances the Matrix `/sync` cursor (l.662-665). A feedback ack must do neither, so it cannot
share the `chat_ack` kind or its `ack` function. The chat pending answer is already versioned three times, so a new field in that tuple is a new
document version for every consumer, which a feedback record must not force.
- Rejected: injecting records into `pending` as synthetic Matrix deliveries (no inlet, forgeable, lost on restart);
posting to a Matrix room as a bot (wrong identity authority, spam and forgery, and the Mind would read it as chat).
- **Decided (Eureka, 2026-11-12): feedback does not ride the mouth.** The endpoint is loopback RUDP with no bearer and trusts the persona id in the document, so
routing feedback through it would make the Matrix organ a bearer-less deputy holding every Mind's authority and would put the receiver's own seq space and durable
cursor in a Matrix process. A Mind pulls and acks **directly over HTTPS from her own process to the receiver, with the section 4b bearer**; she admits the text-free
record into her own mind afterwards. The three kinds `feedback_pending_request`, `feedback_pending`, `feedback_ack` are therefore HTTP endpoints of the receiver, not
kinds added to `served`; Option B's "same endpoint" clause is withdrawn and the mouth is untouched. The delivery document and its seq space are as above. This is a
Bifrost HTTP service, a xenos boundary for CultNet, so JSON/HTTP there is the tolerated kind. Pre-deploy test: section 9.
## 3. Records
One CultCache file, owned and written only by the receiver (section 8). Append-only through the API: nothing edits or
deletes a record except the redaction of section 7a. Every record has a `recordId` and a receiver-assigned `seq`
per owning Mind (the delivery sequence). PR #24 names the answer row `<question-label>.a<N>`, N the receiver's per-question
sequence; I use that for answers and the same shape for the others.
| Record | Fields | Notes |
|---|---|---|
| `answer` | `recordId`, `questionId`, `owner`, `choice?` (an option label), `text?` (at most 2000 characters), `answerer`, `kind`, `weightPermille`, `nameAtAnswer`, `clientRequestId`, `time` | at least one of `choice`/`text`; an unknown option label is refused; the request id makes a retry idempotent, as `txnId = request_id` does in the mouth; `weightPermille` is 0 for a guest and 1000 otherwise, stamped, and the page sums nothing |
| `close_request` | `recordId`, `questionId`, `requester`, `kind`, `time` | refused for a guest and for an account not `active` at that moment (R4c) |
| `report` | `recordId`, `targetId`, `reporter`, `kind`, `reason`, `time` | delivered to the Mind as input; never shown, never counted (R6b) |
| `withhold`, `withholdLifted` | `recordId`, `answerRecordId`, `owner`, `withheldBy` or `liftedBy`, `time`, `reasonCode` (withhold only) | text-free log of section 6a |
| delivery state | per owning Mind: highest seq acked; per record: `delivered`, `failed`, `withheld` | a cursor, not a record the page reads |
Invariants: `answerer`, `kind`, `weightPermille`, `time` and `nameAtAnswer` come from the verified token and Bifrost state, never from the
request body, and the body schema is closed: an unknown key refuses the submission. The latest record per account is that
account's standing answer, derived at read time. A submission is refused if the question has no listing in force or is hidden.
An answer to a question that has settled is kept and the page says so (PR #24 section 8). A refused submission leaves nothing behind.
## 4. Credential and claims check
PR #24 section 7a rules **route 2** for people: Ratatoskr presents an OIDC access token that Bifrost issued, and Bifrost verifies it itself. Meta
ruled on 2026-11-12 that Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (4b). This section records what
Bifrost's source does on main `e58e953` (the Oidc, Desk and Registration files cited here are unchanged since `b1725a1`, bar `DeskEndpoints.cs:49`, which registers `UnixSocketDeskDoor`).
### What main does with tokens
| Token | Who may get it | Gate at issue | Gate on use |
|---|---|---|---|
| **Chat token** (scopes `openid`, `profile`; no audience) | an account that exists, has a Bifrost identity that is not reserved, and whose membership is not `Suspended`; membership is neither required nor written (`OidcEndpoints.cs:55-78`) | a live training-notice receipt: without one the member is redirected to the consent page and the parked request waits (`:104-111`) | the token and userinfo endpoints re-read it: `ChatRefusalAsync` refuses an absent or `Suspended` account and a chat token with no live receipt (`:164-180`, called at `:127` and `:144`) |
| **Desk token** (every scope is `desk.read` or `desk.answer`, `DeskEndpoints.cs:21-26`) | the same sign-in, then only a client whose permissions allow the desk scope | **no receipt**: `IsDeskToken` is true only when every scope is a desk scope (`:118`), so a desk token skips the gate (`:104`, `:108`); `principal.SetResources(DeskEndpoints.Audience)` puts audience `bifrost-desk` on it (`:91-94`) | per request, in `DeskEndpoints.HandleAsync`: audience (`DeskEndpoints.cs:110`), scope (`:116-121`), then `Active` membership re-read through `OidcEndpoints.ActiveMemberIdAsync` (`:123-139`; `OidcEndpoints.cs:183-194`, which is `CurrentBifrostActor.IsActiveMember`, `Status == Active`, `CurrentBifrostActor.cs:17`) |
The token claims are unchanged: `sub` is `UserAccount.Id` in form `D`; `preferred_username` and `name` ride the token only with scope `profile`
(`OidcEndpoints.cs:80-102`). No email, Heimdall id or GitHub id is in it. The access token is an encrypted OpenIddict token and its lifetime is OpenIddict's
default, which I believe is one hour and have not verified (no lifetime override in `src`; `ratatoskr-voting-auth.md` section 14 D3 owes explicit values). There is no refresh token.
How a bearer is validated has changed: OpenIddict's validation handler is registered against the local server (`DeskEndpoints.cs:36-38`), reading the
`Authorization` header only (`:40-44`), and `OidcDeskCredential` authenticates through it by its own scheme name and judges nothing else
(`OidcDeskCredential.cs:14-27`; audience, scope and membership are the desk's, per its header comment). That path is registered only when `Bifrost:Oidc` is configured (`:30-33`).
**Only one client exists.** The seeder creates the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). MAS's permissions
carry no desk scope (`:42-50`), so **no desk token can be issued on main**: the desk's second client is owed (`ratatoskr-voting-auth.md` section 14 D7). `AllowAuthorizationCodeFlow` is the only
grant and the token endpoint takes `client_secret_basic` only (`OidcServiceCollectionExtensions.cs:39`, `:45-46`).
### 4a. What the receiver checks on an answer from a person
In this order; a submission that fails any step leaves nothing behind.
1. **The token opens and has not expired**, and `sub` is a Guid naming an existing `UserAccount`.
2. **The account is `Active` now** (`ActiveMemberIdAsync` as the desk does). A chat token is issued to non-`Active` accounts (`OidcEndpoints.cs:55-62`), so the token alone proves nothing about standing. Under PR #9 `IsActiveMember` would also require
`Role == Member` and exclude guests, so the receiver reads `Status == Active` itself and not that expression.
3. **A live training-notice receipt, read by the receiver on every submission that carries text** (Meta's ruling, 2026-11-12): `ConsentEndpoints.HasLiveReceiptAsync`
(`Features/Registration/ConsentEndpoints.cs:146`), the one reading of "may speak": a receipt for the notice version in force, not withdrawn. The notice covers any
text entered into the app except a short designated exception list, which Eureka drafts. The receiver checks the receipt itself because the token's
gate does not cover it: a chat token's receipt was checked at issue and again only at the token and userinfo endpoints (`:127`, `:144`), never at the receiver, and a token whose audience is
not a chat token skips the gate altogether (`:104`). A receipt withdrawn after issue must stop the next submission. The check applies to answers, close requests and reports alike. **Until Eureka's list exists the gate applies to every submission with text**; the list narrows it, and only by being written down.
4. The body is closed-schema, an option label must exist, the question must be in force and not hidden (section 3).
A refusal for a missing receipt is typed and carries the consent page's path, so the page can send the person to agree; it does not store the text.
What the receiver refuses: a token that does not open or has expired; a `sub` that is not a Guid or names no account; an account that is not `Active`; a missing receipt (above);
a body carrying `answerer`, `kind`, `name`, `weight` or any key the closed schema does not name; an unknown option label; a question with no listing in force, or hidden; a close request from a guest.
What source says route 2 still has to answer:
1. **No client reaches Ratatoskr.** MAS holds the only client, and it holds the access token; Ratatoskr gets a Matrix session from MAS, not a Bifrost token. The reading that fits what the desk now does is **2a**: Ratatoskr as a
second, public, PKCE client with its own audience and scope, issued by the same code path as the desk's (`ratatoskr-voting-auth.md` sections 3-4). The audience machinery it needs now exists on main; the client does not. Reading **2b** (check the Matrix token against the homeserver) hands the receiver a bearer good for the person's whole Matrix account; I recommend against. I lean to 2a, confidence medium.
2. **The logout reconciler covers only MAS** (`OidcLogoutReconciler.cs:108`, `:180`); a second client's token lives to expiry. The receiver's own `Active` and receipt reads at every submission cover that.
3. A token with an audience of its own would not be accepted from any other client, which closes the weakness an unaudienced chat token had here.
### 4b. How a Mind authenticates (Meta's ruling, 2026-11-12; names are proposed)
A Mind authenticates to the receiver through an OIDC client with an audience, the shape the desk uses: a scope at issue puts an audience on the token
(`OidcEndpoints.cs:91-94`), the validation handler opens it (`OidcDeskCredential.cs:14-27`), and the endpoint judges audience, then scope, then subject
(`DeskEndpoints.cs:110-139`). The analogous parts:
| Part | Desk (main) | Receiver (**proposed**) |
|---|---|---|
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | **`bifrost-feedback`** |
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | **`feedback.read`** (pull deliveries and the Mind's own projection) and **`feedback.ack`** (ack a delivery; also the Mind's acts that pass through the receiver) |
| Credential | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | **the `sub` must be the owning Mind's configured client**, matched against the question's `owner`; a Mind is not a `UserAccount` and has no membership to read |
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
**Decided (Eureka, 2026-11-12): OAuth 2.0 `client_credentials`, one confidential client per Mind.** `client_id = mind-<persona>`; scopes `feedback.read` and
`feedback.ack` only; audience `bifrost-feedback`. The token's `sub` maps to the Mind's registered persona and is matched against the question's `owner`. The grant
is RFC 6749 section 4.4 ("using only its client credentials"): short-lived, audience-bound, no refresh token, no cookie session. It is not extended to the desk in the
same cut.
**The issuer today cannot issue it.** It enables one grant, `server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange()`
(`OidcServiceCollectionExtensions.cs:39`); the token endpoint accepts `client_secret_basic` only (`:44-46`: `ClientAuthenticationMethods.Clear()`, then `ClientSecretBasic`);
the seeder creates only the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). **Enabling `client_credentials`
(`AllowClientCredentialsFlow`), seeding the per-Mind clients and the audience/scope registration is an issuer change this design owes**, with a matching edit to
`docs/oidc-issuer.md`. Until it lands no Mind token can be issued on main. The audience and scope names are accepted as proposed.
**Client authentication: `private_key_jwt` with the Mind's Ed25519 key is not available on the pinned stack; use a secret.** Finding, read against the pinned packages
(`src/Bifrost.Web/packages.lock.json`: `OpenIddict.Server`, `.Validation` and the rest at 7.7.1):
- *Supported:* OpenIddict.Server 7.7.1 has `private_key_jwt` client authentication. Its assembly carries the literal `private_key_jwt`, the `client_assertion` and
`client_assertion_type` parameters, `OpenIddictServerOptions.ClientAssertionTypes`, and the handlers `ValidateClientAssertion`, `...Issuer`, `...Audience` and `...WellknownClaims`
(`OpenIddict.Server.xml`); each application can carry a `JsonWebKeySet` (`OpenIddictApplicationDescriptor.JsonWebKeySet`, `IOpenIddictApplicationManager.GetJsonWebKeySetAsync`, `OpenIddict.Abstractions.xml`).
Bifrost does not enable it today: the method list is cleared to `ClientSecretBasic` (`:44-46`).
- *Not supported with Ed25519:* the assertion is a JWT checked by `Microsoft.IdentityModel` 8.19.2, the version the lock file pulls in. Scanning
`Microsoft.IdentityModel.Tokens.dll` for algorithm identifiers finds only `HS256/384/512`, `RS256/384/512`, `PS256/384/512` and `ES256/384/512`; there is no `EdDSA`, `Ed25519`
or `OKP` anywhere in it or in `Microsoft.IdentityModel.JsonWebTokens.dll`, and none in `OpenIddict.Server.dll` or `OpenIddict.Abstractions.dll`. So a Mind's existing Ed25519 key
(the one moderator-registered through `MindIntakeService.cs`) cannot sign a `client_assertion` this server would accept. (Evidence is a string scan of the shipped assemblies, not a
runtime test; I did not run an assertion against a live issuer. Confidence high that it fails, since the algorithm names are absent.)
- *Consequence:* `private_key_jwt` would need a second, ECDSA P-256 (or RSA) key per Mind registered as the client's key set, which is a new key to custody and no improvement on a secret.
Decision: **`client_secret_basic` with one secret per Mind**, the method the token endpoint already takes. If the stack gains EdDSA the choice reopens, and `private_key_jwt`
with the existing key is then preferred because it removes the per-Mind secret (RFC 7523 section 2.2).
**The client secret lives only in a file the Mind's process reads** (for example a systemd credential in her own home, mode 0400), never in an environment variable, a command
line or a log. The seeder stores the hash Bifrost holds; the plaintext is handed to the Mind once, through the same custody path as her other credentials.
| Part | Desk (main) | Receiver (decided) |
|---|---|---|
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | `bifrost-feedback` |
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | `feedback.read` (pull deliveries, read the Mind's own projection) and `feedback.ack` (ack a delivery; also the Mind's acts that pass through the receiver) |
| Credential port | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | `sub` is the owning Mind's client; a Mind is not a `UserAccount` and has no membership to read |
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
Rejected (Eureka): reusing the mouth's signed envelope per request. It is motion-bound (`verb` is commit/reveal, `MotionId` in the signed text) and would need a new verb set.
Token lifetimes: ruled in Rulings of the steward (15 and 10 minutes, set explicitly in the issuer change).
## 5. Account-kind lookup
- The account is found by the token's `sub`, which is `UserAccount.Id` (section 4). **`answerer` is `UserAccount.Id`**, which survives role
change and Heimdall unlink; the Heimdall account id is only an authenticator handle and is cleared on erase (guest-accounts design, PR #9).
- On main there is only `Membership.Status` (`Authenticated | PendingApproval | Active | Suspended`) and no role
(`BifrostModels.cs` l.5-10, l.335). The role field and `gamecult.bifrost.account.v1` are in PR #9 and unbuilt.
Until then, `kind` can only be derived: `Active` is `member`, `Authenticated` and `PendingApproval` are `user`, and
nobody is a `guest`. That is the PR #9 backfill, repeated here, and R4a-c have no guest to apply to until PR #9 lands.
- **An answer needs `Status == Active`**, read at answer time, not `IsActiveMember` (section 4a). On main the chat token is issued to any account that is not
suspended (`OidcEndpoints.cs:55-62`), so an `Authenticated` or `PendingApproval` account can hold a token and the receiver refuses it on its own read.
- **`kind` is read from Bifrost's database**, never from the token: the session carries identity only.
- **`mind` is not a Bifrost account** in main or PR #9. A Mind authenticates as an OIDC client with an audience (section 4b), not as an account; `kind` is stamped only on a person's answer.
- **The display name must not be `CurrentBifrostActor.DisplayName`.** It falls back to `GitHubLogin`
(`CurrentBifrostActor.cs:11-15`) and then to the literal "Guest". The receiver stores `nameAtAnswer` from
`MemberProfile.Nickname` or `UserAccount.DisplayName` only, and "an account" when both are empty. Never a provider handle,
GitHub login, Heimdall id or Matrix localpart.
## 6. Delivery as quoted data
- The receiver delivers each record as a `gamecult.persona.feedback_delivery.v1` document on the typed kinds of section 2. A feedback
record is therefore never a `chat_pending` delivery and a Matrix sender cannot produce one.
- The delivery carries its fields separately: `recordId`, `questionId`, `kind`, `nameAtAnswer`, `answerer`, `time`, `choice`, `text`. The
**Persona's side renders the frame**, not the receiver, and puts `text` in a data position only: attributed, quoted, never in an instruction
position, with the author's `kind` stated (R4d). The receiver does no keyword screening.
- **She admits the text-free `feedback_answer` into her own mind and acks only after that** (PR #24 section 3). The text reaches her turn once, in the
delivery, and is never admitted; she cites the record id, not the text. A restart redelivers the same `recordId`, which Huginn answers
`AlreadyAdmitted`. So an ack means *admitted*, a stronger meaning than the mouth's chat ack, which means only *taken*.
- **Untrusted by kind.** Text from `guest`, `user` and `patron` is untrusted input; so is `member` text, which differs only in weight. She records a
ruling from an answer, never an action.
- **A record that will not encode, or that she cannot admit, is marked `failed` and stays visible**, never acked past silently (the opposite of
`pageOf`). The mouth's cap and backpressure do not apply: the receiver keeps accepting answers while a Mind has a backlog, because they are durable.
- Close requests and reports are delivered the same way. A close request changes nothing until the Mind resolves the question; a report changes nothing.
- **An erase notice is delivered the same way** (section 7a), and she answers it with a `feedback_hide` of reason `ErasedAuthor`.
## 6a. Withholding an answer from its Mind (Meta's ruling, 2026-11-12)
The host may withhold an answer from its Mind in an emergency, with a logged receipt. It changes **delivery only**.
- **The withhold record** is one more receiver record, append-only, text-free: `recordId` (its own), `answerRecordId` (the answer withheld), `owner` (the Mind),
`withheldBy` (the host actor's `UserAccount.Id`, read from Bifrost state, never the body), `time`, `reasonCode` (closed, exactly `Emergency` (imminent harm), `PersonalData` (the answer contains someone's data) or `Legal` (counsel or a legal request); no free text, because free text is where content would get in; a fourth code needs a ruling) and, when it ends, a second record, `withholdLifted`, with `liftedBy` and `time`.
**No answer text, no choice, no name of the answerer and no excerpt is in either.** It is the log the ruling asks for, readable by the host. **Decided (Eureka, 2026-11-12): the Mind and the member both see a text-free `withheld` marker carrying the code**, so silence is not read as "no answer"; the member sees the same marker on their feedback page. The marker carries no text, no choice and no withholder.
- **What it changes:** the answer's delivery state becomes `withheld`, and the delivery cursor does not pass it silently. The receiver does not put it on the wire to the Mind
while the withhold is in force. Lifting it makes the answer deliverable again, in its original seq order, unchanged.
- **What it never changes:** the stored text (it is not redacted, edited or moved: only erase redacts, section 7a); the projection rules of section 7 (a withhold is not a hide);
and the Mind's own authority over the question: she may still hide the answer, close the question or rule on it by her own acts, and a withhold takes none of that away. She cannot lift it; the host's act is the host's.
- **Who may do it: `PlatformAdmin` only** (`CanManageMembership`, `CurrentBifrostActor.cs:30`, the check suspension uses; no new role). The act is refused to anyone else and to the Mind herself. **The withholder's identity (`withheldBy`, `liftedBy`) appears in the audit receipt only**, never on the marker the Mind or the member sees.
Open: whether the member should be told in a safety case (for example a threat) is Meta's call (Eureka 60%); the default written here is that the member is told.
- A withheld answer is not erased, and an erase of its author redacts the text as in 7a; the withhold record stays, because it carries none.
## 7. Projection read
The page polls the receiver, and the receiver reads the Mind's side through Huginn over RUDP and writes nothing to it
(PR #24 section 3). The page does not talk to Huginn.
- Questions, their listing (`audience`, `close_grants`), status, rulings and hide acts come from the Mind's mind. Answer text and the name come from the receiver's store; an answer row is assembled from the receiver's record and the Mind's text-free `feedback_answer`.
- **A hidden item's text is withheld on the server**, not collapsed in the client: author, kind, time and reason
stay, the text does not leave the receiver (R6d). A hidden question's title, body, options and answers are withheld.
- Questions and answers are filtered by the viewer's `kind` against the question's `audience` at read time (R4b).
- The projection carries its own `as of`, so the page can say when it is stale.
## 7a. Erase
Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. **Ruled by Meta, 2026-11-12: erase redacts the answer text in the receiver's store and keeps a text-free record.** The receiver's part, as PR #24 section 7 fixes it:
1. Bifrost's erase records its intent in Postgres first, then the receiver applies it idempotently. The two stores are not one transaction, so
erase is crash-replayable, as `docs/bridge.md` describes for the persona-feedback pump.
2. For every record the account authored the receiver **drops the text and the name** and keeps **record id, kind and time only**. `choice`,
`weightPermille`, `answerer` and `clientRequestId` are dropped too. The row shows "erased account". **No content hash is kept.** Redaction is in the store itself, not a flag over retained text.
3. The receiver delivers an erase notice, and the Persona admits a `feedback_hide` of reason `ErasedAuthor`: the Mind's acknowledgement, not a moderation call.
4. **The honest residue:** a Mind's own memory or ruling may already quote an answer, and Huginn cannot redact it. That the Persona cites record ids and
never text is a prompt, not an invariant. Erase does not reach what she already holds.
5. **Backups: 56 days, restores replay erasures** (Eureka, 2026-11-12). "Append-only" is the receiver's API contract, not a property of the file, so erased text survives in
backups of it. The stated purge is: **erased text can survive in backups at most 56 days**, the existing Raven tier (newest 14 daily plus the newest of each of 8 Monday-weeks;
`gamecult-ops` `runbooks/gamecult-state-backup.md`, read at `origin/main`). No backup is changed. The receiver keeps an **erased-id list** (account id and record ids, no text,
no hash) beside its store, and **any restore must re-apply it over `receiver.cc` before the receiver serves, never trusting the two files to be the same generation (section 8)**. The notice to people says it (R2 of the exceptions draft). If Meta wants shorter, put the
store outside the weekly tier (14 days), a small backup-config edit.
*Is the store under a backed-up path? Yes, by choice (Eureka, 2026-11-12):* `/var/lib/gamecult/bifrost/feedback-receiver/` (section 8), under `/var/lib/gamecult`, which the nightly
snapshot takes whole bar Idunn caches and Ghostlight acceptance output. It avoids the excluded `/srv/bifrost/persona-feedback/runtime`, `app`, `log` and `backups`. A file with a sibling
`<name>.lock` is re-copied under a shared lock, so `receiver.cc` and `erased.cc` each carry a `.lock` and travel in one directory in every snapshot, the erased-id list included. The 56 days is read from the runbook, not measured.
## 8. Where it lives
**CultCache files in one directory, written only by the receiver, which is C# inside the Bifrost app** (`bifrost.service`, `Bifrost.Web`), sharing its issuer and auth (Eureka, 2026-11-12). Not Node,
not the mouth, and not the `bifrost-feedback` user of `bifrost-persona-feedback.service`.
- **Path:** `/var/lib/gamecult/bifrost/feedback-receiver/` holding `receiver.cc` (records and delivery state), `erased.cc` (the erased-id list of section 7a) and a `.lock` file for each.
Directory `bifrost:bifrost`, mode 0750, **created by root at install**: `/var/lib/gamecult/bifrost` is `root:root` 0755 today, so the `bifrost` user cannot create the child itself (or a
`StateDirectory=gamecult/bifrost/feedback-receiver` in the unit that writes it). `bifrost.service` has no sandbox directives, so it needs nothing more; if it is later hardened with `ProtectSystem=strict`,
add `ReadWritePaths=/var/lib/gamecult/bifrost/feedback-receiver`. The backup reads as root, so 0750 does not block it.
- **Nothing else opens the files.** There is no Node mouth reader and no Rust writer. The receiver is the only process that opens `receiver.cc` or `erased.cc`; its delivery cursor lives in the same
store. The cross-runtime refusals are real and are not worked around: the Rust runtime refuses to write until it has pulled, and the C# runtime refuses to open a store whose records it cannot read
(`CultStoreUnreadableException`). The CultCache contract would allow a lock-free stale read or a shared-lock read by another process; **this design takes neither: Mind-side readers get no file
access at all, they pull over HTTPS** (section 2).
- **Two stores, two locks, no pair consistency.** `receiver.cc` and `erased.cc` take independent locks (`receiver.cc.lock`, `erased.cc.lock`; `AcquireLock`, `CultCache.cs:5164-5185`, `FileShare.None`, retry 30 s),
so a backup copying under each shared lock can capture them at different generations. The design never trusts the pair to be consistent: **restore re-applies `erased.cc` over `receiver.cc` by
design** (section 7a, item 5), whichever generation each came from, and the receiver does not serve until it has.
- **The C# runtime fit is "needs work" (Eyes).** `CultLib` C# (`src/GameCult.Caching` and `GameCult.Caching.MessagePack`, about 10.6k lines, netstandard2.1, MIT) fits net8.0, but:
- **No published package exists**: nuget.org has none, the `cultlib-v` tags stop at 1.0.32, and packaging is a PowerShell local feed (`CultLib` `docs/nuget-packaging.md`). Bifrost has no `nuget.config` and no vendor directory.
**The consumption route (checkout `ProjectReference`, local feed, or vendoring) is an open fork for Eureka and Libby.**
- **Version conflict (predicted, not run):** `Caching.MessagePack` needs System.Text.Json 9.0.10; Bifrost's Direct pin is `[8.0.5, )` (`packages.lock.json:108-112`), so restore likely fails with NU1605.
- **It drags CultMath** (MIT AND MPL-2.0 AND Apache-2.0, shaders) into a web service.
- **Writer precedent:** the Node sidecar already writes `/srv/bifrost/persona-feedback/deliveries.cc` (`docs/bridge.md:294`), so the receiver is the second `.cc` writer in the app and the first in C#.
- **Gaps owed to Libby (CultLib steward); no local workaround is built here:** (1) no consumable C# package; (2) the System.Text.Json pin conflict; (3) the CultMath transitive closure;
(4) `src/GameCult.Caching/README.md:310` says single-writer, no competing writers, which contradicts the store-composition contract's lock; (5) no documented stale `.tmp` / crash procedure;
(6) no documented backup-while-writing contract for a pair of stores; (7) no cross-runtime test pairing a Linux C# writer with a Rust reader.
- Session check, account state and role stay in Postgres, where they already are.
I withdraw my earlier lean to Postgres rows: it contradicts the ruled store, and the `.cc` witness it would have owed is what the file is.
Naming: `persona-feedback` already means the Discord-to-Epiphany ingress (`docs/bridge.md`, "Organizational Persona Feedback Ingress"). This
receiver's records and kinds use `feedback_page` and `feedback_delivery`, not `persona_feedback`.
## 9. Gaps and open forks, plainly
Rulings of 2026-11-12 closed these, and they are no longer forks: whether erase redacts the receiver's text (it does, section 7a); whether a person's answer to a Mind needs the training-notice receipt (it does, bar a designated exception list, section 4a); whether the host can withhold an answer (it can, in an emergency, section 6a); whether a Mind authenticates through the issuer (it does, with an audience, section 4b).
**Closed by Eureka's decisions (2026-11-12), folded above:** how a Mind gets a token (`client_credentials`, a confidential client per Mind, secret in a file; section 4b); the carrier (HTTPS with the bearer, not the mouth; section 2); who may withhold, the reason codes and who is told (section 6a); the backup purge number (56 days, with the erased-id list; section 7a). Their remaining work is listed below.
**Open forks:**
1. **The exception list is owed.** The notice covers any text entered into the app except a short designated list, which Eureka drafts. This doc names no entries; until the list exists the receipt gate applies to every submission with text (section 4a).
2. **Route 2 for people has no client.** Only MAS is seeded (`OidcClientSeeder.cs:35-75`); a Ratatoskr client with its own audience is owed, as is the desk's (section 4a).
3. **Telling the member of a withhold** in a safety case (section 6a): Meta's call.
4. **Erase is bounded** by what a Mind already holds; Huginn cannot redact it (section 7a, item 4).
**Owed by this design, not forks:**
- The issuer change of section 4b (`client_credentials`, per-Mind clients, `docs/oidc-issuer.md`).
- **Pre-deploy test: a Mind's process can reach Bifrost over HTTPS, and get a token.** Done 2026-11-12 from a Self run (not a Persona turn's sandbox): `https://bifrost.gamecult.org/healthz` returns 200 with a verified certificate. `/.well-known/openid-configuration` returns 404 because the live app (`67072b2`) predates the issuer, so the token path is untested. **Re-run when the issuer ships**, adding discovery, a `client_credentials` token request and one authorised receiver call; and run `curl -sI https://bifrost.gamecult.org/healthz` once from inside a Persona turn, which has not been tested. No Mind-to-Bifrost HTTP call exists in the repo except the signed intake POST (`Program.cs:768`).
- **Install step:** root creates `/var/lib/gamecult/bifrost/feedback-receiver/` as `bifrost:bifrost` 0750 (section 8).
- **Retention: 56 days**, stated in the notice.
**Dependencies and unbuilt things:**
- **`gamecult.bifrost.account.v1` and `Membership.Role` are unbuilt** (PR #9, design only at last read). Without them `kind` is derived from `Status`, and no guest exists.
- **The Huginn kinds are Eureka's.** PR #24 names `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`, owned by
`huginn-feedback-kinds-proposal.md`, which I could not read. PR #24 also notes the proposal's `feedback_answer` lists a `content_hash` that must go.
Their dependency order is Huginn readers, then the Persona organ, then this receiver, so nothing here can ship first.
- **The Persona side must learn the three feedback kinds** and pull them over HTTPS with its bearer (section 2); the heartbeat that polls the mouth is outside this repo and is not the carrier.
- **Deploy is behind main.** The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`, read at an earlier main; main has since added the image path, reply links, `CHAT_FETCH`
and the v3/v4 pending answers. The deployed web app is `/srv/bifrost/app/current` at `67072b2`, which predates the desk, the chat-tier issuer change and the consent gate on main. I did not re-read `/srv/bifrost` for this pass. Anything in this design runs on a release that does not exist yet.
- **Unread:** Eureka's receiver notes in #minds, `huginn-feedback-kinds-proposal.md`, MAS's side of the session, and Heimdall PR #6 (merged, `docs/answer-collector-consent.md`; it drops a sender with no linked member at l.197 and does not mention guests; the page's "guest answers are collected for nothing but delivery" is unchecked against it).
- The receiver writes nothing to Matrix and nothing to Huginn.

300
docs/guest-accounts.md Normal file
View file

@ -0,0 +1,300 @@
# Guest accounts
Status: **design, nothing implemented.** Written for `eureka-body:follow_up:bifrost-guest-accounts` under
`eureka-body:ruling:guest-arrival-bifrost` (Meta: guests need no Matrix account of their own; they get a GameCult
account through Bifrost with a guest role, and move to member, user or patron on the same account seamlessly),
building on `eureka-body:ruling:bifrost-owns-accounts-invite-only`. I could not `view` either ruling from the
Heimdall mind, so their text here is the follow-up brief's wording. Eureka's review (PR comment, 2026-11-07) checked
it against the rulings and found guest-arrival matches; the conflicts it found are folded in below. First guest:
sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant list is hers to name.
Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/Membership`, `Features/Heimdall`,
`Features/Oidc`, `Features/Motions`, `Features/ConceptVotes`, `Pages/App/Members`) and Heimdall `575be9c`
(`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`). File and line references re-read at Bifrost main `e58e953` and Heimdall main
`afa1b74` on 2026-11-12; where main moved the subject, the text says so. Nothing in this design has landed on main.
## Rulings of the steward (Heimdall, 2026-11-12)
These four are the Bifrost steward's; they replace the recommendations further down. Eureka's, Aqua's and Meta's items are not ruled here.
1. **Name and shape: a stored `Membership.Role` (enum `AccountRole`), not a boolean.** One stored field is one reading, and an account with no role is denied by default (least privilege). `MemberRole` keeps its name and meaning; renaming it is not needed, and nothing existing is removed.
2. **Invite: a single-use code redeemed from a form, never a token in a URL.** The issuer is shown the code once; the guest pastes it into `POST /invite/guest` (a body, not a path or query), so it reaches no log, referrer or browser history. Hashed at rest, seven days, one use, receipted on issue, accept and revoke. The issuer may bind `ExpectedProvider` (optional, narrows the invite). The sign-in return path carries only the Heimdall attempt, never the code.
3. **Provider lists: Bifrost narrows to the intersection with Heimdall's `bifrost` profile, `discord, patreon`; no widening.** Twitch cannot start in Heimdall, so dropping it removes nothing live; it logs a deprecation warning for one release before the entry goes (deprecate before remove). Google stays link-only (PRs #81, #82) and becomes a sign-in provider only when an issued invite needs it, by a profile change in Heimdall first. Which provider sletz uses is still the issuer's to learn.
4. **Collector: a guest has no `member_id` and none of their messages are collected.** A sender with no linked member is already dropped (`docs/answer-collector-consent.md` l.197 in Heimdall). A `member_id`, and a consent record under it, exists only on promotion to `member`.
## The shape in one paragraph
A guest is a `UserAccount` like any other: same `Id` (the OIDC `sub`), same `NormalizedBifrostIdentity` (the Matrix
localpart, minted once). What is new is one field, `Membership.Role`, with four values: `guest`, `user`, `patron`,
`member`. Role is the single place that says what an account may do. A role change rewrites that field and writes a
receipt; it never creates a second account, never re-mints the identity, so chat history, grants and the Matrix
user stay attached.
## What exists today
- `UserAccount.Id` is the OIDC subject; `NormalizedBifrostIdentity` is `preferred_username` and, through MAS, the
Matrix localpart (`docs/oidc-issuer.md`). `HeimdallAccountId` is only an authenticator handle.
- `Membership.Status` is `Authenticated | PendingApproval | Active | Suspended`. `CurrentBifrostActor.IsActiveMember`
(`CurrentBifrostActor.cs:17`, `Status == Active`) is the one test behind `/App`, bridge transport (`BridgeActionService.cs:492`), motion votes
(`Membership.EffectiveVotingWeight`, `BifrostModels.cs:361`, read at `MotionGovernanceService.cs:72`), concept votes (`VoterIdentity.For`,
`VoterIdentity.cs:20`, member versus account class), Mind moderation (`MindIntakeService.cs:241`) and the fork desk's subject check
(`OidcEndpoints.ActiveMemberIdAsync`, `OidcEndpoints.cs:183-194`). The OIDC chat tier is not behind it: main lets any account that is
not `Suspended` and holds a live training-notice receipt sign in to chat (`OidcEndpoints.cs:55-65` and `ChatRefusalAsync`, `:164-180`,
called at `:127` and `:144`; `docs/oidc-issuer.md` l.72-77), and the back-channel logout reconciler (`OidcLogoutReconciler.cs`, 30 s
interval, l.34) ends sessions of suspended or deleted subjects, never of a chat-tier account for lacking membership.
- `MemberRole` (`StandardMember`, `Producer`, `Maintainer`, `LedgerReviewer`, `PlatformAdmin`) are capability
roles a member holds in addition. They are not the new field.
- Invites are `MembershipInvitation`, keyed by GitHub login (`MembershipSynchronizationService.cs:39-41`,
`Members/Index.cshtml.cs:69-71`). A guest may have no GitHub login at all.
- Heimdall sign-in (`HeimdallAuthService.StartAsync`) with `requireMemberAccess = false` already creates an
identity-only account at `Authenticated`; with member access it creates `Active`.
- Nothing exists for room grants, leaving, erasing, or any "estate" in code (estates are rulings and research
only, `docs/charter-amendment-proposal.md`).
## Decision: one role field, status stays lifecycle
Two authorities cannot both say "may this account vote". Today `Status == Active` says it. The cut:
- `Membership.Status` keeps its meaning as lifecycle of the account: may it hold a session. Add `Left` and
`Erased` (see Leave and erase). `Active` means admitted and in good standing, for any role.
- `Membership.Role` (new, non-null, enum `AccountRole`) decides rights. `IsActiveMember` becomes
`Status == Active && Role == Member`, and is the only place that expression exists. Every current
`IsActiveMember` caller keeps working unchanged and a guest fails all of them without a per-site edit.
- OIDC needs no change for guests: main already admits any non-`Suspended` account with a live training-notice receipt to chat (see
What exists today), so a guest, user or patron holds a chat session, and a member demoted to `Suspended` loses it. The change this
design owes is the two new statuses: `Left` and `Erased` must be refused at the same three places (`OidcEndpoints.cs:62`,
`ChatRefusalAsync` `:174`) and ended by the reconciler, which today looks for `Suspended` only. That is the line to review.
- Backfill: `Active` becomes `member`; `Authenticated` and `PendingApproval` become `user`; an account with a
current `Patron` tier snapshot and not `Active` becomes `patron`. Nobody starts as `guest`.
Naming risk: `MemberRole` already exists for capability roles. If the collision bites, call the new field
`Class`; the published field below follows whichever name is chosen. I use `Role` because that is the ruling's word.
Alternative considered and not recommended: a boolean `IsGuest` on `Membership` with guests kept at `Active`.
Smaller to land, but `user` and `patron` stay implicit in status and tier snapshots, so "the role field Personas
read" would have to be derived in three places. One stored field is one reading.
## Invite shape
New table `GuestInvitation`, separate from `MembershipInvitation` (which is GitHub-login-keyed and leads to
approval, not to a guest). Fields:
| field | rule |
|---|---|
| `Id` | Guid. |
| `CodeHash` | SHA-256 of a 256-bit random code. The code itself is shown to the issuer once and never stored. |
| `IntendedIdentity` | the localpart the issuer picks (for example `sletz`), validated by `BifrostIdentityService.NormalizeIdentity`, reserved while the invite is open. Minted once on accept; a collision gets the usual `-2` suffix. |
| `Label` | free note for the issuer ("sletz, Faust developer"). |
| `RoomGrants` | the rooms to grant on accept (below), as a list of grant requests. |
| `IssuedByUserAccountId`, `IssuedAtUtc` | issuer must be a platform admin (`CanManageMembership`), as for member invites. |
| `ExpiresAtUtc` | default seven days. |
| `AcceptedByUserAccountId`, `AcceptedAtUtc`, `RevokedAtUtc` | one use; revoke before accept ends it. |
The code is a bearer: whoever redeems it first is the guest. That is the cost of not needing sletz's
provider handle in advance, and it is why the code is single-use and short-lived and why the accept is shown
to the issuer. Ruled over binding the invite to a provider handle (which needs a Discord or Google id the
issuer may not have); the issuer may still set `ExpectedProvider` (see Rulings). It is carried in a form body, never in a URL.
## Sign-in via Heimdall
1. Guest opens `/invite/guest`, pastes the code, and posts it. Bifrost checks hash, expiry, unused, unrevoked, and stores the invite id in
the existing Heimdall attempt (`HeimdallAuthService` protects attempt state with the data protector).
2. Bifrost starts Heimdall sign-in with `requireMemberAccess = false` (identity only, no entitlement policy).
Heimdall authenticates; it does not issue the identity (`docs/jurisdiction-map.md`).
3. On the callback, in one transaction: resolve `HeimdallAccountId`.
- No account: create `UserAccount` with `IntendedIdentity`, `Membership { Status = Active, Role = Guest }`, write
the room grants, mark the invite accepted, write the receipt.
- Account exists: the invite never lowers an account. A `member`, `patron` or `user` keeps their role; the grants
are added and the invite is spent. If the existing account is `Left` or `Erased`, refuse.
4. Bifrost issues its own cookie as for any account. From there the guest reaches chat through the one OIDC client.
Gap, drift found while reading, still true on main: Bifrost's `AllowedProviders` is `discord, patreon, twitch`
(`HeimdallAuthService.cs:37-42`), but Heimdall's `bifrost` app profile allows `discord, patreon, google`
(`app-profiles.ts:152`, `bifrostProfile`). Twitch cannot start in Heimdall and Google is refused by Bifrost as a sign-in provider.
Main's only Google path is the explicit link by an already signed-in member (`HeimdallAttemptPurpose.LinkAwaitGoogle`,
`HeimdallAuthService.cs:672`, PRs #81 and #82); it never signs anyone in, so it does not close the gap. A guest with
neither Discord, Patreon nor a Google account cannot sign in, and GitHub is not a Heimdall sign-in provider for
this app. Which provider sletz uses is unknown to me; align the two lists before the first invite.
## Guest rights
A guest may: sign in, hold a Bifrost cookie, hold a chat session through OIDC (after accepting the training notice, as any chat account does on main, `ConsentEndpoints.HasLiveReceiptAsync`, `ConsentEndpoints.cs:146`), see and edit their own profile
(`MemberProfile`), be in the rooms granted to them, and leave or erase (below). A guest may not:
- vote on motions: `EffectiveVotingWeight` is `0` for any role but `member` (today it is `Max(1, ...)` for
`Active` (`BifrostModels.cs:361-363`), which would give every guest weight 1; that line must read the role);
- cast concept-mill appraisals: `VoterIdentity.For` returns no voter for a guest (not even `VoterClass.Account`);
- appear on any estate roll or in any tally. The charter proposal snapshots each estate's roll at notice (A6);
`guest` is on no roll, so a guest cannot be counted, cannot make a quorum, and reclassifying a guest after a
snapshot changes nothing for that snapshot;
- use bridge transport, claim or log work, earn points or ledger credit, or hold tier snapshots (those make them
a patron or member by changing role, not by decorating a guest);
- hold a `MemberRole` or any of the `CanManage*` flags;
- invite anyone.
This list is enforced by role in one place (`CurrentBifrostActor`), not by a check at each use.
## Per-room grants
Rooms are Matrix rooms in chat, not Bifrost objects. Bifrost owns the decision, so a grant is Bifrost state:
`GuestRoomGrant { Id, UserAccountId, RoomRef, GrantedByUserAccountId, GrantedAtUtc, ExpiresAtUtc?, RevokedAtUtc? }`
`RoomRef` is a room id or alias as chat names it. Aqua's `#aquasynth` is the first one if she says yes.
**Scope (per `guest-room-moderation-ruled`, `guest-room-authority-ruled`).** Project rooms are public-join and
hand-moderated by the steward, with Eureka as backstop, so a guest who can reach a public room needs no grant. A
grant exists only for a room its steward chooses to keep closed, and its grantor is that room's steward, not a
platform admin (the invite itself is still issued by a platform admin; `GrantedByUserAccountId` records the
steward). Room entry for public rooms is not Bifrost's decision.
The grant has to reach Matrix to mean anything. Today Bifrost's only Matrix writer is the Persona mouth, with
per-Persona access tokens (`docs/bridge.md`), and it holds no admin or invite right.
Gap (largest in this doc): something must perform "invite this Matrix user to that room" on a grant and "remove
them" on revoke or expiry. Candidates: a Bifrost-owned bot user that is a member with invite power in the granted
rooms (reuses the Matrix client API, no Synapse admin credential), or the Synapse admin API. I recommend the bot
user: narrower credential, and the room's power levels remain the real gate. Until it exists, the grant is a
record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Whether the closed rooms a
steward keeps are invite-only in Synapse is a chat configuration fact I have not verified.
What Matrix takes from the Bifrost account is exactly this effector: invite or remove on a grant, and the power
level the effector sets on invite. Role never enters OIDC, so Matrix reads no role from the issuer
(`bifrost-owns-accounts-invite-only`: grants flow from the account through the effector, not through claims).
**Guest DMs (`guest-dms-ruled`) conflict, open.** The mouth declines invites from other servers and accepts
same-server DM invites (`tools/persona-matrix-mouth.mjs` `answerInvites`). The ruling's decline-for-now assumed
federated guests; a Bifrost guest is a local `@sletz:<server>`, so the mouth would accept their DM. Either the mouth
declines DMs from role `guest` (using the account document below), or the ruling is revisited. That is Eureka's and
the ruling's owner's call, not this doc's; until it is made, the mouth's behaviour is the ruling's opposite for local
guests.
Second gap: a guest has a full Matrix account once MAS provisions the localpart, so Matrix itself will let them
create rooms or DM unless Synapse's room-creation and invite settings (`room_list_publication_rules`,
`allow_public_rooms_*`, a spam-checker or module) say otherwise. These are global in Synapse, not per user, so a
guest limit may have to be power levels in every room they are in. I did not find a per-user switch.
## Role change on the same account id, with a receipt
`ChangeRole(accountId, to, actor, reason)` is the only writer of `Membership.Role`. It runs in one transaction:
1. refuse `Erased` and `Left`; refuse a no-op;
2. write `Role`, and for `member` also set `Status = Active`, `ApprovedAtUtc`, and the default `StandardMember`
capability role (the same effects as `OnPostApproveAsync`); for `patron`, nothing beyond the role (tier
snapshots come from support events as before);
3. write `AccountRoleReceipt { Id, UserAccountId, From, To, ChangedAtUtc, ChangedByUserAccountId, Reason }`;
4. leave `UserAccount.Id`, `NormalizedBifrostIdentity`, `HeimdallAccountId`, grants and profile untouched.
Who may change: a platform admin (`CanManageMembership`). Promoting to `member` is the existing approve path;
this design routes it through `ChangeRole` so one writer exists. Promoting to `member` also applies whatever the
charter says about joining an estate (not decided; see Questions). Demotion (member to guest) is allowed and
receipted; it also removes the `MemberRole`s and capability flags in the same transaction.
The receipt is a Postgres row today, like `AuditEvent`. Bifrost's rule is that durable state has a CultCache `.cc`
witness (`docs/jurisdiction-map.md`, Design Rules); the witness is owed (Gaps).
Seamless means: the guest's session cookie, OIDC subject, Matrix id, display name and room memberships do not
change on promotion. A cookie issued before promotion carries identity claims only, never the role, so it needs
no refresh; the actor is read from the database each request (`CurrentBifrostActorAccessor`).
## Leave and erase
Neither exists in Bifrost today. Two different acts:
**Leave** (guest or any role, self-service): sets `Status = Left`, revokes every grant, and expels the account
from granted rooms through the same effector as grants. Once the reconciler and the chat refusals name `Left` (see Decision),
it ends the MAS session within its 30 s interval, and the token and userinfo endpoints refuse. The account row and
`Id` stay, so a later invite can restore it (a `Left` account re-accepting is a role change from itself, receipted).
Nothing else about the person is deleted by leaving.
**Erase** (self-service, or admin on request): irreversible. In one transaction: blank `DisplayName`, `AvatarUrl`,
`MemberProfile`, `GitHubLogin`/`GitHubUserId`, clear `HeimdallAccountId` (the authenticator link), set
`Status = Erased`, keep `Id` and `NormalizedBifrostIdentity` as a tombstone so the localpart is never reissued to
someone else (OIDC says it is minted once), keep receipts but with names stripped. The reason to keep a tombstone
row is that receipts and audit events point at the id.
An erasing guest has no votes, ledger entries or points, because guests cannot create them. A member or patron
who erases does, and those are accounting records: their erase is a separate question (Questions). Guest erase
is the case this design covers.
Outside Bifrost, erase is incomplete without two effectors:
- Matrix: Synapse's deactivate-with-erase admin call and MAS deactivation for the user. Federation is disabled in
the chat map (`gamecult-ops/docs/gamecult-chat-map.md`: federation paths 404), which helps: messages are
not copied to other servers. Other members' clients, and any export or training copy, are not reached by an
erase. Whether chat content is training data is `heimdall-body`'s consent rulings (Heimdall PR #6), which already
define exit by redaction; a guest's messages follow whatever that doc says, not this one.
- Heimdall: I found no account-delete or unlink path in Heimdall `src`. Clearing `HeimdallAccountId` on the Bifrost
side leaves the Heimdall account and its provider link alive. Heimdall needs an erase call keyed by the account id
before "erased" is true end to end.
## What Eureka reads
Personas read the role, not the account. Do not put it in OIDC: the issuer promises "no membership detail leaves
Bifrost" (`docs/oidc-issuer.md`) and chat does not need it. Publish a typed CultMesh document under the
`gamecult.bifrost.governance` family (the nearest existing schema namespace, `verse-service-contract.md` l.60), id
`gamecult.bifrost.account.v1`, key = account id:
| field | type | note |
|---|---|---|
| `account_id` | uuid | OIDC `sub`; never changes. |
| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart. |
| `matrix_id` | string | the full mxid `@<identity>:<server>`, server name taken from MAS config at publish time so no reader assumes it. The lookup key for a Persona, which holds the Matrix sender id and not the account uuid. |
| `display_name` | string | blank once erased. |
| `role` | `guest` \| `user` \| `patron` \| `member` | the field Personas read. |
| `status` | `authenticated` \| `pending_approval` \| `active` \| `suspended` \| `left` \| `erased` | |
| `role_since` | UTC ms | from the latest `AccountRoleReceipt`. |
| `rooms` | list of room refs | only for `guest`: the live grants. Others get rooms by estate. |
Contract the document carries, not left to each reader:
- **Lookup.** The document is queryable by `matrix_id`.
- **Absence is `unknown`.** A sender with no document is not a Bifrost account (a bridge ghost or an unlinked user).
Readers must treat it as `unknown`, never default it to `guest`, `user` or `member`. This reverses today's Persona
reading (everyone unlisted is `member`), which narrows to the Bifrost `member` role.
- **Status gates the role.** Only `status = active` yields a usable role; `suspended`, `left`, `erased`,
`authenticated` and `pending_approval` yield none.
- **Not Bifrost accounts.** Operator, the Persona's own id and Minds are typed by the reader's config, which takes
precedence over the document. Minds are not Bifrost accounts and must not fall to `unknown`.
- **Freshness.** A reader loads the document when it builds each turn, never once at start, so a role change
applies from the next message; past messages keep the role they were read under. `role_since` lets a trace say
which role was read. Publisher bound: a role or status change is published within 30 seconds, the same bound the
OIDC reconciler already keeps for a non-`active` subject. No publisher exists, so this is the requirement it is
built to, not a measurement.
Bifrost does not decide Persona behavior; it states the role.
No such publisher or witness exists; Bifrost has no `.cc` export for accounts today.
## Cut sequence (not specs)
1. `Membership.Role`, backfill, `IsActiveMember` and the OIDC tests moved to the new reading, vote and concept-vote
exclusions, with tests that fail if a guest gets weight or a voter key.
2. `GuestInvitation`, the accept path, the Heimdall provider list aligned.
3. `AccountRoleReceipt` and `ChangeRole`, with `OnPostApproveAsync` routed through it.
4. `GuestRoomGrant` and the Matrix effector.
5. Leave, then erase (Bifrost side), then the Matrix and Heimdall effectors.
6. The account document and its `.cc` witness.
## Gaps, plainly
- The Matrix effector for grants, leave and erase does not exist, and no credential for it is provisioned.
- Synapse cannot cap what a guest does per user; room-creation and invite limits are global (unverified: I did not
read the live Synapse config).
- Heimdall has no erase path (re-checked at `afa1b74`: no account-delete or unlink route in `src`, and `docs/answer-collector-consent.md`, merged as Heimdall PR #6, redacts by exit and says nothing of guests). Bifrost and Heimdall disagree on provider lists today.
- No `.cc` witness or CultMesh publisher for accounts or receipts. Bifrost's account state is Postgres only.
- Matrix id shape (`@<identity>:<server>`) depends on MAS importing `preferred_username` as localpart; the OIDC doc
says so, I did not check the deployed MAS.
- Tie to the answer-collector consent doc (Heimdall PR #6, merged; `docs/answer-collector-consent.md`): a guest posting in a room may be posting to a sample
collector. That doc keys on `member_id`, and says a sender with no linked member is dropped (l.197). Whether a guest has a
`member_id`, and so whether their answers are collected at all, is not decided here. Default: not collected.
- Erase for member and patron roles (ledger and vote records) is unmapped.
## Questions for the deciders
1. **Name and shape**: ruled (stored `Role`), see Rulings.
2. **Bearer link or provider-bound invite?** Ruled (single-use code from a form), see Rulings.
3. **Which Heimdall provider will sletz sign in with?** The lists are ruled (see Rulings); this is the issuer's to learn before the first invite.
4. **`member_id` for the answer collector**: ruled (none for a guest), see Rulings.
5. **Does promotion to member join the Members estate immediately**, or does the charter's notice and snapshot
rule apply to the roll? The charter text is a proposal, not in force.

View file

@ -0,0 +1,18 @@
# Heimdall app secret from a file
`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the
`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile`
(`Heimdall__AppSecretFile`) names a file holding it instead.
- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`.
- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names
`Heimdall:AppSecretFile` and the path, never the secret.
- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value).
- Neither set: startup fails outside Development and Testing, naming `Heimdall:AppSecretFile` and `Heimdall:AppSecret`
(`StartupConfigurationValidator`). Development and Testing still start, with Discord and Patreon sign-in off.
Under systemd, pass the file with `LoadCredential=heimdall-app-secret:<root-owned file>` and
`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`.
Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at
startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`.

View file

@ -163,8 +163,8 @@ to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configu
and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
Without `BackchannelLogoutUri` none of this runs: suspension stops new sign-ins and token refresh (the authorize, token and userinfo
endpoints refuse a suspended account) but does not end a chat session that is already live, and the issuer logs one warning at
Without `BackchannelLogoutUri` none of this runs: suspension stops new chat tokens (the authorize, token and userinfo
endpoints refuse a suspended account; there is no refresh token, and MAS does not call userinfo) but does not end a chat session that is already live, which runs to its natural expiry, and the issuer logs one warning at
startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.

View file

@ -0,0 +1,190 @@
# How a Ratatoskr voting module authenticates to Bifrost (design, no code)
Status: proposal, 2026-11-10, written by Heimdall (Self) against `origin/main` 276f611 (sections 1-13, citations as first read); section 14 and the corrections marked "e8e8fcc" in sections 1-13 are re-read against `origin/main` e8e8fcc. Decided 2026-11-11 by the steward of Bifrost for the read-only Desk feed (section 14); the cast scopes (`votes.cast`, `desk.answer`) stay gated by D1, D2 and D4. Docs only; nothing here is built or deployed.
Labels: **FACT** = read at the cited `file:line` at the base named for that section or citation, 276f611 unless it says e8e8fcc (paths under `src/Bifrost.Web/` unless written otherwise). **INFERENCE** = my reading, not stated by code. **EXTERNAL** = OpenIddict/ASP.NET behaviour I know from its documentation and did not run here; each one needs a probe before the cut (section 11).
Companion reads: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Eyes, at 416f11a; its Program.cs/OIDC lines still hold at 276f611, only `Features/Motions/MotionGovernanceService.cs` changed in between, in the close path), `heimdall-ratatoskr-sso-plan*.md`.
## 1. The problem in four facts
- FACT: a member's only Bifrost credential is the cookie `__Host-bifrost_session` (`Program.cs:189-200`; `Features/Shared/BifrostCookies.cs:10`). `__Host-` makes it host-only: chat.gamecult.org never holds it.
- FACT: the member API carries no CORS: the single CORS policy is the anonymous tally GET (`Features/ConceptVotes/ConceptTallyExport.cs:8-27`), so a JSON POST from the chat origin is blocked at preflight.
- FACT: Bifrost is an OIDC issuer with exactly one client, MAS, confidential, secret basic auth only, scopes `openid profile` (`Features/Oidc/OidcServiceCollectionExtensions.cs:39-46`; `Features/Oidc/OidcClientSeeder.cs:35-51` is the one MAS descriptor; startup deletes every other client, loop `:63-75`, all at e8e8fcc). No OpenIddict validation handler is registered (grep for `AddValidation` under `src` returns nothing), so no access token authenticates any `/api/*` route.
- FACT: there is no JSON read or cast API for Standard motions (Razor form POST with 302 and no receipt: `Features/Motions/MotionGovernanceService.cs:47-86`, `Pages/App/Motions/Index.cshtml.cs`); sealed-ballot motions have JSON endpoints but no list (`Program.cs:718-763`).
- INFERENCE: the access token chat holds today is Matrix's, minted by MAS after MAS authenticates the person at Bifrost (SSO plan section D). Bifrost's own access token goes to MAS, never to the browser. PR #24's "the OIDC access token Bifrost already issues to chat" is therefore not true as written: this design makes Bifrost issue a token to chat directly.
- FACT (deployment): Bifrost has no `Bifrost__Oidc__*` variable live, MAS is disabled, Synapse is its own password authority (`heimdall-ratatoskr-sso-plan-live-read-20261110.md` section 1). Everything below is for after OIDC is enabled; `Bifrost:Oidc` absent means the provider surface is not mapped (`docs/oidc-issuer.md:37`).
## 2. Proposal in one paragraph
Ratatoskr's voting module is a second OIDC client of Bifrost: **public, PKCE (S256), no secret**, redirect URI exactly one URL on https://chat.gamecult.org. It runs authorization-code + PKCE in a top-level redirect to Bifrost (so the person's existing `__Host-` session cookie is used first-party; chat never sees it), receives an **access token for audience `bifrost-votes` and scopes `votes.read` / `votes.cast` (10 minutes for cast scopes; `desk.read`: see section 14)**, keeps it in memory only, and sends it as `Authorization: Bearer` to a **new `/api/votes` surface that accepts bearer tokens and refuses cookies**. CORS on exactly two route groups (`/connect/token`, `/api/votes`) allows exactly `https://chat.gamecult.org`, no credentials. Everything else in Bifrost keeps its current cookie-only behaviour. The Persona/Mind signed path is untouched.
## 3. Client registration
- A second client entry beside MAS, from configuration (`Bifrost:Oidc:Clients:Ratatoskr:{ClientId,RedirectUri}`), seeded by the same startup seeder. FACT the seeder today builds one descriptor and deletes all others (`OidcClientSeeder.cs:33-75` at e8e8fcc); it needs to build two and delete the rest.
- `ClientType = Public`, no `ClientSecret`; permissions: authorization endpoint, token endpoint, authorization_code grant, response type `code`, scopes `profile`, `openid`, `votes.read`, `votes.cast`. PKCE is already required server-wide (`OidcServiceCollectionExtensions.cs:39`).
- `RedirectUri` is one absolute URL, compared exactly (OpenIddict does exact match; EXTERNAL). No wildcard, no localhost entry in production configuration; a dev client is a separate config file never deployed.
- FACT the token endpoint accepts only `client_secret_basic` (`OidcServiceCollectionExtensions.cs:45-46` clears and sets the list). EXTERNAL/needs probe: a public client authenticates by `client_id` alone and OpenIddict still accepts it with that list; if it does not, add `none` to the list for public clients only.
- Not in scope: dynamic registration, implicit/hybrid flows, a client secret in the browser. The MAS client and its flow do not change.
## 4. Scopes, audience, what a token carries
| Scope | Grants | Never grants |
|---|---|---|
| `openid`, `profile` | `sub` = `UserAccount.Id`, `preferred_username`, `name` (FACT `OidcEndpoints.cs:79-84`, `docs/oidc-issuer.md:55-61`) | email, Heimdall id, membership detail |
| `votes.read` | read motions the member may see, their own ballot status, results after close | other members' sealed ballots; weights of other members |
| `votes.cast` | cast/change a Standard vote; sealed commit and reveal for the token's own `sub` | anything in the next list |
Audience: access tokens for these scopes carry audience `bifrost-votes` (INFERENCE: OpenIddict `SetResources`/audience per request; EXTERNAL detail). `/api/votes` requires the audience **and** a vote scope. This stops the MAS client's access token (same issuer, same signing key, scopes `openid profile`) from being accepted on `/api/votes`, and a Ratatoskr token from being accepted by anything else.
**A token may never:** grant or change a role (`RoleAssignments`); set, read-modify or choose a weight (weight is `Membership.EffectiveVotingWeight` computed server-side at cast, `Domain/BifrostModels.cs:360-364`; the API request has no weight field and the receipt does not echo it); open, close or edit a motion; register or revoke Mind keys (`Program.cs:754-763`, moderator, cookie only); reach `/App/*`, `/eve/*`, `/connect/userinfo` beyond the three profile claims; mint another token (no refresh token, `docs/oidc-issuer.md:26`; no token-exchange); act for a different account (the voter is always `sub`, never a body field). These are enforced by structure, not by a deny list: the vote endpoints take the account from the principal and expose no parameter for the rest.
## 5. The bearer surface
- Register OpenIddict validation (local server) as a **named, non-default** authentication scheme `BifrostBearer` (the desk's credential is built on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; keep that name, and read `BifrostBearer` as the role). FACT the default scheme is Cookie (`Program.cs:189-200`), so every existing endpoint stays cookie-only and a bearer token cannot reach `/App`, `/api/sealed-motions` or `/eve` (a test pins it).
- New group `app.MapGroup("/api/votes")` with a policy `ActiveMember + scope + audience` whose `AuthenticationSchemes` is `BifrostBearer` only. A request that also carries the Bifrost session cookie is judged on the bearer alone, so the surface has no ambient credential: this is what removes CSRF from it (section 9).
- **Actor resolution is a new job.** FACT `CurrentBifrostActorAccessor` builds the actor from cookie claims (`BifrostIdentity`, `HeimdallAccountId`, GitHub `NameIdentifier`) and returns Anonymous when none are present (`Features/Shared/CurrentBifrostActorAccessor.cs:33-46`). A bearer principal has only `sub`, so existing services would see an anonymous actor. FACT `OidcEndpoints.ActiveMemberIdAsync` already resolves by `sub` and re-reads membership (`OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`; e8e8fcc). Proposal: one accessor path "principal with `sub` and `BifrostBearer` → load `UserAccount` + `Membership` by id", reusing that reading, and the `/api/votes` handlers call the existing services (`MotionGovernanceService.CastVoteAsync`, `SealedBallotService.CommitAsync/RevealAsync`) with that actor. No second vote implementation.
- Endpoints (names are a proposal, shapes belong to the cut spec): `GET /api/votes/motions`, `GET /api/votes/motions/{id}`, `PUT /api/votes/motions/{id}/ballot` `{choice, comment?}` (Standard), `POST …/commit` and `…/reveal` for sealed, `GET …/result`. FACT today's Standard cast returns silently when the motion is not open and throws on an unknown id (`MotionGovernanceService.cs:57-61`); the API wraps it with typed errors (404, 409 not open) rather than copying that.
- Errors are JSON 401/403/404/409/429, never a 302 to a sign-in page (FACT the cookie challenge redirects, `Program.cs:197`; a `fetch` client must get a status it can act on, e.g. 401 with `WWW-Authenticate: Bearer error="invalid_token"`).
- Persona/Mind path: FACT `POST /api/sealed-motions/{id}/mind` is anonymous, Ed25519-signed, rate-limited at 30/min/source and 4096 bytes (`Program.cs:767-809`). Unchanged. Minds never use this bearer flow; `/api/votes` has no Mind route, and a Persona's account is not a Ratatoskr login.
## 6. The flow (browser)
1. Ratatoskr voting module starts login: generates `state`, `nonce`, PKCE verifier/challenge (S256), stores them in `sessionStorage` for the one tab, navigates to `GET /connect/authorize` with `client_id`, exact `redirect_uri`, `scope=openid profile votes.read votes.cast`, `response_type=code`.
2. FACT authorize without a session challenges to `/auth/sign-in` and resumes via a short `request_uri` (`OidcEndpoints.cs:44-51`); with a session it admits any account that is not Suspended and has an identity (chat tier; membership is neither required nor written), otherwise `access_denied` (`OidcEndpoints.cs:53-70`). So the sign-in leg does not prove Active: the `/api/votes` and `/api/desk` surfaces re-check Active on every request, which is what makes this safe. Heimdall sign-in and the Bifrost session happen on the Bifrost host exactly as for MAS.
3. For a chat token without a live notice receipt (a desk or vote token needs none: the receipt gates the chat token only, Meta's ruling 2026-11-11), Bifrost first redirects to its training-notice consent page and resumes the parked request (`OidcEndpoints.cs:99-102`, e8e8fcc; not a grant to the client). Then, for cast scopes only, the per-client grant screen (section 7, D1); `desk.read` alone has none. Then 302 to `https://chat.gamecult.org/<redirect path>?code=…&state=…`.
4. The module checks `state`, immediately removes `code` and `state` from the address bar (`history.replaceState`), POSTs `/connect/token` (form: `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `redirect_uri`) cross-origin. FACT the token endpoint re-reads the account and refuses with `invalid_grant` when `ChatRefusalAsync` says so: account missing, Suspended, or no live notice receipt (`OidcEndpoints.cs:107-120`, `ChatRefusalAsync` `:151`). It does not test Active; the per-request Active check is the new surfaces' own (section 14 D3).
5. The access token lives in a JS variable. Not `localStorage`, not `sessionStorage`, not IndexedDB. A page reload logs the module out of Bifrost votes (not out of chat) and the next vote action restarts step 1; with an existing Bifrost session it is two redirects and no typing.
INFERENCE on re-auth without refresh tokens: FACT none exist (`docs/oidc-issuer.md:26`). EXTERNAL: `prompt=none` in a hidden iframe is dead (third-party cookie rules), so renewal is a top-level redirect. That is the cost of a no-refresh-token design; see D3.
## 7. Consent screen: needed, because one hop is cheap and a vote is not
- FACT (e8e8fcc) there is no per-client grant consent: authorize redirects to `ConsentEndpoints` when the member has no live receipt (`OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`), but that is the training-notice receipt, a once-per-notice-version agreement independent of any client, not a grant to Ratatoskr. "Consent" below means the per-client grant unless it says notice. MAS needs no grant screen (it is the chat's own login service).
- Reasoning (INFERENCE): `votes.cast` lets whoever holds the token change the member's recorded vote. The member is already redirected to Bifrost, so a consent step costs one click and makes the grant visible and revocable in Bifrost, which is the only place it can be. Recommend **explicit consent for `votes.cast`, implicit for `votes.read`** (OpenIddict `ConsentType` per client or per authorization; EXTERNAL).
- Page content: client name ("Ratatoskr, chat.gamecult.org"), the account being used, the two powers in plain words ("see motions and your own ballots", "cast and change your votes"), Allow / Deny, a note that the grant lasts until revoked and tokens last 10 minutes (cast scopes). Served with `frame-ancestors 'none'` and `X-Frame-Options: DENY` (clickjacking). The consent wording for the chat side is `ratatoskr-consent-wording-draft-20261109.md` and is separate from this one.
- A stored authorization (OpenIddict permanent authorization) lets the next login skip the consent page; it is the object revoked in section 8.
## 8. Token lifetime, revocation, receipts, rate limits
**Lifetime.** FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 min, `OidcServiceCollectionExtensions.cs:47`); INFERENCE/EXTERNAL OpenIddict's defaults apply (about 1 hour for access tokens, 5 min for codes). Set explicitly: **access token 10 minutes for cast scopes (`desk.read`: 60 minutes, section 14 D3)**, authorization code 60 seconds, id_token short. No refresh token. Because every `/api/votes` call re-reads the account and membership (section 5), the lifetime bounds token theft, not demotion.
**Revocation.**
- Demotion is immediate: each request re-checks Active (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, e8e8fcc). A token held by a suspended member returns 403.
- Authorization cleanup: FACT `OidcLogoutReconciler` finds subjects who fail the chat-tier test (Suspended, account gone, or no live notice receipt) and hold authorizations of the MAS client and tells MAS (`docs/oidc-issuer.md:146-164`); it is MAS-specific (it needs `Clients:Mas:BackchannelLogoutUri`, `OidcLogoutReconciler.cs:180` at e8e8fcc) and nothing in it serves another client today. To be built: a derived-from-membership sweep that revokes the Ratatoskr/desk client's authorizations for non-Active subjects (no outbound POST needed; the client is ours). Membership is never written by it.
- Member-initiated revoke: needs a place to list and revoke grants. Not built; D4.
- Operator kill: with the seeder extended to more than one client, removing a client from configuration deletes it and its tokens at next start (the delete-others loop, `OidcClientSeeder.cs:63-75` at e8e8fcc, today spares only the MAS client); a runtime brake is D5.
**Receipts.** Every cast returns a JSON receipt, since Standard votes return none today (facts file section 5): `{motionId, choice, revision, castAtUtc, receiptId}`; sealed commit/reveal keep their existing receipt (`SealedBallotReceipt`, `SealedBallotService.cs`). Receipt contains no weight. The audit trail already records casts (`MotionGovernanceService.cs:86`); the audit row gains the client id and `jti` so a vote can be traced to the grant that cast it (INFERENCE: this is what "receipts" is for after an XSS: the member can see which client cast it).
**Rate limits.** FACT none on the member API (facts file section 5); the primitives exist: `FixedWindowBudget` keyed by account (`Features/ConceptVotes/ConceptVoteRateLimits.cs:14-24`) and a 429 + Retry-After pattern (`Program.cs:~773-774`, e8e8fcc). Proposal: per-account budgets — casts 30 per 10 min (the Mill's number), reads 120 per minute; token endpoint per source address; 429 with `Retry-After`. Numbers are D6.
## 9. Threat list
| Threat | Why it matters here | Answer |
|---|---|---|
| **Token theft by XSS in chat** | Ratatoskr renders other people's Matrix messages; a script in the chat origin can read an in-memory token or call the module's own functions | Token memory-only, 10 min (cast scopes), scopes limited to votes, audience-bound, no refresh. Honest limit: an XSS can **cast during the page's life** whatever the token allows; shortening and scoping bound it, they do not stop it. Mitigations to require: strict CSP on chat.gamecult.org, message sanitisation, and an in-module confirmation step for `votes.cast` that is rendered by the module itself (Ratatoskr's cut). D2 asks whether casting should demand a fresh re-authentication (`prompt=login`, `max_age`) for high-stakes motions. Sealed motions are better off: the salt is generated and held client-side, but a compromised page holds it too. |
| **CSRF** | A third-party page makes the member's browser vote | `/api/votes` accepts no cookie (section 5), so a forged cross-site request carries no credential. CORS has no `AllowCredentials`, so a credentialed cross-origin read is unreadable. The cookie surfaces keep their current protection (FACT: Razor antiforgery, `Index.cshtml:127,139`; the sealed API relies on SameSite and JSON binding, `Program.cs:718-763`) and are untouched. |
| **Authorization-code interception / injection** | Code arrives in a URL on the chat origin | PKCE S256 required (FACT `:39`), exact redirect match, codes single-use and 60 s, `state` checked, URL cleaned at once, `Referrer-Policy: no-referrer` on the callback page. |
| **Login CSRF / mix-up** | Attacker finishes a flow with their own code | `state` bound to the tab, `nonce` checked in the id_token, one issuer only. |
| **Replay** | A captured request resent | Token expiry; codes single-use; Standard cast is `PUT` and idempotent by `(motion, sub)` (FACT one row per member per motion, `MotionGovernanceService.cs:64-85`); sealed commit/reveal already carry `revision` and phase checks; the commitment binds the voter id (`docs/sealed-ballot-motion.md:22-26`). Replaying the same cast cannot double a vote. |
| **Token substitution** | MAS-bound token used at `/api/votes`, or a Ratatoskr token elsewhere | Audience + scope + named scheme (sections 4-5). |
| **Open redirect / redirect abuse** | | One exact redirect URI; Bifrost local return paths unchanged (FACT `Features/Shared/LocalReturnPath.cs:13-31`). |
| **Overreach by a demoted or compromised account** | Membership changes after issue | Per-request Active check; sweep revokes authorizations. |
| **CORS misread as security** | CORS stops browsers only | Not relied on for authorisation; it is an availability measure. Tokens authenticate; non-browser callers need a token. |
| **Consent phishing** | Look-alike client asks for `votes.cast` | One registered client; consent page names it and shows the redirect host. |
| **Scraping / vote spam** | | Rate limits (section 8); no list of other members' ballots exists. |
| **Clickjacking of consent/sign-in** | | `frame-ancestors 'none'` on Bifrost auth pages. |
| **Logout gap** | The Bifrost session ends but a token lives on: 10 min for cast scopes; up to 60 min for `desk.read` (section 14 D3) | Accepted; bounded by lifetime and the per-request Active check. For `desk.read` the Bifrost session can end while the token keeps feeding `/api/desk` for up to 60 minutes; accepted because the feed is read-only and every request re-checks Active. |
## 10. CORS, exactly
- A **second named policy**, separate from `ConceptTallies` (FACT that policy reads `ConceptVotes:AllowedOrigins`, `ConceptTallyExport.cs:13-27`, so reusing it would couple two features' origin lists): `WithOrigins("https://chat.gamecult.org")`, methods `GET, POST, PUT, OPTIONS`, headers `Authorization, Content-Type`, **no `AllowCredentials`**, no exposed headers except `Retry-After`, preflight cache bounded. Origin comes from configuration as one exact value; no wildcard, no subdomain pattern, no `null`.
- Applied by route metadata to `/connect/token` and `/api/votes/*` only. `/connect/authorize` is a top-level navigation and needs none; `/.well-known/*` is not fetched by the module (the client config is static). Every response under those routes carries `Vary: Origin` (the pattern the tally export already follows).
- Bifrost still sends no CORS headers for `/api/sealed-motions/*`, `/App`, `/eve`, `/connect/userinfo` (the facts file's blocker 1 stays closed there on purpose).
## 11. Probes owed before the cut spec (each is an EXTERNAL claim above)
1. A public client with no secret completes code exchange when `ClientAuthenticationMethods` holds only `client_secret_basic`.
2. Per-client explicit grant consent (D1) works with the request-caching path (`request_uri`) used at `OidcEndpoints.cs:44-51`, alongside the existing notice-receipt redirect (`:99-102`, e8e8fcc), which parks and resumes the same request.
3. Audience and scope reach the access token and the validation handler enforces them; the MAS client's token is refused at `/api/votes`.
4. Default token lifetimes before the explicit settings.
5. OpenIddict validation as a non-default scheme leaves cookie-default endpoints unchanged (test: bearer on `/App` and `/api/sealed-motions` is 302/401, as now).
6. CORS preflight on `/connect/token` through OpenIddict's passthrough (the endpoint is mapped as a minimal API at `OidcEndpoints.cs:35`).
7. (owed by D3) Per-audience lifetimes: `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` set at authorize give a `desk.read`-only request a 60-minute access token and a cast-scope request 10 minutes, and the token's `exp` shows it.
## 12. Questions as raised (decided in section 14)
Decided 2026-11-11 by the steward of Bifrost: D1-D7 below are answered in section 14; the questions are kept as asked and are not open.
For **Meta**:
- D1 Is explicit consent for `votes.cast` wanted (recommended) or is a first-party client implicit?
- D2 Should a vote on a high-stakes motion demand re-authentication (`prompt=login`) at cast time? Recommend yes for sealed commit and for any motion whose result moves money or roles; no for ordinary reads.
- D3 Accept that a 10-minute cast token with no refresh means a redirect round-trip to keep voting, or allow a refresh token (it widens theft impact; I recommend against).
For **Eureka** (platform/ops, MAS and deploy order):
- D4 Where does a member list and revoke grants (a Bifrost `/App` page)? Needed before `votes.cast` ships if consent is explicit.
- D5 Is a runtime brake wanted on the Ratatoskr client (disable without redeploy), and who holds it?
- D6 Rate-limit numbers, and whether they belong in configuration (recommended) or code.
- D7 Order against the MAS rollout: this design needs Bifrost OIDC live (it is not, section 1), and the two clients can ship separately; do voting tokens wait for MAS, or does voting go first as the first OIDC consumer? Recommend independent; the collision-and-import hazards in the SSO live read concern MAS only.
For **Ratatoskr (Void)**: the module owns the CSP, the in-module confirmation step, the callback page behaviour (section 6) and memory-only storage; this doc cannot enforce them from Bifrost's side.
## 13. What this deliberately does not do
No new identity: `sub` stays `UserAccount.Id`. No change to Heimdall, MAS, or the Mind key path. No weights or roles in tokens. No second vote implementation: the bearer surface is a thin projection of the existing services. No cookie sharing between chat and Bifrost, and no proxy on the chat origin pretending to hold one.
## 14. Decision record (steward, 2026-11-11)
Decided by the steward of Bifrost (the Persona), recorded by Heimdall (Self). Scope: the first consumer is the read-only Desk feed (scope `desk.read`, audience `bifrost-desk`, `docs/desk-feed-design.md`, draft PR #67), not votes. Each decision is the least-power, most-inspectable option that keeps exit (the member or operator can end it) and receipts (the act can be traced afterwards). Numbering: D1-D7 here are this document's (D1 consent, D2 re-auth, D3 token lifetime, D4 grant page, D5 brake, D6 rate limits, D7 ordering vs MAS); the "D1: `options_hash` covers everything shown" of the fork-desk answer-binding handoff is a different ruling and is not decided here.
**D1 consent (per-client grant).**
- Decision: explicit per-client grant consent stays REQUIRED for any scope that can cast or answer (`votes.cast`, `desk.answer`), as recommended in section 7. `desk.read` (read-only, the member's own account) needs no per-client grant screen: implicit. Two senses of "consent" exist and are kept apart: the training-notice receipt (every authorize already redirects to `ConsentEndpoints` without a live receipt, `OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`, e8e8fcc), which applies to all clients and is unchanged; and the per-client grant, which does not exist yet.
- Reasons: FACT (e8e8fcc) there is no per-client grant consent today: the only consent step is the notice receipt; a `desk.read` grant changes nothing, so a click would buy no protection; a cast changes a recorded vote or answer, so the member must see the grant.
- Keeps: exit is the revocable stored authorization (D4); receipts are the audit row carrying client id and `jti` (section 8).
- Probe owed: 2 (section 11), the grant screen beside the notice redirect on the parked-request path.
- Holder: the steward of Bifrost.
**D2 re-authentication.**
- Decision: yes (`prompt=login` at cast time) for sealed commit, and for answering or casting on any motion or fork whose result moves money or roles. Not needed for `desk.read`.
- Reasons: a stolen 10-minute token must not be enough to move money or roles; a read-only feed has nothing to protect that a fresh login would add.
- Keeps: exit, because the member re-proves the account each time; receipts, because the cast receipt follows a fresh authentication.
- Holder: the steward of Bifrost.
**D3 token lifetime and refresh.**
- Decision: NO refresh token, anywhere. Access token 10 minutes for the cast and answer scopes; 60 minutes for the read-only audience `bifrost-desk`. Authorization code 60 seconds. One client may request both audiences; the lifetime follows the requested scope set: a request containing any cast or answer scope gets 10 minutes, a `desk.read`-only request gets 60.
- Reasons: FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 minutes, `OidcServiceCollectionExtensions.cs:47`, e8e8fcc), so the explicit values replace OpenIddict defaults. Every `/api/desk` request re-checks Active membership (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`), so lifetime bounds only theft of a read-only credential, not demotion; a 10-minute token would force a redirect round trip into a polling feed. Not to be confused with `ChatRefusalAsync` (`OidcEndpoints.cs:151`), which the token and userinfo endpoints use for chat subjects and which refuses a Suspended account or one without a live notice receipt, not a non-Active one. A refresh token would outlive the redirect and widen theft impact.
- Per-audience lifetime is not built or proven: it would be set with `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` at authorize. Probe 7 (section 11) is owed before the cut.
- Keeps: exit, because demotion is immediate and the sweep (to be built, section 8) revokes grants of non-Active subjects; no long-lived secret exists to leak. Accepted gap: the Bifrost session can end while a `desk.read` token feeds `/api/desk` for up to 60 minutes; acceptable for a read-only feed because every request re-checks Active.
- Holder: the steward of Bifrost.
**D4 grant list and revoke page.**
- Decision: a member-facing page in `/App` that lists and revokes grants is a BLOCKING condition for shipping any cast or answer scope. It is not needed for `desk.read`, which is ended by the derived-from-membership sweep and by the operator brake (D5). Nothing further is decided here.
- Reasons: explicit consent (D1) without a place to withdraw it is half a control; a read-only grant ends with the sweep and the brake. Neither exists for a second client today: the sweep is to be built (`OidcLogoutReconciler` is MAS-specific, section 8), and the brake covers only the three `/connect` endpoints (D5).
- Keeps: exit (the page itself) before any scope that can act.
- Holder: the steward of Bifrost (the page is Bifrost code).
**D5 runtime brake.**
- Decision: yes. Reuse the existing marker-file brake of PR #50 (commit 5201508; `OidcGuardMiddleware`, `OidcGuard.cs:32`, `BrakeEngaged` at `OidcGuard.cs:71`, option `Bifrost:Oidc:BrakePath`, `Configuration/OidcIssuerOptions.cs:26`, `docs/oidc-issuer.md` "Limits, prune, brake") rather than a new mechanism. It is held by the Bifrost steward (the Persona).
- The brake is NOT per-client. FACT (e8e8fcc) the guard matches `/connect/authorize`, `/token`, `/userinfo` for all clients (`EndpointOf`, `OidcGuard.cs:64-68`), so engaging it also 503s MAS sign-in; it is registered only when `Bifrost:Oidc` is configured (`Program.cs:294-297`) and `BrakePath` lives under `Bifrost:Oidc`. The code PR must extend the guard's matching to `/api/desk` and must settle a desk-only deployment (a host running the desk without `Bifrost:Oidc` configured has no guard and no brake path; either the desk requires `Bifrost:Oidc`, which it does for its tokens, or the brake option moves). Not decided here: that settlement is the code PR's to bring back.
- Reasons: FACT the guard is checked per request (no restart) and treats an unreadable marker as engaged; no second brake, no second owner.
- Keeps: exit for the operator (delete the file to release; discovery and keys stay up, as the issuer test asserts); receipts in the 503 `temporarily_unavailable` answers and the guard's log line.
- Holder: the Bifrost steward holds the brake. FLAG, not decided: where the marker file lives on the host is Eureka's (ops) to name.
**D6 rate limits.**
- Decision: the numbers live in configuration, defaults: `desk.read` 120 requests per minute per account; casts 30 per 10 minutes per account (the Mill's number) when they exist. The token endpoint keeps the existing per-address budget (`OidcRateLimit`, `OidcGuard.cs:102`): that is ONE budget shared by authorize, token and userinfo together (`docs/oidc-issuer.md:187-188`), not a token-endpoint budget of its own.
- Reasons: FACT the member API has none today; the primitive `FixedWindowBudget` keyed by account exists (`ConceptVoteRateLimits.cs:14-24`) and the 429 plus `Retry-After` pattern too (`Program.cs:~773-774`, e8e8fcc; `RefuseAsync` in the guard). Config keeps the numbers inspectable and changeable without a build. Per-client budgets stay out, as in `docs/oidc-issuer.md` (the client id is public).
- Keeps: exit via the brake (D5); receipts via the 429 answers. The desk cut map's open point (a 429 has no refusal code in the contract, Q3 there) is not decided here.
- Holder: the steward of Bifrost.
**D7 ordering against MAS.**
- Decision: independent. A second client is seeded only when `Bifrost:Oidc:Clients:Ratatoskr` is configured. This is a code change, not existing behaviour: FACT (e8e8fcc) the seeder builds one MAS descriptor and its delete-others loop (`OidcClientSeeder.cs:63-75`) removes everything else. Constraint on the code PR: under the MAS-less or default configuration it adds nothing observable (no new client, route, scope or discovery change), so window B (cut stays ab89556) is not widened. `desk.read` is the first consumer. It needs Bifrost OIDC live, as MAS does.
- Reasons: nothing in this client touches the MAS client, the Heimdall path or the Mind key path; "seeded only when configured, removed when no longer configured" is the behaviour to build, and the loop must be changed to spare configured clients rather than only MAS.
- Keeps: exit, because removing the configuration section deletes the client and its tokens at next start (once built); no receipts needed beyond the audit rows above.
- Holder: the steward of Bifrost.
**Decided with the feed.** The Desk feed never carries answerer account ids (the door withholds them from a non-decider, `eureka-substrate:ruling:desk-read-visibility-withheld-ids`; Bifrost masks nothing it cannot read, and this supersedes the Bifrost mask that closed X3 of `docs/desk-feed-design.md`).
**Flags to other stewards.**
- Eureka: the Huginn door list and read wire and its audience policy are written in Huginn's `docs/desk-feed-door.md` (Huginn main 79d954c or later, with golden fixtures); the host path of the D5 marker is still open. The listing gate that implements the audience policy is NOT live on Huginn main (cut `cut-desk-feed-listing`): until it lands, the door returns every showable question to every reader, so no Mind that holds private questions goes behind a live receiver.
- Libby (CultLib steward), ruled 2026-11-11: ForkCard gains `raised_at`, which stays OUT of `options_hash` (that hash keeps its five members: title, context, options, recommendation, depends, so digests are unchanged) and is a REQUIRED card key, an RFC3339 string or nil; the card goes from 16 to 17 keys and its hex changes. This closes X1 and X2 of `docs/desk-feed-design.md` (X2 withdrawn).
**Who sees which card: Huginn's door (Eureka), not Bifrost.** The policy is written in Huginn's `docs/desk-feed-door.md` ("Listing"), and this note holds no copy of it: a fork is shown to a reader who is not on the call's `deciders` only while a listing record for it is in force; a decider sees every showable fork; a hidden fork reads as `fork_unknown`, with the same bytes as a fork that does not exist. Bifrost applies no audience rule of its own: it relays exactly what the door returns. Reads carry `subject` and `deciders` (no `authority`; that is only on an answer), so anyone who must see unlisted forks is put on the Mind's decider list in Bifrost's admin. Bifrost adds three acts on the desk path: the per-request Active membership check, the `desk.read` bearer (audience `bifrost-desk`), and nilling notes for a non-decider; they sit beside the two already built (the note strip for non-deciders and the configured-Mind 404). Bifrost does NOT filter by audience, hold any per-card visibility rule, or keep a second copy of the policy (two copies would drift). The card is opaque to Bifrost (its `raised_at` key and order are being re-pinned in CultLib), so it is relayed as the door sent it.

View file

@ -137,9 +137,10 @@ to one member account and the account to that one persona (`MindPersonas`, uniqu
cannot share the account and overwrite the first's ballot, and a registration race loses on the index). The trust is the moderator's: Bifrost cannot tell whether a registered public
key really belongs to the Mind.
**What a Mind holds.** An Ed25519 private key (32-byte seed) at `/etc/gamecult/minds/<persona>/sealed-motion.key`, mode 0400,
owned by that Mind's own user, never in the mind store or a room. Eureka generates it on the host and prints only the public
half; a moderator registers it. **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot.
**What a Mind holds.** An Ed25519 private key (32-byte seed) at `/var/lib/mind-<persona>/sealed-motion.key`. The generator script writes it mode 0400 and the signer accepts 0400 or 0600.
It is owned by `mind-<persona>`, the Persona user, which is also the signer; never in the mind store or a room. Eureka generates it on the host and prints only the public
half. By rule, a moderator registers that public half only after the Persona shell, running as `mind-<persona>`, has signed a real `commit` envelope
(production domain `gamecult.sealed_intake.v0`, a throwaway motion and voter, never sent to Bifrost) that verifies against it (`scripts/verify-sealed-motion-key.sh` in gamecult-ops, which must print `PASS`). **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot.
**Signing.** `tools/sealed-motion-sign.mjs` (`pubkey`, `commit`, `reveal`) reads the key file, checks its type, mode and owner,
and prints the envelope; it posts nothing. `commit` also prints the ballot (candidate, reason, salt), which the Mind keeps in

View file

@ -41,7 +41,7 @@ asked again on their next chat sign-in.
(not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The
notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your
messages are not used"), and the consent page shows the person that their agreement is to an older version and
offers the new one. Since the chat gate, a bump also ends everyone's chat token until they agree (their next chat sign-in lands on that page). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no.
offers the new one. Since the chat gate, a bump also stops new chat tokens for everyone until they agree (their next chat sign-in lands on that page; a session already open runs to its natural expiry unless `BackchannelLogoutUri` is set, when the 30 s reconciler ends it). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no.
3. **Withdrawal had no CSRF check.** True. The session cookie's SameSite default is Lax, which already keeps a cross-site
POST from carrying it, but nothing in the handler relied on that. Both state changes (agree, withdraw) are now
form POSTs refused with 403 unless they came from the site itself (`RequestOriginGuard`, the strict form), before

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -18,6 +19,8 @@
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.Validation.ServerIntegration" Version="7.7.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
</ItemGroup>

View file

@ -14,7 +14,11 @@ public sealed class HeimdallOptions
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
public string AppSecret { get; init; } = string.Empty;
/// <summary>Deprecated: rides in the process environment. Use <see cref="AppSecretFile"/>; the file wins when both are set.</summary>
public string AppSecret { get; set; } = string.Empty;
/// <summary>Path of a file holding the app secret (systemd <c>LoadCredential</c> style: <c>%d/heimdall-app-secret</c>).</summary>
public string AppSecretFile { get; init; } = string.Empty;
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
@ -39,4 +43,37 @@ public sealed class HeimdallOptions
IsConfigured &&
EnablePatronSupportIntake &&
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
/// <summary>
/// Resolves <see cref="AppSecret"/> once, when the options are built. A set <see cref="AppSecretFile"/> must be a
/// readable, non-empty file, or startup fails; the message names the option and path, never a secret.
/// </summary>
public static void ResolveAppSecret(HeimdallOptions options, ILogger logger)
{
if (!string.IsNullOrWhiteSpace(options.AppSecret))
{
logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set.");
}
if (!string.IsNullOrWhiteSpace(options.AppSecretFile))
{
string secret;
try
{
secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n');
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
throw new InvalidOperationException(
$"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name}).");
}
if (string.IsNullOrWhiteSpace(secret))
{
throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty.");
}
options.AppSecret = secret;
}
}
}

View file

@ -4,9 +4,13 @@ using System.Text.Json.Serialization;
namespace Bifrost.Web.Features.Desk;
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
// (commit de8a062a) spells them: snake_case keys, every key written (nil where absent), declared in the
// (commit d3025d3c) spells them: snake_case keys, every key written (nil where absent), declared in the
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
// DeskAnswerDocument, ForkList and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
// The card (gamecult.fork.card.v0) is deliberately NOT typed: the Huginn door owns what a reader's card holds (the
// decider view carries ids, the non-decider view withholds them and keeps decider_count), and Bifrost neither reads
// nor edits it, so it is an opaque JsonElement that keeps every key, key order and integer. The door refuses a float,
// a binary or an extension value anywhere in a reply (the contract has none), so no number is ever rewritten.
public static class DeskSchemas
{
@ -53,32 +57,6 @@ public sealed record DeskUnavailable(
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
public sealed record ForkOption(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
public sealed record ForkRef(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("id")] string Id);
public sealed record ForkCard(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("options")] ForkOption[] Options,
[property: JsonPropertyName("recommendation")] string Recommendation,
[property: JsonPropertyName("depends")] string[] Depends,
[property: JsonPropertyName("leans")] string? Leans,
[property: JsonPropertyName("flag")] string? Flag,
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
[property: JsonPropertyName("deciders")] string[] Deciders,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("ruling")] string? Ruling,
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
[property: JsonPropertyName("order")] long Order,
[property: JsonPropertyName("options_hash")] string OptionsHash);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
@ -94,7 +72,9 @@ public sealed record DeskAnswerDocument(
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("received_at")] string ReceivedAt,
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
[property: JsonPropertyName("answerer")] string Answerer,
// Nil in a non-decider's view (Huginn withholds the ids). The key is required, and the door's wire walker (Closed in
// UnixSocketDeskDoor) is the one owner of that: a missing key is a door fault, never a nil Bifrost invented.
[property: JsonPropertyName("answerer")] string? Answerer,
[property: JsonPropertyName("authority")] string Authority,
[property: JsonPropertyName("request_id")] string RequestId);
@ -103,11 +83,12 @@ public sealed record ForkRecord(
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
[property: JsonPropertyName("answerer_count")] int AnswererCount,
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
public sealed record ForkEntry(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("card")] ForkCard Card,
[property: JsonPropertyName("card")] JsonElement Card,
[property: JsonPropertyName("record")] ForkRecord Record);
public sealed record ForkList(
@ -122,7 +103,7 @@ public sealed record ForkList(
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
[property: JsonPropertyName("card")] ForkCard? Card,
[property: JsonPropertyName("card")] JsonElement? Card,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).
@ -183,14 +164,3 @@ public static class DeskUnavailables
"unavailable: the outcome is not confirmed, try again with the same request_id",
retryAfterSeconds);
}
// Notes are returned only to deciders. For everyone else Bifrost nils every note at the door: the key stays written
// (every key written, nil where absent), so the stripped body is still the contract's shape. Cards, outcomes, codes
// and counts are untouched; Huginn returns the full record.
public static class DeskNotes
{
public static ForkEntry Strip(ForkEntry entry) =>
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
public static ForkList Strip(ForkList list) => list with { Forks = [.. list.Forks.Select(Strip)] };
}

View file

@ -4,6 +4,7 @@ using Bifrost.Web.Data;
using Bifrost.Web.Features.Oidc;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
@ -22,8 +23,30 @@ public static class DeskEndpoints
public const string AnswerScope = "desk.answer";
public const string CorsPolicy = "DeskCors";
public static IServiceCollection AddBifrostDesk(this IServiceCollection services)
public static bool IsDeskScope(string scope) => scope is ReadScope or AnswerScope;
// The desk's credential is OpenIddict validation against the local server, so it exists only where Bifrost:Oidc is
// configured. Without it nothing is registered here and startup fails in the validator, with its own message.
public static IServiceCollection AddBifrostDesk(this IServiceCollection services, bool oidcConfigured)
{
if (oidcConfigured)
{
services.AddOpenIddict()
.AddServer(server => server.RegisterScopes(ReadScope, AnswerScope))
.AddValidation(validation =>
{
validation.UseLocalServer();
// The bearer travels in the Authorization header only: a token in a URL lands in access logs.
validation.UseAspNetCore().Configure(options =>
{
options.DisableAccessTokenExtractionFromQueryString = true;
options.DisableAccessTokenExtractionFromBodyForm = true;
});
});
services.TryAddSingleton<IDeskCredential, OidcDeskCredential>();
}
services.TryAddSingleton<IDeskDoor>(_ => new UnixSocketDeskDoor());
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
services.AddCors();
// Exactly the configured origin, no credentials, and only on the routes that require this policy.
@ -167,7 +190,7 @@ public static class DeskEndpoints
var listed = await door.ListAsync(mind.Socket, context, listRequest! with { Mind = mind.Id }, cancellationToken);
if (listed is DeskListOutcome.Listed(var list))
{
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskNotes.Strip(list));
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskRedaction.ForNonDecider(list));
}
else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder))
{
@ -187,7 +210,7 @@ public static class DeskEndpoints
var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken);
if (found is DeskReadOutcome.Found(var entry))
{
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskNotes.Strip(entry));
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskRedaction.ForNonDecider(entry));
}
else if (found is DeskReadOutcome.ForkTooLarge(var largeFork))
{
@ -253,7 +276,8 @@ public static class DeskEndpoints
}
}
// A note is a decider's own words: only a caller on the admin-set list reads them (visibility ruling c).
// A note is a decider's own words: only a caller on the admin-set list reads one. Bifrost strips notes itself; answerer
// and decider ids are withheld by Huginn's door (desk-read-visibility-withheld-ids), not here.
private static bool IsDecider(DeskDoorContext context) =>
context.Deciders.Contains(context.Subject, StringComparer.Ordinal);

View file

@ -2,8 +2,9 @@ namespace Bifrost.Web.Features.Desk;
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
// peer-checked door. No production implementation is registered here: with the Desk section configured and a port
// missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// peer-checked door. The door's production implementation is UnixSocketDeskDoor (registered by AddBifrostDesk unless
// a test or host registered its own); the credential has none registered here, so with the Desk section configured
// and a port missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// What a validated bearer token claims. The credential port verifies the token; the desk judges audience, scope and
// membership. A null Subject or Audience means the token did not carry one.

View file

@ -0,0 +1,14 @@
namespace Bifrost.Web.Features.Desk;
// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else: every note is
// nil, with every key still written, so the body is still the contract's shape. Account ids are not Bifrost's to hide:
// Huginn's door withholds the decider ids and answerer ids from a non-decider and returns counts
// (eureka-substrate:ruling:desk-read-visibility-withheld-ids), and Bifrost cannot mask what it cannot read (the card is
// opaque to it). Cards, outcomes, codes, counts and the number of answers are untouched.
public static class DeskRedaction
{
public static ForkEntry ForNonDecider(ForkEntry entry) =>
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
public static ForkList ForNonDecider(ForkList list) => list with { Forks = [.. list.Forks.Select(ForNonDecider)] };
}

View file

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authentication;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Desk;
// The production IDeskCredential: OpenIddict's validation handler against the local server, asked for by its own
// scheme name and no other. It authenticates only. The default scheme stays the cookie, so a bearer token reaches no
// cookie surface, and this credential never reads the cookie, so the session cookie authenticates nothing on the
// desk. Audience, scope and Active membership are the desk's judgments (DeskEndpoints), not this port's.
public sealed class OidcDeskCredential : IDeskCredential
{
public async ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken)
{
var result = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
if (!result.Succeeded || result.Principal is null)
{
return null;
}
// A token may name several audiences; the desk's own is reported when present so the desk judges it, else the
// first (or none).
var audiences = result.Principal.GetAudiences();
var audience = audiences.Contains(DeskEndpoints.Audience) ? DeskEndpoints.Audience : audiences.FirstOrDefault();
return new DeskToken(result.Principal.GetClaim(Claims.Subject), audience, [.. result.Principal.GetScopes()]);
}
}

View file

@ -0,0 +1,624 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using MessagePack;
namespace Bifrost.Web.Features.Desk;
// The door could not confirm an outcome: no connection, no whole reply in time, or a reply that is not the contract's
// (an error, a failure, a Mind refusal, an unknown variant). The desk edge maps any exception from the door to 503
// `unavailable`; this is the one type the client throws for every one of those, so a failure is never an empty page.
public sealed class DeskDoorException(string message, Exception? inner = null) : Exception(message, inner);
// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at 232764bf). One new
// connection per call, one request frame, one reply frame, closed after, all inside one deadline. The client holds no
// connection state, so a dead socket fails only the calls addressed to that Mind.
//
// frame = u32 big-endian length || CultNet message (MessagePack map), at most 65536 bytes each way
// payload = standard base64 of the named MessagePack of Huginn's externally tagged DeskRequest / DeskResponse
//
// The reply is decoded by key, never by position, and strictly: every contract document is a closed map whose keys are
// all present with the right type (the variants' own maps too: as_of is an unsigned integer, as Huginn's DeskResponse
// declares), a key written twice anywhere is a failure, and so is a float, a binary or an extension value (the
// contract has none; integers keep every digit). The card inside an entry is not read beyond
// being a map: it stays an opaque JsonElement, so whatever keys and integers Huginn wrote leave unchanged.
public sealed partial class UnixSocketDeskDoor : IDeskDoor
{
public const int MaxFrameBytes = 65536;
public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(1);
private const string ServiceId = "huginn.desk";
private const string RequestSchema = "huginn.desk_request.v1";
private const string ResponseSchema = "huginn.desk_response.v1";
private const string Encoding64 = "messagepack-base64";
private const int MaxMessageIdBytes = 256;
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly Func<string> messageIds;
public UnixSocketDeskDoor()
: this(() => Guid.NewGuid().ToString("N"))
{
}
public UnixSocketDeskDoor(Func<string> messageIds) => this.messageIds = messageIds;
public async Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
byte[] request;
try
{
// Every key, null and key order of the client's body, as MessagePack.
request = MessagePackSerializer.ConvertFromJson(StrictUtf8.GetString(body.Span));
}
catch (Exception e) when (e is not OperationCanceledException)
{
throw new DeskDoorException("the answer body does not convert to the request map", e);
}
var payload = Payload("Answer", 5, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("authority");
w.Write(context.Authority);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("request");
w.WriteRaw(request);
});
var (variant, value) = await ExchangeAsync(socket, "answer", payload, cancellationToken);
switch (variant)
{
case "Answered":
Closed(value, "Answered", string.Empty, ("answer", Kind.Map), ("counted_answer_id", Kind.TextOrNull), ("as_of", Kind.Unsigned));
var answer = AnswerDocument(value, "answer");
// Huginn's reply carries no card: a stale_fork answer gets the current card from the edge's read.
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult, answer, null, OptionalString(value, "counted_answer_id")));
case "NotStored":
var code = RefusalCode(value);
return code is DeskRefusals.ForkTooLarge
? throw new DeskDoorException("fork_too_large is not an answer refusal")
: new DeskAnswerOutcome.NotStored(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
var payload = Payload("Fork", 4, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("fork_id");
w.Write(forkId);
});
var (variant, value) = await ExchangeAsync(socket, "fork", payload, cancellationToken);
switch (variant)
{
case "Fork":
Closed(value, "Fork", string.Empty, ("entry", Kind.Map), ("as_of", Kind.Unsigned));
var entry = Entry(Required(value, "entry"), "entry");
// The route asked for one fork. An entry whose card names another is not the answer to it. (That the record
// agrees with the card is a rule of the entry, CultLib's validators' and Huginn's, not the door's.)
if (!entry.Card.TryGetProperty("fork_id", out var cardFork) || cardFork.ValueKind != JsonValueKind.String
|| cardFork.GetString() != forkId)
{
throw new DeskDoorException("the entry is not for the fork asked");
}
return new DeskReadOutcome.Found(entry);
case "NotStored":
var code = RefusalCode(value);
// The route's fork id, never the refusal's informational text.
return code == DeskRefusals.ForkTooLarge
? new DeskReadOutcome.ForkTooLarge(forkId)
: new DeskReadOutcome.NotFound(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
{
var payload = Payload("Forks", 6, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("status");
w.Write(request.Status);
w.Write("cursor");
w.Write(request.Cursor);
w.Write("limit");
w.Write(request.Limit);
});
var (variant, value) = await ExchangeAsync(socket, "forks", payload, cancellationToken);
switch (variant)
{
case "Forks":
Closed(value, "Forks", string.Empty, ("list", Kind.Map), ("as_of", Kind.Unsigned));
var list = List(Required(value, "list"));
return list.Mind == context.Mind
? new DeskListOutcome.Listed(list)
: throw new DeskDoorException("the list is for another Mind");
case "NotStored":
var code = RefusalCode(value);
if (code != DeskRefusals.ForkTooLarge)
{
return new DeskListOutcome.NotListed(code);
}
// The fork and its order exist only in the informational text; anything but the exact form is a fault.
var text = RequiredString(value, "error");
var match = ListTooLarge().Match(text);
if (!match.Success || !int.TryParse(match.Groups["order"].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var order))
{
throw new DeskDoorException("fork_too_large does not name its fork and order in the list form");
}
return new DeskListOutcome.ForkTooLarge(match.Groups["id"].Value, order);
default:
throw Unexpected(variant);
}
}
// fork_too_large: fork <id> at order <N> is past the page budget by itself; ...
[GeneratedRegex(@"\Afork_too_large: fork (?<id>\S+) at order (?<order>0|[1-9][0-9]*) is past the page budget(?:[ ;][^\r\n]*)?\z", RegexOptions.CultureInvariant)]
private static partial Regex ListTooLarge();
// ---- strict documents: a closed map, every key present with its type, then bound to the typed record ----
private enum Kind
{
Text,
TextOrNull,
Flag,
Count,
Unsigned,
Map,
Items,
}
private static readonly (string, Kind)[] AnswerKeys =
[
("schemaVersion", Kind.Text), ("answer_id", Kind.Text), ("fork_id", Kind.Text), ("outcome", Kind.Text),
("of", Kind.TextOrNull), ("retracts", Kind.TextOrNull), ("code", Kind.TextOrNull), ("options_hash", Kind.Text),
("choice", Kind.TextOrNull), ("note", Kind.TextOrNull), ("answered_at", Kind.Text), ("received_at", Kind.Text),
("claim_in_future", Kind.Flag), ("answerer", Kind.TextOrNull), ("authority", Kind.Text), ("request_id", Kind.Text),
];
private static void Closed(JsonElement map, string what, string schema, params (string Key, Kind Kind)[] keys)
{
if (map.ValueKind != JsonValueKind.Object || map.EnumerateObject().Count() != keys.Length)
{
throw new DeskDoorException($"{what} is not a closed map of {keys.Length} keys");
}
foreach (var (key, kind) in keys)
{
var ok = Property(map, key) is { } v && kind switch
{
Kind.Text => v.ValueKind == JsonValueKind.String,
Kind.TextOrNull => v.ValueKind is JsonValueKind.String or JsonValueKind.Null,
Kind.Flag => v.ValueKind is JsonValueKind.True or JsonValueKind.False,
Kind.Count => v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) && n >= 0,
Kind.Unsigned => v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out _),
Kind.Map => v.ValueKind == JsonValueKind.Object,
_ => v.ValueKind == JsonValueKind.Array,
};
if (!ok)
{
throw new DeskDoorException($"{what}.{key} is missing or not the contract's type");
}
}
if (schema.Length > 0 && Property(map, "schemaVersion")!.Value.GetString() != schema)
{
throw new DeskDoorException($"{what} is not {schema}");
}
}
private static DeskAnswerDocument AnswerDocument(JsonElement value, string what)
{
var map = Required(value, what);
Closed(map, what, DeskSchemas.DeskAnswer, AnswerKeys);
return Bind<DeskAnswerDocument>(map, what);
}
private static ForkEntry Entry(JsonElement map, string what)
{
Closed(map, what, "gamecult.fork.entry.v0", ("schemaVersion", Kind.Text), ("card", Kind.Map), ("record", Kind.Map));
var record = Property(map, "record")!.Value;
Closed(record, what + ".record", "gamecult.fork.record.v0",
("schemaVersion", Kind.Text), ("fork_id", Kind.Text), ("options_hash", Kind.Text), ("counted_answer_id", Kind.TextOrNull),
("answerer_count", Kind.Count), ("answers", Kind.Items));
foreach (var answer in Property(record, "answers")!.Value.EnumerateArray())
{
Closed(answer, what + ".record.answers[]", DeskSchemas.DeskAnswer, AnswerKeys);
}
return Bind<ForkEntry>(map, what);
}
private static ForkList List(JsonElement map)
{
Closed(map, "list", "gamecult.fork.list.v0",
("schemaVersion", Kind.Text), ("mind", Kind.Text), ("stamp", Kind.Text), ("open_count", Kind.Count),
("recorded_count", Kind.Count), ("forks", Kind.Items), ("next_cursor", Kind.TextOrNull));
foreach (var entry in Property(map, "forks")!.Value.EnumerateArray())
{
Entry(entry, "list.forks[]");
}
return Bind<ForkList>(map, "list");
}
private static T Bind<T>(JsonElement map, string what)
{
try
{
return map.Deserialize<T>(DeskJson.Options) ?? throw new DeskDoorException($"{what} does not decode");
}
catch (JsonException e)
{
throw new DeskDoorException($"{what} does not decode", e);
}
}
private static JsonElement Required(JsonElement map, string name) =>
Property(map, name) ?? throw new DeskDoorException($"{name} is missing");
private static DeskDoorException Unexpected(string variant) =>
new($"the door answered with {(variant == "Refused" ? "a Mind refusal" : "a variant this call does not take")}");
private static string RefusalCode(JsonElement refusal)
{
Closed(refusal, "the refusal", DeskSchemas.Refusal, ("schemaVersion", Kind.Text), ("code", Kind.Text), ("error", Kind.Text));
var code = RequiredString(refusal, "code");
return code is DeskRefusals.ForkUnknown or DeskRefusals.BodyInvalid or DeskRefusals.RequestIdConflict or DeskRefusals.ForkTooLarge
? code
: throw new DeskDoorException("the refusal code is not one the contract names");
}
// ---- request ----
private delegate void FieldWriter(ref MessagePackWriter writer);
private static byte[] Payload(string variant, int fields, FieldWriter write)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(1);
w.Write(variant);
w.WriteMapHeader(fields);
write(ref w);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
private static void Strings(ref MessagePackWriter w, IReadOnlyList<string> values)
{
w.WriteArrayHeader(values.Count);
foreach (var value in values)
{
w.Write(value);
}
}
// Operation messages write their keys sorted, absent optionals omitted.
private static byte[] RequestMessage(string messageId, string operation, byte[] payload)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(7);
w.Write("messageId");
w.Write(messageId);
w.Write("operation");
w.Write(operation);
w.Write("payload");
w.Write(Convert.ToBase64String(payload));
w.Write("payloadEncoding");
w.Write(Encoding64);
w.Write("payloadSchema");
w.Write(RequestSchema);
w.Write("schemaVersion");
w.Write("cultnet.operation_request.v0");
w.Write("serviceId");
w.Write(ServiceId);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
// ---- exchange ----
private async Task<(string Variant, JsonElement Value)> ExchangeAsync(
string socketPath, string operation, byte[] payload, CancellationToken cancellationToken)
{
var messageId = messageIds();
if (messageId.Length == 0 || StrictUtf8.GetByteCount(messageId) > MaxMessageIdBytes)
{
throw new DeskDoorException("the message id is empty or over 256 bytes");
}
var message = RequestMessage(messageId, operation, payload);
if (message.Length > MaxFrameBytes)
{
throw new DeskDoorException("the request is past the frame bound");
}
var frame = new byte[4 + message.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
message.CopyTo(frame, 4);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(Deadline);
byte[] reply;
try
{
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), deadline.Token);
for (var sent = 0; sent < frame.Length;)
{
sent += await socket.SendAsync(frame.AsMemory(sent), SocketFlags.None, deadline.Token);
}
var header = new byte[4];
await ReadWholeAsync(socket, header, deadline.Token);
var length = BinaryPrimitives.ReadUInt32BigEndian(header);
if (length == 0 || length > MaxFrameBytes)
{
// Refused on the prefix alone: the body is never read.
throw new DeskDoorException("the reply's length prefix is outside the frame bound");
}
reply = new byte[length];
await ReadWholeAsync(socket, reply, deadline.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new DeskDoorException("no whole reply within the deadline");
}
catch (Exception e) when (e is SocketException or IOException or ObjectDisposedException or ArgumentException)
{
throw new DeskDoorException("the desk socket failed", e);
}
return Decode(reply, messageId, operation);
}
// Loops until the buffer is whole: a stream socket may hand it over in pieces. A peer closing early is a failure.
private static async Task ReadWholeAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken)
{
for (var read = 0; read < buffer.Length;)
{
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, cancellationToken);
if (count == 0)
{
throw new DeskDoorException("the peer closed before a whole reply frame");
}
read += count;
}
}
// ---- reply ----
// The envelope judged by key; the payload's one variant returned with its value. Everything else throws.
private static (string Variant, JsonElement Value) Decode(byte[] frame, string messageId, string operation)
{
try
{
using var envelope = ToJson(frame);
var root = envelope.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the reply is not a map");
}
// A CultNet Error message is not an operation reply (and its keys are not in operation order).
if (RequiredString(root, "schemaVersion") != "cultnet.operation_response.v0")
{
throw new DeskDoorException("the reply is not an operation response");
}
if (RequiredString(root, "serviceId") != ServiceId
|| RequiredString(root, "operation") != operation
|| RequiredString(root, "messageId") != messageId)
{
throw new DeskDoorException("the reply is not for this request");
}
var status = RequiredString(root, "status");
if (RequiredString(root, "payloadSchema") != ResponseSchema || RequiredString(root, "payloadEncoding") != Encoding64)
{
// Including gamecult.cultnet.operation_failure.v1: the envelope or payload did not decode.
throw new DeskDoorException("the reply carries no desk response");
}
// Strict standard base64: Convert would skip whitespace, so the text must be the canonical encoding of what it decodes to.
var text = RequiredString(root, "payload");
byte[] payload;
try
{
payload = Convert.FromBase64String(text);
}
catch (FormatException e)
{
throw new DeskDoorException("the payload is not base64", e);
}
if (Convert.ToBase64String(payload) != text)
{
throw new DeskDoorException("the payload is not strict standard base64");
}
using var body = ToJson(payload);
if (body.RootElement.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the payload is not a tagged variant");
}
var variants = body.RootElement.EnumerateObject().ToList();
if (variants.Count != 1)
{
throw new DeskDoorException("the payload is not a one-key variant");
}
var (variant, expectedStatus) = (variants[0].Name, variants[0].Name switch
{
"Answered" or "Forks" or "Fork" => "accepted",
"NotStored" or "Refused" => "rejected",
_ => null,
});
var takesVariant = operation switch
{
"answer" => variant == "Answered",
"forks" => variant == "Forks",
"fork" => variant == "Fork",
_ => false,
} || variant is "NotStored" or "Refused";
if (expectedStatus is null || !takesVariant || status != expectedStatus)
{
throw new DeskDoorException("the reply's variant or status does not fit the operation");
}
return (variant, variants[0].Value.Clone());
}
catch (Exception e) when (e is not (DeskDoorException or OperationCanceledException))
{
throw new DeskDoorException("the reply does not decode", e);
}
}
// MessagePack to JSON text by walking the reader: the JSON writer escapes every control character, keys keep their
// order, integers keep every digit. A map key that is not text, a key written twice, a float, a binary, an
// extension, invalid UTF-8 or nesting past 64 is a failure; none is in the contract.
private static JsonDocument ToJson(ReadOnlyMemory<byte> messagePack)
{
var reader = new MessagePackReader(messagePack);
var json = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(json, new JsonWriterOptions { MaxDepth = 64 }))
{
Walk(ref reader, writer, 0);
}
if (!reader.End)
{
throw new DeskDoorException("bytes follow the message");
}
return JsonDocument.Parse(json.WrittenMemory, new JsonDocumentOptions { MaxDepth = 64 });
}
private static void Walk(ref MessagePackReader reader, Utf8JsonWriter writer, int depth)
{
if (depth > 64)
{
throw new DeskDoorException("the message nests past 64");
}
switch (reader.NextMessagePackType)
{
case MessagePackType.Nil:
reader.ReadNil();
writer.WriteNullValue();
break;
case MessagePackType.Boolean:
writer.WriteBooleanValue(reader.ReadBoolean());
break;
case MessagePackType.Integer:
if (reader.NextCode == MessagePackCode.UInt64)
{
writer.WriteNumberValue(reader.ReadUInt64());
}
else
{
writer.WriteNumberValue(reader.ReadInt64());
}
break;
case MessagePackType.String:
writer.WriteStringValue(Text(ref reader));
break;
case MessagePackType.Array:
var items = reader.ReadArrayHeader();
writer.WriteStartArray();
for (var i = 0; i < items; i++)
{
Walk(ref reader, writer, depth + 1);
}
writer.WriteEndArray();
break;
case MessagePackType.Map:
var pairs = reader.ReadMapHeader();
var seen = new HashSet<string>(StringComparer.Ordinal);
writer.WriteStartObject();
for (var i = 0; i < pairs; i++)
{
if (reader.NextMessagePackType != MessagePackType.String)
{
throw new DeskDoorException("a map key is not text");
}
var key = Text(ref reader);
if (!seen.Add(key))
{
throw new DeskDoorException($"{key} is written twice");
}
writer.WritePropertyName(key);
Walk(ref reader, writer, depth + 1);
}
writer.WriteEndObject();
break;
default:
throw new DeskDoorException("the message holds a float, binary or extension value, which the contract does not use");
}
}
private static string Text(ref MessagePackReader reader) =>
StrictUtf8.GetString(reader.ReadStringSequence() ?? throw new DeskDoorException("text is nil"));
// A key's value by name; a key written twice never gets here (the walk refuses it).
private static JsonElement? Property(JsonElement map, string name) =>
map.ValueKind == JsonValueKind.Object && map.TryGetProperty(name, out var value) ? value : null;
private static string RequiredString(JsonElement map, string name) =>
Property(map, name) is { ValueKind: JsonValueKind.String } value
? value.GetString()!
: throw new DeskDoorException($"{name} is missing or not text");
private static string? OptionalString(JsonElement map, string name) =>
Property(map, name) switch
{
{ ValueKind: JsonValueKind.String } value => value.GetString(),
{ ValueKind: JsonValueKind.Null } => null,
_ => throw new DeskDoorException($"{name} is missing or not text"),
};
}

View file

@ -41,6 +41,11 @@ public sealed class HeimdallAuthService(
"twitch"
};
/// <summary>The headers HttpClient logging must never write: the app secret and any bearer.</summary>
public static bool RedactsHeader(string header) =>
string.Equals(header, AppSecretHeader, StringComparison.OrdinalIgnoreCase)
|| string.Equals(header, "Authorization", StringComparison.OrdinalIgnoreCase);
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1");
/// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
@ -53,13 +58,54 @@ public sealed class HeimdallAuthService(
}
}
private const int RefusalBodyLimit = 4096;
/// <summary>
/// Names the HTTP status and, when the first 4 KiB of the body carry one, Heimdall's error code. The code
/// is shown only if it is a short snake_case token, so nothing else from the body (free text, tokens)
/// reaches a log. A body that cannot be read or parsed leaves the status alone: a refusal never becomes a 500.
/// </summary>
private static async Task<string> RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var detail = $"HTTP {(int)response.StatusCode}";
try
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var head = new byte[RefusalBodyLimit];
var length = 0;
while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read)
{
length += read;
}
using var body = JsonDocument.Parse(head.AsMemory(0, length));
if (body.RootElement.ValueKind == JsonValueKind.Object
&& body.RootElement.TryGetProperty("error", out var error)
&& error.ValueKind == JsonValueKind.String
&& error.GetString() is { Length: > 0 and <= 64 } code
&& code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_'))
{
detail += ", " + code;
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
}
return $"Heimdall rejected the auth start ({detail}).";
}
public async Task<HeimdallAuthStartResult> StartAsync(
string provider,
string callbackBaseUrl,
LocalReturnPath returnPath,
bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn,
string? expectedAccountId = null,
string? pendingLinkId = null,
string? replaceSlot = null)
{
if (!AllowedProviders.Contains(provider))
{
@ -84,14 +130,16 @@ public sealed class HeimdallAuthService(
new HeimdallHandoff("browser_completion"),
entitlementPolicy);
var response = await httpClient.PostAsJsonAsync(
$"/v1/oauth/{provider}/start",
request,
cancellationToken);
// Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole.
using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start")
{
Content = JsonContent.Create(request)
};
using var response = await httpClient.SendAsync(startMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HeimdallAuthException("Heimdall rejected the auth start.");
throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken));
}
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
@ -105,11 +153,40 @@ public sealed class HeimdallAuthService(
provider,
requireMemberAccess,
returnPath,
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime);
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime,
purpose,
expectedAccountId,
pendingLinkId);
return new HeimdallAuthStartResult(
new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt));
new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt));
}
/// <summary>The provider a link's fresh sign-in uses: the one the person signed in with, else Discord.</summary>
public static string LinkSignInProvider(string? sessionProvider) =>
sessionProvider is not null && AllowedProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : "discord";
/// <summary>
/// The account page's link button: an ordinary Heimdall sign-in whose attempt says it is only the proof for a
/// link, and which person it must prove. The session is never replaced by it.
/// </summary>
public Task<HeimdallAuthStartResult> StartLinkAsync(
string sessionAccountId,
string? sessionProvider,
string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken) =>
StartAsync(
LinkSignInProvider(sessionProvider),
callbackBaseUrl,
LocalReturnPath.Parse(LinkReturnPath),
requireMemberAccess: false,
requestCookies,
cancellationToken,
HeimdallAttemptPurpose.LinkStart,
sessionAccountId);
public const string LinkReturnPath = "/Membership/Status";
/// <summary>
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose
/// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and
@ -156,15 +233,13 @@ public sealed class HeimdallAuthService(
return null;
}
public async Task<HeimdallCompletedSignIn> CompleteAsync(
string callbackBaseUrl,
string? nonce,
string? cookieValue,
string? code,
CancellationToken cancellationToken)
/// <summary>What the attempt this browser holds for the nonce is for, or null when it holds none.</summary>
public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose;
/// <summary>Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else.</summary>
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemVerifiedAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
{
var attempt = Unprotect(cookieValue, nonce)
?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser.");
if (string.IsNullOrWhiteSpace(code))
{
throw new HeimdallAuthException("Heimdall completion carried no code.");
@ -182,6 +257,24 @@ public sealed class HeimdallAuthService(
}
var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken);
return (identity, redeemed.AccessToken);
}
public async Task<HeimdallCompletedSignIn> CompleteAsync(
string callbackBaseUrl,
string? nonce,
string? cookieValue,
string? code,
CancellationToken cancellationToken)
{
var attempt = Unprotect(cookieValue, nonce)
?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser.");
if (attempt.Purpose != HeimdallAttemptPurpose.SignIn)
{
throw new HeimdallAuthException("Heimdall completion is not a sign-in attempt.");
}
var (identity, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (attempt.RequiresMemberAccess && !identity.HasMemberAccess)
{
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
@ -197,6 +290,155 @@ public sealed class HeimdallAuthService(
attempt.ReturnPath);
}
/// <summary>
/// One step of a Google link, chosen by the purpose of the attempt this browser holds. The browser's session must be
/// the account the link was begun for, checked before Heimdall is asked anything and again against the identity the
/// fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none.
/// </summary>
public async Task<HeimdallLinkStep> ContinueLinkAsync(
string callbackBaseUrl,
string? nonce,
string cookieName,
string? cookieValue,
string? code,
string? pendingLinkId,
string? sessionAccountId,
CancellationToken cancellationToken)
{
var attempt = Unprotect(cookieValue, nonce)
?? throw new HeimdallAuthException("Heimdall link has no attempt for this browser.");
if (string.IsNullOrEmpty(attempt.ExpectedAccountId)
|| !string.Equals(sessionAccountId, attempt.ExpectedAccountId, StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall link belongs to another session.");
}
switch (attempt.Purpose)
{
case HeimdallAttemptPurpose.LinkStart:
{
var (_, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
var authorizationUrl = await StartGoogleLinkAsync(callbackBaseUrl, attempt, bearer, cancellationToken);
return new HeimdallLinkStep(authorizationUrl.Url, cookieName, authorizationUrl.CookieValue, null);
}
case HeimdallAttemptPurpose.LinkAwaitGoogle:
{
if (string.IsNullOrWhiteSpace(pendingLinkId))
{
throw new HeimdallAuthException("Heimdall link carried no pending link.");
}
var next = await StartAsync(
LinkSignInProvider(attempt.Provider),
callbackBaseUrl,
attempt.ReturnPath,
requireMemberAccess: false,
[],
cancellationToken,
HeimdallAttemptPurpose.LinkConfirm,
attempt.ExpectedAccountId,
pendingLinkId,
replaceSlot: cookieName);
return new HeimdallLinkStep(next.AuthorizationUrl, next.CookieName, next.CookieValue, null);
}
case HeimdallAttemptPurpose.LinkConfirm:
{
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
var outcome = await ConfirmLinkAsync(attempt.PendingLinkId!, bearer, cancellationToken);
if (outcome == HeimdallLinkOutcome.Linked)
{
// Membership follows the verified identity exactly as at sign-in; the session principal is not replaced.
await UpsertUserAccountAsync(identity, attempt.Provider, grantMemberAccess: false, cancellationToken);
}
return new HeimdallLinkStep(null, cookieName, null, outcome);
}
default:
throw new HeimdallAuthException("Heimdall completion is not a link attempt.");
}
}
/// <summary>Redeems a link attempt's code and requires it to prove the account the link was begun for.</summary>
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemOwnerAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
{
var proven = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (!string.Equals(proven.Identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall link was proven by another account.");
}
return proven;
}
private async Task<(Uri Url, string CookieValue)> StartGoogleLinkAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken)
{
var options = heimdallOptions.Value;
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/oauth/google/start")
{
Content = JsonContent.Create(new HeimdallStartRequest(
options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null))
};
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken));
}
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
if (payload is null || string.IsNullOrWhiteSpace(payload.AuthorizationUrl))
{
throw new HeimdallAuthException("Heimdall link start returned no authorization URL.");
}
var awaiting = new HeimdallAttempt(
nonce,
attempt.Provider,
false,
attempt.ReturnPath,
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime,
HeimdallAttemptPurpose.LinkAwaitGoogle,
attempt.ExpectedAccountId,
null);
return (new Uri(payload.AuthorizationUrl, UriKind.Absolute), Protect(awaiting));
}
private async Task<HeimdallLinkOutcome> ConfirmLinkAsync(string pendingLinkId, string bearer, CancellationToken cancellationToken)
{
try
{
using var message = new HttpRequestMessage(
HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/links/confirm")
{
Content = JsonContent.Create(new HeimdallConfirmLinkRequest(pendingLinkId))
};
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
switch (response.StatusCode)
{
case HttpStatusCode.Created:
var linked = await response.Content.ReadFromJsonAsync<HeimdallConfirmLinkResponse>(cancellationToken);
return linked?.Status == "linked" ? HeimdallLinkOutcome.Linked : HeimdallLinkOutcome.Error;
case HttpStatusCode.Conflict:
return HeimdallLinkOutcome.IdentityLinkedElsewhere;
case HttpStatusCode.Gone:
return HeimdallLinkOutcome.Expired;
default:
return HeimdallLinkOutcome.Error;
}
}
catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException && !cancellationToken.IsCancellationRequested)
{
return HeimdallLinkOutcome.Error;
}
}
private async Task<HeimdallRedeemResponse> RedeemAsync(string code, CancellationToken cancellationToken)
{
try
@ -221,7 +463,8 @@ public sealed class HeimdallAuthService(
private string Protect(HeimdallAttempt attempt) =>
_protector.Protect(JsonSerializer.Serialize(new AttemptWire(
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt)));
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt,
attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
@ -238,13 +481,14 @@ public sealed class HeimdallAuthService(
try
{
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue));
if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow())
if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow() || !Enum.IsDefined(wire.Purpose))
{
return null;
}
return new HeimdallAttempt(
wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt);
wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt,
wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId);
}
catch (Exception error) when (error is CryptographicException or JsonException or FormatException)
{
@ -257,7 +501,10 @@ public sealed class HeimdallAuthService(
string Provider,
bool RequiresMemberAccess,
string ReturnPath,
DateTimeOffset ExpiresAt);
DateTimeOffset ExpiresAt,
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
string? ExpectedAccountId = null,
string? PendingLinkId = null);
private async Task<UserAccount> UpsertUserAccountAsync(
HeimdallVerifiedIdentity identity,
@ -409,7 +656,65 @@ public sealed record HeimdallAttempt(
string Provider,
bool RequiresMemberAccess,
LocalReturnPath ReturnPath,
DateTimeOffset ExpiresAt);
DateTimeOffset ExpiresAt,
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
string? ExpectedAccountId = null,
string? PendingLinkId = null);
/// <summary>
/// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in.
/// The link purposes are the fresh sign-ins and the Google round trip of an explicit link; they never sign anyone in.
/// </summary>
public enum HeimdallAttemptPurpose
{
SignIn = 0,
LinkStart = 1,
LinkAwaitGoogle = 2,
LinkConfirm = 3
}
public enum HeimdallLinkOutcome
{
Linked,
IdentityLinkedElsewhere,
Expired,
Error
}
/// <summary>
/// Where a link step sends the browser: to <see cref="Next"/> with a new attempt cookie in the held slot, or, when the
/// link is finished, back to the account page with <see cref="Outcome"/>.
/// </summary>
public static class HeimdallLinkOutcomes
{
public static string Redirect(HeimdallLinkOutcome outcome) => HeimdallAuthService.LinkReturnPath + "?link=" + Code(outcome);
public static string Code(HeimdallLinkOutcome outcome) => outcome switch
{
HeimdallLinkOutcome.Linked => "linked",
HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere",
HeimdallLinkOutcome.Expired => "expired",
_ => "error"
};
/// <summary>The words for an outcome code: a closed set, never Heimdall's text. Anything else gets none.</summary>
public static string? Wording(string? code) => code switch
{
"linked" => "Google is now linked to your Heimdall account.",
"identity_linked_elsewhere" => "That Google identity is already linked to a different Heimdall account. Nothing was linked.",
"expired" => "The Google link expired before it was confirmed. Nothing was linked; start the link again.",
"error" => "The Google link could not be completed. Nothing was linked.",
_ => null
};
}
public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome);
public sealed record HeimdallConfirmLinkRequest(
[property: JsonPropertyName("pendingLinkId")] string PendingLinkId);
public sealed record HeimdallConfirmLinkResponse(
[property: JsonPropertyName("status")] string? Status);
public sealed record HeimdallStartRequest(
[property: JsonPropertyName("appSlug")] string AppSlug,

View file

@ -3,7 +3,8 @@ namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which
/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with
/// the attempt to the same path. The page is static: it never contains a code.
/// the attempt to the same path. A Google link returns a pending link id the same way instead of a code. The page
/// is static: it never contains a code or an id.
/// </summary>
public static class HeimdallCompletePage
{
@ -22,21 +23,24 @@ public static class HeimdallCompletePage
<form id="complete" method="post" action="/auth/heimdall/complete">
<input type="hidden" name="attempt" />
<input type="hidden" name="code" />
<input type="hidden" name="pendingLinkId" />
</form>
<script>
(function () {
var fragment = new URLSearchParams(location.hash.slice(1));
var attempt = new URLSearchParams(location.search).get("attempt");
var code = fragment.get("heimdall_completion_code");
var pendingLinkId = fragment.get("heimdall_pending_link_id");
var failed = fragment.get("heimdall_status") === "error";
history.replaceState(null, "", location.pathname + location.search);
if (failed || !code || !attempt) {
if (failed || !(code || pendingLinkId) || !attempt) {
location.replace("/?auth=heimdall-error");
return;
}
var form = document.getElementById("complete");
form.elements.attempt.value = attempt;
form.elements.code.value = code;
form.elements.code.value = code || "";
form.elements.pendingLinkId.value = pendingLinkId || "";
form.submit();
})();
</script>

View file

@ -2,6 +2,7 @@ using System.Security.Claims;
using Microsoft.AspNetCore;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared;
@ -85,6 +86,13 @@ public static class OidcEndpoints
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.GetScopes());
// A desk scope is what makes a token a desk token: its audience is the desk's. The client's permissions already
// decided whether it may ask for the scope (the MAS client may not), so this branch is reached only by one that may.
if (request.GetScopes().Any(DeskEndpoints.IsDeskScope))
{
principal.SetResources(DeskEndpoints.Audience);
}
principal.SetDestinations(claim => claim.Type switch
{
Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken],
@ -93,10 +101,11 @@ public static class OidcEndpoints
_ => [Destinations.AccessToken]
});
// The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11).
// Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree.
// Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request
// (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it.
if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
if (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
{
return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString));
}
@ -104,6 +113,10 @@ public static class OidcEndpoints
return Results.SignIn(principal, properties: null, OpenIddictScheme);
}
/// <summary>A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt.</summary>
// Only a token whose every scope is a desk scope skips the consent gate: a client that may hold chat scopes too cannot ride a desk scope past the receipt.
private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes() is { Length: > 0 } scopes && scopes.All(DeskEndpoints.IsDeskScope);
private static async Task<IResult> ExchangeAsync(HttpContext http, BifrostDbContext db)
{
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
@ -111,7 +124,7 @@ public static class OidcEndpoints
// because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens).
var result = await http.AuthenticateAsync(OpenIddictScheme);
var subject = result.Principal?.GetClaim(Claims.Subject);
if (await ChatRefusalAsync(db, subject, http.RequestAborted) is { } refusal)
if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
{
return Deny(Errors.InvalidGrant, refusal);
}
@ -128,7 +141,7 @@ public static class OidcEndpoints
}
// An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered.
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted) is { } refusal)
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
{
return Deny(Errors.InsufficientAccess, refusal);
}
@ -146,9 +159,9 @@ public static class OidcEndpoints
/// <summary>
/// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler:
/// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending
/// or Active all chat), and it holds a live receipt for the notice version in force. Re-read on every call.
/// or Active all chat), and, for a chat token (not a desk token), it holds a live receipt for the notice version in force. Re-read on every call.
/// </summary>
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken, bool deskToken = false)
{
if (!Guid.TryParse(subject, out var accountId))
{
@ -163,7 +176,7 @@ public static class OidcEndpoints
return SuspendedDescription;
}
return await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
return deskToken || await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
}
/// <summary>The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call.</summary>

View file

@ -15,12 +15,16 @@ public sealed class StartupConfigurationValidator(
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions,
IOptions<DeskOptions> deskOptions,
IOptions<RegistrationOptions> registrationOptions)
IOptions<RegistrationOptions> registrationOptions,
OidcIssuerOptions? oidcOptions = null)
{
public void Validate()
{
var failures = new List<string>();
// Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment.
_ = heimdallOptions.Value;
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
var conceptVotes = conceptVoteOptions.Value;
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
@ -101,6 +105,13 @@ public sealed class StartupConfigurationValidator(
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
}
// The options are already resolved, so a secret from Heimdall:AppSecretFile or Heimdall:AppSecret lands in AppSecret.
// Blank would silently switch Discord and Patreon member sign-in off.
if (string.IsNullOrWhiteSpace(heimdallOptions.Value.AppSecret))
{
failures.Add("Heimdall:AppSecretFile (or Heimdall:AppSecret) must supply the Heimdall app secret outside development.");
}
if (stripeOptions.Value.EnableCheckout)
{
if (!stripeOptions.Value.IsCheckoutConfigured)
@ -149,6 +160,12 @@ public sealed class StartupConfigurationValidator(
// mis-attribute answers. Account existence is a request-time question, not a startup one.
private void ValidateDesk(DeskOptions desk, List<string> failures)
{
// The desk's bearer is validated against Bifrost's own issuer: without Bifrost:Oidc there is no credential to serve with.
if (oidcOptions is null && configuration.GetSection(DeskOptions.SectionName).Exists())
{
failures.Add("Bifrost:Oidc must be configured when the Desk section exists: the desk validates its bearer tokens against Bifrost's own issuer.");
}
if (!desk.IsConfigured)
{
if (configuration.GetSection(DeskOptions.SectionName).Exists())

View file

@ -24,6 +24,19 @@
<p class="muted">@Model.StatusMessage</p>
</div>
@if (Model.LinkMessage is not null)
{
<div class="callout" role="status">@Model.LinkMessage</div>
}
@if (Model.CanLinkGoogle)
{
<form method="post" asp-page-handler="LinkGoogle" class="panel-actions">
<button class="button secondary" type="submit">Link a Google login</button>
<span class="muted">You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in.</span>
</form>
}
@if (Model.Invitation is not null)
{
<div class="callout success">

View file

@ -1,17 +1,62 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using System.Security.Claims;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Pages.Membership;
[Authorize]
public sealed class StatusModel(
ICurrentBifrostActorAccessor actorAccessor,
BifrostDbContext dbContext) : PageModel
BifrostDbContext dbContext,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
ILogger<StatusModel> logger) : PageModel
{
/// <summary>The wording of the link outcome the page was sent back with: a closed set, null for anything else.</summary>
public string? LinkMessage { get; private set; }
/// <summary>Whether this session belongs to a Heimdall account a Google login can be attached to.</summary>
public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
/// <summary>
/// Begins an explicit Google link: an ordinary Heimdall sign-in for the account already signed in here, kept as
/// an attempt that is only the proof for the link. POST with antiforgery only; nothing is started without a session.
/// </summary>
public async Task<IActionResult> OnPostLinkGoogleAsync(CancellationToken cancellationToken)
{
var accountId = User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId);
if (string.IsNullOrEmpty(accountId))
{
return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) });
}
try
{
var start = await heimdallAuthService.StartLinkAsync(
accountId,
User.FindFirstValue(BifrostClaimTypes.AuthProvider),
hostOptions.Value.PublicBaseUrl,
Request.Cookies,
cancellationToken);
Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options());
return Redirect(start.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
logger.LogWarning("Heimdall link start refused: {Reason}", error.Message);
return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) });
}
}
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public MembershipInvitation? Invitation { get; private set; }
@ -45,6 +90,7 @@ public sealed class StatusModel(
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
LinkMessage = HeimdallLinkOutcomes.Wording(Request.Query["link"].ToString());
if (Actor.UserAccount is null)
{

View file

@ -64,7 +64,8 @@ builder.Services
builder.Services
.AddOptions<HeimdallOptions>()
.BindConfiguration(HeimdallOptions.SectionName);
.BindConfiguration(HeimdallOptions.SectionName)
.PostConfigure<ILogger<HeimdallOptions>>(HeimdallOptions.ResolveAppSecret);
builder.Services
.AddOptions<StripeOptions>()
@ -151,7 +152,7 @@ builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
builder.Services.AddSingleton<HeimdallTokenVerifier>();
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
@ -267,7 +268,7 @@ if (oidcOptions is not null)
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
if (deskConfigured)
{
builder.Services.AddBifrostDesk();
builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null);
}
var app = builder.Build();
@ -825,8 +826,32 @@ app.MapPost("/auth/heimdall/complete", async (
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
var attempt = form?["attempt"].ToString();
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
var linking = held is not null
&& heimdallAuthService.PurposeOf(held.Value.Value, attempt) is not (null or HeimdallAttemptPurpose.SignIn);
try
{
if (linking)
{
// A link step never signs anyone in: the session in this browser is the one being linked.
var step = await heimdallAuthService.ContinueLinkAsync(
hostOptions.Value.PublicBaseUrl,
attempt,
held!.Value.Name,
held.Value.Value,
form?["code"].ToString(),
form?["pendingLinkId"].ToString(),
httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId),
cancellationToken);
if (step.Next is not null)
{
httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options());
return Results.Redirect(step.Next.ToString());
}
httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions());
return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value));
}
var signIn = await heimdallAuthService.CompleteAsync(
hostOptions.Value.PublicBaseUrl,
attempt,
@ -845,7 +870,9 @@ app.MapPost("/auth/heimdall/complete", async (
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
}
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
return linking
? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error))
: Results.Redirect(HeimdallAuthService.ErrorRedirect);
}
}).AllowAnonymous();

View file

@ -28,6 +28,7 @@
"BaseUrl": "https://heimdall.gamecult.org",
"AppSlug": "bifrost",
"AppSecret": "",
"AppSecretFile": "",
"DiscordGuildId": "",
"DiscordAllowedRoleIds": [],
"PatreonTierTitle": "Inner Sanctum",

View file

@ -8,6 +8,17 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"MessagePack": {
"type": "Direct",
"requested": "[3.1.12, )",
"resolved": "3.1.12",
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
"dependencies": {
"MessagePack.Annotations": "3.1.12",
"MessagePackAnalyzer": "3.1.12",
"Microsoft.NET.StringTools": "17.11.4"
}
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
"requested": "[8.0.0, )",
@ -75,6 +86,25 @@
"OpenIddict.Server": "7.7.1"
}
},
"OpenIddict.Validation.AspNetCore": {
"type": "Direct",
"requested": "[7.7.1, )",
"resolved": "7.7.1",
"contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==",
"dependencies": {
"OpenIddict.Validation": "7.7.1"
}
},
"OpenIddict.Validation.ServerIntegration": {
"type": "Direct",
"requested": "[7.7.1, )",
"resolved": "7.7.1",
"contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==",
"dependencies": {
"OpenIddict.Server": "7.7.1",
"OpenIddict.Validation": "7.7.1"
}
},
"System.Text.Json": {
"type": "Direct",
"requested": "[8.0.5, )",
@ -86,6 +116,16 @@
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
},
"MessagePackAnalyzer": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
@ -269,6 +309,14 @@
"Microsoft.IdentityModel.Abstractions": "8.19.2"
}
},
"Microsoft.IdentityModel.Protocols": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"Microsoft.IdentityModel.Tokens": {
"type": "Transitive",
"resolved": "8.19.2",
@ -279,6 +327,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "2.2.1",
@ -323,6 +376,17 @@
"OpenIddict.Abstractions": "7.7.1"
}
},
"OpenIddict.Validation": {
"type": "Transitive",
"resolved": "7.7.1",
"contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==",
"dependencies": {
"Microsoft.Extensions.Logging": "8.0.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.19.2",
"Microsoft.IdentityModel.Protocols": "8.19.2",
"OpenIddict.Abstractions": "7.7.1"
}
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "4.4.0",

View file

@ -23,6 +23,7 @@
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\desk-feed\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -1061,18 +1061,16 @@ rl.on("line", (line) => {
await RunGitAsync(["commit", "-m", "Initial commit"], workDir);
await RunGitAsync(["remote", "add", "origin", remoteDir], workDir);
var blocked = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "git push -u origin main"],
var blocked = await RunShellAsync(
"git push -u origin main",
workDir,
BuildDispatchedGitHookEnv());
Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked git push", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
var blockedNoVerify = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "git push --no-verify -u origin main"],
var blockedNoVerify = await RunShellAsync(
"git push --no-verify -u origin main",
workDir,
BuildDispatchedGitGateEnv());
@ -1154,19 +1152,13 @@ console.log("https://github.com/GameCult/Bifrost/pull/999");
try
{
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run");
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "gh pr comment 42 --body blocked"],
var blocked = await RunShellAsync(
"gh pr comment 42 --body blocked",
RepoRoot,
environment);
@ -1188,19 +1180,13 @@ exit /b 0
try
{
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run");
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr comment 42 --body blocked"],
var blocked = await RunShellAsync(
"gh -R GameCult/Bifrost pr comment 42 --body blocked",
RepoRoot,
environment);
@ -1222,19 +1208,13 @@ exit /b 0
try
{
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run");
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "gh workflow run ci.yml"],
var blocked = await RunShellAsync(
"gh workflow run ci.yml",
RepoRoot,
environment);
@ -1256,19 +1236,13 @@ exit /b 0
try
{
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo read-only-ok
exit /b 0
""", Encoding.UTF8);
var fakeGhPath = await WriteFakeGhAsync(tempDir, "read-only-ok");
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath;
var allowed = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr view 42"],
var allowed = await RunShellAsync(
"gh -R GameCult/Bifrost pr view 42",
RepoRoot,
environment);
@ -1449,27 +1423,77 @@ import('./tools/bifrost-crossing-documents.mjs')
private static Dictionary<string, string?> BuildDispatchedGitHookEnv()
{
var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks");
var nodePath = ResolveExecutable("node");
var gatePath = Path.Combine(RepoRoot, "tools", "git-gate");
var path = Environment.GetEnvironmentVariable("Path")
?? Environment.GetEnvironmentVariable("PATH")
?? string.Empty;
var nextPath = string.IsNullOrWhiteSpace(path)
? gatePath
: $"{gatePath};{path}";
return new Dictionary<string, string?>
var environment = new Dictionary<string, string?>
{
["BIFROST_ENFORCE_GITHUB_GATE"] = "true",
["BIFROST_GIT_EXECUTABLE"] = Path.Combine(gatePath, "git.cmd"),
["BIFROST_GH_EXECUTABLE"] = Path.Combine(gatePath, "gh.cmd"),
["BIFROST_NODE_EXECUTABLE"] = ResolveExecutable("node"),
["BIFROST_NODE_EXECUTABLE"] = nodePath,
["GIT_CONFIG_COUNT"] = "1",
["GIT_CONFIG_KEY_0"] = "core.hooksPath",
["GIT_CONFIG_VALUE_0"] = hooksPath,
["PATH"] = nextPath,
["Path"] = nextPath,
};
var path = Environment.GetEnvironmentVariable("Path")
?? Environment.GetEnvironmentVariable("PATH")
?? string.Empty;
if (OperatingSystem.IsWindows())
{
// The checked-in gate wrappers are .cmd files that PATH lookup finds as `git` and `gh`.
var nextPath = string.IsNullOrWhiteSpace(path) ? gatePath : $"{gatePath};{path}";
environment["BIFROST_GIT_EXECUTABLE"] = Path.Combine(gatePath, "git.cmd");
environment["BIFROST_GH_EXECUTABLE"] = Path.Combine(gatePath, "gh.cmd");
environment["PATH"] = nextPath;
environment["Path"] = nextPath;
return environment;
}
// Non-Windows has no .cmd wrappers: stand in for them with sh shims that run the same
// Node proxies, placed first on PATH. The proxies get the real git by explicit path so
// the shim cannot resolve itself.
var shimDir = Path.Combine(Path.GetTempPath(), $"bifrost-gate-shims-{Guid.NewGuid():N}");
Directory.CreateDirectory(shimDir);
var gitShim = WriteShim(shimDir, "git", nodePath, Path.Combine(gatePath, "git-proxy.mjs"));
var ghShim = WriteShim(shimDir, "gh", nodePath, Path.Combine(gatePath, "gh-proxy.mjs"));
environment["BIFROST_REAL_GIT"] = ResolveExecutable("git");
environment["BIFROST_GIT_EXECUTABLE"] = gitShim;
environment["BIFROST_GH_EXECUTABLE"] = ghShim;
environment["PATH"] = string.IsNullOrEmpty(path) ? shimDir : $"{shimDir}{Path.PathSeparator}{path}";
return environment;
}
private static string WriteShim(string directory, string name, string nodePath, string script)
{
var shim = Path.Combine(directory, name);
File.WriteAllText(shim, $"#!/bin/sh\nexec '{nodePath}' '{script}' \"$@\"\n", new UTF8Encoding(false));
File.SetUnixFileMode(shim, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return shim;
}
private static async Task<string> WriteFakeGhAsync(string directory, string output)
{
if (OperatingSystem.IsWindows())
{
var cmd = Path.Combine(directory, "fake-gh.cmd");
await File.WriteAllTextAsync(cmd, $"@echo off\r\necho {output}\r\nexit /b 0\r\n", Encoding.UTF8);
return cmd;
}
var script = Path.Combine(directory, "fake-gh.sh");
await File.WriteAllTextAsync(script, $"#!/bin/sh\necho {output}\nexit 0\n", new UTF8Encoding(false));
File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private static Task<ProcessResult> RunShellAsync(
string command,
string workingDirectory,
IReadOnlyDictionary<string, string?> environment)
=> OperatingSystem.IsWindows()
? RunCommandAsync("powershell", ["-NoProfile", "-Command", command], workingDirectory, environment)
: RunCommandAsync("sh", ["-c", command], workingDirectory, environment);
private static Dictionary<string, string?> BuildDispatchedGitGateEnv() => BuildDispatchedGitHookEnv();
private static string ResolveExecutable(string command)
@ -1478,7 +1502,7 @@ import('./tools/bifrost-crossing-documents.mjs')
{
StartInfo = new ProcessStartInfo
{
FileName = "where.exe",
FileName = OperatingSystem.IsWindows() ? "where.exe" : "which",
WorkingDirectory = RepoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,

View file

@ -210,7 +210,8 @@ public sealed class ConceptRankedExportTests : IClassFixture<ConceptRankedExport
yield return ["not base64!"];
yield return ["AAAA"];
yield return [new string('A', 201)];
yield return [new string('A', 100_000)];
// 50,000 chars is the longest the client Uri (65,519 cap on Unix) can carry with the path; far past the 200-char RankCursor.MaxLength.
yield return [new string('A', 50_000)];
yield return [B64("")];
yield return [B64("1|0|0|5")];
yield return [B64("1|0|0|5|alpha|extra")];

View file

@ -0,0 +1,420 @@
using Bifrost.Web.Data;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Tests;
// The production desk credential, on real tokens: the test host's own issuer mints them through the code flow, and the
// OpenIddict validation handler (the production IDeskCredential) judges them. Only the door is faked.
public sealed class DeskCredentialTests : IDisposable
{
private const string DeskClient = "ratatoskr-test";
private const string DeskRedirect = "https://ratatoskr.example.test/callback";
private const string DeskSecret = "ratatoskr-test-secret";
private static readonly Uri Origin = new("https://localhost");
private static readonly string ListTarget = "/api/desk/gamecult-chat/forks";
private readonly DeskWebApplicationFactory factory = new(realCredential: true);
public void Dispose() => factory.Dispose();
[Fact]
public async Task A_valid_desk_read_token_lists_through_the_door_and_the_door_is_asked_once()
{
var (_, token) = await MemberWithTokenAsync("desk.read");
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null));
var reply = await GetAsync(token);
Assert.Equal(200, reply.Status);
Assert.Equal("list", Assert.Single(factory.Door.Calls).Kind);
}
[Fact]
public async Task No_token_is_401_and_the_door_is_never_asked()
{
await MemberWithTokenAsync("desk.read");
var reply = await GetAsync(null);
Assert.Equal(401, reply.Status);
Assert.Equal("unauthenticated", Code(reply));
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_tampered_token_is_401()
{
var (_, token) = await MemberWithTokenAsync("desk.read");
var middle = token.Length / 2;
var tampered = token[..middle] + (token[middle] == 'A' ? 'B' : 'A') + token[(middle + 1)..];
Assert.Equal(401, (await GetAsync(tampered)).Status);
Assert.Equal(401, (await GetAsync(token + "x")).Status);
Assert.Equal(401, (await GetAsync("not-a-token")).Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task An_expired_token_is_401()
{
var (_, token) = await MemberWithTokenAsync("desk.read");
Assert.Equal(200, (await GetAsync(token)).Status);
factory.Clock.Advance(TimeSpan.FromHours(3));
Assert.Equal(401, (await GetAsync(token)).Status);
}
[Fact]
public async Task A_token_for_another_audience_is_401()
{
var (_, token) = await MemberWithTokenAsync($"desk.read {DeskWebApplicationFactory.OtherAudienceScope}");
var reply = await GetAsync(token);
Assert.Equal(401, reply.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_desk_read_token_cannot_answer_and_the_door_is_never_called()
{
var (_, token) = await MemberWithTokenAsync("desk.read");
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork),
configure: Bearer(token));
Assert.Equal(403, reply.Status);
Assert.Equal("insufficient_scope", Code(reply));
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_bearer_in_the_query_string_or_a_form_body_authenticates_nothing()
{
var (_, token) = await MemberWithTokenAsync("desk.answer");
DeskReplay.HoldVectorForks(factory.Door);
var query = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null) + "?access_token=" + token, DeskReplay.AnswerBody(DeskReplay.ChatFork));
var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null) + "?access_token=" + token, null);
Assert.Equal(401, query.Status);
Assert.Equal(401, list.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_desk_answer_token_reaches_the_answer_door_but_cannot_list()
{
var (_, token) = await MemberWithTokenAsync("desk.answer");
DeskReplay.HoldVectorForks(factory.Door);
var list = await GetAsync(token);
var answer = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(token));
Assert.Equal(403, list.Status);
Assert.Equal(200, answer.Status);
Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind);
}
[Fact]
public async Task A_desk_token_issues_for_a_member_with_no_training_notice_receipt_and_a_chat_token_does_not()
{
var (client, identity) = await SignedInAsync();
await RemoveReceiptsAsync(identity, withdrawOnly: false);
// Chat: sent to the consent page, no code.
var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile");
var chatFinal = await DriveAsync(client, chat);
Assert.Equal(HttpStatusCode.Redirect, chatFinal.StatusCode);
Assert.DoesNotContain("code=", chatFinal.Headers.Location!.OriginalString);
// Desk: a code, a token, and the desk answers it.
var request = NewRequest(DeskClient, DeskRedirect, "desk.read");
var code = await CodeAsync(client, request);
var token = await ExchangeAsync(client, request, code, DeskClient, DeskSecret);
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null));
Assert.Equal(200, (await GetAsync(token)).Status);
// A desk code survives the receipt going between authorize and the exchange; a suspended account still does not.
var later = NewRequest(DeskClient, DeskRedirect, "desk.read");
var laterCode = await CodeAsync(client, later);
await RemoveReceiptsAsync(identity, withdrawOnly: true);
Assert.False(string.IsNullOrEmpty(await ExchangeAsync(client, later, laterCode, DeskClient, DeskSecret)));
}
[Fact]
public async Task A_desk_scope_mixed_with_a_chat_scope_does_not_skip_the_training_notice_receipt()
{
var (client, identity) = await SignedInAsync();
await RemoveReceiptsAsync(identity, withdrawOnly: false);
var mixed = NewRequest(DeskClient, DeskRedirect, "openid desk.read");
var final = await DriveAsync(client, mixed);
Assert.Equal(HttpStatusCode.Redirect, final.StatusCode);
Assert.DoesNotContain("code=", final.Headers.Location!.OriginalString);
}
[Fact]
public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk()
{
var (client, _) = await SignedInAsync();
// Asking for the scope: the issuer refuses, so no code and no token exist for the chat client to carry.
var request = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile desk.read");
var refused = await DriveAsync(client, request);
Assert.True(refused.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.BadRequest, $"got {(int)refused.StatusCode}");
var back = refused.Headers.Location?.ToString() ?? await refused.Content.ReadAsStringAsync();
Assert.Contains("not allowed to use the specified scope", back);
Assert.DoesNotContain("code=", back);
// The token chat does hold: validly signed by the same issuer, and no use at the desk.
var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile");
var chatToken = await ExchangeAsync(client, chat, await CodeAsync(client, chat), OidcTestKit.ClientId, factory.OidcSecret);
Assert.Equal(401, (await GetAsync(chatToken)).Status);
var post = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(chatToken));
Assert.Equal(401, post.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task The_session_cookie_alone_authenticates_nothing_on_the_desk()
{
// A signed-in Active member's real session cookie, sent by hand to the desk: it is no credential there.
using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin });
var identity = $"member-{Guid.NewGuid():N}";
using var registered = await RegistrationKit.RegisterAsync(bare, identity);
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
await factory.SetMembershipAsync(identity, MembershipStatus.Active);
var cookie = string.Join("; ", registered.Headers.GetValues("Set-Cookie").Select(c => c.Split(';')[0]));
Assert.Contains("bifrost", cookie, StringComparison.OrdinalIgnoreCase);
var list = await factory.SendAsync("GET", ListTarget, configure: http => http.Request.Headers.Cookie = cookie);
var answer = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork),
configure: http => http.Request.Headers.Cookie = cookie);
Assert.Equal(401, list.Status);
Assert.Equal(401, answer.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_bearer_token_does_not_authenticate_a_cookie_page()
{
var (_, token) = await MemberWithTokenAsync("desk.read desk.answer");
using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin });
foreach (var path in new[] { "/App", "/App/Members", "/api/sealed-motions" })
{
using var message = new HttpRequestMessage(HttpMethod.Get, path);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await bare.SendAsync(message);
Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed,
$"{path} answered {(int)response.StatusCode} to a bearer");
Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
}
}
[Fact]
public async Task A_valid_token_whose_account_turns_non_active_is_refused_on_its_next_request_without_reissue()
{
var (identity, token) = await MemberWithTokenAsync("desk.read");
Assert.Equal(200, (await GetAsync(token)).Status);
await factory.SetMembershipAsync(identity, MembershipStatus.Suspended);
var reply = await GetAsync(token);
Assert.Equal(403, reply.Status);
Assert.Equal("forbidden", Code(reply));
Assert.Single(factory.Door.Calls); // only the first request got to the door
await factory.SetMembershipAsync(identity, MembershipStatus.Active);
Assert.Equal(200, (await GetAsync(token)).Status);
}
[Fact]
public async Task A_member_who_is_not_a_decider_reads_no_notes_through_a_real_token_and_ids_pass_as_the_door_sent_them()
{
var (_, token) = await MemberWithTokenAsync("desk.read");
const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it
var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork));
var answer = new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.GetProperty("options_hash").GetString(), "a", "private words",
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, secretAnswerer, "Steward", "r-1");
entry = entry with { Record = entry.Record with { Answers = [answer] } };
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry], null));
var reply = await GetAsync(token);
Assert.Equal(200, reply.Status);
Assert.Contains(secretAnswerer, reply.Text); // Bifrost masks no id; Huginn's door withholds them from non-deciders
Assert.DoesNotContain("private words", reply.Text);
}
// ---- helpers ----
private static Action<Microsoft.AspNetCore.Http.HttpContext> Bearer(string token) =>
http => http.Request.Headers.Authorization = "Bearer " + token;
private Task<DeskReply> GetAsync(string? token) =>
factory.SendAsync("GET", ListTarget, configure: token is null ? null : Bearer(token));
private static string? Code(DeskReply reply) => (string?)JsonNode.Parse(reply.Body)!["code"];
private HttpClient NewClient() => factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = true,
BaseAddress = Origin,
});
// A registered, Active, agreed member holding the session cookie.
private async Task<(HttpClient Client, string Identity)> SignedInAsync()
{
var client = NewClient();
var identity = $"member-{Guid.NewGuid():N}";
using var registered = await RegistrationKit.RegisterAsync(client, identity);
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
await factory.SetMembershipAsync(identity, MembershipStatus.Active);
await SeedDeskClientAsync();
return (client, identity);
}
// Leaves the account with no live training-notice receipt: none at all, or each one withdrawn.
private async Task RemoveReceiptsAsync(string identity, bool withdrawOnly)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id;
var receipts = db.ConsentReceipts.Where(x => x.UserAccountId == id).ToList();
if (withdrawOnly)
{
receipts.ForEach(r => r.WithdrawnAtUtc = DateTimeOffset.UtcNow);
}
else
{
db.ConsentReceipts.RemoveRange(receipts);
}
await db.SaveChangesAsync();
}
private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope)
{
var (client, identity) = await SignedInAsync();
var request = NewRequest(DeskClient, DeskRedirect, scope);
var token = await ExchangeAsync(client, request, await CodeAsync(client, request), DeskClient, DeskSecret);
return (identity, token);
}
// The test-only desk client: the production seeder creates none (Ratatoskr's redirect and host are a separate decision).
private async Task SeedDeskClientAsync()
{
using var scope = factory.Services.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
if (await manager.FindByClientIdAsync(DeskClient) is not null)
{
return;
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = DeskClient,
ClientSecret = DeskSecret,
ClientType = ClientTypes.Confidential,
RedirectUris = { new Uri(DeskRedirect) },
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.ResponseTypes.Code,
Permissions.Prefixes.Scope + Scopes.OpenId,
Permissions.Prefixes.Scope + DeskEndpoints.ReadScope,
Permissions.Prefixes.Scope + DeskEndpoints.AnswerScope,
Permissions.Prefixes.Scope + DeskWebApplicationFactory.OtherAudienceScope,
},
});
}
private sealed record AuthRequest(string ClientId, string RedirectUri, string Verifier, string State, string Path);
private static AuthRequest NewRequest(string clientId, string redirectUri, string scope)
{
var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48));
var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24));
var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
var path = "/connect/authorize?response_type=code" +
$"&client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
$"&scope={Uri.EscapeDataString(scope)}&state={state}&code_challenge={challenge}&code_challenge_method=S256";
return new AuthRequest(clientId, redirectUri, verifier, state, path);
}
// Starts an authorize request and follows the provider's own redirects until the first response that is not one of them.
private static async Task<HttpResponseMessage> DriveAsync(HttpClient client, AuthRequest request)
{
var response = await client.GetAsync(request.Path);
for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++)
{
var next = response.Headers.Location!;
response.Dispose();
response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString);
}
return response;
}
private static bool IsAuthorizeRedirect(HttpResponseMessage response) =>
response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found &&
response.Headers.Location is { } location &&
(location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal);
private static async Task<string> CodeAsync(HttpClient client, AuthRequest request)
{
var final = await DriveAsync(client, request);
Assert.True(final.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect to the client, got {(int)final.StatusCode}");
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(final.Headers.Location!.Query);
Assert.True(query.ContainsKey("code"), final.Headers.Location.ToString());
return query["code"].ToString();
}
private async Task<string> ExchangeAsync(HttpClient client, AuthRequest request, string code, string clientId, string secret)
{
var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = request.RedirectUri,
["code_verifier"] = request.Verifier,
}),
};
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{secret}")));
using var bare = NewClient();
using var response = await bare.SendAsync(message);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
return body.GetProperty("access_token").GetString()!;
}
}

View file

@ -64,12 +64,31 @@ public sealed class DeskDocumentTests
public void Cards_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(5, rows.Count);
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
var card = message.Deserialize<JsonElement>(DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
}
}
// The card is opaque: whatever keys, in whatever order, with whatever numbers, leave as they came.
[Fact]
public void An_unknown_card_passes_through_byte_for_byte()
{
const string card =
"{\"order\":7,\"raised_at\":\"2026-11-11T09:00:00Z\",\"title\":\"It's \u00e9\",\"fork_id\":\"x:question:y\"," +
"\"future\":{\"z\":1.50,\"a\":[3,null,1e2],\"big\":12345678901234567890},\"options_hash\":\"ab\",\"leans\":null}";
var entryJson =
"{\"schemaVersion\":\"gamecult.fork.entry.v0\",\"card\":" + card +
",\"record\":{\"schemaVersion\":\"gamecult.fork.record.v0\",\"fork_id\":\"x:question:y\",\"options_hash\":\"ab\",\"counted_answer_id\":null,\"answerer_count\":0,\"answers\":[]}}";
var entry = JsonSerializer.Deserialize<ForkEntry>(entryJson, DeskJson.Options)!;
var written = JsonSerializer.Serialize(entry, DeskJson.Options);
Assert.Equal(entryJson, written);
var result = new AnswerResult(DeskSchemas.AnswerResult, null!, entry.Card, null);
Assert.Contains("\"card\":" + card + ",", JsonSerializer.Serialize(result, DeskJson.Options));
}
}

View file

@ -0,0 +1,200 @@
using System.Security.Cryptography;
namespace Bifrost.Web.Tests;
// Huginn's desk-feed golden bytes, vendored byte for byte from crates/huginn-mind/tests/fixtures/desk-feed/ at Huginn
// main 232764bf78fa93c807d2e18b1d77f45c36159be0 (docs/desk-feed-door.md is the contract): 99 files, the
// subdirectory cultlib-d3025d3c/ (fourteen CultLib vectors, unedited) included. The pin is the exact file list and each
// sha256; a changed byte, an extra file or a missing file fails, and the verifier is proved to catch each.
// A silent re-vendor fails here: move the pin on purpose, with the contract and the door's tests.
public sealed class DeskFeedFixturePinTests
{
public const string HuginnCommit = "232764bf78fa93c807d2e18b1d77f45c36159be0";
public static readonly string Directory = Path.Combine(AppContext.BaseDirectory, "Fixtures", "desk-feed");
public static readonly IReadOnlyDictionary<string, string> Sha256 = new Dictionary<string, string>(StringComparer.Ordinal)
{
[".gitattributes"] = "171872d6445cb1ffef0263b613d1ac318a60398449831d4cc4f772f78a7ebcff",
["README.md"] = "fd3429ee796ac1c3de9571bc2d09e24f0f91aae48d461f1c8eefa51c6b50a3bc",
["cultlib-d3025d3c/README.md"] = "b0f6ce43f4c5aa9ae263071b814e2f71445bd50db1d55c6fbcdaf2714166b47a",
["cultlib-d3025d3c/answer-counted.msgpack"] = "55d69ae3cc7ebbeeae7d501e8641640e115c3fb5aac399ea760588bca343b9e6",
["cultlib-d3025d3c/answer-refused-stale.msgpack"] = "3183bc9c483f4a625332cda11ef22a9c8560868a7728b77daa63a1960a3cd3d6",
["cultlib-d3025d3c/answer-retraction.msgpack"] = "89156f41ec3ea54f5d47bd3318913ff013785f4727916875be97785935cb7735",
["cultlib-d3025d3c/answer-revision.msgpack"] = "19d7289395ae68cbaf6de1c0a1ef6c843211b2caf9462e1d2268cd0883386315",
["cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack"] = "7d0cc555bf9b6f7c345b0da7c504bcd1ed7b46040ff51d1e57c84b2f56655213",
["cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack"] = "93f7c875f963a7cfeab2a5980b6d5ce81e6753fc9f3f8bf0d7353f3f388144b7",
["cultlib-d3025d3c/answer-withheld-answerer.msgpack"] = "b66b4f921cdd374be04af3de34c59e254a5e542027ae8c57f196a3ea3406e427",
["cultlib-d3025d3c/card-operator-fork.msgpack"] = "56bfdb65ded60b562001d09d0b8da6e092900f404bf5f68f7084dff29bcc34f5",
["cultlib-d3025d3c/card-raised-at-nil.msgpack"] = "b81bb825790ffe03fabe63c3f8a61c36bd50ca268aa6dff67b5bcfad3957e58c",
["cultlib-d3025d3c/card-withheld-deciders.msgpack"] = "d13b9b77461cfdf78f943ef56c6aeab852ab6b6f0b355e26c5b361f99c79c7f7",
["cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack"] = "f7859c7d04d338d6b1c634308b08ae58ae77b0ff459767859a5e40f0af2f16e5",
["cultlib-d3025d3c/entry-open-counted.msgpack"] = "b51f499e8dd0465cf5df9d68d8fccbe94dd1339fd3157969693dd0d4ecb2549b",
["cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack"] = "daaa819bfa2612616826ef3217dfcf8d8d8cb71d86547d7106426c779134f827",
["cultlib-d3025d3c/entry-withheld-open-counted.msgpack"] = "62ccad7a9f41262d6e35d0247315d0bb75d1313b3c9dabe8a99d1aa1b844607f",
["error.response.frame.bin"] = "fa03f44b82d7c4796f45dd36086a54342dc1de7e660a58cf3ddff4ff59d85a4c",
["error.response.frame.hex"] = "21c75b6e1d000ddc1a5455a9cc4d6c5b37bd1640c22e1055e96ff85bdff461bb",
["failure.response.frame.bin"] = "edc2277943334744d3057902800c25bbc913ff8ed336b4612f1a19772ec46dc9",
["failure.response.frame.hex"] = "b3c262ac506b51529e8063b2d38cc8c0047b431b75fe6ede1a91adebefd014d3",
["failure.response.payload.hex"] = "13ca85b5868451892746d2531833d394b66d25b5069cd0208169c5fcc94bf350",
["failure.response.payload.msgpack"] = "4bf7c80eb2d75b9559ab430757a98ebfd424745ed3fc1888d21bc6e41ed99403",
["list-counts.request.frame.bin"] = "be338fc06f127f9c20f82952708ffe95f8b6b8de7c5098834eb8cb257893637f",
["list-counts.request.frame.hex"] = "496bbc58292a1d029361bd553eef2ccf786cfa6520b1a3ad97c831893c36cf50",
["list-counts.request.payload.hex"] = "5ec076a5d8006a0290ad269deffb2bfe45ac7ee8b6a11ea2d4968a6ac8603e4b",
["list-counts.request.payload.msgpack"] = "52f244c3d704067f79561c8d46f4069ac3f4452a73d916fb92154d28b28ea6c7",
["list-counts.response.frame.bin"] = "bf51c933fbbaa61934907cb66f5e07ac813de48e849ac80bdf3ba4f75d3952a3",
["list-counts.response.frame.hex"] = "3f395ae2302d0882fb2456fe94512bba045a1c967f98b4ebf6b306dd5e3d74a3",
["list-counts.response.payload.hex"] = "9a6f6b0be985d6ff8d313fa9e12ffe6662a59ff7305e574f4bfe8bf9a3ce2291",
["list-counts.response.payload.msgpack"] = "1f83a7c9ab1ce1af7c245a8f11f54ea67ef273fc2533554db3a1711e3f650c13",
["list-empty.response.frame.bin"] = "faf3c5008bd50c7f76121a3f27c5c4f944d1c7ad32d8bbd4e67d025c45b3f4f1",
["list-empty.response.frame.hex"] = "9b0f15bf8948e44f9e43718914f6621402c98c06e0d21bf90640c521ab7f1a45",
["list-empty.response.payload.hex"] = "d93cd65c1d9f4fc052b9c7f17d16ea6e468a7af296cafd6a31d671ae855d2860",
["list-empty.response.payload.msgpack"] = "7b1a26f303d3bdd0317d24a0eb4a2d9144ada0a878c927905a1a345efabf3a9f",
["list-non-decider.request.frame.bin"] = "ebf8664681fcf624e4c6b82eb5e23e4c31b56d0f8a03f1cfbadc766408eaa18e",
["list-non-decider.request.frame.hex"] = "f0d7f662425ca1c16dea4f5eb78d894898d0dda2ff04f9614663c74620371d26",
["list-non-decider.request.payload.hex"] = "383122413f2bd276d27c53bdfcac9f65ba704ea2b1e1c18e72b89b164aad2c2d",
["list-non-decider.request.payload.msgpack"] = "9915249f377a6afe4dda2f007c23e8f2598f759bd9d3991834c9ee162b0d6a5b",
["list-open-cursor.request.frame.bin"] = "13671e1c83c10350d86b149a86a98b69763b9a3e40eae0024c10f3e70b96ef8b",
["list-open-cursor.request.frame.hex"] = "e2270f26d122374554855bf60686edbc5d4abfd63374cde08ab479a5f310aa12",
["list-open-cursor.request.payload.hex"] = "437f4c0cbdc712e9b3f8e1f8666cf8ffe88061cce8d0da3a64e648eb7b6bb27f",
["list-open-cursor.request.payload.msgpack"] = "05889e90f8143aa1a1375539f6e76593a682413b70cb40a8aaa3c7db2882622a",
["list-open-non-decider.response.frame.bin"] = "8bf4539f305a9530ac88090bdb2f3a3c851f14f66bd2c69f8009ad4934aec3ad",
["list-open-non-decider.response.frame.hex"] = "d377e096ce781b0fb86398a58e69ee86205770c298120facac9e739937e88e7d",
["list-open-non-decider.response.payload.hex"] = "c99b932fb7c19fc28a8fb04f44b80951a55722cbab9c32114ca41c1cd13ef84e",
["list-open-non-decider.response.payload.msgpack"] = "14616093522771954b34b7414849c2896b0d328c8cf0feac6d8038116d813d8c",
["list-open.request.frame.bin"] = "2af34627a45d7bfca034b880bf61f5dfd78022a8df425f418c0a084181663903",
["list-open.request.frame.hex"] = "03f74ba78996b2ccb3fcf5fb712ae1d19f7594099ba04250ee0223c8631b9a24",
["list-open.request.payload.hex"] = "a58e8583d41d4a04cbd983c6b5a8bdae26105f2eb7f25dfb7d5f86aa81847e93",
["list-open.request.payload.msgpack"] = "431e000c3f002c254adc57273557c8111e815ca42a8dd4272762d3e7697d7f0a",
["list-open.response.frame.bin"] = "82d7b9bd8a5d3a98f2d5780b3cb59fb75b598a333069a03243c1ec83faa6f099",
["list-open.response.frame.hex"] = "ebc75b6ae8d16b1cbe0bece1ed3fa32d0ead42fc39ca939e542a8fa45e1dadee",
["list-open.response.payload.hex"] = "618918c8020e7bf673758a7e6a58b78469449838b287402bd383a7d1258adc57",
["list-open.response.payload.msgpack"] = "f16c74f6340054e40d0d0862381da717a1fa651a67f3250b7bd555cea2834a6e",
["list-response-too-large.response.frame.bin"] = "b95218e48c1f8e4162f515b97a85c324b9ccfe2b3216780ed0eca1d2e2066f19",
["list-response-too-large.response.frame.hex"] = "b4bedb2c59b1362e5059caf6dbea63952b8fe9c47b681da3825422bc677f8632",
["list-response-too-large.response.payload.hex"] = "4f5f18915e0e536c387e659eb2021570a5b0bc5e0f530521ca226dc687d6780d",
["list-response-too-large.response.payload.msgpack"] = "9749f0dc7a3ec69aba7f45ba62ef61332461747cf42b188b84998da7bbd4d5d9",
["list-too-large.response.frame.bin"] = "1210fb82189a23d7ee4eb3851d4b83013f4f2a2298533734df348156097b5c60",
["list-too-large.response.frame.hex"] = "aad762b1e1deea39330223eb9ea7ebd2bb0fdd3326e259383b90846c1310456f",
["list-too-large.response.payload.hex"] = "880ae2c51454a3d6b06d2e827e44b6990365d8cfa304b55e25eb898641117762",
["list-too-large.response.payload.msgpack"] = "710d145c1ded7e7d02343342becd61e4520da979580f13dbebe6f0eda5aabec6",
["read-non-decider.request.frame.bin"] = "3f2729eca79e3c21feebc763f22b5ee86803b333a22b39d5cc1635e2fae245b6",
["read-non-decider.request.frame.hex"] = "9dce0664c145dff66d52e2c2d21cf12b187884392908f031a310534716646aa5",
["read-non-decider.request.payload.hex"] = "32d77b81c10bc8accbc31a667d2ad631bf5e3404a485d3d3bed3889c50571f8e",
["read-non-decider.request.payload.msgpack"] = "c2ec1ecb06123fbb6292ed7a409035f7cc58408885886259131226a8c0aabc08",
["read-non-decider.response.frame.bin"] = "15da06225cdd950bf0fe61e49a7ba18d6117b073fdaa6b9732f902564e47d574",
["read-non-decider.response.frame.hex"] = "9cdb0efaaf52f8234ea4ad9c357d563f134cedc8dc7f94ae1b297a30670dd013",
["read-non-decider.response.payload.hex"] = "0ae37b5f6920c4a41f4f8fe7c45f03c90ec74e384991920811273a0ef4250f9c",
["read-non-decider.response.payload.msgpack"] = "41e947355eac70e948633986bc19506fef5c2f92d7fc4242a1783dc1aa688890",
["read-too-large.response.frame.bin"] = "5995c03914851ea95aaff4b75fa3ebaee4f19a2c7ce101eab85b7475515690bd",
["read-too-large.response.frame.hex"] = "b150aa192bf3fa415b5175f8b9f43731c5b73c2a2153c87da2dc3a4b6d986ee2",
["read-too-large.response.payload.hex"] = "18b7ad6c617ee1d9a3aa726a012e9a3af8ca9c14b8af774d082cf17879c85b90",
["read-too-large.response.payload.msgpack"] = "4a9135ede7d6588ba65e49b372b99d5da956fab9c693f1aa018e0ebe1406246a",
["read-unknown.response.frame.bin"] = "a62293ff79c0bbe626d020140f4c8cbe3f62a87ac0f730f3ba3c2a7acd3d49a5",
["read-unknown.response.frame.hex"] = "97ab11a04a2d62917186b23666ed26ef09df220ffb1c3e188d919985085733e2",
["read-unknown.response.payload.hex"] = "ca8d4d1fa4d40915b6e062767967e62343b360ce669801da96d99509e24787fb",
["read-unknown.response.payload.msgpack"] = "0d4fe47159db88b5048bc1f42a0e72857b568eac48572777a0c896ee94f7c208",
["read.request.frame.bin"] = "3a9bc16ce4ce706e8c839bf4cfe65d3e2fdf759992555e68be6fc7bcc15653ea",
["read.request.frame.hex"] = "3c3d9540c4af789d230b7108f98d5d5f3dd9b291ff618064ddc374eb2a7bb082",
["read.request.payload.hex"] = "48aed263254eac7bc156c1af1f2ee210a08dda2b453a5be2dc17083ab73800b2",
["read.request.payload.msgpack"] = "c5447f462247703af4329e071bc0ac14dce1cb94a63c85517400791d022263ca",
["read.response.frame.bin"] = "34acacf58f3bd10e86bad6b10f3f14b6a263709b54ff81873af7dcfaf3d95a06",
["read.response.frame.hex"] = "600848ae27be452d9a7845c5d822c8eabc1df07b163071d35cc38f6f2ec330bb",
["read.response.payload.hex"] = "c3322aa9989a47b9b255cd8d20d6026a159c6b9a8a5ee22f135407bf96cfd2d3",
["read.response.payload.msgpack"] = "79fb2f9dd3fde3335bca5cc4337abcf0d7e195afc341352087e6432b7f5ae0a7",
["record-whole.payload.hex"] = "871ee32813f46656aa372f6d791d36f885e83194294d3a9fa1fcb906cab73eb4",
["record-whole.payload.msgpack"] = "e85e420e7450cfecc698268299b1a26e1ed256d0420143cbad74f80b5a52731d",
["record-withheld.payload.hex"] = "a0e7afd9131387cc25d5a2ff5b5f12d546ddd38dfe2bc0d74d3c55a4439048d6",
["record-withheld.payload.msgpack"] = "aff799fe787258c35e658cf4d6dea33b95938c47f2d4e72fcdf194f0b537bfed",
["refused-unit.response.frame.bin"] = "4f86943a6947dd3c6041ee7d1b52d3f73697604dd224ca5dcf5b66064b0de1f1",
["refused-unit.response.frame.hex"] = "f25f35911d242d6fdb7ac4e1ecb3fe01901aa4b816d957104bde6f21b29a1bed",
["refused-unit.response.payload.hex"] = "858d49c8c66ce1b9c32160e2aecb320a8c00166728c80f5a02d62ec90b4f410d",
["refused-unit.response.payload.msgpack"] = "f2eedbece230b164adf1897c3a047317a4e3074b2d5fd4c5abef3fbb98336c32",
["unavailable.response.frame.bin"] = "0cb241568bb20d0ccf62f337f4daa01ca3dd82cedd476fb4c1f9e7dc3272cabe",
["unavailable.response.frame.hex"] = "86f0e5bb2d3e0ea39ae2f0dbc2c1b62e5a77a35bffc6f4adfa87d280d718bf44",
["unavailable.response.payload.hex"] = "5a8e7d6e3cd7abf3649941d6b55a511d025c2baf2d8dde08e505e57e9cb4fd22",
["unavailable.response.payload.msgpack"] = "dfa613abaf5f23e6aff5bcb0a59b3a1784f2fbf6d12cbdfa82f10e80963f0a4e",
};
// Every way the directory differs from the pin, by relative path (forward slashes).
public static List<string> Problems(string directory)
{
var present = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", SearchOption.AllDirectories))
{
var name = Path.GetRelativePath(directory, file.FullName).Replace('\\', '/');
present[name] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file.FullName))).ToLowerInvariant();
}
var problems = new List<string>();
problems.AddRange(Sha256.Keys.Except(present.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"missing: {n}"));
problems.AddRange(present.Keys.Except(Sha256.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"extra: {n}"));
problems.AddRange(Sha256.Where(p => present.TryGetValue(p.Key, out var actual) && actual != p.Value).Select(p => $"changed: {p.Key}"));
return problems;
}
[Fact]
public void Fixtures_are_huginn_232764bf_byte_for_byte()
{
Assert.Equal(99, Sha256.Count);
Assert.True(Problems(Directory).Count == 0,
$"not Huginn {HuginnCommit}:crates/huginn-mind/tests/fixtures/desk-feed/: {string.Join("; ", Problems(Directory))}. " +
"Re-vendor from that commit or move the pin on purpose.");
}
private static string Copy()
{
var copy = System.IO.Directory.CreateTempSubdirectory("pin").FullName;
foreach (var file in new DirectoryInfo(Directory).EnumerateFiles("*", SearchOption.AllDirectories))
{
var to = Path.Combine(copy, Path.GetRelativePath(Directory, file.FullName));
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(to)!);
file.CopyTo(to);
}
return copy;
}
[Theory]
[InlineData("list-open.request.frame.bin", "changed")]
[InlineData("cultlib-d3025d3c/entry-open-counted.msgpack", "changed")]
[InlineData("README.md", "changed")]
public void A_changed_byte_fails_the_pin(string name, string kind)
{
var copy = Copy();
try
{
var path = Path.Combine(copy, name);
var bytes = File.ReadAllBytes(path);
bytes[^1] ^= 1;
File.WriteAllBytes(path, bytes);
Assert.Equal([$"{kind}: {name}"], Problems(copy));
}
finally
{
System.IO.Directory.Delete(copy, true);
}
}
[Fact]
public void An_extra_or_missing_file_fails_the_pin_at_either_level()
{
var copy = Copy();
try
{
File.WriteAllText(Path.Combine(copy, "extra.hex"), "00\n");
File.WriteAllText(Path.Combine(copy, "cultlib-d3025d3c", "extra.msgpack"), "x");
File.Delete(Path.Combine(copy, "read.request.frame.hex"));
File.Delete(Path.Combine(copy, "cultlib-d3025d3c", "answer-counted.msgpack"));
Assert.Equal(
["missing: cultlib-d3025d3c/answer-counted.msgpack", "missing: read.request.frame.hex", "extra: cultlib-d3025d3c/extra.msgpack", "extra: extra.hex"],
Problems(copy));
}
finally
{
System.IO.Directory.Delete(copy, true);
}
}
}

View file

@ -32,7 +32,7 @@ public sealed class DeskOptionsTests
return options;
}
private static void Validate(Dictionary<string, string?> values, string environment = "Testing")
private static void Validate(Dictionary<string, string?> values, string environment = "Testing", bool oidc = true)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
new StartupConfigurationValidator(
@ -45,7 +45,8 @@ public sealed class DeskOptionsTests
Options.Create(new BifrostHostOptions()),
Options.Create(new ConceptVoteOptions()),
Options.Create(Bind(values)),
Options.Create(new RegistrationOptions())).Validate();
Options.Create(new RegistrationOptions()),
oidc ? new OidcIssuerOptions() : null).Validate();
}
private static void Refused(Dictionary<string, string?> values, string expectedFragment)
@ -65,6 +66,15 @@ public sealed class DeskOptionsTests
Validate(Good());
}
[Fact]
public void A_desk_without_the_oidc_issuer_stops_startup_and_says_why()
{
var failure = Assert.Throws<InvalidOperationException>(() => Validate(Good(), oidc: false));
Assert.Contains("Bifrost:Oidc must be configured when the Desk section exists", failure.Message);
Validate([], oidc: false); // no desk, no need of an issuer
}
[Fact]
public void No_desk_section_is_not_configured_and_passes()
{

View file

@ -78,33 +78,6 @@ public sealed class DeskReadTests
Assert.Equal(200, bare.Status);
}
[Fact]
public async Task List_scenarios_pass_through()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios"))
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var request = step["request"]!;
var response = step["response"]!.AsObject();
var list = response.Deserialize<ForkList>(DeskJson.Options)!;
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!);
steps++;
}
}
Assert.Equal(9, steps);
}
[Fact]
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
{
@ -374,7 +347,13 @@ public sealed class DeskReadTests
private static DeskAnswerDocument Doc(string id, string outcome, string? of, string? retracts, string? note) => new(
DeskSchemas.DeskAnswer, id, DeskReplay.ChatFork, outcome, of, retracts, null, "h", note == null ? null : "a", note,
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, DeskWebApplicationFactory.Alice, "Steward", "r-" + id);
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, AnswererOf(id), "Steward", "r-" + id);
// Four different accounts, so a mask that kept any one of them, or told two apart, is seen.
private static readonly string[] Answerers =
[DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward, DeskWebApplicationFactory.Member, "0badc0de-0000-4000-8000-00000000000d"];
private static string AnswererOf(string answerId) => Answerers[int.Parse(answerId[1..]) - 1];
// Counted, Revision, Retraction and Refused documents, every one carrying the decider's note.
private static ForkEntry NotedEntry()
@ -424,6 +403,9 @@ public sealed class DeskReadTests
Assert.DoesNotContain("decider's own words", list);
Assert.False(AnyNote(JsonNode.Parse(read)));
Assert.False(AnyNote(JsonNode.Parse(list)));
// Bifrost masks no id: what the door sent for the answerers is what the reader gets (the door withholds ids from non-deciders).
var readAnswerers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]);
Assert.Equal(NotedEntry().Record.Answers.Select(x => x.Answerer), readAnswerers);
// Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape.
var expected = JsonSerializer.SerializeToNode(NotedEntry() with
{
@ -436,6 +418,22 @@ public sealed class DeskReadTests
Assert.Equal(2, JsonNode.Parse(list)!["forks"]!.AsArray().Count);
}
[Fact]
public async Task Bifrost_relays_exactly_the_entries_the_door_returns_to_any_reader()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var forks = Enumerable.Range(1, 7).Select(i => DeskReplay.Entry(DeskReplay.Card("gamecult-chat:question:q" + i))).ToArray();
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 7, 0, forks, null));
foreach (var account in new[] { DeskWebApplicationFactory.Member, DeskWebApplicationFactory.Alice })
{
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, factory.Token(account, "bifrost-desk", "desk.read"));
Assert.Equal(forks.Select(f => f.Card.GetProperty("fork_id").GetString()), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"]));
}
}
[Fact]
public async Task A_decider_reads_and_lists_the_notes()
{
@ -445,6 +443,7 @@ public sealed class DeskReadTests
var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Alice);
Assert.Contains(Secret + " 3", read);
Assert.Equal(Answerers, JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]));
Assert.Equal(8, JsonNode.Parse(list)!["forks"]!.AsArray().Sum(f => f!["record"]!["answers"]!.AsArray().Count(a => a!["note"] is not null)));
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), JsonSerializer.SerializeToNode(NotedEntry(), DeskJson.Options)));
}

View file

@ -0,0 +1,89 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// CultLib d3025d3c's reader views (append-only on 3a51bbab), as Bifrost meets them: the 18-key card, DE0012 (raised_at, decider_count) stays opaque,
// the record is typed with answerer_count, and answerer is nil in a non-decider's view. Entries, records, list pages and
// stored answers are replayed from their wire bytes through the real door in DeskVectorWireTests.
public sealed class DeskReaderViewTests
{
private static readonly string[] CardKeys =
[
"schemaVersion", "fork_id", "title", "context", "options", "recommendation", "depends", "leans", "flag", "raised_in",
"raised_at", "deciders", "decider_count", "status", "ruling", "ruled_from", "order", "options_hash",
];
[Fact]
public void Valid_cards_are_the_17_key_card_with_both_views_and_pass_through_unchanged()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!.AsObject();
Assert.Equal(CardKeys, message.Select(p => p.Key));
Assert.StartsWith("DE0012", (string)row["messagePackHex"]!, StringComparison.Ordinal);
Assert.True(message["raised_at"] is null || message["raised_at"]!.GetValueKind() == JsonValueKind.String);
var count = (int)message["decider_count"]!;
Assert.InRange(count, 1, 64);
var deciders = message["deciders"]!.AsArray();
Assert.True(deciders.Count == 0 || deciders.Count == count, (string)row["name"]!);
var card = message.Deserialize<JsonElement>(DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(card, DeskJson.Options)), (string)row["name"]!);
}
Assert.Contains(rows, r => r["message"]!["raised_at"] is null);
Assert.Contains(rows, r => r["message"]!["deciders"]!.AsArray().Count == 0 && (string)r["name"]! == "withheld_deciders");
}
[Fact]
public void The_options_hash_vectors_are_the_15_and_Bifrost_computes_no_hash()
{
Assert.Equal(15, DeskVectors.Array("optionsHash").Count);
Assert.Equal(9, DeskVectors.Array("bodyDigests").Count);
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
foreach (var file in Directory.GetFiles(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk"), "*.cs"))
{
var text = File.ReadAllText(file);
Assert.DoesNotContain("System.Security.Cryptography", text, StringComparison.Ordinal);
Assert.DoesNotContain("SHA256", text, StringComparison.Ordinal);
}
}
[Fact]
public void Stored_answers_round_trip_and_every_variant_carries_a_nil_answerer_with_the_key_written()
{
var rows = DeskVectors.Rows("deskAnswer", "valid").ToList();
Assert.Equal(8, rows.Count);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction");
var outcomes = new HashSet<string>();
foreach (var row in rows)
{
var message = row["message"]!.AsObject();
Assert.StartsWith("DE0010", (string)row["messagePackHex"]!, StringComparison.Ordinal);
outcomes.Add((string)message["outcome"]!);
Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(message.Deserialize<DeskAnswerDocument>(DeskJson.Options), DeskJson.Options)), (string)row["name"]!);
var withheld = JsonNode.Parse(message.ToJsonString())!.AsObject();
withheld["answerer"] = null;
var back = JsonSerializer.SerializeToNode(withheld.Deserialize<DeskAnswerDocument>(DeskJson.Options), DeskJson.Options)!.AsObject();
Assert.True(back.ContainsKey("answerer"));
Assert.Null(back["answerer"]);
Assert.True(DeskVectors.SameShape(withheld, back), (string)row["name"]!);
}
Assert.Superset(new HashSet<string> { "Counted", "Revision", "Retraction", "Refused" }, outcomes);
}
}

View file

@ -22,7 +22,7 @@ public sealed class DeskScenarioTests
{
using var factory = new DeskWebApplicationFactory();
var door = factory.Door;
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<JsonElement>(DeskJson.Options));
var scenarioName = (string)scenario["name"]!;
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
@ -36,7 +36,7 @@ public sealed class DeskScenarioTests
if (kind == "card")
{
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<JsonElement>(DeskJson.Options));
continue;
}
@ -155,7 +155,7 @@ public sealed class DeskScenarioTests
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
message["card"] is { } card ? card.Deserialize<JsonElement>(DeskJson.Options) : DeskReplay.Card(answer.ForkId));
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);

View file

@ -36,13 +36,14 @@ public sealed class DeskSurfaceTests
}
[Fact]
public void A_configured_desk_without_its_ports_stops_startup()
public void A_configured_desk_with_no_door_supplied_gets_the_production_unix_socket_door()
{
using var factory = new DeskWebApplicationFactory(registerPorts: false);
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
factory.CreateClient();
Assert.Contains("IDeskCredential", failure.ToString());
// The credential is production wiring (OpenIddict validation) and so is the door; a test supplies a fake only by replacing it.
Assert.IsType<UnixSocketDeskDoor>(factory.Services.GetRequiredService<IDeskDoor>());
}
[Fact]

View file

@ -0,0 +1,99 @@
using System.Reflection;
using System.Text.Json.Nodes;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// Every section of the vendored vectors file (each top-level key, and each key under an object section down to depth 3) is
// classified here: replayed by a named test of Bifrost's, or not judged by Bifrost because the rule belongs to CultLib's
// validators and Huginn's door. A re-pin that adds a section fails until someone classifies it; a section that goes
// fails too. A replayed section names the test that replays it, and the test must exist.
public sealed class DeskVectorCensusTests
{
private const string Wire = nameof(DeskVectorWireTests);
// path -> "Class.Method" of a test that replays it.
private static readonly Dictionary<string, string> Replayed = new(StringComparer.Ordinal)
{
["card.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_card_leaves_the_door_inside_an_entry_unchanged)}",
["request.valid"] = "DeskBodyShapeTests.Valid_request_vectors_pass",
["request.invalid"] = "DeskBodyShapeTests.Invalid_request_vectors_refuse_with_their_code",
["deskAnswer.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included)}",
["results"] = "DeskScenarioTests.Answer_results_pass_through_unchanged",
["doorRefusals"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors",
["unavailable"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors",
["scenarios"] = "DeskScenarioTests.Every_scenario_answer_step_replays_through_http",
["transport.listRequest.valid"] = "DeskReadTests.Valid_list_queries_reach_the_door_as_typed_requests",
["transport.listRequest.invalid"] = "DeskReadTests.List_and_read_request_invalid_vectors",
["transport.entry.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_entry_leaves_a_read_unchanged_in_both_views)}",
["transport.entry.invalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract)}",
["transport.listInvalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_a_negative_count_and_judges_no_other_list_rule)}",
["transport.listScenarios"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message)}",
["transport.listShortPages"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader)}",
["transport.readTooLarge"] = "DeskReadTests.A_read_past_the_cap_follows_the_vectors",
["transport.answerTooLarge"] = "DeskAnswerTests.A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed",
["transport.doorFaults"] = "DeskReadTests.Door_faults_follow_the_vectors_per_route",
["transport.mindRoutes.minds"] = "DeskMindRoutesTests.Every_vector_segment_resolves_or_misses",
["transport.mindRoutes.forks"] = "DeskMindRouteTests.Vector_route_cases",
["transport.mindRoutes.cases"] = "DeskMindRouteTests.Vector_route_cases",
["transport.mindRoutes.sameResponse"] = "DeskMindRouteTests.Same_response_pairs_are_byte_equal",
["transport.authCases"] = "DeskAuthCaseTests.Vector_auth_cases",
};
// path -> why Bifrost does not judge it.
private static readonly Dictionary<string, string> NotJudged = new(StringComparer.Ordinal)
{
["provenance"] = "metadata text, not a vector",
["optionsHash"] = "Bifrost computes no hash of a card (DeskReaderViewTests pins the 15 and that the Desk source holds no SHA256); CultLib's validators and Huginn's door own the options hash",
["bodyDigests"] = "the body digest behind request_id_conflict is the receiver's (Huginn's door); Bifrost forwards the body as written (DeskReaderViewTests pins the 9)",
["card.invalid"] = "the card is an opaque map to Bifrost; every card rule (R-card, depends, options, deciders, hash) is CultLib's validators' and Huginn's door's",
["deskAnswer.invalid"] = "outcome, code and target pairing are CultLib's and Huginn's; the door judges only the closed 16-key typed shape (all twelve have it; DeskVectorWireTests passes them through)",
["resultsInvalid"] = "R-answer-result (the sender's own answer carries their account) is Huginn's door's rule; DeskVectorWireTests reads the three as any answer",
["rulingCites"] = "which answer a ruling may cite is a rule of CultLib's validators and the ruling's author; Bifrost forwards answers and rules on nothing",
["transport.readRequest.valid"] = "the read request is the route's own path segments (mind, fork id); the golden read.request frame pins what the door sends",
["transport.readRequest.invalid"] = "an empty fork id or mind is a routing miss before any request exists (DeskReadTests, DeskMindRoutesTests)",
};
private static IEnumerable<string> Sections(JsonNode node, string path, int depth)
{
if (node is JsonObject map && depth < 3)
{
foreach (var (key, value) in map)
{
foreach (var section in Sections(value!, path.Length == 0 ? key : path + "." + key, depth + 1))
{
yield return section;
}
}
}
else
{
yield return path;
}
}
[Fact]
public void Every_section_of_the_vendored_vectors_is_classified_replayed_or_not_judged_and_no_section_is_both()
{
var sections = Sections(DeskVectors.Root, string.Empty, 0).Order(StringComparer.Ordinal).ToList();
Assert.Empty(Replayed.Keys.Intersect(NotJudged.Keys));
Assert.Equal(sections, Replayed.Keys.Concat(NotJudged.Keys).Order(StringComparer.Ordinal).ToList());
Assert.Contains("transport.entry.invalid", sections);
Assert.Contains("resultsInvalid", sections);
}
[Fact]
public void Every_replayed_section_names_a_test_that_exists()
{
var assembly = typeof(DeskVectorCensusTests).Assembly;
foreach (var (section, test) in Replayed)
{
var (className, method) = (test[..test.IndexOf('.', StringComparison.Ordinal)], test[(test.IndexOf('.', StringComparison.Ordinal) + 1)..]);
var type = assembly.GetType("Bifrost.Web.Tests." + className);
Assert.True(type is not null, $"{section}: no test class {className}");
var found = type.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == method);
Assert.True(found is not null && found.GetCustomAttributes<FactAttribute>().Any(), $"{section}: no test {test}");
}
}
}

View file

@ -6,8 +6,9 @@ namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_de8a062a()
public void Vectors_are_cultlib_d3025d3c()
{
Assert.StartsWith("d3025d3c", DeskVectors.SourceCommit, StringComparison.Ordinal);
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
Assert.True(

View file

@ -0,0 +1,236 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// CultLib's vectors, replayed from their WIRE form (responseHex / messagePackHex) through the real UnixSocketDeskDoor on
// a socket double, then compared with the vector's `message`: same keys in the same order, integers unchanged
// (compared as JSON text; the vector side is parsed JSON, so an escaped apostrophe cannot differ). Only what Huginn
// sends is served here. The refusals, 503 documents and edge-authored results in the vectors are Bifrost's own
// output and are replayed on the edge (DeskReadTests, DeskAnswerTests, DeskDocumentTests).
//
// What the door judges of a reply (the single owner is the wire walker's Closed): every contract document is a closed
// map with every key present and typed; the card inside an entry is an opaque map. What it does not judge: every
// rule a schema cannot say (R-card, R-record, R-entry, R-list, R-answer-result, option and hash rules, ordering,
// the reader-view agreement between deciders and answerers). Those belong to CultLib's validators and to Huginn's door;
// Bifrost holds no audience rule and masks nothing it cannot read.
public sealed class DeskVectorWireTests
{
private static string Mind(JsonNode request) => (string)request["mind"]!;
private static IEnumerable<(string Name, JsonNode Request, JsonNode Response, string Hex)> ListSteps()
{
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios").Concat(DeskVectors.Rows("transport", "listShortPages")))
{
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()).Where(s => s.ContainsKey("responseHex")))
{
yield return ((string)scenario["name"]!, step["request"]!, step["response"]!, (string)step["responseHex"]!);
}
}
}
[Fact]
public async Task Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message()
{
var steps = 0;
foreach (var (name, request, response, hex) in ListSteps())
{
var list = await DeskServe.ListAsync(Convert.FromHexString(hex), Mind(request));
Assert.True(DeskServe.Text(response) == DeskServe.Text(list), name);
steps++;
}
Assert.Equal(16, steps);
}
[Fact]
public async Task Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader()
{
var steps = 0;
foreach (var (name, request, response, hex) in ListSteps())
{
var scenario = DeskVectors.Rows("transport", "listScenarios").FirstOrDefault(s => (string)s["name"]! == name);
var nonDecider = scenario is not null && scenario.ContainsKey("twin");
using var huginn = DeskServe.Socket(DeskServe.Forks(Convert.FromHexString(hex)));
using var factory = new DeskWebApplicationFactory(DeskServe.ConfigurationOn(Mind(request), huginn.Path), fakeDoor: false);
var reader = nonDecider ? DeskWebApplicationFactory.Member : DeskWebApplicationFactory.Alice;
await factory.EnsureAccountAsync(reader, true);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{Mind(request)}/forks" + query, null, factory.Token(reader, "bifrost-desk", "desk.read"));
// The edge's one change for a non-decider: every note nil, the key kept.
var expected = JsonNode.Parse(response.ToJsonString())!;
if (nonDecider)
{
foreach (var answer in expected["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()))
{
answer!["note"] = null;
}
}
Assert.Equal(200, reply.Status);
Assert.True(DeskServe.Text(expected) == DeskServe.Text(JsonNode.Parse(reply.Body)!), name);
steps++;
}
Assert.Equal(16, steps);
}
[Fact]
public async Task Every_valid_entry_leaves_a_read_unchanged_in_both_views()
{
var rows = DeskVectors.Rows("transport", "entry", "valid").ToList();
Assert.Equal(8, rows.Count);
foreach (var row in rows)
{
var entry = await DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), (string)row["name"]!);
}
}
[Fact]
public async Task Every_valid_card_leaves_the_door_inside_an_entry_unchanged()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var card = row["message"]!;
var record = DeskServe.FromJson(JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{(string)card["fork_id"]!}}","options_hash":"{{(string)card["options_hash"]!}}","counted_answer_id":null,"answerer_count":0,"answers":[]}""")!);
var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(Convert.FromHexString((string)row["messagePackHex"]!), record));
Assert.True(DeskServe.Text(card) == DeskServe.Text(entry.Card), (string)row["name"]!);
}
}
[Fact]
public async Task Every_scenario_record_leaves_the_door_unchanged_the_6_key_record()
{
var count = 0;
foreach (var scenario in DeskVectors.Rows("scenarios"))
{
var hex = (string)scenario["recordHex"]!;
Assert.StartsWith("86", hex, StringComparison.Ordinal);
var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(DeskServe.FromJson(scenario["card"]!), Convert.FromHexString(hex)));
Assert.True(DeskServe.Text(scenario["record"]!) == DeskServe.Text(entry.Record), (string)scenario["name"]!);
count++;
}
Assert.Equal(63, count);
}
[Fact]
public async Task Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included()
{
var rows = DeskVectors.Rows("deskAnswer", "valid").ToList();
Assert.Equal(8, rows.Count);
foreach (var row in rows)
{
var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!);
}
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision" && r["message"]!["answerer"] is null);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction" && r["message"]!["answerer"] is null);
}
// The entry negatives on the wire. The door refuses the four that break its closed-map typing: an entry without its
// record, and a missing, negative or nil answerer_count (never a count Bifrost may invent). The other fourteen are
// rules a schema cannot say (R-record, R-entry, the options-hash and derivation rules; the two new ones pin a
// withheld card beside a named Retraction and a count over the answers): CultLib's validators and Huginn's door
// judge them, Bifrost reads a closed map and passes it, and a change to either side shows here.
private static readonly HashSet<string> EntryNegativesTheDoorRefuses =
["entry_without_record", "answerer_count_missing", "answerer_count_negative", "answerer_count_null"];
[Fact]
public async Task The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract()
{
var rows = DeskVectors.Rows("transport", "entry", "invalid").ToList();
Assert.Equal(18, rows.Count);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_beside_a_named_retraction");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_count_over_the_answers");
foreach (var row in rows)
{
var name = (string)row["name"]!;
var served = Task.Run(() => DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!)));
if (EntryNegativesTheDoorRefuses.Contains(name))
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
var entry = await served;
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), name);
}
}
}
// The list negatives on the wire. The door refuses count_negative (a count is an unsigned integer in its closed typing).
// The others are rules a schema cannot say or text grammars the door does not hold: a cursor is c<N> and a stamp is s<N>
// (Bifrost passes both on, opaque), a page is one reader's view (R-list), and an order is unique among a Mind's cards.
// CultLib's validators and Huginn's door judge them.
[Fact]
public async Task The_door_refuses_a_negative_count_and_judges_no_other_list_rule()
{
var rows = DeskVectors.Rows("transport", "listInvalid").ToList();
Assert.Equal(5, rows.Count);
foreach (var row in rows)
{
var name = (string)row["name"]!;
var served = Task.Run(() => DeskServe.ListAsync(Convert.FromHexString((string)row["messagePackHex"]!)));
if (name == "count_negative")
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(await served), name);
}
}
}
// The stored-answer negatives and the answer_result negatives. The door judges that an answer is a closed 16-key map with
// every key typed; all twelve deskAnswer negatives have the 16 keys, so none is refused here. They are rules (outcome
// and code pairing, targets, case, and "no_answerer": a nil answerer on a Counted answer is a withheld view only a
// reader can tell from a fault) that the contract's validators and Huginn's door own. A MISSING answerer key is the
// door's fault, pinned in UnixSocketDeskDoorWireTests. The three resultsInvalid vectors are R-answer-result (the
// sender's own answer carries their account, never nil): Huginn's rule; for Bifrost a nil answerer in an Answered
// reply is a string-or-nil key like any other. Whether the edge should add that check is not decided here.
[Fact]
public async Task The_door_judges_no_deskAnswer_rule_of_the_contract_only_its_closed_typed_shape()
{
var rows = DeskVectors.Rows("deskAnswer", "invalid").ToList();
Assert.Equal(12, rows.Count);
foreach (var row in rows)
{
var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!);
}
}
[Fact]
public async Task The_three_answer_result_negatives_carry_an_answer_the_door_reads_as_any_answer()
{
var rows = DeskVectors.Rows("resultsInvalid").ToList();
Assert.Equal(3, rows.Count);
foreach (var row in rows)
{
var answer = row["message"]!["answer"]!;
var served = await DeskServe.AnswerAsync(DeskServe.FromJson(answer));
Assert.Null(served.Answerer);
Assert.True(DeskServe.Text(answer) == DeskServe.Text(served), (string)row["name"]!);
}
}
}

View file

@ -0,0 +1,3 @@
fork-card-vectors.json text eol=lf
desk-feed/**/*.md text eol=lf
desk-feed/*.md text eol=lf

View file

@ -35,12 +35,13 @@ function run(name, search, hash) {
},
};
const form = {
elements: { attempt: { value: "" }, code: { value: "" } },
elements: { attempt: { value: "" }, code: { value: "" }, pendingLinkId: { value: "" } },
submit() {
events.push("submit");
submit = {
attempt: form.elements.attempt.value,
code: form.elements.code.value,
pendingLinkId: form.elements.pendingLinkId.value,
href: location.href,
hash: location.hash,
};
@ -59,6 +60,10 @@ console.log(
run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete),
run("no-code", "?attempt=NONCE", "#heimdall_status=success"),
run("no-attempt", "", "#heimdall_status=success" + complete),
run("link-return", "?attempt=NONCE", "#heimdall_status=success&heimdall_pending_link_id=PEND1"),
run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"),
run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"),
run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""),
run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
]),
);

View file

@ -0,0 +1,3 @@
*.bin binary
*.msgpack binary
*.hex text eol=lf

View file

@ -0,0 +1,50 @@
# Desk feed golden fixtures
The bytes on a Mind's desk socket for a list or a read. The contract is `docs/desk-feed-door.md`.
`crates/huginn-mind/tests/desk_feed_fixtures.rs` writes these files with Huginn's own encoders and
checks them on every `cargo test -p huginn-mind --test desk_feed_fixtures`. Set
`HUGINN_WRITE_DESK_FIXTURES=1` to rewrite them. Do not edit them by hand.
Each case has up to four files:
- `<case>.frame.bin` and `<case>.frame.hex`: the whole frame on the socket (u32 big-endian length,
then the CultNet message).
- `<case>.payload.msgpack` and `<case>.payload.hex`: the base64-decoded `payload` of an operation
message (named MessagePack). The `record-*` cases are bare payload fragments with no frame.
Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The
record's answers, notes and hash are synthetic.
**The listing gate is not live.** On `main` every showable question goes to every reader. Do not
put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing`
lands (see `docs/desk-feed-door.md`).
| case | direction | content |
|---|---|---|
| `list-open.request` | Bifrost to Huginn | `Forks`, status open, no cursor, limit 50 |
| `list-open-cursor.request` | Bifrost to Huginn | `Forks`, status open, cursor `c42`, limit 50 |
| `list-counts.request` | Bifrost to Huginn | `Forks`, status open, limit 0 (counts and stamp only) |
| `read.request` | Bifrost to Huginn | `Fork` for one fork id |
| `list-non-decider.request` | Bifrost to Huginn | `Forks` as above, from a subject who is not on the call's deciders |
| `read-non-decider.request` | Bifrost to Huginn | `Fork`, from a subject who is not on the call's deciders |
| `list-open.response` | Huginn to Bifrost | a decider's page of one open fork: card with `raised_at` a string, `decider_count` 2 and both ids, record with `answerer_count` 3 and every `answerer` named |
| `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes |
| `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil |
| `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account id |
| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` |
| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` |
| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) |
| `list-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large` naming the cursor to continue (Bifrost: 422) |
| `read-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large`, read form: names the fork, no order or cursor (Bifrost: 422) |
| `list-response-too-large.response` | Huginn to Bifrost | `Refused` `ResponseTooLarge` (Bifrost: 503) |
| `refused-unit.response` | Huginn to Bifrost | `Refused` with a unit variant: a bare string `"MissingIdentity"` (shape only; Bifrost: 503) |
| `unavailable.response` | Huginn to Bifrost | `Refused` `Unavailable` (Bifrost: 503 for this Mind) |
| `failure.response` | Huginn to Bifrost | operation failure, `payloadSchema` `gamecult.cultnet.operation_failure.v1` (Bifrost: 503) |
| `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) |
| `record-whole` | fragment | `gamecult.fork.record.v0` in a decider's view: all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole, `answerer_count` 3 (6 keys) |
| `record-withheld` | fragment | the same record in a non-decider's view: every `answerer` nil, notes whole, `answerer_count` 3 |
The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are
also CultLib's own bytes, vendored unedited in `cultlib-d3025d3c/` (see its README for the pinned
commit and each vector). Huginn's types decode every one of them and re-encode it byte for byte;
the fixtures above are what Huginn's encoders write for the same shapes.

View file

@ -0,0 +1,28 @@
# CultLib desk-feed vectors, vendored
Source: `GameCult/CultLib` at commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118, on
`3a51bbab` #117, on `af914253` #115), file `contracts/cultnet/governance/fork-card-vectors.json`. Each `.msgpack` file is
the vector's `messagePackHex` decoded to bytes, nothing else changed. Do not edit them or rewrite them
from Huginn's encoders: they are the reference Huginn's types are checked against (see
`docs/desk-feed-door.md`). To re-pin, take the new commit's vectors and update the sha here, in
`docs/desk-feed-door.md` and in `CULTLIB_SHA` in `desk_feed_fixtures.rs`.
| file | upstream vector | what it pins |
|---|---|---|
| `card-operator-fork` | `card.valid` `operator_fork` | 18 keys (`DE 00 12`), `raised_at` a string, a decider's `deciders` and `decider_count` 1 |
| `card-raised-at-nil` | `card.valid` `raised_at_nil` | `raised_at` nil, key kept |
| `card-withheld-deciders` | `card.valid` `withheld_deciders` | empty `deciders`, `decider_count` 2 |
| `answer-counted` | `deskAnswer.valid` `counted` | 16 keys (`DE 00 10`), `answerer` a string |
| `answer-revision` | `deskAnswer.valid` `revision` | a `Revision`, `answerer` a string |
| `answer-retraction` | `deskAnswer.valid` `retraction` | a `Retraction`, `answerer` a string |
| `answer-withheld-answerer` | `deskAnswer.valid` `withheld_answerer` | `answerer` nil |
| `answer-withheld-answerer-revision` | `deskAnswer.valid` `withheld_answerer_revision` | a `Revision`, `answerer` nil |
| `answer-withheld-answerer-retraction` | `deskAnswer.valid` `withheld_answerer_retraction` | a `Retraction`, `answerer` nil |
| `answer-refused-stale` | `deskAnswer.valid` `refused_stale` | a `Refused` answer, `answerer` a string |
| `entry-open-counted` | `transport.entry.valid` `open_counted` | decider view: entry `83`, record `86`, `answerer_count` 1 |
| `entry-withheld-open-counted` | `transport.entry.valid` `withheld_open_counted` | non-decider twin: `deciders` empty, `answerer` nil, count 1 |
| `entry-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `closed_with_ruling_and_a_refusal` | decider view, a refusal among the answers, `answerer_count` 2 |
| `entry-withheld-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `withheld_closed_with_ruling_and_a_refusal` | its non-decider twin, count 2 |
The test `desk_feed_fixtures.rs` checks each file's SHA-256, decodes it with a decoder of its own
(not `rmp_serde`), then decodes it into Huginn's real type and re-encodes it, byte for byte.

View file

@ -0,0 +1 @@
0000007385ad736368656d6156657273696f6eb063756c746e65742e6572726f722e7630a56572726f72d92f687567696e6e2e6465736b20726566757365732061206d6573736167655f6964206f76657220323536206279746573ab726f7574696e6748696e74c0a4636f6465c0a764657461696c73c0

View file

@ -0,0 +1 @@
000001708aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d95c6771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c76325a705a57786b49455a76636d747a4c6d787062576c304f6942766458517462325974636d46755a32553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765bf6669656c6420466f726b732e6c696d69743a206f75742d6f662d72616e6765

View file

@ -0,0 +1 @@
うcodeオpayload-not-a-requestァmessageソfield Forks.limit: out-of-range

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706441413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697400

View file

@ -0,0 +1 @@
0000019289a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5144726e4a6c593239795a47566b58324e76645735304261566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564

View file

@ -0,0 +1 @@
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7403ae7265636f726465645f636f756e7405a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce

View file

@ -0,0 +1 @@
▼Forksうlist<EFBFBD>schemaVersionオgamecult.fork.list.v0、mindヲeureka・stampヲs10190ェopen_countョrecorded_count・forks性next_cursorタ・as_ofヘ'ホ

View file

@ -0,0 +1 @@
0000019289a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5141726e4a6c593239795a47566b58324e7664573530414b566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564

View file

@ -0,0 +1 @@
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7400ae7265636f726465645f636f756e7400a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4f5755345a44646a4e6d49744e5745304f5330304d7a67794c574978595441745a6a6c6c4f475133597a5a694e5745307147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2

View file

@ -0,0 +1 @@
000001a687a96d6573736167654964a36d2d32a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f46761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d59335679633239796f324d304d715673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72a3633432a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursor£c42¥limit2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more