Compare commits

..
Author SHA1 Message Date
c20947af1c Merge pull request 'Linked accounts page: list, link, unlink with fresh proof (merge design step 2)' (#84) from heimdall/linked-accounts into main 2026-11-13 20:24:16 +00:00
Heimdall
18f1ec6aca Linked accounts: drop the list after an unlink with an uncertain end; Google never mints; docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:23:54 +00:00
Heimdall
8e65f56b0c Linked accounts: never guess a proof provider; require stepUpRequested; no-store on completion
With no current list the proof goes only through a provider the member names, never the
session's sign-in provider: a sign-in through an unlinked provider makes Heimdall mint an
orphan account holding that login. Link buttons need a current list. The unlink completion
must say a step-up was requested. POST /auth/heimdall/complete answers no-store. The fake
Heimdall now models orphan minting, derives step_up_at from the start's stepUp flag, and
enforces the app secret, sign_in-only, unsupported-provider and 600 s rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:08:07 +00:00
Heimdall
b65bec65ce Linked accounts: docs, README line, proof-provider and no-store tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:43:59 +00:00
Heimdall
59ecaa606b Linked accounts page: list, link any admitted provider, unlink with step-up proof
A member sees their Heimdall logins, links Discord, Patreon or Google, and unlinks one
with a fresh Google step-up sign-in. Bifrost keeps no Heimdall token: each action is a
fresh Heimdall sign-in started by Bifrost whose bearer lives inside the completion
request. The list is kept five minutes in this process as non-secret fields only.

The Status page's Google-only link button and handler are cut; the Linked accounts page
owns link starts. HeimdallStartRequest gains stepUp. Heimdall's member routes use a
client without the app secret.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:35:40 +00:00
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
1da912d5e9 Members: approve refuses no-membership/unknown accounts; neutral text for legacy suspensions (#65) 2026-11-11 16:39:48 +00:00
e12b8e2298 Mouth: v4 fixes after Soul pass (target cache, scrub, read cap) (#64) 2026-11-11 16:17:09 +00:00
dc35c4a254 Mouth: chat_pending.v4 reply_to (flag, default off) (#63) 2026-11-11 16:06:28 +00:00
ab89556101 Chat sign-in needs a live consent receipt; notice frozen as version 1 (#59) 2026-11-11 14:32:06 +00:00
e8df60630e Prefix lengths: host-level startup tests (tests only) (#62) 2026-11-11 14:12:55 +00:00
7813426e6d Drop HTMX (unpkg, no integrity); guard against third-party assets (#61) 2026-11-11 14:12:51 +00:00
70a2038435 Suspension gaps: bootstrap, demotion, members-page notices, admin-only reason (#60) 2026-11-11 14:12:47 +00:00
1571bf3e32 Consent: optional at registration, a consent page, CSRF-guarded accept/withdraw, notice v2 (#58) 2026-11-11 12:11:02 +00:00
028bc2fdf6 Registration: sign-up form with proof-of-work and a no-JS time-lock fallback (#56) 2026-11-11 11:17:25 +00:00
b2e0bb2514 Mouth: tail_gaps on chat_pending.v3, the rooms whose tail is incomplete (#57) 2026-11-11 11:07:35 +00:00
09addd83d8 Docs: proposed training notice v2 and consent-flow gaps (#55) 2026-11-11 10:32:18 +00:00
33be966b7d Registration gates: proof-of-work CAPTCHA and training-notice consent receipt (#54) 2026-11-11 10:15:46 +00:00
fc8242f655 Suspension survives sign-in; admin-only Suspend/Unsuspend with a receipt (#53) 2026-11-11 08:38:09 +00:00
e8722d0abf Mouth: room tail on chat_pending.v3 (#51) 2026-11-11 08:15:38 +00:00
24f3deb88e OIDC issuer: chat tier for accounts without Active membership (#52) 2026-11-11 08:13:09 +00:00
5201508bdc OIDC issuer: address budget, token-row cap, scheduled prune, marker brake (#50) 2026-11-11 08:12:48 +00:00
be44a09d52 OIDC: refuse reserved localparts at authorize; gap list in oidc-issuer.md (#49) 2026-11-11 06:39:35 +00:00
1d0548313c Mouth: addressing facts on deliveries (chat_pending.v3 basis and form) (#48) 2026-11-11 06:26:30 +00:00
4e86c33cd2 Resolve CultLib via VOIDBOT_CULTLIB_ROOT in tests; test formerly indexed stores (#47) 2026-11-11 05:01:33 +00:00
68dee25e3a Drop indexes: declarations from TS document types (#46) 2026-11-11 04:53:55 +00:00
781e6eabf6 Desk: pin vectors to CultLib de8a062a (#45) 2026-11-11 02:16:28 +00:00
05dcceb203 Desk: a stale answer on an oversize fork is 422 fork_too_large (#44) 2026-11-11 01:54:04 +00:00
815304568f Desk: pin vectors to CultLib f6c9bf5f (#43) 2026-11-11 01:49:37 +00:00
8385eca00f Desk: a read of an oversize fork is 422 fork_too_large (#42) 2026-11-11 01:35:07 +00:00
7e9ba5796b Desk: the fork desk's Bifrost half (#40) 2026-11-11 01:02:36 +00:00
12a153f66f Mill votes: ranked, paged read and not-yet-voted-by-this-network filter (#41) 2026-11-10 21:06:56 +00:00
73c6f50440 persona mouth: open a direct room for a Persona; answer unknown kinds loudly (#39) 2026-11-10 18:35:51 +00:00
752cd66686 deploy plan: restore reads a postgres-readable copy of the root-only dump (#38) 2026-11-10 18:28:31 +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
306 changed files with 46227 additions and 402 deletions

View file

@ -30,7 +30,7 @@
## Locked Technical Defaults ## Locked Technical Defaults
- stack: ASP.NET Core 8 LTS - stack: ASP.NET Core 8 LTS
- UI: Razor Pages + HTMX - UI: Razor Pages
- database: PostgreSQL - database: PostgreSQL
- hosting target: `bifrost.gamecult.org` on Yggdrasil - hosting target: `bifrost.gamecult.org` on Yggdrasil
- deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops` - deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops`

View file

@ -11,6 +11,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
- Razor Pages app under `src/Bifrost.Web` - Razor Pages app under `src/Bifrost.Web`
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations` - PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
- Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating - Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating
- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a fresh Google step-up proof ([docs/linked-accounts.md](docs/linked-accounts.md))
- explicit member roles and admin-managed patron or contributor tier snapshots - explicit member roles and admin-managed patron or contributor tier snapshots
- patron support event recording with derived patron point summaries and tier refresh for voting weight - patron support event recording with derived patron point summaries and tier refresh for voting weight
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links - shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
@ -36,7 +37,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m
## Chosen Stack ## Chosen Stack
- ASP.NET Core 8 LTS - ASP.NET Core 8 LTS
- Razor Pages + HTMX - Razor Pages
- PostgreSQL - PostgreSQL
- Bifrost-native identity registration - Bifrost-native identity registration
- GitHub OAuth for linked sign-in and repository integration - GitHub OAuth for linked sign-in and repository integration
@ -73,7 +74,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
## Local Dev Notes ## Local Dev Notes
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in - set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access - POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`. - HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed. - external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.
@ -89,7 +90,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
- default connection string points at local PostgreSQL - default connection string points at local PostgreSQL
- build with `dotnet build Bifrost.slnx` - 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` - 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` - 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` - export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`

View file

@ -4,7 +4,7 @@
- Create Bifrost as a separate GameCult app repo and deploy it on Yggdrasil. - Create Bifrost as a separate GameCult app repo and deploy it on Yggdrasil.
- Build a member alpha, not a public launch. - Build a member alpha, not a public launch.
- Use ASP.NET Core 8 with Razor Pages + HTMX and PostgreSQL. - Use ASP.NET Core 8 with Razor Pages and PostgreSQL.
- Support invite-only GitHub-linked members, GitHub-backed and internal work items, app-native governance, Reddit organizing threads, and internal ledgers. - Support invite-only GitHub-linked members, GitHub-backed and internal work items, app-native governance, Reddit organizing threads, and internal ledgers.
- Preserve the Labor Platform loop: user/member/patron demand raises work priority and reward pressure, contributors pick up valuable work, maintainers accept completion artifacts, and Bifrost credits the contributor plus any bounty/reward allocation. - Preserve the Labor Platform loop: user/member/patron demand raises work priority and reward pressure, contributors pick up valuable work, maintainers accept completion artifacts, and Bifrost credits the contributor plus any bounty/reward allocation.
- Do not include Ethereum, wallets, DAO mechanics, or payout execution in v1. - Do not include Ethereum, wallets, DAO mechanics, or payout execution in v1.
@ -13,7 +13,7 @@
## System Shape ## System Shape
- One ASP.NET web app process for v1. - One ASP.NET web app process for v1.
- Server-rendered UI with HTMX for progressive interactions. - Server-rendered UI; no client-side framework or third-party script.
- PostgreSQL as the system of record. - PostgreSQL as the system of record.
- Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity. - Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity.
- GitHub App plus webhooks for issue and pull request lifecycle sync. - GitHub App plus webhooks for issue and pull request lifecycle sync.

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 ### 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). 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 ### Proxy and client address
@ -53,7 +53,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only
21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key. 21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key.
22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`). 22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`).
23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`). 23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`).
24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`). 24. The shared layout loads no third-party script or stylesheet (HTMX was dropped; `NoThirdPartyAssetTests` fails on any cross-origin `<script src>` or `<link href>`), and no page uses `hx-` attributes. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`).
25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools. 25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools.
### The Mill page ### The Mill page
@ -85,6 +85,7 @@ Derived from them, not asked separately:
- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists. - **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists.
- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters. - **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters.
- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing. - **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing.
- **Ranked read.** Beside the map, anonymous `GET /public/concepts/{collection}/ranked?sort=net|total|newest&limit=1..100&cursor=&unvoted=network` returns `{ items: [{ key, title, up, down, members }], next_cursor }`, largest first, ties by key. The cursor is opaque, keyset and bound to its sort and filter; any other query shape or cursor is a 400. `unvoted=network` omits the concepts the caller's own network tag has voted on (tag derived from the connection, never accepted or returned) and is never cached. Same CORS and Vary policy as the map; the map is unchanged.
- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/<ship id>`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. - **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/<ship id>`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party.
- **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key. - **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key.
- **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path. - **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path.

View file

@ -54,9 +54,16 @@ permits, windows and `MaxTrackedKeys`.
New since the live release, and not read by the validator: 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 - **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one
`IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail, trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never
so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost` 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 (`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
from logs. from logs.
- **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be - **`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. not a workstation.
6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret` 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` 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 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"). 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 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

@ -105,6 +105,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity.
A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`).
Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed.
### Patron Collection Crossing ### Patron Collection Crossing

63
docs/linked-accounts.md Normal file
View file

@ -0,0 +1,63 @@
# Linked accounts
`/Membership/LinkedAccounts` (`src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml[.cs]`) is where a signed-in member sees the provider logins attached to their Heimdall account, links another one, and unlinks one. Heimdall owns the logins and every rule about them; this page starts the flows and shows what Heimdall answered. A member reaches it from the membership status page. A session with no Heimdall account (a Bifrost-native GitHub sign-in) gets a note instead of controls.
## What it does
| Action | What Bifrost does | Heimdall call |
|---|---|---|
| Show my logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer |
| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Needs a current list. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` |
| Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer |
The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here.
## How the bearer is kept: it is not
Bifrost keeps no Heimdall token. Each action is an attempt: a Data Protection cookie (`__Host-bifrost_heimdall_<slot>`, HttpOnly, Secure, ten minutes) holding a purpose (`ListProof`, `UnlinkProof`, `LinkStart`, `LinkAwaitGoogle`, `LinkConfirm`, `LinkAwaitDirect`), the account the session must be, and a target (the provider to link, or the identity to unlink). The browser returns from Heimdall to `/auth/heimdall/complete`; Bifrost redeems the code, verifies the token, requires that the proven account is the attempt's account and that the browser's session is that same account, calls Heimdall with the bearer inside that one request, and drops it. The bearer is never stored, logged, put in a cookie or a redirect, or rendered. Heimdall's member routes go through an HTTP client that carries no app secret (`HeimdallMemberClient`), so a Bifrost fault cannot call them without a member present.
The list is kept for five minutes in this process only (`LinkedAccountsSnapshots`), keyed by the Heimdall account id, so the page can render after the completion's redirect: identity id, provider, display name (or username when there is none) and link date. Never the bearer, the email, the provider user id or the scopes. It is not given to the browser. A restart or the five minutes empties it and the page offers "Show my linked logins" again.
Every action is a POST with antiforgery; a GET starts nothing. The completion route's answers are `Cache-Control: no-store`. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's.
## Which provider proves the account
An ordinary proof sign-in through Discord or Patreon when the account no longer holds that login makes Heimdall mint a new, unrelated account that holds it (those providers are `mints_account`), and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. Google is `requires_link`: Heimdall refuses a Google sign-in for a login no account holds (`identity_not_linked`) and never mints. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now). This guarantee covers this page's proofs only: the older anonymous `GET /auth/heimdall/{provider}` still starts an ordinary sign-in through any named provider, and can overwrite an in-flight attempt slot; that route is unchanged here.
- With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google.
- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Google (cannot create an account) / Discord / Patreon" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named Discord or Patreon the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`).
- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. After an unlink whose result is not a definitive refusal (a 500, a lost or malformed answer, not found) the list is dropped, since Heimdall may have applied it; only `403 step_up_required`, `409 last_identity` and `401` leave it.
## The proof limit
Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So:
- An account with no Google login linked cannot unlink anything from here. The page says so and shows no Unlink button; a posted unlink starts nothing.
- Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie.
- Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed."
- A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`).
The unlink completion must also say a step-up was requested (`stepUpRequested`, set by Heimdall when the start asked for one); one that does not is refused without calling Heimdall. Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall.
A link refused because the login already belongs to another account (`409 identity_linked_elsewhere` from `links/confirm`; for Discord and Patreon the callback's `link_initiator_mismatch`, carried on by the complete page as a fixed result) is shown as "That login belongs to another account." The other account is never named.
## Not here
- Merging two accounts. A refused link ends there; nothing is moved or merged.
- Step-up through Discord, Patreon or Twitch. Heimdall does not offer it to the `bifrost` profile.
- Linking Twitch or GitHub: not admitted by the `bifrost` profile, and Heimdall's GitHub runtime is not implemented.
- A list of other apps' logins, grants or consent receipts (Heimdall's grants page).
## Where the code is
- `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers.
- `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in.
- `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping.
- `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list.
- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`.
## Known leftover
Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_<slot>`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do.
A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again.

View file

@ -5,7 +5,8 @@ Authentication Service (MAS) can sign chat users in with their Bifrost account.
Bifrost; it does not issue the identity chat sees. Bifrost; it does not issue the identity chat sees.
The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two
decisions at the authorize endpoint: who the chat user is, and whether they may get a session. decisions at the authorize endpoint: who the chat user is, and whether they may get a session (not suspended, and agreed to
the training notice in force; see "Chat needs a receipt").
## Endpoints ## Endpoints
@ -44,6 +45,8 @@ echoes its value.
| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. | | `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. |
| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. | | `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. |
| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. | | `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. |
| `Bifrost:Oidc:BrakePath` | Optional. While a file exists at this path, `/connect/authorize`, `/token` and `/userinfo` answer 503 `temporarily_unavailable` and nothing is issued; delete the file to release. Checked per request, so no restart. Unset: no marker brake. |
| `Bifrost:Oidc:Limits:*` | `PerAddressPermits` (30), `Window` (1 min), `MaxTrackedKeys` (10000), `MaxTokenRows` (20000), `PruneInterval` (5 min). |
| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https://<mas>/upstream/backchannel-logout/<provider ULID>`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. | | `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https://<mas>/upstream/backchannel-logout/<provider ULID>`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. |
There are no development or ephemeral keys in the host. Key files are provisioned like the other There are no development or ephemeral keys in the host. Key files are provisioned like the other
@ -57,7 +60,7 @@ There are no development or ephemeral keys in the host. Key files are provisione
| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) | | `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) |
| `name` | `DisplayName`, else `BifrostIdentity` | | `name` | `DisplayName`, else `BifrostIdentity` |
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. A native account's verified address, once that is built, stays inside Bifrost as sign-in recovery and is never a claim (`registration-gates.md`, Email verification).
## Who may sign in ## Who may sign in
@ -66,21 +69,88 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A
- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the - No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the
full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and
the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256. the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256.
- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and - Signed in, membership `Suspended`: redirect to the client's `redirect_uri` with `error=access_denied` and
`error_description=The account is not an active GameCult member.` `error_description=The account is suspended.`
- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with - Signed in with no membership, `Authenticated` or `PendingApproval` (the chat tier): treated like an active member for
sign-in to chat and nothing else. The token carries the same `openid profile` claims; the issuer writes no membership,
role or vote, and every member or governance surface (`/App`, `/eve/governance`, `/api/sealed-motions`, the fork desk)
still asks for `Active` itself, so neither the session nor the token opens them.
- Signed in, with an account that can chat, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
`error_description=The account has no Bifrost identity.` `error_description=The account has no Bifrost identity.`
- Signed in and `Active`: a code is issued. - Signed in, with an account that can chat, but the identity is a reserved localpart (`BifrostIdentityService.IsReserved`: the 13 Synapse
- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. accounts and the system names, the same 28 as MAS `banned_usernames`): `error=access_denied` with
`error_description=The account's Bifrost identity is reserved.` Creation refuses these names, but a row minted before
that refusal may hold one and there is no rename path, so the issuer never vouches for a Mind's or a system's localpart. The refusal is at authorize only: a code or access token issued for such a row before this check deployed stays redeemable until it expires (minutes), and token and userinfo re-read membership, not the name. Whether any such row exists is not known from the code; one query of `UserAccounts` against the 28 names would say.
- Signed in, not refused above, but no live consent receipt for the training notice version in force
(`TrainingNotice.Version`; none ever, withdrawn, or for another version): **not an OIDC error.** A 302 to
`/auth/bifrost/consent?returnUrl=<this request's own /connect/authorize?request_uri=... path>`. No code is issued and the
client's `redirect_uri` is not called. The consent page shows the notice and an Agree button; agreeing is a 303 back to
the `returnUrl`, which resumes the same parked request and issues the code. The parked request is single use and lives ten
minutes (it is spent only by an OpenIddict response, which the redirect is not), so a member who agrees after it has expired
gets MAS's error and starts the sign-in again, now with a receipt. This check comes last, so a suspended, identity-less or
reserved account is refused as before and never invited to agree.
- Otherwise: a code is issued.
- Token endpoint and userinfo: suspension and the receipt are read again, on every call. An account suspended or deleted
since, or without a live current receipt since (withdrawn, or the notice version bumped), gets `invalid_grant` (token) or
403 `insufficient_access` (userinfo), with the description of the reason, and no claim. So an access token already in a
chat client's hands stops answering userinfo the moment the receipt goes, and answers again if the member agrees again
(this path does not revoke the token; the back-channel reconciler below does, when it is configured). There is no refresh token, so refresh needs no gate.
The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership.
## Chat needs a receipt
The issuer is the chat tier: every token it issues is a chat token. Ruled by Meta, 2026-11-11: speaking in chat needs agreement
to the training notice, and nothing else does. A live `ConsentReceipt` for `TrainingNotice.Version` is required (the same
reading as the consent page's "agreed", `ConsentEndpoints.HasLiveReceiptAsync`); account sign-in, the members page and votes need no
receipt. A member with no receipt keeps all of those and loses only the chat token until they agree on the consent page.
Where they learn it: the refused authorize lands them on the consent page, which states what agreement is for ("to speak in a
recorded room"), shows the notice, and returns them to chat sign-in when they agree. Nothing else prompts them: the
members page and the sign-in form do not nag, and an existing member who never opens chat is never asked.
What a Persona or Eureka sees:
| Step | Receipt live and current | No receipt, withdrawn, or another version |
| --- | --- | --- |
| Authorize | 302 to the client with `code` | 302 to `/auth/bifrost/consent?returnUrl=...`; no `code`, no `error` |
| Token | 200 with tokens | 400 `invalid_grant`, description "Agreement to the training notice in force is needed to speak in a recorded room." |
| Userinfo | 200 with `sub`, `preferred_username`, `name` | 403 `insufficient_access`; no claim in the body |
| Back-channel logout | none | Only when `BackchannelLogoutUri` is set (the reconciler runs): MAS is sent a logout for the subject and its authorizations are revoked, within one reconciler tick. Otherwise an already-open session is not ended; only new logins and userinfo calls are refused |
Deploy note: on the first reconciler tick, every subject holding a MAS authorization and no receipt (all existing chat users, admins included) is sent a back-channel logout and loses its authorizations. That is the ruling working as designed, not a fault; they return through the consent page.
The gate is on the token, so as built it covers every room the MAS login reaches. Which rooms are "recorded", and whether any
room is not, is the chat side's to say; the issuer does not know.
### Userinfo smoke notes for Eureka
Run against a deployed instance with two throwaway accounts that hold a membership of any kind: A has agreed on its consent
page, B has not (register without ticking the box, or click Withdraw). `$ISS` is the issuer, `$MAS` the registered client with
its secret, sessions are the cookie jars of a browser signed in as A or as B. Use the MAS client's own redirect URI.
1. Sign in as A through chat (MAS), or by hand (the first authorize answer is a 302 to `?request_uri=...`; follow that hop, the next answer is the code redirect): `GET $ISS/connect/authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20profile&state=s&nonce=n&code_challenge=...&code_challenge_method=S256` with A's cookie.
Expect 302 to the redirect URI with `code` and `state`. Exchange it: `POST $ISS/connect/token` with `client_secret_basic`
and `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. Expect 200 with `access_token` and `id_token`.
2. `GET $ISS/connect/userinfo` with `Authorization: Bearer <A's access_token>`. Expect 200 and exactly `sub`, `preferred_username`, `name`.
3. Same authorize request with B's cookie. Expect 302 whose `Location` starts `/auth/bifrost/consent?returnUrl=%2Fconnect%2Fauthorize%3Frequest_uri`,
and no `code` anywhere. (OpenIddict first parks the request and redirects to the same URL with a `request_uri`; follow
that one hop, the consent redirect is the next answer. A missing receipt ignores `prompt=none`: still a 302 to the consent page, never `consent_required`.) `GET` that Location with B's cookie: expect 200 and a page that contains
"to speak in a recorded room" and a form posting to `/auth/bifrost/consent/accept`.
4. Withdraw A: on A's consent page click Withdraw (a form `POST /auth/bifrost/consent/withdraw`, 303 back). Repeat step 2 with
A's old access token: expect 403, `WWW-Authenticate` with `error="insufficient_access"`, and no `sub` or `preferred_username`
in the body. Repeat step 1's authorize for A: expect the consent redirect of step 3, not a code.
5. A clicks Agree. Repeat step 1's authorize and token, then step 2: expect 302 with `code`, 200 tokens, 200 userinfo.
6. A code obtained before step 4 and exchanged after it: expect 400 `invalid_grant` and no `access_token`.
7. Control: with the receipt gone, B can still sign in to Bifrost, open `/App/Members` and vote; only chat is refused.
## Back-channel logout ## Back-channel logout
When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the
subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same subjects holding valid authorizations of the MAS client; for each subject who may no longer chat (the same
reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, a subject that is not an account id, or
`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, no live receipt for the notice version in force; an account with no membership or a pending one that has agreed may chat and is left alone) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations,
which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin which also invalidates their tokens. Membership and receipts are never written by this path: any way of being suspended, deleted or withdrawn (admin
action, sync, a database edit, a future suspend) is covered because the work is derived from membership state. action, sync, a database edit, the consent page) is covered because the work is derived from stored state.
The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it,
`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the `aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the
@ -93,6 +163,10 @@ 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 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. 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 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. Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an
@ -107,6 +181,38 @@ Configure MAS's upstream provider with the issuer above, the client id and secre
from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an
account. account.
## Limits, prune, brake
`OidcGuardMiddleware` sits ahead of OpenIddict and guards the three endpoints, matched by path segment so a trailing slash
is no way around it. In order: the marker brake (503; a marker that cannot be read is treated as engaged); one per-address
budget over the three endpoints together (429 with `Retry-After`; the address is the connection's after forwarded-headers
handling that trusts only the local proxy, IPv6 keyed by /64); and a cap on token rows: a new authorize request (no
`request_uri`) is refused with 503 while the table holds `MaxTokenRows`. Any failure inside the guard is a 503, never a pass.
MAS's flow is a handful of requests per sign-in from one address, far under the defaults (30 a minute is about 15 sign-ins).
There is no per-client budget. The one client's id is public and what a caller names is unverified, so a budget keyed on it
would let anyone spend the real client's permits and lock sign-in out. The bound on the anonymous endpoint is the address
budget plus the row cap, and the cap is itself a switch an attacker with many addresses can flip: it refuses new sign-ins
(never resumed ones) until the next prune frees rows, which is the bounded failure chosen over unbounded parked rows.
Not parking state before the user authenticates (as pushed authorization requests avoid by authenticating the client first)
would remove the switch, and OpenIddict's request caching does not offer that.
`OidcPruner` runs every `PruneInterval` and deletes tokens past expiry (parked authorize requests, codes, access tokens) or no
longer valid, then authorizations no longer valid or ad hoc with no token left (younger than five minutes are left alone).
Live rows are untouched. Plain EF queries rather than OpenIddict's own prune, which uses ExecuteDelete and cannot run on the
in-memory provider the tests use.
`docs/oidc-reserved-accounts.sql` is the read-only query for existing accounts that hold one of the 28 reserved names.
## Not built
Checked against the Window B list (2026-11-11). Present and tested: discovery, JWKS, authorize/token/userinfo, PKCE S256,
the one client, claims to a Matrix-legal localpart (`[a-z0-9._-]`, 3-80, lower case), reserved names at creation and now at
issuance, back-channel logout. Not present:
- **MAS upstream block.** `gamecult-ops` `chat/matrix-auth/config.yaml` has no `upstream_oauth2` (provider ULID,
`on_conflict: fail`, `on_backchannel_logout: logout_all`, the redirect URI above); that lives in the ops repo.
## Remaining scar ## Remaining scar
Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle

View file

@ -0,0 +1,12 @@
-- Read-only. Which existing UserAccounts hold one of the 28 reserved localparts
-- (BifrostIdentityService.ReservedIdentities). Written for review; not run on live.
-- Run it in a read-only transaction by whoever holds the database:
-- psql -v ON_ERROR_STOP=1 -c 'BEGIN READ ONLY' -f docs/oidc-reserved-accounts.sql
-- Keep the list in step with ReservedIdentities; the issuer refuses these at authorize (#49).
SELECT a."Id", a."BifrostIdentity", a."NormalizedBifrostIdentity", a."CreatedAtUtc", a."LastSeenAtUtc"
FROM "UserAccounts" a
WHERE lower(a."NormalizedBifrostIdentity") IN (
'admin', 'aqua', 'deru', 'eureka', 'heimdall', 'ivixey', 'kiko', 'libby', 'meta', 'nibu', 'unnie', 'void', 'weksa',
'root', 'system', 'matrix', 'synapse', 'support', 'bot', 'mod', 'moderator', 'postmaster', 'gamecult',
'bifrost', 'idunn', 'odin', 'huginn', 'ratatoskr')
ORDER BY a."NormalizedBifrostIdentity";

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.

142
docs/registration-gates.md Normal file
View file

@ -0,0 +1,142 @@
# Public registration gates
Meta ruled yes to public registration on 2026-11-10. The gates she required: self-hosted CAPTCHA, email verification,
a training-notice consent receipt, rate limits and a brake, reserved names, and new accounts in Users with no vote.
This page scopes the three that did not exist, says where each lives, and records what is built.
## What exists
| Gate | Where | State |
| --- | --- | --- |
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. A Suspended row with no stored reason (suspended before the column existed) shows admins the fixed text "Suspended before reasons were recorded"; the old text is never read out of the receipt. An unsuspend still requires a reason, but nothing stores it. |
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
| Email verification | nowhere | Scoped below, not built. |
The hole these gates close: `POST /auth/bifrost/register` is anonymous, and since the chat tier merged, an
`Authenticated` account it creates can sign in to chat. Before this cut it took an identity name and nothing else.
## CAPTCHA
Lives in Bifrost, because the register endpoint is Bifrost's. Heimdall authenticates people through providers and has no
registration form.
Design: a hashcash proof of work. `GET /auth/bifrost/challenge` returns a Data Protection-protected token (random id and
expiry, five minutes) and a difficulty in bits. The client finds a decimal `nonce` such that
`SHA-256(challenge + ":" + nonce)` has at least that many leading zero bits, and posts both with the registration. The
server verifies the protection, the expiry and the hash, and spends the id once.
- Self-hosted: no third-party script, request or cookie; nothing to track.
- Accessible: there is nothing to see or hear and nothing to solve by hand. A screen-reader user, a keyboard user and a
script all pass the same way, at the cost of CPU time on the client.
- Cost: difficulty 18 is about 260,000 hashes on average, well under a second natively and a few seconds in browser
JavaScript. It prices a bulk registration run in CPU; it does not stop a determined attacker with CPUs, and it is not a
proof of humanity. The difficulty is embedded in the signed challenge, so a challenge verifies at the difficulty it was issued with.
- Replay: the spent-id set is in memory and bounded (10,000, entries dropped at expiry); a restart forgets it, so a challenge
can be replayed for at most its five-minute life. When the set is full the endpoint refuses rather than grows. One address
cannot fill it alone: `GET /auth/bifrost/challenge` and `POST /auth/bifrost/register` share one per-address budget
(`Bifrost:Registration:Limits`, 10 per minute by default, IPv6 by /64, over budget is 429 with Retry-After). An attacker
with many addresses can still fill the set; that is the accepted bound, the same as oidc-issuer.md.
### The sign-up form and the no-JS fallback
`GET /auth/bifrost/register` renders the form: identity, optional display name, the notice in full beside an unchecked
consent box (the box is the consent act), and a challenge. With JavaScript the page solves the proof of work in the
background and reports progress in a polite live region. Without JavaScript the form still submits and succeeds, by a
time-lock:
- The challenge carries its issue time. A submission that says it waited is accepted only once `FallbackWaitSeconds`
(default 30) have passed since issue; earlier, the page comes back saying how many seconds remain and the challenge is
not spent (a fresh challenge would restart the clock). The same holds for a refused identity (taken, reserved or
malformed): those rules are checked before any permit or challenge is spent, and the still-valid challenge is kept. Every
other failure re-renders with a fresh challenge, the consent box unchecked again (tick it again) and the identity kept.
`FallbackWaitSeconds` is validated at 5 to under 300, since a challenge lives 300 seconds; `Captcha:Difficulty` at 1 to 24.
- Fallback submissions have their own, stricter per-address budget (`Limits:FallbackPermits`, default 3 per
`FallbackWindow`, default one hour; over it is 429 with Retry-After).
- A submission names a nonce or says it waited, never both.
- The page has a second submit button, always enabled, that abandons a solve in progress and submits on the time-lock, so
a slow device is never stuck behind the proof of work.
- `POST /auth/bifrost/register/form` refuses a request that did not come from the site itself (`RequestOriginGuard`: `Origin`
equal to the site origin, else `Sec-Fetch-Site: same-origin`), 403, before it reads the body or spends anything, so a
third-party page cannot register an account and replace a visitor's session. A request with neither header and no
session cookie is allowed, because text browsers send neither and are the people the fallback is for. The body is capped
at 16 KB (413) and the per-address budget is spent before it is read.
- Whether a name is taken is answerable without a proof, bounded by the per-address budget. Names are public (they are
chat localparts), so that leaks nothing.
Tradeoff, stated plainly. The time-lock is accessible to everyone who can use a browser: nothing to see, hear, read or
solve, no script, no third party. It is also weaker than the proof of work. It costs an attacker wall-clock time, not CPU,
and an attacker can start many challenges at once and wait once, so what bounds the fallback is the per-address budget
and nothing else. An attacker with many addresses passes it at the price of the wait. We accept that because a
registration buys little: the account is `Authenticated`, in Users, with no vote and no member surface, and the other
gates (consent, verification once built, reserved names, suspension) still apply. If fallback registrations are abused,
raise the wait or lower the budget; do not add a visual or audio puzzle, which excludes the people the fallback is for.
## Consent receipt
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
decision (`admission-is-bifrost-membership`).
- Consent is optional at registration. Registration succeeds with or without it; a receipt exists only if the person
agreed. It is not optional for chat: the OIDC chat sign-in needs a live receipt for the version in force
(`oidc-issuer.md`, Who may sign in). See `training-notice.md` for why and for what the notice says.
- Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's
`answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 1 is in force, and its text is frozen (a test pins its hash).
Changing the text is a version bump, never an edit in place. The text states that nothing from Discord is used.
- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`). One live (not withdrawn)
receipt per account and version; withdrawn rows stay as history. At registration an `acceptedNoticeVersion` equal to
the version in force writes the receipt in the same save as the account; a different version is refused (400) and creates
nothing; absent means no receipt.
- Consent page: `GET /auth/bifrost/consent`, for any signed-in account (native, provider, existing). It shows the notice,
the account's state (no agreement, agreed to the current version, agreed to an older one, withdrawn) and the buttons
that apply (with a `returnUrl` query a refused chat sign-in sends, a local path only, Agree returns the member there):
Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account
on an older version sees both). `POST /auth/bifrost/consent/accept` and `POST /auth/bifrost/consent/withdraw` are form posts refused
with 403 unless they came from the site itself (the strict `RequestOriginGuard`), before anything is read or written.
Agree is idempotent; withdraw stamps `WithdrawnAtUtc` once and deletes nothing; agreeing again after a withdrawal writes
a new live receipt. Withdrawal stops future use and cannot recall past use; the notice says so.
- A version bump: receipts name the version agreed to, so after a bump no receipt matches and everyone is out of the set
until they agree to the new version on the consent page, which tells them their agreement is to an older version. No
one is prompted anywhere else; silence means no.
- Consumer: a training export must include an account's data only while a live receipt for the notice version in force
exists. No export exists in Bifrost; this page is the contract it will be built against, and the notice is not
operative for real people until the export enforces it with a test that a withdrawn or never-agreed account's rows are
absent.
Limits: accounts without a receipt (provider-created, existing, registered without agreeing) are excluded, which is the
rule, and they opt in on the consent page. A native account that loses its session cannot reach the page (no credential
until sign-in recovery exists), so it cannot withdraw.
## Email verification
Not built. Decided (steward, 2026-11-11): Bifrost keeps a verified address, as sign-in recovery.
Why: a native account has no credential and no sign-in route (`concept-mill-votes-target.md`, claim 12). A person who
loses the session cookie loses the account, and the chat localpart minted from it is permanent. An address that was
verified is the one way back, and the usual way (MAS, Matrix homeservers and most accounts hold one). Hashing it instead
cannot send mail, so it recovers nothing.
Privacy handling, which the build must honour:
- Stored only for native accounts, one verified address per account, unique across accounts (a keyed hash of the
normalized address is the lookup; the address itself is encrypted with Data Protection, since sending needs it).
- Never in a token or claim (the issuer doc's claim table is unchanged), never in a log line, a dashboard, a Verse
document, a training export, or Heimdall. The account page shows it only to its owner.
- Used for two things: the verification mail and a single-use sign-in link (15 minutes, spent on use, per-address and
per-account send budgets). No notices, no newsletters, no sharing.
- The owner can remove it (then there is no recovery) or change it (a new verification first). Deleting the account
deletes it.
- Existing accounts and provider accounts are not asked.
The build cannot start until ops provides a transport: there is no mail sender anywhere in Bifrost, Heimdall or the chat
stack. Bifrost's side is an `IEmailSender` port with the relay's settings as secret files; the relay, its sending domain
and SPF/DKIM are ops infrastructure.
Shape: registration becomes two steps. Step one takes identity, address, consent and the CAPTCHA, and mails a single-use
token; no account exists yet. Step two redeems the token and creates the account, the receipt and the stored address
together. An unverified account then cannot exist, so it cannot chat.
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts.

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 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. 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, **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.
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 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; a moderator registers it. **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot. 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, **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 and prints the envelope; it posts nothing. `commit` also prints the ballot (candidate, reason, salt), which the Mind keeps in

78
docs/training-notice.md Normal file
View file

@ -0,0 +1,78 @@
# Training notice, version 1
The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and what each gap in
the receipt flow became, and does not repeat the wording. Contract for the receipt and the export is in
`registration-gates.md`. What the receipt gates in chat is in `oidc-issuer.md`.
Version 1 is the first notice that will be live under that number, but the number has been used before: main carried a
version 1 with different words from #54 onward, and #58 labelled its drafts version 2. Receipts therefore do not migrate.
**The text is frozen as version 1**: a test pins its hash, so editing the words without bumping `TrainingNotice.Version`
fails a test. A reworded notice is version 2, with its own pinned hash.
## Before deploy
Migration `20261111120000_ClearLegacyConsentReceipts` deletes every row from `ConsentReceipts` when it runs, because
version 1 receipts were given to different words and must not count as agreement to these. Manual emptying is therefore only
a belt-and-braces check: confirm the table is empty after the migrate on any database that ever ran main at or after
`33be966`. Meta/ops must still confirm whether anything was deployed with receipts in it; if so, the people who agreed are
asked again on their next chat sign-in.
## What the notice says and why
| The notice says | Why |
| --- | --- |
| What counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | Names the data instead of "messages and activity". |
| "that GameCult builds and runs" | Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice claims nothing either way. |
| "Nothing that came from Discord is used, in any form." | `gamecult-chat:ruling:discord-taints-training`. Whether any path carries Discord content into a chat room is for the chat steward to confirm (not established here), and any that exists must be excluded from every set for the sentence to stay true. |
| "Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing." | Meta's ruling, 2026-11-11: the chat token needs a live receipt for the version in force; nothing else does. "to speak in a recorded room" is settled wording. |
| "Lines you have already spoken stay: withdrawing deletes nothing." | Withdrawing ends future speaking and future training use; it recalls nothing. The sentence is on the page where the Agree button is (the consent page and the registration form both render the notice beside it), so nobody agrees believing they can unsay a line. |
| Names the door: the consent page, while signed in. | "Withdraw at any time" with no door is no promise. |
## Rulings on the five gaps
1. **A native account with no session cannot withdraw.** True. A native account has no credential, so a visitor who loses
the session cookie has no way back in, to the consent page or anywhere else. Nothing in the consent flow can fix that;
sign-in recovery is the verified address (`registration-gates.md`, Email verification), not built. Until it is, such
a person cannot withdraw and the notice cannot promise otherwise. The consequence is bounded: consent is optional, so
the people affected are those who ticked it and then lost the session. This stays an open limit, listed under
"What remains".
2. **A version bump silently drops everyone.** True, and it stays the behavior because it is the honest one: a receipt
records agreement to specific words, and new words are not agreed to. The export includes an account only while a live
(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 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
anything is read or written.
4. **Provider-created and existing accounts have no receipt.** Correct, and now stated as the rule: no receipt, no use.
Since the 2026-11-11 ruling it is also: no receipt, no chat token. They keep their account, the members page and their
votes, and learn it where it bites: the refused chat sign-in lands them on the consent page (`oidc-issuer.md`, Chat needs a
receipt). Nobody is emailed or prompted elsewhere.
5. **No export exists.** True. Every promise about use is a promise about code not written. The notice must not be
treated as operative for real people until the export enforces the contract with a test that a withdrawn or
never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). Confirm (Before deploy) that no
deploy has put an earlier version 1 in front of anyone.
## Consent is optional
Registration succeeds with or without agreement. A receipt exists only if the person agreed. The account is not
conditioned on agreement, which removes the objection that agreement obtained as a condition of an account is not a free
choice; what agreement buys is speaking in a recorded room, and the form says so beside the checkbox. This is the
steward's ruling on Eureka's recommendation, and Meta's ruling of 2026-11-11 added the chat gate. One consequence: the
registration form does not enforce the notice.
## Still open for Meta
- Purpose and scope: whether a dataset or model may leave GameCult, and whether a third-party model API processing
messages to answer them is covered. The notice is silent on both.
- Deletion of messages on request, who does it, and the contact words. The notice offers none.
- Controller and contact, and minimum age. Nothing here addresses either.
- Counsel's reading of consent under the law that applies to the people registering. Not researched here.
## What remains
- A signed-out native account cannot withdraw until sign-in recovery exists.
- No training export, so no operative use.
- Wording changes are a version bump with a new text and a new pinned hash in `TrainingNoticeFrozenTests`; never edit a version's text in place; the frozen-hash test fails if you do.

View file

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

View file

@ -0,0 +1,68 @@
namespace Bifrost.Web.Configuration;
// The fork desk's configuration (CultLib fork-card.md, Transport; M1 and M5). Minds is an ARRAY, never a map keyed
// by Mind id: configuration keys are case-insensitive, so a keyed section would let GAMECULT-CHAT reach
// gamecult-chat. Account ids are lowercase D-format Guids. An empty Minds list means the desk is not configured.
public sealed class DeskOptions
{
public const string SectionName = "Desk";
public List<DeskMindOptions> Minds { get; init; } = [];
public DeskAttributionOptions Attribution { get; init; } = new();
// The one origin allowed to call /api/desk from a browser (exactly this, no credentials).
public string AllowedOrigin { get; init; } = string.Empty;
// The cap on an answer body, in bytes, judged before it is parsed.
public int MaxBodyBytes { get; init; } = 16 * 1024;
// What an `unavailable` document tells the client to wait.
public int RetryAfterSeconds { get; init; } = 30;
public bool IsConfigured => Minds.Count > 0;
}
public sealed class DeskMindOptions
{
public string Id { get; init; } = string.Empty;
// The full path of this Mind's socket on the peer-checked door. Bifrost only connects to it.
public string Socket { get; init; } = string.Empty;
// Accounts that may answer this Mind's forks. Sent to Huginn with every call; no request sets or extends it.
public List<string> Deciders { get; init; } = [];
// Huginn's bound on the list a call carries (fork-card.md: one to 64 ids).
public const int MaxDeciders = 64;
// One to 64 ids, none repeated. The startup validator and the request path both ask this one question.
public static bool DecidersWithinBounds(IReadOnlyCollection<string> deciders) =>
deciders.Count is >= 1 and <= MaxDeciders && deciders.Distinct(StringComparer.Ordinal).Count() == deciders.Count;
}
// The authority a stamped answer carries (fork-card.md: Operator, Steward or Member), from configuration because
// Bifrost has no such roles: Operator is one account, Stewards a list, everyone else a Member.
public sealed class DeskAttributionOptions
{
public string? Operator { get; init; }
public List<string> Stewards { get; init; } = [];
public string AuthorityOf(string accountId)
{
if (!string.IsNullOrEmpty(Operator) && string.Equals(accountId, Operator, StringComparison.Ordinal))
{
return DeskAuthority.Operator;
}
return Stewards.Contains(accountId, StringComparer.Ordinal) ? DeskAuthority.Steward : DeskAuthority.Member;
}
}
public static class DeskAuthority
{
public const string Operator = "Operator";
public const string Steward = "Steward";
public const string Member = "Member";
}

View file

@ -14,7 +14,11 @@ public sealed class HeimdallOptions
public string PatreonTierTitle { get; init; } = "Inner Sanctum"; 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; public string PatronSupportIntakeSecret { get; init; } = string.Empty;
@ -39,4 +43,37 @@ public sealed class HeimdallOptions
IsConfigured && IsConfigured &&
EnablePatronSupportIntake && EnablePatronSupportIntake &&
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret); !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

@ -16,6 +16,15 @@ public sealed class OidcIssuerOptions
public OidcClientsOptions Clients { get; init; } = new(); public OidcClientsOptions Clients { get; init; } = new();
public OidcLimitsOptions Limits { get; init; } = new();
/// <summary>
/// Optional path of the issuer's brake marker. While a file exists there, /connect/* answers 503 and nothing is issued;
/// remove it to release. Checked on every request (one stat), so engaging and releasing need no restart.
/// Unset: the issuer has no marker brake (removing this section and restarting is the other off switch).
/// </summary>
public string BrakePath { get; init; } = string.Empty;
/// <summary>Null when the section is absent.</summary> /// <summary>Null when the section is absent.</summary>
public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration) public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration)
{ {
@ -42,3 +51,22 @@ public sealed class OidcClientOptions
/// <summary>MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended.</summary> /// <summary>MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended.</summary>
public string BackchannelLogoutUri { get; init; } = string.Empty; public string BackchannelLogoutUri { get; init; } = string.Empty;
} }
/// <summary>
/// One budget on /connect/authorize, /connect/token and /connect/userinfo together, per source address in one fixed window (a refused caller gets 429 with Retry-After),
/// and the bound on what the anonymous authorize endpoint can park. No per-client budget: the client id is public. The defaults suit one client (MAS) behind one proxy.
/// </summary>
public sealed class OidcLimitsOptions
{
public int PerAddressPermits { get; init; } = 30;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int MaxTrackedKeys { get; init; } = 10_000;
/// <summary>The most OpenIddict token rows (parked authorize requests, codes, access tokens) allowed to exist; a new authorize request beyond it is refused until the prune frees room.</summary>
public int MaxTokenRows { get; init; } = 20_000;
/// <summary>How often expired and orphaned authorizations and tokens are deleted.</summary>
public TimeSpan PruneInterval { get; init; } = TimeSpan.FromMinutes(5);
}

View file

@ -0,0 +1,43 @@
namespace Bifrost.Web.Configuration;
/// <summary>Gates on the anonymous native registration (section <c>Bifrost:Registration</c>).</summary>
public sealed class RegistrationOptions
{
public const string SectionName = "Bifrost:Registration";
public const int MinDifficulty = 1;
public const int MaxDifficulty = 24;
public const int MinFallbackWaitSeconds = 5;
public const int MaxFallbackWaitSeconds = 300;
public RegistrationCaptchaOptions Captcha { get; init; } = new();
public RegistrationLimitsOptions Limits { get; init; } = new();
}
/// <summary>One budget on GET /auth/bifrost/challenge and POST /auth/bifrost/register together, per source address in one fixed window.</summary>
public sealed class RegistrationLimitsOptions
{
public int PerAddressPermits { get; init; } = 10;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int MaxTrackedKeys { get; init; } = 10_000;
/// <summary>Time-lock submissions (no script, no proof of work) per address per <see cref="FallbackWindow"/>; far tighter than <see cref="PerAddressPermits"/> because they cost the client nothing.</summary>
public int FallbackPermits { get; init; } = 3;
public TimeSpan FallbackWindow { get; init; } = TimeSpan.FromHours(1);
}
public sealed class RegistrationCaptchaOptions
{
/// <summary>Leading zero bits the SHA-256 of <c>challenge:nonce</c> must have. 18 is about 262k hashes, a second or two in a browser.</summary>
public int Difficulty { get; init; } = 18;
/// <summary>Seconds after the challenge was issued before a <c>waited</c> (no proof of work) submission is accepted.</summary>
public int FallbackWaitSeconds { get; init; } = 30;
}

View file

@ -43,6 +43,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>(); public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
public DbSet<MindKey> MindKeys => Set<MindKey>(); public DbSet<MindKey> MindKeys => Set<MindKey>();
public DbSet<MindNonce> MindNonces => Set<MindNonce>(); public DbSet<MindNonce> MindNonces => Set<MindNonce>();
@ -322,6 +323,16 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<ConsentReceipt>(entity =>
{
// One live receipt per account and version; a withdrawn row stays as history and a later agreement is a new row.
entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique().HasFilter("\"WithdrawnAtUtc\" IS NULL");
entity.HasOne(x => x.UserAccount)
.WithMany()
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MindKey>(entity => modelBuilder.Entity<MindKey>(entity =>
{ {
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();

View file

@ -0,0 +1,40 @@
using System;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like AddMindKeys: no dotnet-ef on the authoring host; the snapshot was edited to match.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261111090000_AddConsentReceipts")]
public partial class AddConsentReceipts : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConsentReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
NoticeVersion = table.Column<int>(type: "integer", nullable: false),
AcceptedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
WithdrawnAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConsentReceipts", x => x.Id);
table.ForeignKey("FK_ConsentReceipts_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts", columns: new[] { "UserAccountId", "NoticeVersion" }, unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ConsentReceipts");
}
}
}

View file

@ -0,0 +1,36 @@
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match.
// A person may withdraw and agree again to the same version, so uniqueness narrows to the live receipt.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261111100000_ConsentReceiptsOneLivePerVersion")]
public partial class ConsentReceiptsOneLivePerVersion : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts");
migrationBuilder.CreateIndex(
name: "IX_ConsentReceipts_UserAccountId_NoticeVersion",
table: "ConsentReceipts",
columns: new[] { "UserAccountId", "NoticeVersion" },
unique: true,
filter: "\"WithdrawnAtUtc\" IS NULL");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts");
migrationBuilder.CreateIndex(
name: "IX_ConsentReceipts_UserAccountId_NoticeVersion",
table: "ConsentReceipts",
columns: new[] { "UserAccountId", "NoticeVersion" },
unique: true);
}
}
}

View file

@ -0,0 +1,25 @@
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like ConsentReceiptsOneLivePerVersion: no dotnet-ef on the authoring host; the snapshot was edited to match.
// Reasons already written to AuditEvent.Detail stay there as history; new ones live only in this admin-read column.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261111110000_AddMembershipSuspensionReason")]
public partial class AddMembershipSuspensionReason : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(name: "SuspensionReason", table: "Memberships", type: "text", nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "SuspensionReason", table: "Memberships");
}
}
}

View file

@ -0,0 +1,26 @@
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like AddMembershipSuspensionReason: data only, so no schema change and the snapshot is untouched.
// Notice v1 was reused for new words, so any receipt written against the earlier v1 text must not count as agreement
// to these. Every row goes; people who agreed are asked again on their next chat sign-in.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261111120000_ClearLegacyConsentReceipts")]
public partial class ClearLegacyConsentReceipts : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM \"ConsentReceipts\";");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Deleted agreements cannot be restored, and must not be.
}
}
}

View file

@ -821,6 +821,9 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("SuspensionReason")
.HasColumnType("text");
b.Property<Guid>("UserAccountId") b.Property<Guid>("UserAccountId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@ -1440,6 +1443,33 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("VolunteerClaims"); b.ToTable("VolunteerClaims");
}); });
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("NoticeVersion")
.HasColumnType("integer");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("WithdrawnAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserAccountId", "NoticeVersion")
.IsUnique()
.HasFilter("\"WithdrawnAtUtc\" IS NULL");
b.ToTable("ConsentReceipts");
});
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b => modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -2142,6 +2172,17 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkItem"); b.Navigation("WorkItem");
}); });
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("UserAccount");
});
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
{ {
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")

View file

@ -334,6 +334,9 @@ public sealed class Membership
public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated; public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated;
/// <summary>Why an admin suspended the account; set and cleared only by <c>MembershipSuspensionService</c> and projected only to admins.</summary>
public string? SuspensionReason { get; set; }
public bool IsPlatformAdmin { get; set; } public bool IsPlatformAdmin { get; set; }
public bool CanManageProjects { get; set; } public bool CanManageProjects { get; set; }
@ -363,7 +366,8 @@ public sealed class Membership
public sealed class RoleAssignment public sealed class RoleAssignment
{ {
public Guid Id { get; set; } = Guid.NewGuid(); // Left empty until saved: a key preset on a row added to an already-saved membership makes EF treat it as existing and fail the save.
public Guid Id { get; set; }
public Guid MembershipId { get; set; } public Guid MembershipId { get; set; }
@ -1154,6 +1158,25 @@ public sealed class AuditEvent
public UserAccount? ActorUserAccount { get; set; } public UserAccount? ActorUserAccount { get; set; }
} }
/// <summary>
/// An account's consent to the training notice, recorded with the notice version it accepted. Withdrawal sets
/// <see cref="WithdrawnAtUtc"/>; the row is never deleted, so what was agreed and when stays answerable.
/// </summary>
public sealed class ConsentReceipt
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public int NoticeVersion { get; set; }
public DateTimeOffset AcceptedAtUtc { get; set; }
public DateTimeOffset? WithdrawnAtUtc { get; set; }
public UserAccount? UserAccount { get; set; }
}
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary> /// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
public sealed class MindKey public sealed class MindKey
{ {

View file

@ -1,29 +0,0 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Http;
namespace Bifrost.Web.Features.ConceptVotes;
// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous
// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
// are all refused. Header values are compared, never echoed.
public static class CastRequestGuard
{
public static bool Allows(HttpRequest request, BifrostHostOptions host)
{
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
{
return false;
}
var origin = request.Headers.Origin;
if (request.Headers.ContainsKey("Origin"))
{
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
}
var fetchSite = request.Headers["Sec-Fetch-Site"];
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
}
}

View file

@ -23,6 +23,18 @@ public sealed record StanceCounts(int Up, int Down);
public sealed record ConceptTally(int Up, int Down, StanceCounts Members); public sealed record ConceptTally(int Up, int Down, StanceCounts Members);
public enum RankOrder
{
Net,
Total,
Newest
}
// One row of the ranked read: counts and the subject's title only, never a voter key, account id or network tag.
public sealed record RankedSubject(string Key, string Title, ConceptTally Tally);
public sealed record RankedPage(IReadOnlyList<RankedSubject> Items, string? NextCursor);
public sealed record ConceptSubjectView( public sealed record ConceptSubjectView(
string Collection, string Collection,
string Key, string Key,
@ -189,6 +201,74 @@ public sealed class ConceptAppraisalService(
StringComparer.Ordinal); StringComparer.Ordinal);
} }
// The ranked read: live subjects of one collection ordered by net (up minus down), total (up plus down) or
// registration time, largest first, ties by key ascending, so the order is total and a cursor names a position.
// The cursor is keyset: the next page is whatever sorts after the last row's (value, key), so a vote landing
// between pages can move rows but never makes a page repeat the cursor's own row. With unvoted and a client
// address, subjects whose stored tag equals the client's are left out (no address filters nothing) ("not yet voted by this network"); the tag is only compared
// inside the query and is never returned. Null when the collection has no live subject.
public async Task<RankedPage?> GetRankedAsync(
string collection,
RankOrder order,
int limit,
RankCursor? after,
bool unvoted,
IPAddress? client,
CancellationToken cancellationToken)
{
var live = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection && x.RetiredAtUtc == null)
.Select(x => new { x.Id, x.Key, x.Title, x.RegisteredAtUtc })
.ToListAsync(cancellationToken);
if (live.Count == 0)
{
return null;
}
var ids = live.Select(x => x.Id).ToList();
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => ids.Contains(x.SubjectId))
.GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member })
.Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() })
.ToListAsync(cancellationToken);
var voted = new HashSet<Guid>();
if (unvoted && client is not null)
{
var opts = options.Value;
var tag = NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
voted = (await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => ids.Contains(x.SubjectId) && x.NetworkTag == tag)
.Select(x => x.SubjectId)
.ToListAsync(cancellationToken)).ToHashSet();
}
var rows = live
.Where(s => !voted.Contains(s.Id))
.Select(s =>
{
var tally = Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count)));
var value = order switch
{
RankOrder.Net => (long)tally.Up - tally.Down,
RankOrder.Total => (long)tally.Up + tally.Down,
_ => s.RegisteredAtUtc.UtcTicks
};
return (Value: value, s.Key, s.Title, Tally: tally);
})
.Where(r => after is null || r.Value < after.Value || (r.Value == after.Value && string.CompareOrdinal(r.Key, after.Key) > 0))
.OrderByDescending(r => r.Value)
.ThenBy(r => r.Key, StringComparer.Ordinal)
.Take(limit + 1)
.ToList();
var page = rows.Take(limit).ToList();
var next = rows.Count > limit
? new RankCursor(order, unvoted, page[^1].Value, page[^1].Key).Encode()
: null;
return new RankedPage(page.Select(r => new RankedSubject(r.Key, r.Title, r.Tally)).ToList(), next);
}
public async Task<ConceptSubjectView?> GetSubjectAsync( public async Task<ConceptSubjectView?> GetSubjectAsync(
string collection, string collection,
string key, string key,

View file

@ -65,4 +65,89 @@ public static class ConceptTallyExport
response.Headers.CacheControl = "public, max-age=60"; response.Headers.CacheControl = "public, max-age=60";
return Results.Json(tallies); return Results.Json(tallies);
}).AllowAnonymous().RequireCors(CorsPolicy); }).AllowAnonymous().RequireCors(CorsPolicy);
public const int DefaultPageSize = 50;
public const int MaxPageSize = 100;
// The ranked, paged read beside the map: GET /public/concepts/{collection}/ranked
// ?sort=net|total|newest (default net) &limit=1..100 (default 50) &cursor=<opaque> &unvoted=network
// returns { items: [{ key, title, up, down, members: { up, down } }], next_cursor }. The query is a closed
// shape: an unknown or repeated key, an out-of-range value or a cursor that is not exactly one this route issued
// for the same sort and filter is a 400. unvoted=network leaves out the concepts the caller's own network tag
// has already voted on; the tag is derived from the connection and compared inside the query, never accepted
// from the request and never returned. That answer depends on the caller, so it is never cached.
public static IEndpointConventionBuilder MapConceptRankedExport(this IEndpointRouteBuilder app) =>
app.MapGet(RoutePrefix + "/{collection}/ranked", async (
string collection,
HttpContext http,
ConceptAppraisalService appraisals,
CancellationToken cancellationToken) =>
{
if (!ConceptKey.IsValid(collection))
{
return Results.NotFound();
}
if (!TryReadRankedQuery(http.Request.Query, out var order, out var limit, out var cursor, out var unvoted))
{
return JsonText(new { error = "request_invalid" }, StatusCodes.Status400BadRequest);
}
var page = await appraisals.GetRankedAsync(
collection, order, limit, cursor, unvoted, http.Connection.RemoteIpAddress, cancellationToken);
if (page is null)
{
return Results.NotFound();
}
http.Response.Headers.CacheControl = unvoted ? "private, no-store" : "public, max-age=60";
return JsonText(new
{
items = page.Items.Select(i => new
{
key = i.Key,
title = i.Title,
up = i.Tally.Up,
down = i.Tally.Down,
members = new { up = i.Tally.Members.Up, down = i.Tally.Members.Down }
}),
next_cursor = page.NextCursor
}, StatusCodes.Status200OK);
}).AllowAnonymous().RequireCors(CorsPolicy);
private static IResult JsonText(object body, int status) =>
Results.Text(System.Text.Json.JsonSerializer.Serialize(body), "application/json", statusCode: status);
internal static bool TryReadRankedQuery(
IQueryCollection query, out RankOrder order, out int limit, out RankCursor? cursor, out bool unvoted)
{
order = RankOrder.Net;
limit = DefaultPageSize;
cursor = null;
unvoted = false;
foreach (var (key, values) in query)
{
if (values.Count != 1 || values[0] is not { } value)
{
return false;
}
switch (key)
{
case "sort" when value == "net": order = RankOrder.Net; break;
case "sort" when value == "total": order = RankOrder.Total; break;
case "sort" when value == "newest": order = RankOrder.Newest; break;
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out var n) &&
n is >= 1 and <= MaxPageSize && value == n.ToString(System.Globalization.CultureInfo.InvariantCulture):
limit = n;
break;
case "cursor" when RankCursor.TryDecode(value, out var decoded): cursor = decoded; break;
case "unvoted" when value == "network": unvoted = true; break;
default: return false;
}
}
return cursor is null || (cursor.Order == order && cursor.Unvoted == unvoted);
}
} }

View file

@ -0,0 +1,59 @@
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace Bifrost.Web.Features.ConceptVotes;
// The opaque cursor of the ranked read: the last row's sort value and key, plus the order and filter it was issued
// for, so a cursor cannot be replayed under another order or filter. Text "1|order|u|value|key", base64url without
// padding; the length cap only bounds decoding work. Not signed: it names a position in public data and grants nothing. Decoding is strict and canonical (a
// cursor that does not re-encode to the same text is refused), and every field is re-validated, so no cursor text
// reaches a query unparsed.
public sealed record RankCursor(RankOrder Order, bool Unvoted, long Value, string Key)
{
public const int MaxLength = 200;
public string Encode() =>
WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(
string.Create(CultureInfo.InvariantCulture, $"1|{(int)Order}|{(Unvoted ? 1 : 0)}|{Value}|{Key}")));
public static bool TryDecode(string? text, out RankCursor cursor)
{
cursor = null!;
if (string.IsNullOrEmpty(text) || text.Length > MaxLength)
{
return false;
}
string plain;
try
{
var bytes = WebEncoders.Base64UrlDecode(text);
plain = new UTF8Encoding(false, true).GetString(bytes);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
return false;
}
var parts = plain.Split('|');
if (parts.Length != 5 || parts[0] != "1" ||
!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var order) ||
!Enum.IsDefined((RankOrder)order) ||
parts[2] is not ("0" or "1") ||
!long.TryParse(parts[3], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) ||
!ConceptKey.IsValid(parts[4]))
{
return false;
}
var candidate = new RankCursor((RankOrder)order, parts[2] == "1", value, parts[4]);
if (candidate.Encode() != text)
{
return false;
}
cursor = candidate;
return true;
}
}

View file

@ -0,0 +1,270 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.Desk;
// The edge's judgment of an answer body (fork-card.md, answer_request, steps 1 and 2). It checks the shape only:
// whether the answer is legal (a retraction's target, the choice, the fork) is Huginn's. A body that passes is
// forwarded as the bytes received; the parsed request is returned for the door's context and for tests.
public sealed record DeskBodyVerdict(AnswerRequest? Request, string? RefusalCode)
{
public static DeskBodyVerdict Pass(AnswerRequest request) => new(request, null);
public static DeskBodyVerdict Refuse(string code) => new(null, code);
}
public static partial class DeskBodyShape
{
public const int MaxNoteBytes = 4000;
// Two keys the receiver alone stamps. Refused whole at any depth, never stripped.
private static readonly string[] ForbiddenKeys = ["answerer", "authority"];
private static readonly string[] TopKeys =
["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
private static readonly string[] AnswerKeys = ["choice", "note"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly JsonDocumentOptions ParseOptions = new() { MaxDepth = 32 };
public static DeskBodyVerdict Judge(ReadOnlySpan<byte> body, int maxBodyBytes)
{
if (body.Length > maxBodyBytes)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
JsonDocument document;
try
{
document = JsonDocument.Parse(body.ToArray(), ParseOptions);
}
catch (JsonException)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
using (document)
{
try
{
return JudgeParsed(document.RootElement);
}
catch (InvalidOperationException)
{
// A string the parser accepted but cannot decode: invalid UTF-8 or a lone surrogate.
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
}
}
private static DeskBodyVerdict JudgeParsed(JsonElement root)
{
if (HasForbiddenKey(root))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyForbiddenField);
}
if (root.ValueKind != JsonValueKind.Object)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
var top = Members(root);
if (top is null)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
// Unknown keys, including inside the answer object, before any value is judged.
if (top.Keys.Any(key => !TopKeys.Contains(key, StringComparer.Ordinal)))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
}
Dictionary<string, JsonElement>? answerMembers = null;
if (top.TryGetValue("answer", out var answerElement) && answerElement.ValueKind == JsonValueKind.Object)
{
answerMembers = Members(answerElement);
if (answerMembers is not null &&
answerMembers.Keys.Any(key => !AnswerKeys.Contains(key, StringComparer.Ordinal)))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
}
}
return Shape(top, answerMembers);
}
private static DeskBodyVerdict Shape(Dictionary<string, JsonElement> top, Dictionary<string, JsonElement>? answerMembers)
{
var invalid = DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
// Map-mode: every key is written.
if (TopKeys.Any(key => !top.ContainsKey(key)))
{
return invalid;
}
if (!IsString(top["schemaVersion"], out var schemaVersion) || schemaVersion != DeskSchemas.AnswerRequest ||
!IsString(top["fork_id"], out var forkId) || forkId.Length == 0 ||
!IsString(top["options_hash"], out var optionsHash) || !Hex64().IsMatch(optionsHash) ||
!IsString(top["answered_at"], out var answeredAt) || !Instant().IsMatch(answeredAt) ||
!IsString(top["request_id"], out var requestId) || !RequestId().IsMatch(requestId))
{
return invalid;
}
var retractedElement = top["retracted"];
if (retractedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return invalid;
}
var retracted = retractedElement.ValueKind == JsonValueKind.True;
string? retracts = null;
if (top["retracts"].ValueKind != JsonValueKind.Null)
{
if (!IsString(top["retracts"], out var target) || !AnswerId().IsMatch(target))
{
return invalid;
}
retracts = target;
}
AnswerObject? answer = null;
if (top["answer"].ValueKind != JsonValueKind.Null)
{
if (answerMembers is null || AnswerKeys.Any(key => !answerMembers.ContainsKey(key)))
{
return invalid;
}
string? choice = null;
if (answerMembers["choice"].ValueKind != JsonValueKind.Null)
{
if (!IsString(answerMembers["choice"], out var picked) || picked.Length == 0)
{
return invalid;
}
choice = picked;
}
string? note = null;
if (answerMembers["note"].ValueKind != JsonValueKind.Null)
{
if (!IsString(answerMembers["note"], out var words) || !IsWords(words))
{
return invalid;
}
note = words;
}
if (choice is null && note is null)
{
return invalid;
}
answer = new AnswerObject(choice, note);
}
// No answer and no retraction is no request. The illegal retraction forms (a target-less retraction, a
// retraction carrying an answer, a target on a plain answer) pass here: Huginn stores them as bad_retraction.
if (answer is null && !retracted)
{
return invalid;
}
return DeskBodyVerdict.Pass(new AnswerRequest(
schemaVersion, forkId, optionsHash, answer, retracted, retracts, answeredAt, requestId));
}
// A note is 1 to 4000 UTF-8 bytes and not whitespace only. Bytes, not characters or UTF-16 units.
private static bool IsWords(string value)
{
int bytes;
try
{
bytes = StrictUtf8.GetByteCount(value);
}
catch (EncoderFallbackException)
{
return false;
}
return bytes is >= 1 and <= MaxNoteBytes && !value.All(IsSchemaSpace);
}
// The whitespace set of the schema's `words` pattern.
private static bool IsSchemaSpace(char c) =>
c is (>= (char)0x09 and <= (char)0x0d) or (char)0x20 or (char)0x85 or (char)0xa0 or (char)0x1680
or (>= (char)0x2000 and <= (char)0x200a) or (char)0x2028 or (char)0x2029 or (char)0x202f or (char)0x205f or (char)0x3000;
private static bool IsString(JsonElement element, out string value)
{
value = string.Empty;
if (element.ValueKind != JsonValueKind.String)
{
return false;
}
value = element.GetString() ?? string.Empty;
return true;
}
// Object members by unescaped name; null when a name repeats, because the forwarded bytes would then mean
// different things to different parsers.
private static Dictionary<string, JsonElement>? Members(JsonElement element)
{
var members = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!members.TryAdd(property.Name, property.Value))
{
return null;
}
}
return members;
}
private static bool HasForbiddenKey(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (ForbiddenKeys.Contains(property.Name, StringComparer.Ordinal) || HasForbiddenKey(property.Value))
{
return true;
}
}
return false;
case JsonValueKind.Array:
return element.EnumerateArray().Any(HasForbiddenKey);
default:
return false;
}
}
[GeneratedRegex(@"^[0-9a-f]{64}\z")]
private static partial Regex Hex64();
[GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?Z\z")]
private static partial Regex Instant();
[GeneratedRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z")]
private static partial Regex RequestId();
[GeneratedRegex(@"^n[1-9][0-9]*\z")]
private static partial Regex AnswerId();
}

View file

@ -0,0 +1,166 @@
using System.Text.Json;
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 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;
// 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
{
public const string AnswerRequest = "gamecult.fork.answer_request.v0";
public const string AnswerResult = "gamecult.fork.answer_result.v0";
public const string Card = "gamecult.fork.card.v0";
public const string DeskAnswer = "gamecult.desk_answer.v0";
public const string Refusal = "gamecult.fork.refusal.v0";
public const string Unavailable = "gamecult.fork.unavailable.v0";
}
public static class DeskJson
{
// Defaults write nulls, which map-mode needs ("every key written").
public static readonly JsonSerializerOptions Options = new()
{
// The documents are not HTML: keep apostrophes and non-ASCII text as written.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
}
public sealed record AnswerObject(
[property: JsonPropertyName("choice")] string? Choice,
[property: JsonPropertyName("note")] string? Note);
public sealed record AnswerRequest(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("answer")] AnswerObject? Answer,
[property: JsonPropertyName("retracted")] bool Retracted,
[property: JsonPropertyName("retracts")] string? Retracts,
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("request_id")] string RequestId);
public sealed record DeskRefusal(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("error")] string Error);
public sealed record DeskUnavailable(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer_id")] string AnswerId,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("outcome")] string Outcome,
[property: JsonPropertyName("of")] string? Of,
[property: JsonPropertyName("retracts")] string? Retracts,
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("choice")] string? Choice,
[property: JsonPropertyName("note")] string? Note,
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("received_at")] string ReceivedAt,
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
// 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);
public sealed record ForkRecord(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[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")] JsonElement Card,
[property: JsonPropertyName("record")] ForkRecord Record);
public sealed record ForkList(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("mind")] string Mind,
[property: JsonPropertyName("stamp")] string Stamp,
[property: JsonPropertyName("open_count")] int OpenCount,
[property: JsonPropertyName("recorded_count")] int RecordedCount,
[property: JsonPropertyName("forks")] ForkEntry[] Forks,
[property: JsonPropertyName("next_cursor")] string? NextCursor);
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
[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).
public static class DeskRefusals
{
public const string Unauthenticated = "unauthenticated";
public const string BodyForbiddenField = "body_forbidden_field";
public const string BodyUnknownField = "body_unknown_field";
public const string BodyInvalid = "body_invalid";
public const string ForkUnknown = "fork_unknown";
public const string InsufficientScope = "insufficient_scope";
public const string Forbidden = "forbidden";
public const string RequestInvalid = "request_invalid";
public const string RequestIdConflict = "request_id_conflict";
private static readonly Dictionary<string, (int Status, string Error)> Table = new(StringComparer.Ordinal)
{
[Unauthenticated] = (401, "unauthenticated: a verified session is required"),
[BodyForbiddenField] = (400, "body_forbidden_field: answerer and authority are set by the receiver"),
[BodyUnknownField] = (400, "body_unknown_field: the body is a closed shape"),
[BodyInvalid] = (400, "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0"),
[ForkUnknown] = (404, "fork_unknown: no such fork"),
[InsufficientScope] = (403, "insufficient_scope: the token lacks the scope this route needs"),
[Forbidden] = (403, "forbidden: only Active members may use the desk"),
[RequestInvalid] = (400, "request_invalid: the request does not satisfy its document"),
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
};
// fork_too_large names its fork, so it is built here, not read from the table; a bare door code of that name is
// never a refusal. The texts are the pinned vectors' (alone is past the response budget).
public const string ForkTooLarge = "fork_too_large";
public static int ForkTooLargeStatus => 422;
// A list page whose next fork alone is past the budget: the client continues with cursor c<order>; at order 0
// it has reached the end.
public static DeskRefusal ForkTooLargeDocument(string forkId, int order) => new(
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} (order {order}) alone is past the response budget");
// A read of such a fork: nothing to page, so no order.
public static DeskRefusal ForkTooLargeDocument(string forkId) => new(
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} alone is past the response budget");
public static bool IsKnown(string code) => Table.ContainsKey(code);
public static int StatusOf(string code) => Table[code].Status;
public static DeskRefusal Document(string code) => new(DeskSchemas.Refusal, code, Table[code].Error);
}
public static class DeskUnavailables
{
public const int Status = 503;
public static DeskUnavailable Document(int? retryAfterSeconds) => new(
DeskSchemas.Unavailable,
"unavailable",
"unavailable: the outcome is not confirmed, try again with the same request_id",
retryAfterSeconds);
}

View file

@ -0,0 +1,480 @@
using System.Text.Json;
using Bifrost.Web.Configuration;
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;
namespace Bifrost.Web.Features.Desk;
// The fork desk's HTTP edge (fork-card.md, Transport). One catch-all under /api/desk judges, in this order and no
// other: the raw target, the bearer, its audience, its scope, Active membership (the body is unread), the body's
// shape, the {mind} lookup, the door. Every refusal is a contract document. Bifrost holds no fork state: it passes
// Huginn's outcome through, attaches the current card to a stale_fork answer, and says `unavailable` when the door
// cannot confirm an outcome.
public static class DeskEndpoints
{
public const string RoutePrefix = "/api/desk";
public const string Audience = "bifrost-desk";
public const string ReadScope = "desk.read";
public const string AnswerScope = "desk.answer";
public const string CorsPolicy = "DeskCors";
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.
services
.AddOptions<CorsOptions>()
.Configure<IOptions<DeskOptions>>((cors, desk) =>
cors.AddPolicy(
CorsPolicy,
policy => policy
.WithOrigins(desk.Value.AllowedOrigin)
.WithMethods("GET", "POST")
.WithHeaders(HeaderNames.Authorization, HeaderNames.ContentType)));
return services;
}
public static IEndpointConventionBuilder MapDesk(this IEndpointRouteBuilder app)
{
// A configured desk with no credential or door port would serve nothing true: stop startup instead.
app.ServiceProvider.GetRequiredService<IDeskCredential>();
app.ServiceProvider.GetRequiredService<IDeskDoor>();
return app.MapMethods(RoutePrefix + "/{**rest}", ["GET", "POST"], HandleAsync)
.RequireCors(CorsPolicy)
.AllowAnonymous();
}
private static async Task HandleAsync(
HttpContext http,
IDeskCredential credential,
IDeskDoor door,
DeskMindRoutes minds,
IOptions<DeskOptions> options,
BifrostDbContext db)
{
var cancellationToken = http.RequestAborted;
var desk = options.Value;
http.Response.Headers.Append(HeaderNames.Vary, "Origin");
http.Response.Headers.CacheControl = "no-store";
// 1. The raw target, never the decoded Request.Path.
var rawTarget = http.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestFeature>()?.RawTarget ?? string.Empty;
var route = DeskRoute.Parse(http.Request.Method, rawTarget);
if (route is null)
{
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
return;
}
// 2-4. Bearer, audience, scope.
DeskToken? token;
try
{
token = await credential.AuthenticateAsync(http, cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// The bearer could not be judged: neither a pass nor a refusal of the caller.
await WriteUnavailableAsync(http, desk);
return;
}
if (token is null || !string.Equals(token.Audience, Audience, StringComparison.Ordinal))
{
await WriteRefusalAsync(http, DeskRefusals.Unauthenticated);
return;
}
var scope = route.Kind == DeskRouteKind.Answer ? AnswerScope : ReadScope;
if (!token.Scopes.Contains(scope, StringComparer.Ordinal))
{
await WriteRefusalAsync(http, DeskRefusals.InsufficientScope);
return;
}
// 5. Active membership, re-read now; the body has not been touched.
Guid? accountId;
try
{
accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
await WriteUnavailableAsync(http, desk);
return;
}
if (accountId is null)
{
await WriteRefusalAsync(http, DeskRefusals.Forbidden);
return;
}
// 6. The request's shape. An answer's bytes are the bytes forwarded; list and read judge their query.
byte[] body = [];
DeskListRequest? listRequest = null;
var forkId = string.Empty;
if (route.Kind == DeskRouteKind.Answer)
{
var read = await ReadBodyAsync(http.Request, desk.MaxBodyBytes, cancellationToken);
var verdict = read is null
? DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid)
: DeskBodyShape.Judge(read, desk.MaxBodyBytes);
if (verdict.RefusalCode is not null)
{
await WriteRefusalAsync(http, verdict.RefusalCode);
return;
}
body = read!;
}
else if (route.Kind == DeskRouteKind.Read
? !DeskQuery.TryRead(route.RawQuery, route.RawFork!, out forkId)
: !DeskQuery.TryList(route.RawQuery, string.Empty, out listRequest))
{
await WriteRefusalAsync(http, DeskRefusals.RequestInvalid);
return;
}
// 7. The {mind} lookup: a miss is the same document as an unknown fork.
var mind = minds.Resolve(route.RawMind);
if (mind is null)
{
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
return;
}
// 8-10. The door. A decider list outside 1..64 unique ids is a configuration fault: nothing is sent.
if (!DeskMindOptions.DecidersWithinBounds(mind.Deciders))
{
await WriteUnavailableAsync(http, desk);
return;
}
var subject = accountId.Value.ToString("D");
var context = new DeskDoorContext(mind.Id, desk.Attribution.AuthorityOf(subject), subject, mind.Deciders.ToArray());
try
{
switch (route.Kind)
{
case DeskRouteKind.List:
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 : DeskRedaction.ForNonDecider(list));
}
else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder))
{
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeId, largeOrder));
}
else if (listed is DeskListOutcome.NotListed(var listCode) && DoorMayRefuse(listCode) && listCode != DeskRefusals.RequestIdConflict)
{
await WriteRefusalAsync(http, ReadListCode(listCode));
}
else
{
await WriteUnavailableAsync(http, desk);
}
return;
case DeskRouteKind.Read:
var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken);
if (found is DeskReadOutcome.Found(var entry))
{
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskRedaction.ForNonDecider(entry));
}
else if (found is DeskReadOutcome.ForkTooLarge(var largeFork))
{
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
}
else if (found is DeskReadOutcome.NotFound(var readCode) && DoorMayRefuse(readCode) && readCode != DeskRefusals.RequestIdConflict)
{
await WriteRefusalAsync(http, ReadListCode(readCode));
}
else
{
await WriteUnavailableAsync(http, desk);
}
return;
default:
await AnswerAsync(http, door, mind, context, body, desk);
return;
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
await WriteUnavailableAsync(http, desk);
}
}
private static async Task AnswerAsync(
HttpContext http, IDeskDoor door, DeskMindOptions mind, DeskDoorContext context, byte[] body, DeskOptions desk)
{
var cancellationToken = http.RequestAborted;
var outcome = await door.AnswerAsync(mind.Socket, context, body, cancellationToken);
switch (outcome)
{
case DeskAnswerOutcome.NotStored(var code) when DoorMayRefuse(code):
await WriteRefusalAsync(http, code);
return;
case DeskAnswerOutcome.Answered(var result) when DeskStatus.Of(result) is { } status:
// A stale_fork answer, fresh or replayed, carries the CURRENT card: nothing is stored for it.
if (string.Equals(result.Answer.Code, DeskStatus.StaleFork, StringComparison.Ordinal))
{
var current = await door.ReadAsync(mind.Socket, context, result.Answer.ForkId, cancellationToken);
if (current is DeskReadOutcome.ForkTooLarge(var largeFork))
{
// Nothing is recorded for a stale answer, and the card it would carry cannot be returned: permanent.
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
return;
}
if (current is not DeskReadOutcome.Found(var entry))
{
await WriteUnavailableAsync(http, desk);
return;
}
result = result with { Card = entry.Card };
}
await WriteJsonAsync(http, status, result);
return;
default:
await WriteUnavailableAsync(http, desk);
return;
}
}
// 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);
// Codes the door may hand back for nothing stored. The three the edge alone judges are never the door's.
private static bool DoorMayRefuse(string code) =>
code is not (DeskRefusals.Unauthenticated or DeskRefusals.InsufficientScope or DeskRefusals.Forbidden) &&
DeskRefusals.IsKnown(code);
// request_id_conflict is the answer's alone (Libby): a read or list carries no request_id, so the door saying it
// there is a door fault and the edge says `unavailable`.
// A read or list sends no body. A door that refuses one for its body (an `authority` field among them) is
// refusing the request, which is request_invalid; it is not an outage.
private static string ReadListCode(string code) =>
code is DeskRefusals.BodyForbiddenField or DeskRefusals.BodyUnknownField or DeskRefusals.BodyInvalid
? DeskRefusals.RequestInvalid
: code;
// At most `cap` bytes. Null when the body is longer, judged by Content-Length first and then while reading.
private static async Task<byte[]?> ReadBodyAsync(HttpRequest request, int cap, CancellationToken cancellationToken)
{
if (request.ContentLength > cap)
{
return null;
}
using var buffer = new MemoryStream();
var chunk = new byte[4096];
int count;
while ((count = await request.Body.ReadAsync(chunk, cancellationToken)) > 0)
{
if (buffer.Length + count > cap)
{
return null;
}
buffer.Write(chunk, 0, count);
}
return buffer.ToArray();
}
private static Task WriteRefusalAsync(HttpContext http, string code) =>
WriteJsonAsync(http, DeskRefusals.StatusOf(code), DeskRefusals.Document(code));
private static async Task WriteUnavailableAsync(HttpContext http, DeskOptions desk)
{
http.Response.Headers.RetryAfter = desk.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
await WriteJsonAsync(http, DeskUnavailables.Status, DeskUnavailables.Document(desk.RetryAfterSeconds));
}
private static async Task WriteJsonAsync<T>(HttpContext http, int status, T document)
{
http.Response.StatusCode = status;
http.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(http.Response.Body, document, DeskJson.Options, http.RequestAborted);
}
}
// The one place a stored outcome becomes an HTTP status. fork-card.md prose says 200 for any stored outcome; the
// vectors (results[].httpStatus) say a Refused answer carries its refusal's status, and the vectors are the check.
public static class DeskStatus
{
public const string StaleFork = "stale_fork";
private static readonly Dictionary<string, int> RefusedStatus = new(StringComparer.Ordinal)
{
[StaleFork] = 409,
["not_a_decider"] = 403,
["fork_closed"] = 409,
["choice_invalid"] = 400,
["bad_retraction"] = 400,
["already_retracted"] = 409,
};
// Null when the door sent an outcome or a refusal code this contract does not name: Bifrost then cannot vouch
// for it and says `unavailable`.
public static int? Of(AnswerResult result) => result.Answer.Outcome switch
{
"Counted" or "Revision" or "Retraction" => 200,
"Refused" when result.Answer.Code is { } code && RefusedStatus.TryGetValue(code, out var status) => status,
_ => null,
};
}
public enum DeskRouteKind
{
List,
Read,
Answer,
}
public sealed record DeskRoute(DeskRouteKind Kind, string RawMind, string? RawFork, string RawQuery)
{
// The raw request target against the three routes. Segments stay percent-encoded: {mind} is decoded once, by
// the resolver, and nothing here normalises a dot segment. Any other shape is not a desk route.
public static DeskRoute? Parse(string method, string rawTarget)
{
var path = rawTarget;
var query = string.Empty;
var split = path.IndexOf('?');
if (split >= 0)
{
query = path[(split + 1)..];
path = path[..split];
}
if (!path.StartsWith(DeskEndpoints.RoutePrefix + "/", StringComparison.Ordinal))
{
return null;
}
var segments = path[(DeskEndpoints.RoutePrefix.Length + 1)..].Split('/');
return (method, segments) switch
{
("GET", [var mind, "forks"]) => new DeskRoute(DeskRouteKind.List, mind, null, query),
("GET", [var mind, "forks", var fork]) => new DeskRoute(DeskRouteKind.Read, mind, fork, query),
("POST", [var mind, "answers"]) => new DeskRoute(DeskRouteKind.Answer, mind, null, query),
_ => null,
};
}
}
// The list and read requests the query string and path stand for (fork-card.md, List and Read). Nothing in a
// query sets a decider, an answerer or a Mind; an unknown key, a repeated key or a bad value is request_invalid.
public static class DeskQuery
{
public const int MaxLimit = 50;
public static bool TryList(string rawQuery, string mind, out DeskListRequest request)
{
request = new DeskListRequest(mind, null, null, MaxLimit);
if (!TryPairs(rawQuery, out var pairs))
{
return false;
}
string? status = null, cursor = null;
var limit = MaxLimit;
foreach (var (key, value) in pairs)
{
switch (key)
{
case "status" when value is "open" or "recorded":
status = value;
break;
case "status" when value.Length == 0:
break;
case "cursor" when IsCursor(value):
cursor = value;
break;
case "cursor" when value.Length == 0:
break;
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n) && n <= MaxLimit:
limit = n;
break;
default:
return false;
}
}
request = new DeskListRequest(mind, status, cursor, limit);
return true;
}
// The read request has no query at all.
public static bool TryRead(string rawQuery, string rawFork, out string forkId)
{
forkId = string.Empty;
return rawQuery.Length == 0 && DeskMindRoutes.TryDecode(rawFork, out forkId) && forkId.Length > 0;
}
// c<N>, N a positive integer without a leading zero.
private static bool IsCursor(string value) =>
value.Length > 1 && value[0] == 'c' && value[1] is >= '1' and <= '9' && value.Skip(2).All(char.IsAsciiDigit);
private static bool TryPairs(string rawQuery, out List<(string Key, string Value)> pairs)
{
pairs = [];
var seen = new HashSet<string>(StringComparer.Ordinal);
if (rawQuery.Length == 0)
{
return true;
}
foreach (var part in rawQuery.Split('&'))
{
var eq = part.IndexOf('=');
var rawKey = eq < 0 ? part : part[..eq];
var rawValue = eq < 0 ? string.Empty : part[(eq + 1)..];
if (!DeskMindRoutes.TryDecode(rawKey, out var key) || !DeskMindRoutes.TryDecode(rawValue, out var value) || !seen.Add(key))
{
return false;
}
pairs.Add((key, value));
}
return true;
}
}

View file

@ -0,0 +1,84 @@
using System.Text;
using Bifrost.Web.Configuration;
namespace Bifrost.Web.Features.Desk;
// The {mind} segment of a desk route (fork-card.md M1). The raw segment is percent-decoded once and compared,
// ordinally, with the configured ids: no case folding, trimming or normalisation, and a decoding failure is a miss.
// The segment is never joined into a path, a socket name or a query, so a value off the list opens nothing.
public sealed class DeskMindRoutes
{
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly Dictionary<string, DeskMindOptions> byId;
public DeskMindRoutes(IEnumerable<DeskMindOptions> minds)
{
byId = new Dictionary<string, DeskMindOptions>(StringComparer.Ordinal);
foreach (var mind in minds)
{
byId.Add(mind.Id, mind);
}
}
public DeskMindOptions? Resolve(string rawSegment) =>
TryDecode(rawSegment, out var id) && byId.TryGetValue(id, out var mind) ? mind : null;
// One pass of percent-decoding. Uri.UnescapeDataString is not used: it leaves a bad escape in place and turns
// bytes that are not UTF-8 into U+FFFD instead of failing.
internal static bool TryDecode(string raw, out string decoded)
{
decoded = string.Empty;
var bytes = new List<byte>(raw.Length);
for (var i = 0; i < raw.Length; i++)
{
var c = raw[i];
if (c == '%')
{
if (i + 2 >= raw.Length)
{
return false;
}
if (!TryHex(raw[i + 1], out var high) || !TryHex(raw[i + 2], out var low))
{
return false;
}
bytes.Add((byte)((high << 4) | low));
i += 2;
}
else if (c > 0x7f)
{
// A raw request target is ASCII; anything else is not a segment this desk issues.
return false;
}
else
{
bytes.Add((byte)c);
}
}
try
{
decoded = StrictUtf8.GetString(bytes.ToArray());
return true;
}
catch (DecoderFallbackException)
{
return false;
}
}
private static bool TryHex(char c, out int value)
{
value = c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1,
};
return value >= 0;
}
}

View file

@ -0,0 +1,71 @@
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. 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.
public sealed record DeskToken(string? Subject, string? Audience, IReadOnlyCollection<string> Scopes);
public interface IDeskCredential
{
// Null when the request carries no valid bearer token.
ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken);
}
// The envelope sent with every answer, read and list call (fork-card.md M5), under the contract's names, built by
// Bifrost alone: the route's configured Mind id, the verified account's authority (Operator, Steward or Member),
// the account id, and the admin-set decider list of that Mind (1 to 64 unique ids). Nothing in the request sets any
// of it, and Huginn keeps no copy of the list.
public sealed record DeskDoorContext(string Mind, string Authority, string Subject, IReadOnlyList<string> Deciders);
public abstract record DeskAnswerOutcome
{
// Huginn stored an outcome (Counted, Revision, Retraction or Refused) and returned its document.
public sealed record Answered(AnswerResult Result) : DeskAnswerOutcome;
// Nothing stored: Huginn refused with one of the contract's refusal codes.
public sealed record NotStored(string Code) : DeskAnswerOutcome;
}
public abstract record DeskReadOutcome
{
public sealed record Found(ForkEntry Entry) : DeskReadOutcome;
public sealed record NotFound(string Code) : DeskReadOutcome;
// The fork is too large to return: permanent, and nothing to page, so no order or cursor.
public sealed record ForkTooLarge(string ForkId) : DeskReadOutcome;
}
// The list request the desk's query string stands for. Mind is the route's, resolved against the configured list.
public sealed record DeskListRequest(string Mind, string? Status, string? Cursor, int Limit);
public abstract record DeskListOutcome
{
public sealed record Listed(ForkList List) : DeskListOutcome;
public sealed record NotListed(string Code) : DeskListOutcome;
// The next fork is too large for any page: the page holds nothing past the cursor. The client continues with
// cursor c<Order>; at Order 0 it has reached the end. A read of such a fork has its own outcome (no order).
public sealed record ForkTooLarge(string ForkId, int Order) : DeskListOutcome;
}
public interface IDeskDoor
{
// The body is exactly the bytes the client sent. Any exception means the outcome is not confirmed.
Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken);
// The entry for one fork, its card built and hashed by Huginn.
Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken);
// A page of entries, the counts and the stamp, as Huginn projects them for this viewer.
Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken);
}

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

@ -20,7 +20,9 @@ namespace Bifrost.Web.Features.Heimdall;
/// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the /// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the
/// browser that began it. The browser that Heimdall authenticated presents the completion code from its /// browser that began it. The browser that Heimdall authenticated presents the completion code from its
/// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the /// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the
/// attempt's cookie and the redeemed return target names the same attempt. /// attempt's cookie and the redeemed return target names the same attempt. The Linked accounts page's steps (list,
/// link, unlink) are the same machine with other attempt purposes: each is a fresh Heimdall sign-in whose bearer is
/// used inside that one request and dropped.
/// </summary> /// </summary>
public sealed class HeimdallAuthService( public sealed class HeimdallAuthService(
HttpClient httpClient, HttpClient httpClient,
@ -29,7 +31,9 @@ public sealed class HeimdallAuthService(
BifrostIdentityService bifrostIdentityService, BifrostIdentityService bifrostIdentityService,
IOptions<HeimdallOptions> heimdallOptions, IOptions<HeimdallOptions> heimdallOptions,
IDataProtectionProvider dataProtectionProvider, IDataProtectionProvider dataProtectionProvider,
TimeProvider timeProvider) TimeProvider timeProvider,
HeimdallMemberClient memberClient,
LinkedAccountsSnapshots snapshots)
{ {
public const string ErrorRedirect = "/?auth=heimdall-error"; public const string ErrorRedirect = "/?auth=heimdall-error";
public const string AppSecretHeader = "x-heimdall-app-secret"; public const string AppSecretHeader = "x-heimdall-app-secret";
@ -41,6 +45,11 @@ public sealed class HeimdallAuthService(
"twitch" "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"); 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> /// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
@ -53,15 +62,38 @@ public sealed class HeimdallAuthService(
} }
} }
/// <summary>
/// Names the HTTP status and, when the body carries one, Heimdall's error code (a short snake_case token only,
/// see <see cref="HeimdallRefusal"/>), so nothing else from the body reaches a log.
/// </summary>
private static async Task<string> RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var detail = $"HTTP {(int)response.StatusCode}";
if (await HeimdallRefusal.CodeAsync(response, cancellationToken) is { } code)
{
detail += ", " + code;
}
return $"Heimdall rejected the auth start ({detail}).";
}
public async Task<HeimdallAuthStartResult> StartAsync( public async Task<HeimdallAuthStartResult> StartAsync(
string provider, string provider,
string callbackBaseUrl, string callbackBaseUrl,
LocalReturnPath returnPath, LocalReturnPath returnPath,
bool requireMemberAccess, bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> requestCookies, IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken) CancellationToken cancellationToken,
HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn,
string? expectedAccountId = null,
string? pendingLinkId = null,
string? replaceSlot = null,
bool stepUp = false,
string? target = null)
{ {
if (!AllowedProviders.Contains(provider)) // The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google
// (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google).
if (!(purpose == HeimdallAttemptPurpose.SignIn ? AllowedProviders : ProofProviders).Contains(provider))
{ {
throw new HeimdallAuthException("Unsupported Heimdall provider."); throw new HeimdallAuthException("Unsupported Heimdall provider.");
} }
@ -82,16 +114,19 @@ public sealed class HeimdallAuthService(
"sign_in", "sign_in",
ToAttemptReturnUrl(callbackBaseUrl, nonce), ToAttemptReturnUrl(callbackBaseUrl, nonce),
new HeimdallHandoff("browser_completion"), new HeimdallHandoff("browser_completion"),
entitlementPolicy); entitlementPolicy,
stepUp ? true : null);
var response = await httpClient.PostAsJsonAsync( // Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole.
$"/v1/oauth/{provider}/start", using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start")
request, {
cancellationToken); Content = JsonContent.Create(request)
};
using var response = await SendStartAsync(startMessage, cancellationToken);
if (!response.IsSuccessStatusCode) 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); var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
@ -105,11 +140,132 @@ public sealed class HeimdallAuthService(
provider, provider,
requireMemberAccess, requireMemberAccess,
returnPath, returnPath,
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime,
purpose,
expectedAccountId,
pendingLinkId,
target);
return new HeimdallAuthStartResult( 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>A start that cannot reach Heimdall is a refusal like any other, never a 500.</summary>
private async Task<HttpResponseMessage> SendStartAsync(HttpRequestMessage message, CancellationToken cancellationToken)
{
try
{
return await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
throw new HeimdallAuthException("Heimdall could not be reached for the auth start.");
}
}
/// <summary>
/// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no
/// longer holds makes Heimdall mint an unrelated account that holds that login (Discord and Patreon; Google refuses instead), so it is never guessed: with the account's
/// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the
/// member named (<paramref name="via"/>), who is the one who knows what they hold. The session's provider claim is not
/// consulted: it records how they signed in once, not what the account holds now.
/// </summary>
public static string? ProofProvider(IReadOnlyCollection<string>? held, string? via)
{
var chosen = via is not null && ProofProviders.Contains(via) ? via.ToLowerInvariant() : null;
if (held is null)
{
return chosen;
}
return new[] { chosen, "discord", "patreon", "google" }
.FirstOrDefault(x => x is not null && held.Contains(x, StringComparer.OrdinalIgnoreCase));
}
private string ProofProviderFor(string accountId, string? via) =>
ProofProvider(snapshots.Get(accountId)?.Select(x => x.Provider).ToArray(), via)
?? throw new HeimdallAuthException("No provider the account is known to hold can prove it.");
/// <summary>
/// The page's proof sign-ins. Each is a Heimdall sign-in for the account already signed in here, kept as an attempt
/// that says what it is the proof for (list, link, unlink) and which person it must prove. The session is never replaced.
/// </summary>
private Task<HeimdallAuthStartResult> StartProofAsync(
HeimdallAttemptPurpose purpose,
string provider,
string sessionAccountId,
string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies,
bool stepUp,
string? target,
CancellationToken cancellationToken) =>
StartAsync(
provider,
callbackBaseUrl,
LocalReturnPath.Parse(LinkReturnPath),
requireMemberAccess: false,
requestCookies,
cancellationToken,
purpose,
sessionAccountId,
stepUp: stepUp,
target: target);
/// <summary>
/// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on
/// the current list, or, when there is none, the one the member names in <paramref name="via"/>.
/// </summary>
public Task<HeimdallAuthStartResult> StartListAsync(
string sessionAccountId, string? via, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.ListProof, ProofProviderFor(sessionAccountId, via),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
/// <summary>
/// Links another provider: an ordinary sign-in as proof through a provider on the account's current list (there is no
/// guessing without one), then the link start for <paramref name="targetProvider"/> with its bearer.
/// </summary>
public Task<HeimdallAuthStartResult> StartLinkAsync(
string sessionAccountId, string targetProvider, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken)
{
if (!LinkableProviders.Contains(targetProvider))
{
throw new HeimdallAuthException("Unsupported provider to link.");
}
return StartProofAsync(
HeimdallAttemptPurpose.LinkStart, ProofProviderFor(sessionAccountId, null),
sessionAccountId, callbackBaseUrl, requestCookies, false, targetProvider.ToLowerInvariant(), cancellationToken);
}
/// <summary>
/// Unlinks one login: a step-up sign-in through Google, then POST /v1/me/identities/{id}/unlink with its bearer. The
/// identity must be on the account's current snapshot and the account must hold Google; otherwise no proof can be had
/// and Heimdall is not asked.
/// </summary>
public Task<HeimdallAuthStartResult> StartUnlinkAsync(
string sessionAccountId, string identityId, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken);
/// <summary>
/// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, <c>identityProviders</c>): a link or a proof can use them.
/// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable.
/// </summary>
public static readonly IReadOnlyList<string> LinkableProviders = ["discord", "patreon", "google"];
private static readonly HashSet<string> ProofProviders = new(LinkableProviders, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The one provider whose step-up a bifrost sign-in can use (docs/service-contract.md, step-up: Discord and Patreon cannot
/// re-authenticate, Twitch and YouTube are not in the bifrost profile). Heimdall answers 503 until a Google client is configured.
/// </summary>
public const string StepUpProvider = "google";
public const string LinkReturnPath = "/Membership/LinkedAccounts";
/// <summary> /// <summary>
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose /// 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 /// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and
@ -156,15 +312,13 @@ public sealed class HeimdallAuthService(
return null; return null;
} }
public async Task<HeimdallCompletedSignIn> CompleteAsync( /// <summary>What the attempt this browser holds for the nonce is for, or null when it holds none.</summary>
string callbackBaseUrl, public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose;
string? nonce,
string? cookieValue, /// <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>
string? code, private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync(
CancellationToken cancellationToken) 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)) if (string.IsNullOrWhiteSpace(code))
{ {
throw new HeimdallAuthException("Heimdall completion carried no code."); throw new HeimdallAuthException("Heimdall completion carried no code.");
@ -182,6 +336,24 @@ public sealed class HeimdallAuthService(
} }
var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken);
return (identity, redeemed.AccessToken, redeemed);
}
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) if (attempt.RequiresMemberAccess && !identity.HasMemberAccess)
{ {
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
@ -197,6 +369,235 @@ public sealed class HeimdallAuthService(
attempt.ReturnPath); attempt.ReturnPath);
} }
/// <summary>
/// One step of a Linked accounts action, chosen by the purpose of the attempt this browser holds. The browser's session
/// must be the account the action 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.
/// The bearer a step redeems is used inside the step and dropped.
/// </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 StartLinkProviderAsync(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(
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);
snapshots.Remove(identity.AccountId);
}
return new HeimdallLinkStep(null, cookieName, null, outcome);
}
case HeimdallAttemptPurpose.LinkAwaitDirect:
{
// A provider other than Google attaches in Heimdall's callback; the browser returns with a link completion.
var (identity, _, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (redeemed.Mode != "link" || !string.Equals(redeemed.Provider, attempt.Target, StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall completion is not this link.");
}
await UpsertUserAccountAsync(identity, attempt.Target!, grantMemberAccess: false, cancellationToken);
snapshots.Remove(identity.AccountId);
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.Linked);
}
case HeimdallAttemptPurpose.ListProof:
{
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
var listed = await memberClient.ListAsync(bearer, cancellationToken);
if (listed.Call == HeimdallMemberCall.Ok)
{
snapshots.Put(identity.AccountId, listed.Identities!);
}
return new HeimdallLinkStep(null, cookieName, null, listed.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Listed : OutcomeOf(listed.Call));
}
case HeimdallAttemptPurpose.UnlinkProof:
{
var (identity, bearer, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (redeemed.StepUpRequested != true)
{
// Heimdall reports "asked" on a step-up completion; one that does not was not a step-up sign-in.
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired);
}
var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken);
// Only Heimdall's definitive refusals leave the account as the list showed it. Any other end (not found, a lost
// answer, a 500, a malformed 200) may have applied the unlink, so the list is dropped rather than trusted.
if (unlinked.Call == HeimdallMemberCall.Ok)
{
snapshots.Put(identity.AccountId, unlinked.Identities!);
}
else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity))
{
snapshots.Remove(identity.AccountId);
}
return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call));
}
default:
throw new HeimdallAuthException("Heimdall completion is not a link attempt.");
}
}
private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch
{
HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired,
HeimdallMemberCall.StepUpRequired => HeimdallLinkOutcome.StepUpRequired,
HeimdallMemberCall.IdentityNotFound => HeimdallLinkOutcome.IdentityNotFound,
HeimdallMemberCall.LastIdentity => HeimdallLinkOutcome.LastIdentity,
_ => HeimdallLinkOutcome.Error
};
/// <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 (identity, bearer, _) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
return (identity, bearer);
}
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemOwnerWithBodyAsync(
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;
}
/// <summary>
/// Starts the link to the attempt's target provider with the member's bearer. Google attaches in two phases (the
/// callback writes a pending link, a second fresh sign-in confirms it); the other admitted providers attach in the callback.
/// A cookie written before the target existed names no provider and is a Google link.
/// </summary>
private async Task<(Uri Url, string CookieValue)> StartLinkProviderAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken)
{
var target = attempt.Target ?? "google";
if (!LinkableProviders.Contains(target))
{
throw new HeimdallAuthException("Unsupported provider to link.");
}
var options = heimdallOptions.Value;
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
using var message = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{target}/start")
{
Content = JsonContent.Create(new HeimdallStartRequest(
options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null, null))
};
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
using var response = await SendStartAsync(message, 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,
target == "google" ? HeimdallAttemptPurpose.LinkAwaitGoogle : HeimdallAttemptPurpose.LinkAwaitDirect,
attempt.ExpectedAccountId,
null,
target);
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) private async Task<HeimdallRedeemResponse> RedeemAsync(string code, CancellationToken cancellationToken)
{ {
try try
@ -221,7 +622,8 @@ public sealed class HeimdallAuthService(
private string Protect(HeimdallAttempt attempt) => private string Protect(HeimdallAttempt attempt) =>
_protector.Protect(JsonSerializer.Serialize(new AttemptWire( _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, attempt.Target)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary> /// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
@ -238,13 +640,14 @@ public sealed class HeimdallAuthService(
try try
{ {
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue)); 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 null;
} }
return new HeimdallAttempt( 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, wire.Target);
} }
catch (Exception error) when (error is CryptographicException or JsonException or FormatException) catch (Exception error) when (error is CryptographicException or JsonException or FormatException)
{ {
@ -257,7 +660,11 @@ public sealed class HeimdallAuthService(
string Provider, string Provider,
bool RequiresMemberAccess, bool RequiresMemberAccess,
string ReturnPath, string ReturnPath,
DateTimeOffset ExpiresAt); DateTimeOffset ExpiresAt,
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
string? ExpectedAccountId = null,
string? PendingLinkId = null,
string? Target = null);
private async Task<UserAccount> UpsertUserAccountAsync( private async Task<UserAccount> UpsertUserAccountAsync(
HeimdallVerifiedIdentity identity, HeimdallVerifiedIdentity identity,
@ -319,7 +726,8 @@ public sealed class HeimdallAuthService(
UserAccountId = userAccount.Id, UserAccountId = userAccount.Id,
CreatedAtUtc = now CreatedAtUtc = now
}; };
if (grantMemberAccess) // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it.
if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended)
{ {
userAccount.Membership.Status = MembershipStatus.Active; userAccount.Membership.Status = MembershipStatus.Active;
userAccount.Membership.ApprovedAtUtc ??= now; userAccount.Membership.ApprovedAtUtc ??= now;
@ -332,7 +740,7 @@ public sealed class HeimdallAuthService(
userAccount, userAccount,
displayName, displayName,
cancellationToken); cancellationToken);
if (grantMemberAccess) if (grantMemberAccess && userAccount.Membership!.Status == MembershipStatus.Active)
{ {
ApplicationBootstrapper.EnsureRole( ApplicationBootstrapper.EnsureRole(
userAccount.Membership!, userAccount.Membership!,
@ -408,7 +816,101 @@ public sealed record HeimdallAttempt(
string Provider, string Provider,
bool RequiresMemberAccess, bool RequiresMemberAccess,
LocalReturnPath ReturnPath, LocalReturnPath ReturnPath,
DateTimeOffset ExpiresAt); DateTimeOffset ExpiresAt,
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
string? ExpectedAccountId = null,
string? PendingLinkId = null,
string? Target = 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 other purposes belong to the Linked accounts page: the fresh sign-ins that prove an account for a list, a link or
/// an unlink, and the provider round trips of a link. They never sign anyone in. Values are persisted in cookies: append only.
/// </summary>
public enum HeimdallAttemptPurpose
{
SignIn = 0,
LinkStart = 1,
LinkAwaitGoogle = 2,
LinkConfirm = 3,
ListProof = 4,
UnlinkProof = 5,
LinkAwaitDirect = 6
}
/// <summary>How a Linked accounts action ended. <see cref="HeimdallLinkOutcomes.Code"/> is its closed wire form.</summary>
public enum HeimdallLinkOutcome
{
Linked,
IdentityLinkedElsewhere,
Expired,
Error,
Listed,
Unlinked,
LastIdentity,
StepUpRequired,
IdentityNotFound,
SignInRequired,
ProofUnavailable,
ListExpired,
ProviderNeeded
}
/// <summary>
/// Where a Linked accounts step sends the browser: to <see cref="HeimdallLinkStep.Next"/> with a new attempt cookie in the held
/// slot, or, when the action is finished, back to the page with the outcome's code in <c>?result=</c>.
/// </summary>
public static class HeimdallLinkOutcomes
{
public const string ResultParameter = "result";
public static string Redirect(HeimdallLinkOutcome outcome) =>
HeimdallAuthService.LinkReturnPath + "?" + ResultParameter + "=" + Code(outcome);
public static string Code(HeimdallLinkOutcome outcome) => outcome switch
{
HeimdallLinkOutcome.Linked => "linked",
HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere",
HeimdallLinkOutcome.Expired => "expired",
HeimdallLinkOutcome.Listed => "listed",
HeimdallLinkOutcome.Unlinked => "unlinked",
HeimdallLinkOutcome.LastIdentity => "last_identity",
HeimdallLinkOutcome.StepUpRequired => "step_up_required",
HeimdallLinkOutcome.IdentityNotFound => "identity_not_found",
HeimdallLinkOutcome.SignInRequired => "sign_in_required",
HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable",
HeimdallLinkOutcome.ListExpired => "list_expired",
HeimdallLinkOutcome.ProviderNeeded => "provider_needed",
_ => "error"
};
/// <summary>The words for an outcome code: a closed set, never Heimdall's text and never the query's. Anything else gets none.</summary>
public static string? Wording(string? code) => code switch
{
"linked" => "The login is now linked to your Heimdall account.",
"identity_linked_elsewhere" => "That login belongs to another account. Nothing was linked.",
"expired" => "The link expired before it was confirmed. Nothing was linked; start the link again.",
"listed" => null,
"unlinked" => "The login was unlinked.",
"last_identity" => "That is the last login on your account, and Heimdall will not remove it. Nothing was changed.",
"step_up_required" => "Heimdall did not accept the sign-in as fresh proof. Nothing was changed; try again.",
"identity_not_found" => "That login is no longer on your account. Nothing was changed; the list below is read again when you ask.",
"sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.",
"proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.",
"provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.",
"list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.",
"error" => "That could not be completed. Nothing was changed.",
_ => 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( public sealed record HeimdallStartRequest(
[property: JsonPropertyName("appSlug")] string AppSlug, [property: JsonPropertyName("appSlug")] string AppSlug,
@ -416,7 +918,9 @@ public sealed record HeimdallStartRequest(
[property: JsonPropertyName("returnTo")] string ReturnTo, [property: JsonPropertyName("returnTo")] string ReturnTo,
[property: JsonPropertyName("handoff")] HeimdallHandoff Handoff, [property: JsonPropertyName("handoff")] HeimdallHandoff Handoff,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy); [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("stepUp")] bool? StepUp = null);
public sealed record HeimdallHandoff( public sealed record HeimdallHandoff(
[property: JsonPropertyName("kind")] string Kind); [property: JsonPropertyName("kind")] string Kind);
@ -439,7 +943,10 @@ public sealed record HeimdallRedeemRequest(
public sealed record HeimdallRedeemResponse( public sealed record HeimdallRedeemResponse(
[property: JsonPropertyName("returnTo")] string? ReturnTo, [property: JsonPropertyName("returnTo")] string? ReturnTo,
[property: JsonPropertyName("accessToken")] string? AccessToken, [property: JsonPropertyName("accessToken")] string? AccessToken,
[property: JsonPropertyName("account")] HeimdallAccount? Account); [property: JsonPropertyName("account")] HeimdallAccount? Account,
[property: JsonPropertyName("mode")] string? Mode = null,
[property: JsonPropertyName("provider")] string? Provider = null,
[property: JsonPropertyName("stepUpRequested")] bool? StepUpRequested = null);
public sealed record HeimdallAccount( public sealed record HeimdallAccount(
[property: JsonPropertyName("id")] string Id); [property: JsonPropertyName("id")] string Id);

View file

@ -3,7 +3,8 @@ namespace Bifrost.Web.Features.Heimdall;
/// <summary> /// <summary>
/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which /// 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 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> /// </summary>
public static class HeimdallCompletePage public static class HeimdallCompletePage
{ {
@ -22,21 +23,30 @@ public static class HeimdallCompletePage
<form id="complete" method="post" action="/auth/heimdall/complete"> <form id="complete" method="post" action="/auth/heimdall/complete">
<input type="hidden" name="attempt" /> <input type="hidden" name="attempt" />
<input type="hidden" name="code" /> <input type="hidden" name="code" />
<input type="hidden" name="pendingLinkId" />
</form> </form>
<script> <script>
(function () { (function () {
var fragment = new URLSearchParams(location.hash.slice(1)); var fragment = new URLSearchParams(location.hash.slice(1));
var attempt = new URLSearchParams(location.search).get("attempt"); var attempt = new URLSearchParams(location.search).get("attempt");
var code = fragment.get("heimdall_completion_code"); var code = fragment.get("heimdall_completion_code");
var pendingLinkId = fragment.get("heimdall_pending_link_id");
var failed = fragment.get("heimdall_status") === "error"; var failed = fragment.get("heimdall_status") === "error";
history.replaceState(null, "", location.pathname + location.search); history.replaceState(null, "", location.pathname + location.search);
if (failed || !code || !attempt) { // A provider other than Google attaches in Heimdall's callback, which refuses an identity another account
// holds. Only this one refusal is carried on, as a fixed result the page words itself; nothing from the fragment is shown.
if (failed && attempt && fragment.get("heimdall_error") === "link_initiator_mismatch") {
location.replace("/Membership/LinkedAccounts?result=identity_linked_elsewhere");
return;
}
if (failed || !(code || pendingLinkId) || !attempt) {
location.replace("/?auth=heimdall-error"); location.replace("/?auth=heimdall-error");
return; return;
} }
var form = document.getElementById("complete"); var form = document.getElementById("complete");
form.elements.attempt.value = attempt; form.elements.attempt.value = attempt;
form.elements.code.value = code; form.elements.code.value = code || "";
form.elements.pendingLinkId.value = pendingLinkId || "";
form.submit(); form.submit();
})(); })();
</script> </script>

View file

@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Bifrost.Web.Configuration;
namespace Bifrost.Web.Features.Heimdall;
/// <summary>One login a Heimdall account holds, as the Linked accounts page shows it. No provider user id, scope or email.</summary>
public sealed record LinkedIdentitySummary(string Id, string Provider, string? DisplayName, DateTimeOffset? LinkedAt);
public enum HeimdallMemberCall
{
Ok,
SignInRequired,
StepUpRequired,
IdentityNotFound,
LastIdentity,
Error
}
public sealed record HeimdallMemberResult(HeimdallMemberCall Call, IReadOnlyList<LinkedIdentitySummary>? Identities);
/// <summary>
/// Heimdall's member routes (docs/service-contract.md: GET /v1/me/identities, POST /v1/me/identities/{id}/unlink). Each
/// takes the member's bearer as an argument, uses it for that one request and keeps nothing. Its HttpClient carries no
/// app secret: these routes act only for a member who is present, so a Bifrost fault cannot reach them without one.
/// </summary>
public sealed class HeimdallMemberClient(HttpClient httpClient)
{
private const int MaxIdentities = 32;
private const int MaxDisplayName = 80;
public static void ConfigureClient(HttpClient client, HeimdallOptions options) =>
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
public Task<HeimdallMemberResult> ListAsync(string bearer, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Get, "/v1/me/identities", bearer, cancellationToken);
public Task<HeimdallMemberResult> UnlinkAsync(string bearer, string identityId, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Post, $"/v1/me/identities/{Uri.EscapeDataString(identityId)}/unlink", bearer, cancellationToken);
private async Task<HeimdallMemberResult> SendAsync(HttpMethod method, string path, string bearer, CancellationToken cancellationToken)
{
try
{
using var message = new HttpRequestMessage(method, path);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == HttpStatusCode.OK)
{
return new HeimdallMemberResult(HeimdallMemberCall.Ok, await ReadIdentitiesAsync(response, cancellationToken));
}
var code = await HeimdallRefusal.CodeAsync(response, cancellationToken);
var call = (response.StatusCode, code) switch
{
(HttpStatusCode.Unauthorized, _) => HeimdallMemberCall.SignInRequired,
(HttpStatusCode.Forbidden, "step_up_required") => HeimdallMemberCall.StepUpRequired,
(HttpStatusCode.NotFound, "identity_not_found") => HeimdallMemberCall.IdentityNotFound,
(HttpStatusCode.Conflict, "last_identity") => HeimdallMemberCall.LastIdentity,
_ => HeimdallMemberCall.Error
};
return new HeimdallMemberResult(call, null);
}
catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException
&& !cancellationToken.IsCancellationRequested)
{
return new HeimdallMemberResult(HeimdallMemberCall.Error, null);
}
}
/// <summary>The identities array of a 200 answer, keeping only the fields the page shows; a malformed entry is dropped.</summary>
private static async Task<IReadOnlyList<LinkedIdentitySummary>> ReadIdentitiesAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var list = new List<LinkedIdentitySummary>();
if (body.RootElement.ValueKind != JsonValueKind.Object
|| !body.RootElement.TryGetProperty("identities", out var identities)
|| identities.ValueKind != JsonValueKind.Array)
{
return list;
}
foreach (var item in identities.EnumerateArray().Take(MaxIdentities))
{
var id = Text(item, "id");
var provider = Text(item, "provider");
if (!Guid.TryParse(id, out _)
|| provider is not { Length: > 0 and <= 32 }
|| !provider.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_'))
{
continue;
}
var name = Text(item, "displayName") ?? Text(item, "username");
if (name is { Length: > MaxDisplayName })
{
name = name[..MaxDisplayName];
}
DateTimeOffset? linkedAt = DateTimeOffset.TryParse(Text(item, "linkedAt"), out var parsed) ? parsed : null;
list.Add(new LinkedIdentitySummary(id!, provider, name, linkedAt));
}
return list;
}
private static string? Text(JsonElement item, string name) =>
item.ValueKind == JsonValueKind.Object && item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}

View file

@ -0,0 +1,42 @@
using System.Text.Json;
namespace Bifrost.Web.Features.Heimdall;
/// <summary>Reads the one thing Bifrost takes from a Heimdall refusal body: its error code, and only if it is a safe token.</summary>
public static class HeimdallRefusal
{
private const int BodyLimit = 4096;
/// <summary>
/// The error code in the first 4 KiB of a body when it is a short snake_case token, else null. A body that cannot be
/// read or parsed yields null: a refusal never becomes a 500, and free text or tokens never reach a log or a page.
/// </summary>
public static async Task<string?> CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
try
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var head = new byte[BodyLimit];
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 '_'))
{
return code;
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
}
return null;
}
}

View file

@ -0,0 +1,35 @@
using System.Collections.Concurrent;
namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// What the Linked accounts page last read from Heimdall for an account: id, provider, display name and link date of
/// each login, nothing else, for <see cref="Lifetime"/>. It lives in this process only, keyed by the Heimdall account id,
/// and is never given to the browser; it holds no bearer. The page renders from it because the list is fetched inside a
/// completion request, which redirects. After a restart it is empty and the page offers to read the list again.
/// </summary>
public sealed class LinkedAccountsSnapshots(TimeProvider timeProvider)
{
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
private readonly ConcurrentDictionary<string, (IReadOnlyList<LinkedIdentitySummary> Identities, DateTimeOffset ExpiresAt)> _held = new(StringComparer.Ordinal);
public void Put(string accountId, IReadOnlyList<LinkedIdentitySummary> identities)
{
var now = timeProvider.GetUtcNow();
foreach (var (key, value) in _held)
{
if (value.ExpiresAt <= now)
{
_held.TryRemove(key, out _);
}
}
_held[accountId] = (identities, now + Lifetime);
}
public IReadOnlyList<LinkedIdentitySummary>? Get(string accountId) =>
_held.TryGetValue(accountId, out var value) && value.ExpiresAt > timeProvider.GetUtcNow() ? value.Identities : null;
public void Remove(string accountId) => _held.TryRemove(accountId, out _);
}

View file

@ -12,10 +12,11 @@ public sealed partial class BifrostIdentityService(
BifrostDbContext dbContext, BifrostDbContext dbContext,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
public async Task<UserAccount> RegisterNativeAsync( /// <summary>
string identity, /// The one rule set for a new native identity: well formed, not reserved, not taken. Returns the normalized identity or
string? displayName, /// throws <see cref="BifrostIdentityException"/>. Spends nothing; the unique index at insert stays the race backstop.
CancellationToken cancellationToken) /// </summary>
public async Task<string> CheckAvailableAsync(string identity, CancellationToken cancellationToken)
{ {
var normalizedIdentity = NormalizeIdentity(identity); var normalizedIdentity = NormalizeIdentity(identity);
if (IsReserved(normalizedIdentity)) if (IsReserved(normalizedIdentity))
@ -23,14 +24,22 @@ public sealed partial class BifrostIdentityService(
throw new BifrostIdentityException("That Bifrost identity is reserved."); throw new BifrostIdentityException("That Bifrost identity is reserved.");
} }
var exists = await dbContext.UserAccounts.AnyAsync( if (await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity, cancellationToken))
x => x.NormalizedBifrostIdentity == normalizedIdentity,
cancellationToken);
if (exists)
{ {
throw new BifrostIdentityException("That Bifrost identity is already registered."); throw new BifrostIdentityException("That Bifrost identity is already registered.");
} }
return normalizedIdentity;
}
public async Task<UserAccount> RegisterNativeAsync(
string identity,
string? displayName,
int? noticeVersion,
CancellationToken cancellationToken)
{
var normalizedIdentity = await CheckAvailableAsync(identity, cancellationToken);
var now = timeProvider.GetUtcNow(); var now = timeProvider.GetUtcNow();
var publicIdentity = ToPublicIdentity(normalizedIdentity); var publicIdentity = ToPublicIdentity(normalizedIdentity);
var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName) var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName)
@ -57,11 +66,42 @@ public sealed partial class BifrostIdentityService(
} }
}; };
// One SaveChanges: an account that agreed never exists without its receipt. No version, no receipt.
dbContext.UserAccounts.Add(userAccount); dbContext.UserAccounts.Add(userAccount);
await dbContext.SaveChangesAsync(cancellationToken); if (noticeVersion is { } agreed)
{
dbContext.ConsentReceipts.Add(new ConsentReceipt
{
UserAccount = userAccount,
NoticeVersion = agreed,
AcceptedAtUtc = now
});
}
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// Only a lost race on the unique index is the caller's mistake; anything else is a fault and propagates.
if (!await IdentityTakenAsync(normalizedIdentity))
{
throw;
}
throw new BifrostIdentityException("That Bifrost identity is already registered.");
}
return userAccount; return userAccount;
} }
// A lost race on the unique index: the tracked insert is dropped and the identity is read fresh.
private async Task<bool> IdentityTakenAsync(string normalizedIdentity)
{
dbContext.ChangeTracker.Clear();
return await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity);
}
public async Task EnsureIdentityAsync( public async Task EnsureIdentityAsync(
UserAccount userAccount, UserAccount userAccount,
string preferredIdentity, string preferredIdentity,

View file

@ -0,0 +1,81 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Membership;
public enum SuspensionOutcome
{
Done,
NotAdmin,
NoReason,
NoSuchAccount,
NotApplicable,
ProtectedAccount
}
/// <summary>
/// The one place a membership becomes Suspended or stops being. Only a platform admin may call it, every use leaves an audit
/// receipt naming the admin and the account (the reason is kept on the membership row, readable by admins only), and no sign-in path writes or lifts a suspension (Heimdall and GitHub
/// sign-in leave a Suspended membership as it is). Unsuspending restores Active for an account that was once approved,
/// Authenticated for one that never was; a pending request is not remembered.
/// </summary>
public sealed class MembershipSuspensionService(BifrostDbContext dbContext, AuditTrailService auditTrailService)
{
public const int MaxReasonLength = 500;
public Task<SuspensionOutcome> SuspendAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
ChangeAsync(actor, userAccountId, reason, suspend: true, cancellationToken);
public Task<SuspensionOutcome> UnsuspendAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
ChangeAsync(actor, userAccountId, reason, suspend: false, cancellationToken);
private async Task<SuspensionOutcome> ChangeAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, bool suspend, CancellationToken cancellationToken)
{
if (!actor.CanManageMembership || actor.UserAccount is null)
{
return SuspensionOutcome.NotAdmin;
}
if (string.IsNullOrWhiteSpace(reason))
{
return SuspensionOutcome.NoReason;
}
var user = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
if (user?.Membership is not { } membership)
{
return SuspensionOutcome.NoSuchAccount;
}
// An admin cannot suspend themselves or another platform admin here (the same derivation that makes an actor an admin):
// bootstrap admins are re-promoted to Active at sign-in by configuration, so the suspension would be undone without a receipt.
if (suspend && (user.Id == actor.UserAccount.Id || new CurrentBifrostActor(true, user).IsPlatformAdmin))
{
return SuspensionOutcome.ProtectedAccount;
}
if (suspend == (membership.Status == MembershipStatus.Suspended))
{
return SuspensionOutcome.NotApplicable;
}
var before = membership.Status;
membership.Status = suspend
? MembershipStatus.Suspended
: membership.ApprovedAtUtc is null ? MembershipStatus.Authenticated : MembershipStatus.Active;
// The reason is a column on the membership, for admins; the receipt names who and what changed, never why.
var clipped = reason.Trim().Length > MaxReasonLength ? reason.Trim()[..MaxReasonLength] : reason.Trim();
membership.SuspensionReason = suspend ? clipped : null;
// One save: the status change, the reason and the receipt stand or fall together (RecordAsync saves the shared context).
await auditTrailService.RecordAsync(
actor.UserAccount.Id,
nameof(Membership),
membership.Id,
suspend ? "membership.suspended" : "membership.unsuspended",
$"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}).",
cancellationToken);
return SuspensionOutcome.Done;
}
}

View file

@ -12,7 +12,9 @@ public sealed class MembershipSynchronizationService(
BifrostDbContext dbContext, BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService, BifrostIdentityService bifrostIdentityService,
IOptions<BootstrapOptions> bootstrapOptions, IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider) TimeProvider timeProvider,
AuditTrailService auditTrailService,
ILogger<MembershipSynchronizationService> logger)
{ {
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{ {
@ -109,9 +111,14 @@ public sealed class MembershipSynchronizationService(
invitation.AcceptedAtUtc = now; invitation.AcceptedAtUtc = now;
} }
MembershipStatus? promotedFrom = null;
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value)) if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{ {
PromoteToBootstrapAdmin(userAccount.Membership!, now); promotedFrom = userAccount.Membership!.Status;
if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger))
{
promotedFrom = null;
}
} }
else if (userAccount.Membership!.Status == MembershipStatus.Active) else if (userAccount.Membership!.Status == MembershipStatus.Active)
{ {
@ -125,22 +132,16 @@ public sealed class MembershipSynchronizationService(
userAccount.MemberProfile!.UpdatedAtUtc = now; userAccount.MemberProfile!.UpdatedAtUtc = now;
if (promotedFrom is { } before && before != userAccount.Membership!.Status)
{
// One save: the promotion and its receipt stand or fall together.
await ApplicationBootstrapper.RecordPromotionAsync(auditTrailService, userAccount, before, cancellationToken);
return;
}
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now)
{
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
}
private static bool TryReadGitHubIdentity( private static bool TryReadGitHubIdentity(
ClaimsPrincipal principal, ClaimsPrincipal principal,
out long gitHubUserId, out long gitHubUserId,

View file

@ -14,10 +14,17 @@ public sealed class OidcClientSeeder(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
OidcIssuerOptions options, OidcIssuerOptions options,
OidcKeyMaterial material, OidcKeyMaterial material,
IOptions<OpenIddictServerOptions> serverOptions) : IHostedService IOptions<OpenIddictServerOptions> serverOptions,
ILogger<OidcClientSeeder> logger) : IHostedService
{ {
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{
logger.LogWarning(
"Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is not set: suspending an account stops new sign-ins and token refresh but does not end its existing chat sessions.");
}
// Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request. // Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request.
_ = serverOptions.Value; _ = serverOptions.Value;

View file

@ -2,6 +2,9 @@ using System.Security.Claims;
using Microsoft.AspNetCore; using Microsoft.AspNetCore;
using Bifrost.Web.Data; using Bifrost.Web.Data;
using Bifrost.Web.Domain; using Bifrost.Web.Domain;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared; using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@ -19,8 +22,10 @@ namespace Bifrost.Web.Features.Oidc;
/// </summary> /// </summary>
public static class OidcEndpoints public static class OidcEndpoints
{ {
public const string NotActiveDescription = "The account is not an active GameCult member."; public const string SuspendedDescription = "The account is suspended.";
public const string NoIdentityDescription = "The account has no Bifrost identity."; public const string NoIdentityDescription = "The account has no Bifrost identity.";
public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved.";
public const string ConsentRequiredDescription = "Agreement to the training notice in force is needed to speak in a recorded room.";
private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme;
private const string IdentityAuthenticationType = "Bifrost.Oidc"; private const string IdentityAuthenticationType = "Bifrost.Oidc";
@ -32,7 +37,7 @@ public static class OidcEndpoints
app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous();
} }
private static async Task<IResult> AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors) private static async Task<IResult> AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db)
{ {
var request = http.GetOpenIddictServerRequest() var request = http.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("The OpenIddict request could not be retrieved."); ?? throw new InvalidOperationException("The OpenIddict request could not be retrieved.");
@ -47,9 +52,16 @@ public static class OidcEndpoints
} }
var actor = await actors.GetAsync(http.RequestAborted); var actor = await actors.GetAsync(http.RequestAborted);
if (actor.UserAccount is null || !actor.IsActiveMember) // Chat tier: any account that is not suspended may sign in to chat. Membership is neither required nor written;
// every member or governance surface keeps asking for Active itself (the Active-member policy, IsActiveMember, ActiveMemberIdAsync).
if (actor.UserAccount is null)
{ {
return Deny(Errors.AccessDenied, NotActiveDescription); return Deny(Errors.AccessDenied, NoIdentityDescription);
}
if (actor.Membership?.Status == MembershipStatus.Suspended)
{
return Deny(Errors.AccessDenied, SuspendedDescription);
} }
var account = actor.UserAccount; var account = actor.UserAccount;
@ -58,6 +70,13 @@ public static class OidcEndpoints
return Deny(Errors.AccessDenied, NoIdentityDescription); return Deny(Errors.AccessDenied, NoIdentityDescription);
} }
// The identity becomes the Matrix localpart. Creation refuses reserved names (BifrostIdentityService), but a row minted
// before that refusal may hold one, and there is no rename path: the issuer never vouches for a Mind's or a system's localpart.
if (BifrostIdentityService.IsReserved(account.NormalizedBifrostIdentity))
{
return Deny(Errors.AccessDenied, ReservedIdentityDescription);
}
var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role); var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, account.Id.ToString("D")); identity.SetClaim(Claims.Subject, account.Id.ToString("D"));
identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity); identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity);
@ -67,6 +86,13 @@ public static class OidcEndpoints
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.GetScopes()); 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 principal.SetDestinations(claim => claim.Type switch
{ {
Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken],
@ -75,19 +101,32 @@ public static class OidcEndpoints
_ => [Destinations.AccessToken] _ => [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 (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
{
return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString));
}
return Results.SignIn(principal, properties: null, OpenIddictScheme); 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) 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. // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
// The code's own principal carries the claims chosen at authorize time; membership is read again here, // The code's own principal carries the claims chosen at authorize time; suspension and agreement are read again here,
// because the code outlives the authorize decision (a member suspended since gets no tokens). // because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens).
var result = await http.AuthenticateAsync(OpenIddictScheme); var result = await http.AuthenticateAsync(OpenIddictScheme);
var subject = result.Principal?.GetClaim(Claims.Subject); var subject = result.Principal?.GetClaim(Claims.Subject);
if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted)) if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
{ {
return Deny(Errors.InvalidGrant, NotActiveDescription); return Deny(Errors.InvalidGrant, refusal);
} }
return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme);
@ -101,10 +140,10 @@ public static class OidcEndpoints
return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); return Results.Challenge(authenticationSchemes: [OpenIddictScheme]);
} }
// An access token outlives the authorize decision too: a member suspended since is no longer answered. // An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered.
if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
{ {
return Deny(Errors.InsufficientAccess, NotActiveDescription); return Deny(Errors.InsufficientAccess, refusal);
} }
var claims = new Dictionary<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; var claims = new Dictionary<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) };
@ -117,18 +156,41 @@ public static class OidcEndpoints
return Results.Json(claims); return Results.Json(claims);
} }
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler.</summary> /// <summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) /// 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, 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, bool deskToken = false)
{ {
if (!Guid.TryParse(subject, out var accountId)) if (!Guid.TryParse(subject, out var accountId))
{ {
return false; return SuspendedDescription;
} }
var account = await db.UserAccounts.AsNoTracking() var account = await db.UserAccounts.AsNoTracking()
.Include(x => x.Membership) .Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
return new CurrentBifrostActor(true, account).IsActiveMember; if (account is null || account.Membership?.Status == MembershipStatus.Suspended)
{
return SuspendedDescription;
}
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>
internal static async Task<Guid?> ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
{
if (!Guid.TryParse(subject, out var accountId))
{
return null;
}
var account = await db.UserAccounts.AsNoTracking()
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
return new CurrentBifrostActor(true, account).IsActiveMember ? accountId : null;
} }
private static IResult Deny(string error, string description) => private static IResult Deny(string error, string description) =>

View file

@ -0,0 +1,172 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore;
using OpenIddict.EntityFrameworkCore.Models;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
using OpenIddict.Server.AspNetCore;
namespace Bifrost.Web.Features.Oidc;
/// <summary>
/// The issuer's front door, ahead of OpenIddict: the marker brake, the per-address budget, and the bound
/// on token rows. The address is the connection's, already corrected by the forwarded-headers handling that trusts only
/// the local proxy, so a caller cannot pick its own. It fails closed: anything this middleware cannot decide is a 503, never a pass.
/// </summary>
public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions options, OidcRateLimit limit, ILogger<OidcGuardMiddleware> logger)
{
public async Task InvokeAsync(HttpContext http, IOpenIddictTokenManager tokens)
{
var endpoint = EndpointOf(http.Request.Path);
if (endpoint is null)
{
await next(http);
return;
}
try
{
if (BrakeEngaged(options.BrakePath))
{
await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1));
return;
}
if (limit.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is { } byAddress)
{
await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.RetryAfter);
return;
}
// A new authorize request parks a row for anyone who can name the (public) client; a resumed one carries request_uri and parks none.
if (endpoint == Authorize && !http.Request.Query.ContainsKey("request_uri") && await tokens.CountAsync(http.RequestAborted) >= options.Limits.MaxTokenRows)
{
await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", options.Limits.PruneInterval);
return;
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError("OIDC guard could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name);
await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1));
return;
}
await next(http);
}
private const string Authorize = "authorize";
// OpenIddict answers these paths with or without a trailing slash, so the match is by segments, never exact equality.
private static string? EndpointOf(PathString path) =>
path.StartsWithSegments(OidcServiceCollectionExtensions.AuthorizationPath, StringComparison.OrdinalIgnoreCase) ? Authorize
: path.StartsWithSegments(OidcServiceCollectionExtensions.TokenPath, StringComparison.OrdinalIgnoreCase) ? "token"
: path.StartsWithSegments(OidcServiceCollectionExtensions.UserInfoPath, StringComparison.OrdinalIgnoreCase) ? "userinfo"
: null;
// A marker that cannot be told apart from an absent one (a permission or I/O error) throws, and the guard answers 503: the brake never releases by accident.
private static bool BrakeEngaged(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
File.GetAttributes(path);
return true;
}
catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
{
return false;
}
}
private static Task RefuseAsync(HttpContext http, int status, string error, TimeSpan retryAfter)
{
http.Response.StatusCode = status;
http.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
http.Response.ContentType = "application/json";
return http.Response.WriteAsync($"{{\"error\":\"{error}\"}}", http.RequestAborted);
}
}
/// <summary>
/// The issuer's per-address permits on the shared fixed-window budget. There is no per-client budget: the one client's id is
/// public and what a caller names unverified, so a budget on it would let anyone spend the real client's permits.
/// </summary>
public sealed class OidcRateLimit(OidcIssuerOptions options, TimeProvider clock)
{
private readonly FixedWindowBudget addresses = new(options.Limits.PerAddressPermits, options.Limits.Window, options.Limits.MaxTrackedKeys, clock);
public BudgetDenial? Spend(string address) => addresses.Spend(address);
}
/// <summary>
/// Deletes expired and orphaned authorizations and tokens on a schedule: tokens past their expiry (parked authorize
/// requests, codes, access tokens) or no longer valid, then authorizations that are no longer valid or are ad hoc with no
/// token left. Live rows are never touched. Plain queries, not OpenIddict's ExecuteDelete prune, which the in-memory test provider
/// cannot run; an authorization takes its remaining tokens with it, because the foreign key has no cascade on Postgres.
/// </summary>
public sealed class OidcPruner(IServiceScopeFactory scopes, OidcIssuerOptions options, TimeProvider clock, ILogger<OidcPruner> logger) : BackgroundService
{
private const int Batch = 500;
// A fresh authorization briefly has no token yet; leave anything younger than this alone.
private static readonly TimeSpan AuthorizationGrace = TimeSpan.FromMinutes(5);
public async Task PruneAsync(CancellationToken cancellationToken)
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var now = clock.GetUtcNow();
var young = now - AuthorizationGrace;
int removed;
do
{
var tokens = await db.Set<OpenIddictEntityFrameworkCoreToken>()
.Where(token => token.ExpirationDate < now || (token.Status != Statuses.Valid && token.CreationDate < young))
.Take(Batch).ToListAsync(cancellationToken);
db.RemoveRange(tokens);
await db.SaveChangesAsync(cancellationToken);
removed = tokens.Count;
}
while (removed == Batch);
do
{
// A token row references its authorization (no cascade in the database), so the tokens go with it.
var authorizations = await db.Set<OpenIddictEntityFrameworkCoreAuthorization>()
.Include(authorization => authorization.Tokens)
.Where(authorization => authorization.CreationDate < young
&& (authorization.Status != Statuses.Valid || (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any())))
.Take(Batch).ToListAsync(cancellationToken);
db.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens));
db.RemoveRange(authorizations);
await db.SaveChangesAsync(cancellationToken);
removed = authorizations.Count;
}
while (removed == Batch);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(options.Limits.PruneInterval, clock);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await PruneAsync(stoppingToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError("OIDC prune failed ({ExceptionType}); the next tick tries again.", exception.GetType().Name);
}
}
}
}

View file

@ -11,8 +11,8 @@ using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Oidc; namespace Bifrost.Web.Features.Oidc;
/// <summary> /// <summary>
/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid /// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, the account gone, or no live receipt for the training notice in force). Each tick it lists the subjects holding valid
/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed /// authorizations of the configured client; for each one the shared chat-eligibility reading refuses, it POSTs one signed
/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The /// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The
/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. /// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it.
/// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the /// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the
@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler(
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>(); var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null)
{ {
_backoff.Remove(subject); _backoff.Remove(subject);
return; return;

View file

@ -67,6 +67,9 @@ public static class OidcServiceCollectionExtensions
server.Issuer = issuer; server.Issuer = issuer;
}); });
services.AddSingleton<OidcRateLimit>();
services.AddSingleton<OidcPruner>();
services.AddHostedService(provider => provider.GetRequiredService<OidcPruner>());
services.AddHostedService<OidcClientSeeder>(); services.AddHostedService<OidcClientSeeder>();
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{ {

View file

@ -0,0 +1,199 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Registration;
/// <summary>
/// The consent page and its two form posts, for any signed-in account. The receipt rows are the only state: a receipt is
/// live while <see cref="ConsentReceipt.WithdrawnAtUtc"/> is null, and an account's messages count only while a live
/// receipt for the version in force exists. Both posts are refused by <see cref="RequestOriginGuard.Allows"/> before
/// anything is read or written, since a signed-in session is a cookie and these carry no antiforgery token.
/// </summary>
public static class ConsentEndpoints
{
public const string Path = "/auth/bifrost/consent";
public static void MapConsent(this IEndpointRouteBuilder app)
{
app.MapGet(Path, async (HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) =>
{
var actor = await actors.GetAsync(ct);
if (!actor.IsAuthenticated || actor.UserAccount is null)
{
return Results.Redirect($"/auth/sign-in?returnUrl={Uri.EscapeDataString(LocalReturnPath.Parse(Path).Value)}");
}
return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct, ReturnPathOf(http.Request.Query["returnUrl"]));
}).AllowAnonymous();
app.MapPost(Path + "/accept", async (
HttpContext http,
ICurrentBifrostActorAccessor actors,
BifrostDbContext db,
TimeProvider clock,
IOptions<BifrostHostOptions> host,
CancellationToken ct) =>
{
if (!RequestOriginGuard.Allows(http.Request, host.Value))
{
return Refused();
}
var actor = await actors.GetAsync(ct);
if (!actor.IsAuthenticated || actor.UserAccount is null)
{
return Results.Unauthorized();
}
var form = await ReadFormAsync(http.Request, ct);
if (form is null || form["version"].ToString() != TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture))
{
return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct, ReturnPathOf(form?["returnUrl"] ?? default));
}
var accountId = actor.UserAccount.Id;
if (!await HasLiveReceiptAsync(db, accountId, ct))
{
db.ConsentReceipts.Add(new ConsentReceipt
{
UserAccountId = accountId,
NoticeVersion = TrainingNotice.Version,
AcceptedAtUtc = clock.GetUtcNow()
});
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException error) when (IsUniqueViolation(error))
{
// The filtered unique index refused a second live receipt: another request holds it, so this accept has nothing to add.
// Only that violation is swallowed; any other store fault propagates. Not re-checked: a withdraw may already have stamped the winner, and the outcome for this request is the same.
db.ChangeTracker.Clear();
}
}
return new SeeOther(ReturnPathOf(form["returnUrl"]) ?? Path);
}).AllowAnonymous();
app.MapPost(Path + "/withdraw", async (
HttpContext http,
ICurrentBifrostActorAccessor actors,
BifrostDbContext db,
TimeProvider clock,
IOptions<BifrostHostOptions> host,
CancellationToken ct) =>
{
if (!RequestOriginGuard.Allows(http.Request, host.Value))
{
return Refused();
}
var actor = await actors.GetAsync(ct);
if (!actor.IsAuthenticated || actor.UserAccount is null)
{
return Results.Unauthorized();
}
var live = await db.ConsentReceipts
.Where(x => x.UserAccountId == actor.UserAccount.Id && x.WithdrawnAtUtc == null)
.ToListAsync(ct);
if (live.Count > 0)
{
var now = clock.GetUtcNow();
foreach (var receipt in live)
{
receipt.WithdrawnAtUtc = now;
}
await db.SaveChangesAsync(ct);
}
return Back();
}).AllowAnonymous();
}
// Postgres SqlState 23505, on the exception or anywhere down its inner chain.
private static bool IsUniqueViolation(Exception error)
{
for (var e = (Exception?)error; e is not null; e = e.InnerException)
{
if (e is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation })
{
return true;
}
}
return false;
}
private static IResult Refused() => Results.Text("Cross-site consent changes are refused.", statusCode: StatusCodes.Status403Forbidden);
/// <summary>Where the consent page sends the member after agreeing: a local path (<see cref="LocalReturnPath"/>) the refused chat authorize names, else the page itself.</summary>
internal static string? ReturnPathOf(Microsoft.Extensions.Primitives.StringValues requested) =>
requested.ToString() is { Length: > 0 } text ? LocalReturnPath.Parse(text).Value : null;
/// <summary>The consent page that a refused chat sign-in lands on, carrying the request to resume once the member agrees.</summary>
public static string PathFor(string returnPath) => $"{Path}?returnUrl={Uri.EscapeDataString(returnPath)}";
private static IResult Back() => new SeeOther(Path);
/// <summary>The one reading of "may speak": a live receipt for the version in force. A receipt for any other version, or a withdrawn one, does not count.</summary>
internal static Task<bool> HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) =>
db.ConsentReceipts.AnyAsync(x => x.UserAccountId == accountId && x.NoticeVersion == TrainingNotice.Version && x.WithdrawnAtUtc == null, ct);
private static async Task<IFormCollection?> ReadFormAsync(HttpRequest request, CancellationToken ct)
{
const int MaxFormBytes = 1024;
if (!request.HasFormContentType || request.ContentLength > MaxFormBytes)
{
return null;
}
if (request.HttpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
{
limit.MaxRequestBodySize = MaxFormBytes;
}
try
{
return await request.ReadFormAsync(ct);
}
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
{
return null;
}
}
private sealed class SeeOther(string location) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = StatusCodes.Status303SeeOther;
httpContext.Response.Headers.Location = location;
httpContext.Response.Headers.CacheControl = "no-store";
return Task.CompletedTask;
}
}
private static async Task<IResult> PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct, string? returnPath = null)
{
var receipts = await db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccountId == accountId).ToListAsync(ct);
return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error, returnPath), status);
}
private sealed class HtmlPage(string html, int status) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = status;
httpContext.Response.ContentType = "text/html; charset=utf-8";
httpContext.Response.Headers.CacheControl = "no-store";
await httpContext.Response.WriteAsync(html);
}
}
}

View file

@ -0,0 +1,105 @@
using System.Net;
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Registration;
/// <summary>Where an account stands against the notice in force. Exactly one holds.</summary>
public abstract record ConsentState
{
public sealed record None : ConsentState;
public sealed record Current(DateTimeOffset AgreedAtUtc) : ConsentState;
public sealed record Older(int Version, DateTimeOffset AgreedAtUtc) : ConsentState;
public sealed record Withdrawn(DateTimeOffset WithdrawnAtUtc) : ConsentState;
}
/// <summary>
/// The server-rendered consent page: the notice in force, the account's state and the buttons that apply. No script,
/// no external asset, no cookie of its own.
/// </summary>
public static class ConsentPage
{
/// <summary>A live receipt for the version in force wins, then a live older one, then the latest withdrawal, else none.</summary>
public static ConsentState StateOf(IReadOnlyCollection<ConsentReceipt> receipts)
{
var live = receipts.Where(x => x.WithdrawnAtUtc is null).ToList();
if (live.FirstOrDefault(x => x.NoticeVersion == TrainingNotice.Version) is { } current)
{
return new ConsentState.Current(current.AcceptedAtUtc);
}
if (live.OrderByDescending(x => x.NoticeVersion).FirstOrDefault() is { } older)
{
return new ConsentState.Older(older.NoticeVersion, older.AcceptedAtUtc);
}
return receipts.Count > 0
? new ConsentState.Withdrawn(receipts.Max(x => x.WithdrawnAtUtc!.Value))
: new ConsentState.None();
}
private static string Iso(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture);
public static string Render(ConsentState state, string? error, string? returnPath = null)
{
static string E(string? text) => WebUtility.HtmlEncode(text ?? "");
var version = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
var (message, anyLive) = state switch
{
ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. You may speak in a recorded room, and your messages may be used as the notice says.", true),
ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it.", true),
ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. You cannot speak in a recorded room, and your messages are not used. Lines you already spoke stay.", false),
_ => ("You have not agreed. You cannot speak in a recorded room, and your messages are not used.", false)
};
var summary = error is null ? "" : $"""<div id="errors" role="alert" tabindex="-1"><strong>That did not go through.</strong><p>{E(error)}</p></div>""";
// Withdraw whenever any live receipt exists (an older one included); Agree whenever none is live for the version in force.
var back = returnPath is null ? "" : $"""<input type="hidden" name="returnUrl" value="{E(returnPath)}" />""";
var withdrawButton = anyLive
? """<form method="post" action="/auth/bifrost/consent/withdraw"><button type="submit">Withdraw</button></form>"""
: "";
var agreeButton = state is ConsentState.Current
? ""
: $"""<form method="post" action="/auth/bifrost/consent/accept"><input type="hidden" name="version" value="{version}" />{back}<button type="submit">Agree to version {version}</button></form>""";
var button = agreeButton + withdrawButton;
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Training consent - Bifrost</title>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; line-height: 1.5; }
main { width: min(92vw, 600px); margin: 24px 0; padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1.1; }
p { color: #c7b8ad; }
fieldset { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #8a7d73; border-radius: 6px; }
legend { padding: 0 6px; color: #d9ad66; font-weight: 750; }
#state { color: #fff8ef; font-weight: 650; }
button { margin-top: 20px; min-height: 46px; padding: 0 18px; border: 0; border-radius: 6px; background: #c43e4f; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
:focus-visible { outline: 3px solid #ffd27a; outline-offset: 2px; }
#errors { margin-top: 18px; padding: 10px 14px; border: 2px solid #ff9aa6; border-radius: 6px; color: #ffc2c9; }
#errors p { margin: 4px 0 0; color: #ffc2c9; }
small { color: #d9ad66; }
</style>
</head>
<body>
<main>
<small>Your account</small>
<h1>Training consent</h1>
{{summary}}
<p id="state" aria-live="polite">{{E(message)}}</p>
<fieldset>
<legend>Training notice, version {{version}}</legend>
{{TrainingNotice.Html("notice")}}
</fieldset>
{{button}}
</main>
</body>
</html>
""";
}
}

View file

@ -0,0 +1,123 @@
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace Bifrost.Web.Features.Registration;
/// <summary>What a client sent to register. <see cref="Waited"/> claims the time-lock path instead of a proof of work.</summary>
public sealed record RegistrationAttempt(
string? Identity,
string? DisplayName,
string? Challenge,
string? Nonce,
bool Waited,
int? AcceptedNoticeVersion);
/// <summary>Which input a refusal is about, so the form can mark it invalid.</summary>
public enum RegistrationField { None, Identity, Consent }
/// <summary>
/// Either a signed-in <see cref="Account"/>, or a <see cref="Refusal"/> to return as is (the rate limits), or a rejection
/// with a status and message. <see cref="KeepChallenge"/> is set when the challenge was not spent and a retry needs only a correction, so the form should carry it on (the time-lock clock must not restart).
/// </summary>
public sealed record RegistrationOutcome(
UserAccount? Account,
IResult? Refusal,
int Status,
string? Message,
RegistrationField Field,
bool KeepChallenge)
{
public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, bool keepChallenge = false) =>
new(null, null, status, message, field, keepChallenge);
}
/// <summary>
/// The one registration gate, run by the JSON endpoint and the form alike after each has spent the general address budget.
/// Order: consent version (when sent), one proof only, the identity rules (which spend nothing), the challenge (proof of work, or the
/// time-lock plus its tighter budget), then the account (and its receipt when consent was sent) in one save, then the cookie. A refusal at any step
/// creates nothing, and a refusal before the challenge step spends neither the challenge nor a fallback permit.
/// </summary>
public sealed class NativeRegistration(
BifrostIdentityService identities,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit)
{
public async Task<RegistrationOutcome> RunAsync(HttpContext http, RegistrationAttempt attempt, CancellationToken cancellationToken)
{
// Consent is optional: none sends no receipt. A version that is sent must be the one in force; a refusal here creates nothing.
if (attempt.AcceptedNoticeVersion is { } sent && sent != TrainingNotice.Version)
{
return RegistrationOutcome.Reject(
StatusCodes.Status400BadRequest,
$"The training notice in force is version {TrainingNotice.Version}; send that version to agree, or send none to register without agreeing.",
RegistrationField.Consent);
}
var hasNonce = !string.IsNullOrEmpty(attempt.Nonce);
if (hasNonce && attempt.Waited)
{
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Send a nonce or waited, not both.");
}
try
{
await identities.CheckAvailableAsync(attempt.Identity ?? "", cancellationToken);
}
catch (BifrostIdentityException error)
{
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity, keepChallenge: true);
}
var failed = RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Registration challenge failed.");
if (hasNonce)
{
if (!challenges.TryConsume(attempt.Challenge, attempt.Nonce))
{
return failed;
}
}
else if (attempt.Waited)
{
if (challenges.WaitRemaining(attempt.Challenge) is not { } remaining)
{
return failed;
}
if (remaining > TimeSpan.Zero)
{
var seconds = (int)Math.Ceiling(remaining.TotalSeconds);
return RegistrationOutcome.Reject(
StatusCodes.Status400BadRequest,
$"Too early: {seconds} more second{(seconds == 1 ? "" : "s")} to wait after the page opened. Your challenge is still good; submit again then.",
keepChallenge: true);
}
if (rateLimit.RefuseFallback(http) is { } fallbackRefused)
{
return new(null, fallbackRefused, 0, null, RegistrationField.None, false);
}
if (!challenges.TryConsumeWaited(attempt.Challenge))
{
return failed;
}
}
else
{
return failed;
}
try
{
var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, attempt.AcceptedNoticeVersion, cancellationToken);
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, identities.BuildPrincipal(account));
return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, false);
}
catch (BifrostIdentityException error)
{
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity);
}
}
}

View file

@ -0,0 +1,180 @@
using System.Security.Cryptography;
using System.Text;
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Registration;
public sealed record RegistrationChallenge(string Challenge, int Difficulty, DateTimeOffset ExpiresAt);
/// <summary>
/// Self-hosted proof-of-work challenge for the anonymous registration. A challenge is a data-protected
/// (random id, expiry) payload, so the server keeps nothing per issue; only consumed ids are remembered, in a bounded
/// set whose entries are dropped at expiry. A full set refuses both issuing and consuming rather than grow.
/// </summary>
public sealed class RegistrationChallengeService(
IDataProtectionProvider dataProtection,
IOptions<RegistrationOptions> options,
TimeProvider clock)
{
public const string Purpose = "Bifrost.Registration.Challenge.v1";
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
public const int MaxConsumed = 10_000;
private const int MaxNonceLength = 20;
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
private readonly HashSet<string> consumed = new(StringComparer.Ordinal);
// Ordered by expiry: a challenge consumed late has an earlier expiry than one consumed before it, so a plain FIFO would strand it.
private readonly PriorityQueue<string, DateTimeOffset> expiries = new();
private readonly object gate = new();
private int Difficulty => options.Value.Captcha.Difficulty;
/// <summary>Null when the consumed set is full: refuse rather than grow.</summary>
public RegistrationChallenge? Issue()
{
var now = clock.GetUtcNow();
lock (gate)
{
Purge(now);
if (consumed.Count >= MaxConsumed)
{
return null;
}
}
var expiresAt = now + Lifetime;
var difficulty = Difficulty;
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}.{now.ToUnixTimeMilliseconds()}");
return new RegistrationChallenge(challenge, difficulty, expiresAt);
}
/// <summary>Proof-of-work path: the nonce must meet the difficulty the challenge was issued with. Spends the challenge.</summary>
public bool TryConsume(string? challenge, string? nonce)
{
if (!IsNonce(nonce) || Open(challenge) is not { } open)
{
return false;
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
return LeadingZeroBits(hash) >= open.Difficulty && Spend(open);
}
/// <summary>
/// Time-lock path, step one: how long until a <c>waited</c> submission of this challenge is accepted. Null when the
/// token is not a live challenge of ours; zero when the wait is over. Spends nothing.
/// </summary>
public TimeSpan? WaitRemaining(string? challenge)
{
if (Open(challenge) is not { } open)
{
return null;
}
var remaining = open.IssuedAt + TimeSpan.FromSeconds(options.Value.Captcha.FallbackWaitSeconds) - clock.GetUtcNow();
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>Time-lock path, step two: spends the challenge if its wait is over. No proof of work is checked.</summary>
public bool TryConsumeWaited(string? challenge) =>
WaitRemaining(challenge) == TimeSpan.Zero && Open(challenge) is { } open && Spend(open);
/// <summary>True when the token is ours, unexpired and not yet spent, so a form may carry it into its next attempt.</summary>
public bool IsLive(string? challenge)
{
if (Open(challenge) is not { } open)
{
return false;
}
lock (gate)
{
return !consumed.Contains(open.Id);
}
}
private sealed record OpenChallenge(string Id, int Difficulty, DateTimeOffset ExpiresAt, DateTimeOffset IssuedAt);
/// <summary>The payload of a challenge that we protected and that has not expired; null otherwise.</summary>
private OpenChallenge? Open(string? challenge)
{
if (string.IsNullOrEmpty(challenge))
{
return null;
}
OpenChallenge open;
try
{
var payload = protector.Unprotect(challenge).Split('.');
if (payload.Length != 4)
{
return null;
}
var culture = System.Globalization.CultureInfo.InvariantCulture;
open = new OpenChallenge(
payload[0],
int.Parse(payload[2], culture),
DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], culture)),
DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[3], culture)));
}
catch (Exception)
{
return null;
}
return clock.GetUtcNow() >= open.ExpiresAt ? null : open;
}
private bool Spend(OpenChallenge open)
{
var now = clock.GetUtcNow();
lock (gate)
{
Purge(now);
if (consumed.Count >= MaxConsumed || !consumed.Add(open.Id))
{
return false;
}
expiries.Enqueue(open.Id, open.ExpiresAt);
return true;
}
}
private void Purge(DateTimeOffset now)
{
while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now)
{
expiries.Dequeue();
consumed.Remove(id);
}
}
private static bool IsNonce(string? nonce) =>
nonce is { Length: >= 1 and <= MaxNonceLength } && nonce.All(c => c is >= '0' and <= '9');
public static int LeadingZeroBits(ReadOnlySpan<byte> hash)
{
var bits = 0;
foreach (var b in hash)
{
if (b == 0)
{
bits += 8;
continue;
}
return bits + System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
}
return bits;
}
}

View file

@ -0,0 +1,177 @@
using System.Net;
using System.Text;
namespace Bifrost.Web.Features.Registration;
/// <summary>What the sign-up page shows. Everything user-supplied is HTML-encoded on the way out.</summary>
public sealed record RegistrationPageModel(
string ReturnPath,
string Challenge,
int Difficulty,
int WaitSeconds,
string Identity = "",
string DisplayName = "",
string? Error = null,
RegistrationField ErrorField = RegistrationField.None);
/// <summary>
/// The server-rendered sign-up form. It works with no script (time-lock path); the inline script, when it can, replaces the
/// wait by solving the proof of work. No external asset, no cookie, no third party.
/// </summary>
public static class RegistrationPage
{
// Hash rule is RegistrationChallengeService's: SHA-256 of challenge + ":" + nonce, leading zero bits >= difficulty.
// Hashes run in batches that yield to the UI between them. If the browser has no crypto.subtle, nothing here runs
// and the form submits on the time-lock path.
public const string Script = """
(function () {
var form = document.getElementById("register");
var status = document.getElementById("status");
if (!form || !status || !window.crypto || !window.crypto.subtle || !window.TextEncoder) { return; }
var BATCH = 256;
var busy = false;
var solved = false;
var skipped = false;
function zeroBits(bytes) {
var bits = 0;
for (var i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) { bits += 8; continue; }
return bits + Math.clz32(bytes[i]) - 24;
}
return bits;
}
async function solve(challenge, difficulty) {
var encoder = new TextEncoder();
for (var start = 0; !skipped; start += BATCH) {
var tries = [];
for (var n = start; n < start + BATCH; n++) {
tries.push(crypto.subtle.digest("SHA-256", encoder.encode(challenge + ":" + n)));
}
var hashes = await Promise.all(tries);
for (var i = 0; i < hashes.length; i++) {
if (zeroBits(new Uint8Array(hashes[i])) >= difficulty) { return String(start + i); }
}
await new Promise(function (resolve) { setTimeout(resolve, 0); });
}
return null;
}
form.addEventListener("submit", function (event) {
if (event.submitter && event.submitter.id === "skip") {
// The way out of a slow solve: stop it and let this submit go on the time-lock path.
skipped = true;
solved = true;
form.elements.nonce.value = "";
form.elements.waited.disabled = false;
status.textContent = "Skipping the automatic check.";
return;
}
if (solved) { return; }
event.preventDefault();
if (busy) { return; }
busy = true;
status.textContent = "Checking. This can take longer on a slow device; press Skip the automatic check to wait " + form.getAttribute("data-wait") + " seconds instead.";
solve(form.elements.challenge.value, parseInt(form.getAttribute("data-difficulty"), 10)).then(function (nonce) {
if (skipped || nonce === null) { return; }
form.elements.nonce.value = nonce;
form.elements.waited.disabled = true;
solved = true;
status.textContent = "Creating your account";
form.submit();
}, function () {
solved = true;
busy = false;
status.textContent = "The automatic check did not run in this browser. Press Skip the automatic check and wait as described below.";
});
});
})();
""";
public static string Render(RegistrationPageModel model)
{
static string E(string? text) => WebUtility.HtmlEncode(text ?? "");
var notice = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
var summary = new StringBuilder();
if (model.Error is not null)
{
var link = model.ErrorField switch
{
RegistrationField.Identity => """<ul><li><a href="#identity">Identity name</a></li></ul>""",
RegistrationField.Consent => """<ul><li><a href="#consent">I agree to this notice, to speak in a recorded room</a></li></ul>""",
_ => ""
};
summary.Append($"""<div id="errors" role="alert" tabindex="-1"><strong>Registration did not go through.</strong><p>{E(model.Error)}</p>{link}</div>""");
}
string Invalid(RegistrationField field) => model.Error is not null && model.ErrorField == field ? " aria-invalid=\"true\" aria-describedby=\"errors\"" : "";
var wait = model.WaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Create an account - Bifrost</title>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; line-height: 1.5; }
main { width: min(92vw, 600px); margin: 24px 0; padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1.1; }
p { color: #c7b8ad; }
fieldset { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #8a7d73; border-radius: 6px; }
legend { padding: 0 6px; color: #d9ad66; font-weight: 750; }
label { display: block; margin-top: 12px; font-weight: 650; }
.hint { margin: 2px 0 0; font-size: .9rem; }
input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; margin-top: 4px; padding: 0 10px; border: 1px solid #8a7d73; border-radius: 6px; background: #0d0b0a; color: #fff8ef; font: inherit; }
input[type=checkbox] { width: 22px; height: 22px; margin: 0 8px 0 0; vertical-align: middle; accent-color: #c43e4f; }
label.check { display: flex; align-items: center; margin-top: 14px; }
button.secondary { margin-left: 10px; background: #2a2522; border: 1px solid #8a7d73; }
button { margin-top: 20px; min-height: 46px; padding: 0 18px; border: 0; border-radius: 6px; background: #c43e4f; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
:focus-visible { outline: 3px solid #ffd27a; outline-offset: 2px; }
#errors { margin-top: 18px; padding: 10px 14px; border: 2px solid #ff9aa6; border-radius: 6px; color: #ffc2c9; }
#errors p { margin: 4px 0 0; color: #ffc2c9; }
[aria-invalid=true] { border-color: #ff9aa6; }
#status { min-height: 1.5em; color: #fff8ef; }
small { color: #d9ad66; }
</style>
</head>
<body>
<main>
<small>Public registration</small>
<h1>Create a Bifrost account</h1>
<p>A new account can chat. It has no vote and opens no member or governance surface.</p>
{{summary}}
<form id="register" method="post" action="/auth/bifrost/register/form" data-difficulty="{{model.Difficulty}}" data-wait="{{wait}}">
<fieldset>
<legend>Your account</legend>
<label for="identity">Identity name</label>
<input type="text" id="identity" name="identity" value="{{E(model.Identity)}}" required autocomplete="username" autocapitalize="none" spellcheck="false"{{Invalid(RegistrationField.Identity)}} />
<label for="displayName">Display name (optional)</label>
<input type="text" id="displayName" name="displayName" value="{{E(model.DisplayName)}}" autocomplete="nickname" />
</fieldset>
<fieldset>
<legend>Training notice, version {{notice}}</legend>
{{TrainingNotice.Html("notice")}}
<label class="check" for="consent"><input type="checkbox" id="consent" name="consent" value="yes" aria-describedby="notice"{{Invalid(RegistrationField.Consent)}} />I agree to this notice, to speak in a recorded room</label>
<p class="hint">You can create an account without agreeing. Agreement is needed to speak in a recorded room, and you can give it later on your consent page. Withdrawing ends future speaking there; lines you have already spoken stay.</p>
<input type="hidden" name="acceptedNoticeVersion" value="{{notice}}" />
</fieldset>
<input type="hidden" name="challenge" value="{{E(model.Challenge)}}" />
<input type="hidden" name="returnUrl" value="{{E(model.ReturnPath)}}" />
<input type="hidden" name="nonce" value="" />
<input type="hidden" name="waited" value="true" />
<p id="status" aria-live="polite"></p>
<button type="submit" id="create">Create account</button>
<button type="submit" id="skip" class="secondary">Skip the automatic check and wait {{wait}} seconds instead</button>
</form>
<p class="hint" id="wait-note">Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick it again if you want to agree. Without the automatic check only a few registrations per hour are accepted from one address.</p>
<p><small>After registering, Bifrost returns you to {{E(model.ReturnPath)}}.</small></p>
</main>
<script>{{Script}}</script>
</body>
</html>
""";
}
}

View file

@ -0,0 +1,56 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Registration;
/// <summary>
/// The per-address permits on the registration endpoints (challenge and register share one budget), on the shared
/// fixed-window budget. It exists so one address cannot fill the consumed-challenge set alone. It fails closed:
/// anything it cannot decide is a 503, never a pass.
/// </summary>
public sealed class RegistrationRateLimit(IOptions<RegistrationOptions> options, TimeProvider clock, ILogger<RegistrationRateLimit> logger)
{
private readonly FixedWindowBudget addresses = new(
options.Value.Limits.PerAddressPermits,
options.Value.Limits.Window,
options.Value.Limits.MaxTrackedKeys,
clock);
// Time-lock submissions cost the client no work, so they get their own, much tighter budget per address.
private readonly FixedWindowBudget fallbacks = new(
options.Value.Limits.FallbackPermits,
options.Value.Limits.FallbackWindow,
options.Value.Limits.MaxTrackedKeys,
clock);
/// <summary>Null when the request may proceed; otherwise the refusal to return.</summary>
public IResult? Refuse(HttpContext http) => Refuse(http, addresses, "Too many registration requests from this address.");
/// <summary>The stricter budget for a submission that passed the time-lock instead of the proof of work.</summary>
public IResult? RefuseFallback(HttpContext http) =>
Refuse(http, fallbacks, "Too many registrations without the quick check from this address; try again later or let the page run its script.");
private IResult? Refuse(HttpContext http, FixedWindowBudget budget, string message)
{
try
{
if (budget.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial)
{
return null;
}
http.Response.Headers.RetryAfter = RetryAfter(denial.RetryAfter);
return Results.Text(message, statusCode: StatusCodes.Status429TooManyRequests);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError("Registration rate limit could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name);
http.Response.Headers.RetryAfter = "60";
return Results.Text("Registration is unavailable; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
private static string RetryAfter(TimeSpan wait) =>
Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,36 @@
using System.Net;
using System.Text;
namespace Bifrost.Web.Features.Registration;
/// <summary>
/// The training notice a person may agree to. Changing the wording in substance means a new <see cref="Version"/> (a
/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for any
/// other version no longer counts, the chat sign-in is refused, and the person is asked again on the consent page.
/// The text is frozen once served: a test pins its hash per version, so an edit without a version bump fails.
/// </summary>
public static class TrainingNotice
{
public const int Version = 1;
public const string Text =
"GameCult chat training notice, version 1\n\n" +
"GameCult may use messages you post in GameCult chat to train and evaluate AI models that GameCult builds and runs.\n\n" +
"What counts. The text of your messages, with their time and room, tied to your account name. Not your email address, your sign-in details or anything you did not post. Nothing that came from Discord is used, in any form.\n\n" +
"It is your choice. Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing. If you do not agree, you cannot speak in a recorded room and none of your messages are used.\n\n" +
"If you agree, we record your account, this notice's version number and the time. Nothing else is stored with it.\n\n" +
"If you withdraw, you can no longer speak in a recorded room, and we leave all your messages out of every training set made after that moment. Lines you have already spoken stay: withdrawing deletes nothing. Your messages stay in chat and on our servers, anything already used to train a model stays used, and a dataset already built is not recalled. We keep the record that you agreed and when you withdrew.\n\n" +
"You can agree or withdraw at any time on your consent page while signed in. If the notice changes, its version changes and we ask again; until you agree to the new version, you cannot speak in a recorded room and your messages are not used.";
/// <summary>The text as HTML-encoded paragraphs inside a div with the given id.</summary>
public static string Html(string id)
{
var html = new StringBuilder($"<div id=\"{id}\">");
foreach (var paragraph in Text.Split("\n\n"))
{
html.Append("<p>").Append(WebUtility.HtmlEncode(paragraph)).Append("</p>");
}
return html.Append("</div>").ToString();
}
}

View file

@ -10,6 +10,7 @@ public sealed class ApplicationBootstrapper(
BifrostDbContext dbContext, BifrostDbContext dbContext,
IOptions<BootstrapOptions> bootstrapOptions, IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider, TimeProvider timeProvider,
AuditTrailService auditTrailService,
ILogger<ApplicationBootstrapper> logger) ILogger<ApplicationBootstrapper> logger)
{ {
public async Task RunAsync(CancellationToken cancellationToken) public async Task RunAsync(CancellationToken cancellationToken)
@ -40,16 +41,11 @@ public sealed class ApplicationBootstrapper(
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null)) foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
{ {
var membership = adminUser.Membership!; var before = adminUser.Membership!.Status;
membership.Status = MembershipStatus.Active; if (TryPromoteBootstrapAdmin(adminUser, now, logger) && before != adminUser.Membership.Status)
membership.IsPlatformAdmin = true; {
membership.CanManageProjects = true; await RecordPromotionAsync(auditTrailService, adminUser, before, cancellationToken);
membership.CanManageLedger = true; }
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
} }
if (dbContext.ChangeTracker.HasChanges()) if (dbContext.ChangeTracker.HasChanges())
@ -58,6 +54,41 @@ public sealed class ApplicationBootstrapper(
} }
} }
/// <summary>
/// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's
/// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted.
/// </summary>
public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger)
{
var membership = account.Membership!;
if (membership.Status == MembershipStatus.Suspended)
{
logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id);
return false;
}
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
return true;
}
/// <summary>The receipt for a bootstrap promotion that changed membership status; saves together with the promotion.</summary>
public static Task RecordPromotionAsync(AuditTrailService auditTrailService, UserAccount account, MembershipStatus before, CancellationToken cancellationToken) =>
auditTrailService.RecordAsync(
null,
nameof(Bifrost.Web.Domain.Membership),
account.Membership!.Id,
"membership.bootstrap-promoted",
$"Bootstrap admin promotion of {account.DisplayName} ({before} -> {account.Membership.Status}).",
cancellationToken);
public static void EnsureRole( public static void EnsureRole(
Bifrost.Web.Domain.Membership membership, Bifrost.Web.Domain.Membership membership,
MemberRole role, MemberRole role,

View file

@ -0,0 +1,32 @@
using System.Net;
namespace Bifrost.Web.Features.Shared;
public static class ClientAddress
{
/// <summary>
/// The partition key for a per-address budget: the connection's address, already corrected by the forwarded-headers
/// handling that trusts only the local proxy. An IPv6 caller owns its /64, so rotating the low bits buys nothing.
/// </summary>
public static string BudgetKey(IPAddress? address)
{
if (address is null)
{
return "unknown";
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
{
return address.ToString();
}
var bytes = address.GetAddressBytes();
Array.Clear(bytes, 8, 8);
return new IPAddress(bytes).ToString() + "/64";
}
}

View file

@ -92,6 +92,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
var recentActivity = await dbContext.AuditEvents var recentActivity = await dbContext.AuditEvents
.AsNoTracking() .AsNoTracking()
.Include(x => x.ActorUserAccount) .Include(x => x.ActorUserAccount)
.Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended") && x.Action != "membership.bootstrap-promoted") // suspension reasons and the bootstrap-admin promotion are for admins, not the member dashboard
.OrderByDescending(x => x.OccurredAtUtc) .OrderByDescending(x => x.OccurredAtUtc)
.Take(8) .Take(8)
.Select(x => new DashboardActivity( .Select(x => new DashboardActivity(

View file

@ -0,0 +1,41 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Http;
namespace Bifrost.Web.Features.Shared;
// The one place that decides whether a state-changing request to an anonymous form (the vote page, the sign-up form) came from
// the site itself. These flows carry no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
// are all refused. Header values are compared, never echoed.
public static class RequestOriginGuard
{
public static bool Allows(HttpRequest request, BifrostHostOptions host)
{
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
{
return false;
}
var origin = request.Headers.Origin;
if (request.Headers.ContainsKey("Origin"))
{
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
}
var fetchSite = request.Headers["Sec-Fetch-Site"];
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
}
/// <summary>
/// For the no-script sign-up form only. Text browsers (Lynx, w3m, Links) send neither Origin nor Sec-Fetch-Site, so a
/// request with neither is let through when it carries no Bifrost session cookie: there is no session to replace.
/// Any Origin or Sec-Fetch-Site present is judged exactly as <see cref="Allows"/> judges it, and a headerless request
/// that does carry a session cookie is refused.
/// </summary>
public static bool AllowsFormWithoutFetchMetadata(HttpRequest request, BifrostHostOptions host) =>
Allows(request, host) ||
(!request.Headers.ContainsKey("Origin") &&
!request.Headers.ContainsKey("Sec-Fetch-Site") &&
!request.Cookies.Keys.Any(name => name.StartsWith(BifrostCookies.Session, StringComparison.Ordinal)));
}

View file

@ -1,5 +1,6 @@
using Bifrost.Web.Configuration; using Bifrost.Web.Configuration;
using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Registration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared; namespace Bifrost.Web.Features.Shared;
@ -12,12 +13,18 @@ public sealed class StartupConfigurationValidator(
IOptions<HeimdallOptions> heimdallOptions, IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions, IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions, IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions) IOptions<ConceptVoteOptions> conceptVoteOptions,
IOptions<DeskOptions> deskOptions,
IOptions<RegistrationOptions> registrationOptions,
OidcIssuerOptions? oidcOptions = null)
{ {
public void Validate() public void Validate()
{ {
var failures = new List<string>(); 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. // Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
var conceptVotes = conceptVoteOptions.Value; var conceptVotes = conceptVoteOptions.Value;
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
@ -45,6 +52,32 @@ public sealed class StartupConfigurationValidator(
failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1."); failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1.");
} }
ValidateDesk(deskOptions.Value, failures);
var difficulty = registrationOptions.Value.Captcha.Difficulty;
if (difficulty < RegistrationOptions.MinDifficulty || difficulty > RegistrationOptions.MaxDifficulty)
{
failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}.");
}
var wait = registrationOptions.Value.Captcha.FallbackWaitSeconds;
if (wait < RegistrationOptions.MinFallbackWaitSeconds || wait > RegistrationOptions.MaxFallbackWaitSeconds ||
wait >= RegistrationChallengeService.Lifetime.TotalSeconds)
{
failures.Add($"Bifrost:Registration:Captcha:FallbackWaitSeconds must be between {RegistrationOptions.MinFallbackWaitSeconds} and {RegistrationOptions.MaxFallbackWaitSeconds} and shorter than the challenge lifetime ({RegistrationChallengeService.Lifetime.TotalSeconds:0} seconds).");
}
var limits = registrationOptions.Value.Limits;
if (limits.PerAddressPermits < 1 || limits.Window <= TimeSpan.Zero || limits.MaxTrackedKeys < 1)
{
failures.Add("Bifrost:Registration:Limits:PerAddressPermits and MaxTrackedKeys must be at least 1 and Window must be positive.");
}
if (limits.FallbackPermits < 1 || limits.FallbackWindow <= TimeSpan.Zero)
{
failures.Add("Bifrost:Registration:Limits:FallbackPermits must be at least 1 and FallbackWindow must be positive.");
}
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{ {
Refuse(failures); Refuse(failures);
@ -72,6 +105,13 @@ public sealed class StartupConfigurationValidator(
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled."); 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.EnableCheckout)
{ {
if (!stripeOptions.Value.IsCheckoutConfigured) if (!stripeOptions.Value.IsCheckoutConfigured)
@ -116,6 +156,112 @@ public sealed class StartupConfigurationValidator(
Refuse(failures); Refuse(failures);
} }
// Checked in every environment: a bad Mind list or decider id would otherwise pass startup and misroute or
// 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())
{
failures.Add("Desk:Minds must list at least one Mind when the Desk section exists.");
}
return;
}
if (!Uri.TryCreate(desk.AllowedOrigin, UriKind.Absolute, out var origin) ||
origin.Scheme is not ("http" or "https") ||
!string.Equals(desk.AllowedOrigin, origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
failures.Add("Desk:AllowedOrigin must be one http(s) origin: scheme, host and optional port, no path.");
}
if (desk.MaxBodyBytes < 1)
{
failures.Add("Desk:MaxBodyBytes must be at least 1.");
}
if (desk.RetryAfterSeconds < 1)
{
failures.Add("Desk:RetryAfterSeconds must be at least 1.");
}
var ids = new HashSet<string>(StringComparer.Ordinal);
var sockets = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < desk.Minds.Count; i++)
{
var mind = desk.Minds[i];
if (string.IsNullOrWhiteSpace(mind.Id))
{
failures.Add($"Desk:Minds:{i}:Id must not be blank.");
}
else if (!ids.Add(mind.Id))
{
failures.Add($"Desk:Minds:{i}:Id repeats an earlier Mind id.");
}
if (!Path.IsPathFullyQualified(mind.Socket))
{
failures.Add($"Desk:Minds:{i}:Socket must be an absolute path.");
}
else if (!sockets.Add(mind.Socket))
{
failures.Add($"Desk:Minds:{i}:Socket repeats an earlier Mind's socket.");
}
if (mind.Deciders.Count is < 1 or > DeskMindOptions.MaxDeciders)
{
failures.Add($"Desk:Minds:{i}:Deciders must hold 1 to {DeskMindOptions.MaxDeciders} account ids.");
}
var deciders = new HashSet<string>(StringComparer.Ordinal);
foreach (var decider in mind.Deciders)
{
if (!IsLowercaseGuid(decider))
{
failures.Add($"Desk:Minds:{i}:Deciders entries must be lowercase D-format account ids.");
}
else if (!deciders.Add(decider))
{
failures.Add($"Desk:Minds:{i}:Deciders repeats an account id.");
}
}
}
var attribution = desk.Attribution;
if (!string.IsNullOrEmpty(attribution.Operator) && !IsLowercaseGuid(attribution.Operator))
{
failures.Add("Desk:Attribution:Operator must be a lowercase D-format account id.");
}
var stewards = new HashSet<string>(StringComparer.Ordinal);
foreach (var steward in attribution.Stewards)
{
if (!IsLowercaseGuid(steward))
{
failures.Add("Desk:Attribution:Stewards entries must be lowercase D-format account ids.");
}
else if (!stewards.Add(steward))
{
failures.Add("Desk:Attribution:Stewards repeats an account id.");
}
else if (string.Equals(steward, attribution.Operator, StringComparison.Ordinal))
{
failures.Add("Desk:Attribution:Operator must not also be a steward.");
}
}
}
private static bool IsLowercaseGuid(string? value) =>
Guid.TryParseExact(value, "D", out var guid) && string.Equals(value, guid.ToString("D"), StringComparison.Ordinal);
private static void Refuse(List<string> failures) private static void Refuse(List<string> failures)
{ {
if (failures.Count > 0) if (failures.Count > 0)

View file

@ -14,12 +14,7 @@
governance pressure, and recent team activity in one place. governance pressure, and recent team activity in one place.
</p> </p>
</div> </div>
<button class="button secondary" <a class="button secondary" href="@Url.Page("/App/Index")">Refresh snapshot</a>
hx-get="@Url.Page("/App/Index", "Summary")"
hx-target="#summary-cards"
hx-swap="outerHTML">
Refresh snapshot
</button>
</div> </div>
<div id="summary-cards"> <div id="summary-cards">

View file

@ -18,11 +18,4 @@ public sealed class IndexModel(
Actor = await actorAccessor.GetAsync(cancellationToken); Actor = await actorAccessor.GetAsync(cancellationToken);
Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken); Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken);
} }
public async Task<PartialViewResult> OnGetSummaryAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken);
return Partial("_SummaryCards", Snapshot);
}
} }

View file

@ -14,6 +14,11 @@
because trust and contributor accountability are product concerns, not just auth concerns. because trust and contributor accountability are product concerns, not just auth concerns.
</p> </p>
@if (!string.IsNullOrEmpty(Model.Notice))
{
<p class="validation-summary-errors" role="alert">@Model.Notice</p>
}
@if (Model.Actor.CanManageMembership) @if (Model.Actor.CanManageMembership)
{ {
<form method="post" asp-page-handler="Invite"> <form method="post" asp-page-handler="Invite">
@ -176,7 +181,7 @@
</div> </div>
</td> </td>
<td> <td>
@if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active) @if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active && user.Status != MembershipStatus.Suspended)
{ {
<form method="post" asp-page-handler="Approve"> <form method="post" asp-page-handler="Approve">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" /> <input type="hidden" name="userAccountId" value="@user.UserAccountId" />
@ -184,6 +189,19 @@
</form> </form>
} }
@if (Model.Actor.CanManageMembership)
{
@if (user.Status == MembershipStatus.Suspended)
{
<div class="muted" style="margin-top: 0.8rem;">@(string.IsNullOrWhiteSpace(user.SuspensionReason) ? Bifrost.Web.Pages.App.Members.IndexModel.LegacySuspensionText : "Suspended: " + user.SuspensionReason)</div>
}
<form method="post" asp-page-handler="@(user.Status == MembershipStatus.Suspended ? "Unsuspend" : "Suspend")" style="margin-top: 0.8rem;">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<input type="text" name="reason" maxlength="500" required placeholder="Reason" />
<button class="button warm" type="submit">@(user.Status == MembershipStatus.Suspended ? "Unsuspend" : "Suspend")</button>
</form>
}
@if (Model.Actor.CanManageMembership) @if (Model.Actor.CanManageMembership)
{ {
<div class="stack-form" style="margin-top: 0.8rem;"> <div class="stack-form" style="margin-top: 0.8rem;">

View file

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data; using Bifrost.Web.Data;
using Bifrost.Web.Domain; using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared; using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
@ -12,6 +13,7 @@ public sealed class IndexModel(
BifrostDbContext dbContext, BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor, ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService, AuditTrailService auditTrailService,
MembershipSuspensionService suspension,
TimeProvider timeProvider) : PageModel TimeProvider timeProvider) : PageModel
{ {
[BindProperty] [BindProperty]
@ -20,6 +22,25 @@ public sealed class IndexModel(
[BindProperty] [BindProperty]
public UpdateProfileInput Profile { get; set; } = new(); public UpdateProfileInput Profile { get; set; } = new();
/// <summary>Why the last membership action did nothing; one of <see cref="RefusalNotices"/>, carried across the redirect.</summary>
[TempData]
public string? Notice { get; set; }
private static readonly IReadOnlyDictionary<SuspensionOutcome, string> RefusalNotices = new Dictionary<SuspensionOutcome, string>
{
[SuspensionOutcome.NoReason] = "Nothing changed: a reason is required to suspend or unsuspend an account.",
[SuspensionOutcome.ProtectedAccount] = "Nothing changed: you cannot suspend yourself or another platform admin.",
[SuspensionOutcome.NoSuchAccount] = "Nothing changed: that account or its membership does not exist.",
[SuspensionOutcome.NotApplicable] = "Nothing changed: the account is already in that state."
};
internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend.";
/// <summary>Admin-only text for a Suspended row whose reason predates the SuspensionReason column; the old text is never read back from the audit trail.</summary>
internal const string LegacySuspensionText = "Suspended before reasons were recorded";
internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet.";
internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role.";
internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended.";
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<MemberDirectoryItem> Users { get; private set; } = []; public IReadOnlyList<MemberDirectoryItem> Users { get; private set; } = [];
@ -100,9 +121,22 @@ public sealed class IndexModel(
var user = await dbContext.UserAccounts var user = await dbContext.UserAccounts
.Include(x => x.Membership!) .Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments) .ThenInclude(x => x.RoleAssignments)
.SingleAsync(x => x.Id == userAccountId, cancellationToken); .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
user.Membership!.Status = MembershipStatus.Active; // The chat tier holds no membership by design (docs/oidc-issuer.md); approval refuses rather than inventing one.
if (user is null || user.Membership is null)
{
Notice = user is null ? RefusalNotices[SuspensionOutcome.NoSuchAccount] : NoMembershipNotice;
return RedirectToPage();
}
if (user.Membership.Status == MembershipStatus.Suspended)
{
Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension
return RedirectToPage();
}
user.Membership.Status = MembershipStatus.Active;
user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow(); user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow();
ApplicationBootstrapper.EnsureRole( ApplicationBootstrapper.EnsureRole(
user.Membership, user.Membership,
@ -124,6 +158,27 @@ public sealed class IndexModel(
return RedirectToPage(); return RedirectToPage();
} }
public async Task<IActionResult> OnPostSuspendAsync(Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
await ChangeSuspensionAsync(userAccountId, reason, suspend: true, cancellationToken);
public async Task<IActionResult> OnPostUnsuspendAsync(Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
await ChangeSuspensionAsync(userAccountId, reason, suspend: false, cancellationToken);
private async Task<IActionResult> ChangeSuspensionAsync(Guid userAccountId, string? reason, bool suspend, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var outcome = suspend
? await suspension.SuspendAsync(Actor, userAccountId, reason, cancellationToken)
: await suspension.UnsuspendAsync(Actor, userAccountId, reason, cancellationToken);
if (outcome == SuspensionOutcome.NotAdmin)
{
return Forbid();
}
Notice = RefusalNotices.GetValueOrDefault(outcome);
return RedirectToPage();
}
public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken) public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken)
{ {
Actor = await actorAccessor.GetAsync(cancellationToken); Actor = await actorAccessor.GetAsync(cancellationToken);
@ -135,10 +190,29 @@ public sealed class IndexModel(
var user = await dbContext.UserAccounts var user = await dbContext.UserAccounts
.Include(x => x.Membership!) .Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments) .ThenInclude(x => x.RoleAssignments)
.SingleAsync(x => x.Id == userAccountId, cancellationToken); .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
if (user is null)
{
Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount];
return RedirectToPage();
}
if (user.Membership is null) if (user.Membership is null)
{ {
Notice = NoMembershipNotice;
return RedirectToPage();
}
if (user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin)
{
Notice = OwnAdminRoleNotice;
return RedirectToPage();
}
if (user.Membership.Status == MembershipStatus.Suspended)
{
Notice = RoleOnSuspendedNotice;
return RedirectToPage(); return RedirectToPage();
} }
@ -153,8 +227,10 @@ public sealed class IndexModel(
Notes = "Assigned from the member admin page." Notes = "Assigned from the member admin page."
}); });
} }
else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin)) else
{ {
// Out of the collection too, so the flags below derive from the roles as they will stand after the save.
user.Membership.RoleAssignments.Remove(existingAssignment);
dbContext.RoleAssignments.Remove(existingAssignment); dbContext.RoleAssignments.Remove(existingAssignment);
} }
@ -198,7 +274,13 @@ public sealed class IndexModel(
var user = await dbContext.UserAccounts var user = await dbContext.UserAccounts
.Include(x => x.Membership!) .Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots) .ThenInclude(x => x.TierSnapshots)
.SingleAsync(x => x.Id == userAccountId, cancellationToken); .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
if (user is null)
{
Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount];
return RedirectToPage();
}
if (user.Membership is null) if (user.Membership is null)
{ {
@ -317,6 +399,7 @@ public sealed class IndexModel(
user.MemberProfile?.Skills ?? string.Empty, user.MemberProfile?.Skills ?? string.Empty,
user.MemberProfile?.Availability ?? string.Empty, user.MemberProfile?.Availability ?? string.Empty,
user.Membership?.Status ?? MembershipStatus.Authenticated, user.Membership?.Status ?? MembershipStatus.Authenticated,
Actor.CanManageMembership ? user.Membership?.SuspensionReason ?? string.Empty : string.Empty,
user.Membership?.RoleAssignments.Select(x => x.Role).OrderBy(x => x.ToString()).ToArray() ?? [], user.Membership?.RoleAssignments.Select(x => x.Role).OrderBy(x => x.ToString()).ToArray() ?? [],
patronTier?.Label ?? string.Empty, patronTier?.Label ?? string.Empty,
patronTier?.Weight ?? 0m, patronTier?.Weight ?? 0m,
@ -404,6 +487,7 @@ public sealed class IndexModel(
string Skills, string Skills,
string Availability, string Availability,
MembershipStatus Status, MembershipStatus Status,
string SuspensionReason,
IReadOnlyList<MemberRole> Roles, IReadOnlyList<MemberRole> Roles,
string PatronTierLabel, string PatronTierLabel,
decimal PatronTierWeight, decimal PatronTierWeight,

View file

@ -64,7 +64,7 @@
</article> </article>
<article class="status-card"> <article class="status-card">
<span>Stack</span> <span>Stack</span>
<strong>ASP.NET Core 8 + Razor Pages + HTMX</strong> <strong>ASP.NET Core 8 + Razor Pages</strong>
</article> </article>
<article class="status-card"> <article class="status-card">
<span>System of record</span> <span>System of record</span>

View file

@ -0,0 +1,119 @@
@page
@model Bifrost.Web.Pages.Membership.LinkedAccountsModel
@{
ViewData["Title"] = "Linked accounts";
}
<section class="panel">
<span class="eyebrow">Heimdall</span>
<h1>Linked accounts</h1>
<p class="panel-intro">
The provider logins that sign in to this account. Heimdall holds them and decides what may change; Bifrost keeps none of
your Heimdall sign-in, so each action sends you through Heimdall once more.
</p>
@if (Model.ResultMessage is not null)
{
<div class="callout" role="status">@Model.ResultMessage</div>
}
@if (!Model.HasHeimdallAccount)
{
<div class="callout warning">
This session is not a Heimdall account, so there are no provider logins to manage here. Sign in with Discord or Patreon
through Heimdall to see them.
</div>
}
else
{
<h2>Your logins</h2>
@if (Model.Identities is null)
{
<p class="muted">
Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for
five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you.
Pick a login you know it holds. Signing in through Discord or Patreon when the account no longer holds that login
would create a separate Heimdall account holding it. Google cannot do that: Heimdall refuses a Google sign-in for a
login no account holds.
</p>
<div class="panel-actions">
@foreach (var provider in Model.LinkableProviders.OrderBy(x => x == "google" ? 0 : 1))
{
<form method="post" asp-page-handler="Refresh" class="inline-actions">
<input type="hidden" name="via" value="@provider" />
<button class="button secondary" type="submit">Show my logins through @LinkedAccountsModel.ProviderName(provider)@(provider == "google" ? " (cannot create an account)" : "")</button>
</form>
}
</div>
}
else
{
<ul>
@foreach (var identity in Model.Identities)
{
<li>
<strong>@LinkedAccountsModel.ProviderName(identity.Provider)</strong>
@if (identity.DisplayName is not null)
{
<span>@identity.DisplayName</span>
}
@if (identity.LinkedAt is { } linkedAt)
{
<span class="muted">linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
}
@if (Model.CanProve)
{
<form method="post" asp-page-handler="Unlink" class="inline-actions">
<input type="hidden" name="identityId" value="@identity.Id" />
<button class="button secondary" type="submit">Unlink</button>
</form>
}
</li>
}
</ul>
@if (Model.Identities.Count == 1)
{
<p class="muted">This is your only login. Heimdall will not remove the last one.</p>
}
@if (!Model.CanProve)
{
<p class="muted">
Unlinking needs a fresh Google sign-in as proof. Discord and Patreon cannot give that proof, and no Google login
is linked to this account, so nothing can be unlinked from here yet. Linking Google first makes it possible
once Heimdall has Google set up.
</p>
}
else
{
<p class="muted">Unlinking asks you to sign in with Google again as proof, then removes only the login you picked.</p>
}
<form method="post" asp-page-handler="Refresh" class="panel-actions">
<button class="button secondary" type="submit">Read the list again</button>
</form>
}
@if (Model.Identities is not null)
{
<h2>Link another login</h2>
<p class="muted">
You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account,
nothing is linked and nothing is merged.
</p>
<div class="panel-actions">
@foreach (var provider in Model.LinkableProviders)
{
<form method="post" asp-page-handler="Link" class="inline-actions">
<input type="hidden" name="provider" value="@provider" />
<button class="button secondary" type="submit">Link @LinkedAccountsModel.ProviderName(provider)</button>
</form>
}
</div>
}
}
<div class="panel-actions">
<a class="button secondary" asp-page="/Membership/Status">Back to membership status</a>
</div>
</section>

View file

@ -0,0 +1,137 @@
using System.Security.Claims;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Pages.Membership;
/// <summary>
/// The member's provider logins (docs/linked-accounts.md). Bifrost keeps no Heimdall token, so every action here begins a
/// fresh Heimdall sign-in for the account already signed in and finishes inside that sign-in's completion
/// (<c>POST /auth/heimdall/complete</c>); this page only starts them and shows what the last read found. Every handler is a
/// POST with antiforgery. Nothing from the query or the form is ever shown: the outcome is a code from a closed set.
/// </summary>
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class LinkedAccountsModel(
HeimdallAuthService heimdallAuthService,
LinkedAccountsSnapshots snapshots,
IOptions<BifrostHostOptions> hostOptions,
ILogger<LinkedAccountsModel> logger) : PageModel
{
private static readonly Dictionary<string, string> ProviderNames = new(StringComparer.Ordinal)
{
["discord"] = "Discord",
["patreon"] = "Patreon",
["google"] = "Google"
};
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
/// <summary>The wording of the outcome the page was sent back with: a closed set, null for anything else.</summary>
public string? ResultMessage { get; private set; }
/// <summary>Whether this session is a Heimdall account. A Bifrost-native GitHub session has none, so there is nothing to list.</summary>
public bool HasHeimdallAccount => AccountId is not null;
/// <summary>The logins the last read found, or null when there is none or it has expired.</summary>
public IReadOnlyList<LinkedIdentitySummary>? Identities { get; private set; }
/// <summary>Whether a fresh Google sign-in can be had for this account: unlinking needs one, and only Google can give it.</summary>
public bool CanProve => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true;
public IReadOnlyList<string> LinkableProviders => HeimdallAuthService.LinkableProviders;
public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider);
public void OnGet()
{
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
Identities = AccountId is { } id ? snapshots.Get(id) : null;
}
/// <summary>Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion.</summary>
public Task<IActionResult> OnPostRefreshAsync(string? via, CancellationToken cancellationToken)
{
// With no current list Bifrost cannot tell which provider the account still holds, so the member names one.
if (AccountId is not { } id || (snapshots.Get(id) is null && (via is null || !HeimdallAuthService.LinkableProviders.Contains(via))))
{
return Task.FromResult(Done(HeimdallLinkOutcome.ProviderNeeded));
}
return BeginAsync(
(account, ct) => heimdallAuthService.StartListAsync(account, via, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
HeimdallLinkOutcome.Error,
cancellationToken);
}
/// <summary>Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing, and so does a missing list.</summary>
public Task<IActionResult> OnPostLinkAsync(string? provider, CancellationToken cancellationToken)
{
if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider))
{
return Task.FromResult(Done(HeimdallLinkOutcome.Error));
}
if (AccountId is not { } id || snapshots.Get(id) is null)
{
return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired));
}
return BeginAsync(
(account, ct) => heimdallAuthService.StartLinkAsync(account, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
HeimdallLinkOutcome.Error,
cancellationToken);
}
/// <summary>
/// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must
/// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so.
/// </summary>
public Task<IActionResult> OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken)
{
var shown = AccountId is { } id ? snapshots.Get(id) : null;
if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId))
{
return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired));
}
if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider))
{
return Task.FromResult(Done(HeimdallLinkOutcome.ProofUnavailable));
}
return BeginAsync(
(account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
HeimdallLinkOutcome.ProofUnavailable,
cancellationToken);
}
private IActionResult Done(HeimdallLinkOutcome outcome) =>
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
private async Task<IActionResult> BeginAsync(
Func<string, CancellationToken, Task<HeimdallAuthStartResult>> start, HeimdallLinkOutcome onRefusal, CancellationToken cancellationToken)
{
if (AccountId is not { } accountId)
{
return Done(HeimdallLinkOutcome.Error);
}
try
{
var started = await start(accountId, cancellationToken);
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
return Redirect(started.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
logger.LogWarning("Linked accounts start refused: {Reason}", error.Message);
return Done(onRefusal);
}
}
}

View file

@ -24,6 +24,14 @@
<p class="muted">@Model.StatusMessage</p> <p class="muted">@Model.StatusMessage</p>
</div> </div>
@if (Model.CanManageLogins)
{
<div class="panel-actions">
<a class="button secondary" asp-page="/Membership/LinkedAccounts">Linked accounts</a>
<span class="muted">See, add and remove the provider logins that sign in to this account.</span>
</div>
}
@if (Model.Invitation is not null) @if (Model.Invitation is not null)
{ {
<div class="callout success"> <div class="callout success">

View file

@ -1,5 +1,7 @@
using Bifrost.Web.Data; using Bifrost.Web.Data;
using Bifrost.Web.Domain; using Bifrost.Web.Domain;
using System.Security.Claims;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared; using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
@ -12,6 +14,9 @@ public sealed class StatusModel(
ICurrentBifrostActorAccessor actorAccessor, ICurrentBifrostActorAccessor actorAccessor,
BifrostDbContext dbContext) : PageModel BifrostDbContext dbContext) : PageModel
{ {
/// <summary>Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage.</summary>
public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public MembershipInvitation? Invitation { get; private set; } public MembershipInvitation? Invitation { get; private set; }

View file

@ -28,15 +28,15 @@
</p> </p>
<div class="mill-actions"> <div class="mill-actions">
<form method="post" asp-page-handler="Up" asp-antiforgery="false" hx-boost="false"> <form method="post" asp-page-handler="Up" asp-antiforgery="false">
<button class="button" type="submit">Up</button> <button class="button" type="submit">Up</button>
</form> </form>
<form method="post" asp-page-handler="Down" asp-antiforgery="false" hx-boost="false"> <form method="post" asp-page-handler="Down" asp-antiforgery="false">
<button class="button" type="submit">Down</button> <button class="button" type="submit">Down</button>
</form> </form>
@if (stance is not null) @if (stance is not null)
{ {
<form method="post" asp-page-handler="Retract" asp-antiforgery="false" hx-boost="false"> <form method="post" asp-page-handler="Retract" asp-antiforgery="false">
<button class="button ghost" type="submit">Retract</button> <button class="button ghost" type="submit">Retract</button>
</form> </form>
} }

View file

@ -29,7 +29,7 @@ public sealed class VoteModel(
// The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler,
// any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own
// origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. // origin (RequestOriginGuard); no antiforgery cookie or token exists on this flow.
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{ {
var method = context.HttpContext.Request.Method; var method = context.HttpContext.Request.Method;
@ -50,7 +50,7 @@ public sealed class VoteModel(
return; return;
} }
if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) if (HttpMethods.IsPost(method) && !RequestOriginGuard.Allows(context.HttpContext.Request, host.Value))
{ {
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
return; return;

View file

@ -17,7 +17,7 @@
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/Bifrost.Web.styles.css" asp-append-version="true" /> <link rel="stylesheet" href="~/Bifrost.Web.styles.css" asp-append-version="true" />
</head> </head>
<body hx-boost="true"> <body>
<div class="shell"> <div class="shell">
<header class="topbar"> <header class="topbar">
<a class="brand" asp-page="/Index"> <a class="brand" asp-page="/Index">
@ -62,7 +62,6 @@
</main> </main>
</div> </div>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>

View file

@ -7,12 +7,14 @@ using Bifrost.Web.Data;
using Bifrost.Web.Domain; using Bifrost.Web.Domain;
using Bifrost.Web.Features.Bridge; using Bifrost.Web.Features.Bridge;
using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Motions;
using Bifrost.Web.Features.Oidc; using Bifrost.Web.Features.Oidc;
using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared; using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@ -62,7 +64,8 @@ builder.Services
builder.Services builder.Services
.AddOptions<HeimdallOptions>() .AddOptions<HeimdallOptions>()
.BindConfiguration(HeimdallOptions.SectionName); .BindConfiguration(HeimdallOptions.SectionName)
.PostConfigure<ILogger<HeimdallOptions>>(HeimdallOptions.ResolveAppSecret);
builder.Services builder.Services
.AddOptions<StripeOptions>() .AddOptions<StripeOptions>()
@ -76,6 +79,10 @@ builder.Services
.AddOptions<ConceptVoteOptions>() .AddOptions<ConceptVoteOptions>()
.BindConfiguration(ConceptVoteOptions.SectionName); .BindConfiguration(ConceptVoteOptions.SectionName);
builder.Services
.AddOptions<DeskOptions>()
.BindConfiguration(DeskOptions.SectionName);
builder.Services.AddAntiforgery(options => builder.Services.AddAntiforgery(options =>
{ {
options.Cookie.Name = BifrostCookies.Antiforgery; options.Cookie.Name = BifrostCookies.Antiforgery;
@ -86,6 +93,7 @@ builder.Services.AddAntiforgery(options =>
builder.Services.AddRazorPages(options => builder.Services.AddRazorPages(options =>
{ {
options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizePage("/Membership/Status");
options.Conventions.AuthorizePage("/Membership/LinkedAccounts");
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
options.Conventions.AllowAnonymousToFolder("/Mill"); options.Conventions.AllowAnonymousToFolder("/Mill");
}); });
@ -112,6 +120,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>(); builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
builder.Services.AddScoped<AuditTrailService>(); builder.Services.AddScoped<AuditTrailService>();
builder.Services.AddScoped<Bifrost.Web.Features.Membership.MembershipSuspensionService>();
builder.Services.AddScoped<DashboardSnapshotService>(); builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<BridgeActionService>(); builder.Services.AddScoped<BridgeActionService>();
builder.Services.AddScoped<AgentDispatchRunService>(); builder.Services.AddScoped<AgentDispatchRunService>();
@ -125,6 +134,10 @@ builder.Services.AddScoped<SealedBallotService>();
builder.Services.AddScoped<MindIntakeService>(); builder.Services.AddScoped<MindIntakeService>();
builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName); builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName);
builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
builder.Services.AddSingleton<RegistrationChallengeService>();
builder.Services.AddSingleton<RegistrationRateLimit>();
builder.Services.AddScoped<NativeRegistration>();
builder.Services.AddHostedService<SealedBallotCloser>(); builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>(); builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>(); builder.Services.AddScoped<PatronageService>();
@ -140,7 +153,12 @@ builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>(); builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) => builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value)) HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
// Heimdall's member routes take only the member's bearer: this client carries no app secret.
builder.Services.AddHttpClient<HeimdallMemberClient>((serviceProvider, client) =>
HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
builder.Services.AddSingleton<LinkedAccountsSnapshots>();
builder.Services.AddSingleton<HeimdallTokenVerifier>(); builder.Services.AddSingleton<HeimdallTokenVerifier>();
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value)); HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
@ -252,6 +270,13 @@ if (oidcOptions is not null)
builder.Services.AddBifrostOidc(oidcOptions); builder.Services.AddBifrostOidc(oidcOptions);
} }
// The desk is mapped only when the Desk section exists, and a configured desk needs its credential and door ports.
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
if (deskConfigured)
{
builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null);
}
var app = builder.Build(); var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
@ -273,6 +298,11 @@ if (!app.Environment.IsDevelopment())
app.UseHttpLogging(); app.UseHttpLogging();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
if (oidcOptions is not null)
{
app.UseMiddleware<OidcGuardMiddleware>(); // before authentication: OpenIddict answers /connect/* inside it
}
app.UseStaticFiles(); app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.UseConceptTallyCors(); app.UseConceptTallyCors();
@ -293,12 +323,18 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke
}).AllowAnonymous(); }).AllowAnonymous();
app.MapConceptTallyExport(); app.MapConceptTallyExport();
app.MapConceptRankedExport();
if (oidcOptions is not null) if (oidcOptions is not null)
{ {
app.MapBifrostOidc(); app.MapBifrostOidc();
} }
if (deskConfigured)
{
app.MapDesk();
}
app.MapPost("/github/webhooks", async ( app.MapPost("/github/webhooks", async (
HttpRequest request, HttpRequest request,
GitHubWebhookService gitHubWebhookService, GitHubWebhookService gitHubWebhookService,
@ -361,35 +397,173 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
app.MapPost("/auth/bifrost/register", async ( app.MapPost("/auth/bifrost/register", async (
NativeBifrostRegistrationRequest request, NativeBifrostRegistrationRequest request,
HttpContext httpContext, HttpContext httpContext,
BifrostIdentityService bifrostIdentityService, NativeRegistration registration,
RegistrationRateLimit rateLimit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
var outcome = await registration.RunAsync(
httpContext,
new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion),
cancellationToken);
if (outcome.Refusal is not null)
{
return outcome.Refusal;
}
if (outcome.Account is not { } userAccount)
{
return Results.Text(outcome.Message!, statusCode: outcome.Status);
}
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
return JsonResult(
new NativeBifrostRegistrationResponse(
userAccount.Id,
userAccount.BifrostIdentity,
userAccount.DisplayName,
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
returnUrl),
appJsonSerializerOptions);
}).AllowAnonymous();
// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge.
app.MapGet("/auth/bifrost/register", (
HttpContext httpContext,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
IOptions<RegistrationOptions> registrationOptions) =>
{
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK);
}).AllowAnonymous();
// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead
// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no
// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with
// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent.
// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie.
app.MapPost("/auth/bifrost/register/form", async (
HttpContext httpContext,
NativeRegistration registration,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
IOptions<BifrostHostOptions> hostOptions,
IOptions<RegistrationOptions> registrationOptions,
CancellationToken cancellationToken) =>
{
if (!RequestOriginGuard.AllowsFormWithoutFetchMetadata(httpContext.Request, hostOptions.Value))
{
return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden);
}
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
{
return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType);
}
// The form is a handful of short fields: refuse a big body before reading any of it.
const int MaxFormBytes = 16 * 1024;
if (httpContext.Request.ContentLength > MaxFormBytes)
{
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
}
if (httpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
{
limit.MaxRequestBodySize = MaxFormBytes;
}
IFormCollection form;
try try
{ {
var userAccount = await bifrostIdentityService.RegisterNativeAsync( form = await httpContext.Request.ReadFormAsync(cancellationToken);
request.Identity,
request.DisplayName,
cancellationToken);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
bifrostIdentityService.BuildPrincipal(userAccount));
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
return JsonResult(
new NativeBifrostRegistrationResponse(
userAccount.Id,
userAccount.BifrostIdentity,
userAccount.DisplayName,
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
returnUrl),
appJsonSerializerOptions);
} }
catch (BifrostIdentityException error) catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge)
{ {
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
} }
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
{
return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest);
}
string Field(string name) => form[name].FirstOrDefault() ?? "";
var returnPath = LocalReturnPath.Parse(Field("returnUrl"));
// The checkbox is the consent act and is optional: unticked, the posted notice version is ignored and no receipt is made.
// Anything but the canonical decimal of the version in force ("02" included) with the box ticked is sent as -1 and refused, never read as "did not agree".
int? accepted = Field("consent") != "yes"
? null
: Field("acceptedNoticeVersion") == TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture) ? TrainingNotice.Version : -1;
var identity = Field("identity");
var displayName = Field("displayName");
var challenge = Field("challenge");
var outcome = await registration.RunAsync(
httpContext,
new RegistrationAttempt(identity, displayName, challenge, Field("nonce"), Field("waited") == "true", accepted),
cancellationToken);
if (outcome.Refusal is not null)
{
return outcome.Refusal;
}
if (outcome.Account is not null)
{
// A Location header takes ASCII only; anything else is not a path worth keeping.
var target = returnPath.Value.All(c => c > ' ' && c < '\u007f') ? returnPath.Value : LocalReturnPath.Default.Value;
httpContext.Response.Headers.CacheControl = "no-store";
httpContext.Response.Headers.Location = target;
return Results.StatusCode(StatusCodes.Status303SeeOther);
}
// A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its
// challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked.
var options = registrationOptions.Value;
return RegistrationFormResult(
httpContext,
challenges,
options,
new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field),
outcome.KeepChallenge && challenges.IsLive(challenge) ? challenge : null,
outcome.Status);
static string Truncate(string text) => text.Length <= 256 ? text : text[..256];
}).AllowAnonymous(); }).AllowAnonymous();
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
{
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
httpContext.Response.Headers.CacheControl = "no-store";
var challenge = challenges.Issue();
return challenge is null
? Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable)
: JsonResult(challenge, appJsonSerializerOptions);
}).AllowAnonymous();
app.MapGet("/auth/bifrost/notice", () =>
JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous();
app.MapConsent();
app.MapGet("/auth/heimdall/{provider}", async ( app.MapGet("/auth/heimdall/{provider}", async (
string provider, string provider,
HttpContext httpContext, HttpContext httpContext,
@ -655,11 +829,37 @@ app.MapPost("/auth/heimdall/complete", async (
ILogger<HeimdallAuthService> logger, ILogger<HeimdallAuthService> logger,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
// Every answer here sets or clears an attempt cookie or signs someone in: none may be stored.
httpContext.Response.Headers.CacheControl = "no-store";
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
var attempt = form?["attempt"].ToString(); var attempt = form?["attempt"].ToString();
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); 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 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( var signIn = await heimdallAuthService.CompleteAsync(
hostOptions.Value.PublicBaseUrl, hostOptions.Value.PublicBaseUrl,
attempt, attempt,
@ -678,7 +878,9 @@ app.MapPost("/auth/heimdall/complete", async (
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); 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(); }).AllowAnonymous();
@ -715,6 +917,36 @@ static async Task<IFormCollection?> ReadFormOrNullAsync(HttpRequest request, Can
} }
} }
// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full).
static IResult RegistrationFormResult(
HttpContext httpContext,
RegistrationChallengeService challenges,
RegistrationOptions options,
RegistrationPageModel model,
string? keepChallenge,
int statusCode)
{
var token = keepChallenge;
var difficulty = options.Captcha.Difficulty;
if (token is null)
{
var issued = challenges.Issue();
if (issued is null)
{
return Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
token = issued.Challenge;
difficulty = issued.Difficulty;
}
httpContext.Response.Headers.CacheControl = "no-store";
return Results.Content(
RegistrationPage.Render(model with { Challenge = token, Difficulty = difficulty }),
"text/html; charset=utf-8",
statusCode: statusCode);
}
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
{ {
httpContext.Response.ContentType = "text/html; charset=utf-8"; httpContext.Response.ContentType = "text/html; charset=utf-8";
@ -768,7 +1000,11 @@ public partial class Program;
public sealed record NativeBifrostRegistrationRequest( public sealed record NativeBifrostRegistrationRequest(
string Identity, string Identity,
string? DisplayName, string? DisplayName,
string? ReturnUrl); string? ReturnUrl,
string? Challenge,
string? Nonce,
int? AcceptedNoticeVersion,
bool? Waited = null);
public sealed record NativeBifrostRegistrationResponse( public sealed record NativeBifrostRegistrationResponse(
Guid UserAccountId, Guid UserAccountId,

View file

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

View file

@ -8,6 +8,17 @@
"resolved": "2.4.0", "resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" "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": { "Microsoft.EntityFrameworkCore.Design": {
"type": "Direct", "type": "Direct",
"requested": "[8.0.0, )", "requested": "[8.0.0, )",
@ -75,6 +86,25 @@
"OpenIddict.Server": "7.7.1" "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": { "System.Text.Json": {
"type": "Direct", "type": "Direct",
"requested": "[8.0.5, )", "requested": "[8.0.5, )",
@ -86,6 +116,16 @@
"resolved": "2.14.1", "resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" "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": { "Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive", "type": "Transitive",
"resolved": "6.0.0", "resolved": "6.0.0",
@ -269,6 +309,14 @@
"Microsoft.IdentityModel.Abstractions": "8.19.2" "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": { "Microsoft.IdentityModel.Tokens": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.19.2", "resolved": "8.19.2",
@ -279,6 +327,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2" "Microsoft.IdentityModel.Logging": "8.19.2"
} }
}, },
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Mono.TextTemplating": { "Mono.TextTemplating": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.2.1", "resolved": "2.2.1",
@ -323,6 +376,17 @@
"OpenIddict.Abstractions": "7.7.1" "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": { "System.CodeDom": {
"type": "Transitive", "type": "Transitive",
"resolved": "4.4.0", "resolved": "4.4.0",

View file

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

View file

@ -0,0 +1,176 @@
using System.Security.Claims;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Pages.App.Members;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Tests;
/// <summary>Bootstrap promotion never lifts a suspension and leaves a receipt when it changes status; demoting an admin clears the admin flags.</summary>
public sealed class BootstrapAdminTests
{
private const long BootstrapGitHubId = 4242;
private static (Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> Factory, CapturingLoggerProvider Logs) NewFactory()
{
var logs = new CapturingLoggerProvider();
var factory = new TestWebApplicationFactory().WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
return (factory, logs);
}
private static async Task<Guid> SeedBootstrapLoginAsync(IServiceProvider services, MembershipStatus status)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = new UserAccount
{
DisplayName = "Test Admin",
GitHubUserId = BootstrapGitHubId,
GitHubLogin = "test-admin",
NormalizedGitHubLogin = "TEST-ADMIN",
BifrostIdentity = "testadmin",
NormalizedBifrostIdentity = "testadmin",
MemberProfile = new MemberProfile { Nickname = "ta" },
Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = status == MembershipStatus.Suspended ? DateTimeOffset.UtcNow : null }
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account.Id;
}
private static async Task<(Bifrost.Web.Domain.Membership Membership, List<AuditEvent> Receipts)> ReadAsync(IServiceProvider services, Guid accountId)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var membership = await db.Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == accountId);
var receipts = await db.AuditEvents.AsNoTracking().Where(x => x.EntityId == membership.Id.ToString()).ToListAsync();
return (membership, receipts);
}
private static ClaimsPrincipal BootstrapPrincipal() => new(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, BootstrapGitHubId.ToString()),
new Claim(ClaimTypes.Name, "test-admin")
], "test"));
[Theory]
[InlineData("startup")]
[InlineData("signin")]
public async Task A_suspended_bootstrap_login_stays_suspended_and_unpromoted(string path)
{
var (factory, logs) = NewFactory();
using var _ = factory;
var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Suspended);
var before = (await ReadAsync(factory.Services, id)).Receipts.Count;
await RunPathAsync(factory.Services, path);
var (membership, receipts) = await ReadAsync(factory.Services, id);
Assert.Equal(MembershipStatus.Suspended, membership.Status);
Assert.False(membership.IsPlatformAdmin);
Assert.Empty(membership.RoleAssignments);
Assert.Equal(before, receipts.Count);
Assert.Contains(logs.Lines, x => x.Contains("suspended account") && x.Contains(id.ToString()));
}
[Theory]
[InlineData("startup")]
[InlineData("signin")]
public async Task A_bootstrap_promotion_that_changes_status_writes_a_receipt(string path)
{
var (factory, _) = NewFactory();
using var _f = factory;
var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Authenticated);
await RunPathAsync(factory.Services, path);
var (membership, receipts) = await ReadAsync(factory.Services, id);
Assert.Equal(MembershipStatus.Active, membership.Status);
Assert.True(membership.IsPlatformAdmin);
var receipt = Assert.Single(receipts, x => x.Action == "membership.bootstrap-promoted");
Assert.Contains("Authenticated -> Active", receipt.Detail);
await RunPathAsync(factory.Services, path); // already Active: no second receipt
Assert.Single((await ReadAsync(factory.Services, id)).Receipts, x => x.Action == "membership.bootstrap-promoted");
}
private static async Task RunPathAsync(IServiceProvider services, string path)
{
using var scope = services.CreateScope();
if (path == "startup")
{
await scope.ServiceProvider.GetRequiredService<ApplicationBootstrapper>().RunAsync(CancellationToken.None);
}
else
{
await scope.ServiceProvider.GetRequiredService<MembershipSynchronizationService>().SynchronizeAsync(BootstrapPrincipal(), CancellationToken.None);
}
}
private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor
{
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor);
}
private static async Task<Guid> SeedAsync(IServiceProvider services, string name, bool admin)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow };
if (admin)
{
membership.IsPlatformAdmin = membership.CanManageProjects = membership.CanManageLedger = membership.CanModerateMotions = true;
membership.RoleAssignments.Add(new RoleAssignment { Role = MemberRole.PlatformAdmin });
}
var account = new UserAccount
{
DisplayName = name,
BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
Membership = membership
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account.Id;
}
private static async Task ToggleAsync(IServiceProvider services, Guid by, Guid target, MemberRole role)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var actor = new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == by));
var page = new IndexModel(db, new FixedActor(actor), scope.ServiceProvider.GetRequiredService<AuditTrailService>(),
scope.ServiceProvider.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
Assert.IsType<RedirectToPageResult>(await page.OnPostToggleRoleAsync(target, role, CancellationToken.None));
}
[Fact]
public async Task Demoting_an_admin_through_the_page_clears_the_admin_flags_and_promoting_sets_them()
{
var (factory, _) = NewFactory();
using var _f = factory;
var actingAdmin = await SeedAsync(factory.Services, "acting", admin: true);
var otherAdmin = await SeedAsync(factory.Services, "other", admin: true);
var member = await SeedAsync(factory.Services, "member", admin: false);
await ToggleAsync(factory.Services, actingAdmin, otherAdmin, MemberRole.PlatformAdmin);
var (demoted, _) = await ReadAsync(factory.Services, otherAdmin);
Assert.False(demoted.IsPlatformAdmin);
Assert.False(demoted.CanManageProjects);
Assert.False(demoted.CanManageLedger);
Assert.False(demoted.CanModerateMotions);
Assert.DoesNotContain(demoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin);
await ToggleAsync(factory.Services, actingAdmin, member, MemberRole.PlatformAdmin);
var (promoted, _) = await ReadAsync(factory.Services, member);
Assert.True(promoted.IsPlatformAdmin);
Assert.Contains(promoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin);
}
}

View file

@ -1061,18 +1061,16 @@ rl.on("line", (line) => {
await RunGitAsync(["commit", "-m", "Initial commit"], workDir); await RunGitAsync(["commit", "-m", "Initial commit"], workDir);
await RunGitAsync(["remote", "add", "origin", remoteDir], workDir); await RunGitAsync(["remote", "add", "origin", remoteDir], workDir);
var blocked = await RunCommandAsync( var blocked = await RunShellAsync(
"powershell", "git push -u origin main",
["-NoProfile", "-Command", "git push -u origin main"],
workDir, workDir,
BuildDispatchedGitHookEnv()); BuildDispatchedGitHookEnv());
Assert.NotEqual(0, blocked.ExitCode); Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked git push", blocked.Stderr, StringComparison.OrdinalIgnoreCase); Assert.Contains("Bifrost blocked git push", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
var blockedNoVerify = await RunCommandAsync( var blockedNoVerify = await RunShellAsync(
"powershell", "git push --no-verify -u origin main",
["-NoProfile", "-Command", "git push --no-verify -u origin main"],
workDir, workDir,
BuildDispatchedGitGateEnv()); BuildDispatchedGitGateEnv());
@ -1154,19 +1152,13 @@ console.log("https://github.com/GameCult/Bifrost/pull/999");
try try
{ {
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv(); var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath; environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync( var blocked = await RunShellAsync(
"powershell", "gh pr comment 42 --body blocked",
["-NoProfile", "-Command", "gh pr comment 42 --body blocked"],
RepoRoot, RepoRoot,
environment); environment);
@ -1188,19 +1180,13 @@ exit /b 0
try try
{ {
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv(); var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath; environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync( var blocked = await RunShellAsync(
"powershell", "gh -R GameCult/Bifrost pr comment 42 --body blocked",
["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr comment 42 --body blocked"],
RepoRoot, RepoRoot,
environment); environment);
@ -1222,19 +1208,13 @@ exit /b 0
try try
{ {
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv(); var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath; environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync( var blocked = await RunShellAsync(
"powershell", "gh workflow run ci.yml",
["-NoProfile", "-Command", "gh workflow run ci.yml"],
RepoRoot, RepoRoot,
environment); environment);
@ -1256,19 +1236,13 @@ exit /b 0
try try
{ {
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); var fakeGhPath = await WriteFakeGhAsync(tempDir, "read-only-ok");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo read-only-ok
exit /b 0
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv(); var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath; environment["BIFROST_REAL_GH"] = fakeGhPath;
var allowed = await RunCommandAsync( var allowed = await RunShellAsync(
"powershell", "gh -R GameCult/Bifrost pr view 42",
["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr view 42"],
RepoRoot, RepoRoot,
environment); environment);
@ -1449,27 +1423,77 @@ import('./tools/bifrost-crossing-documents.mjs')
private static Dictionary<string, string?> BuildDispatchedGitHookEnv() private static Dictionary<string, string?> BuildDispatchedGitHookEnv()
{ {
var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks"); var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks");
var nodePath = ResolveExecutable("node");
var gatePath = Path.Combine(RepoRoot, "tools", "git-gate"); var gatePath = Path.Combine(RepoRoot, "tools", "git-gate");
var path = Environment.GetEnvironmentVariable("Path") var environment = new Dictionary<string, string?>
?? Environment.GetEnvironmentVariable("PATH")
?? string.Empty;
var nextPath = string.IsNullOrWhiteSpace(path)
? gatePath
: $"{gatePath};{path}";
return new Dictionary<string, string?>
{ {
["BIFROST_ENFORCE_GITHUB_GATE"] = "true", ["BIFROST_ENFORCE_GITHUB_GATE"] = "true",
["BIFROST_GIT_EXECUTABLE"] = Path.Combine(gatePath, "git.cmd"), ["BIFROST_NODE_EXECUTABLE"] = nodePath,
["BIFROST_GH_EXECUTABLE"] = Path.Combine(gatePath, "gh.cmd"),
["BIFROST_NODE_EXECUTABLE"] = ResolveExecutable("node"),
["GIT_CONFIG_COUNT"] = "1", ["GIT_CONFIG_COUNT"] = "1",
["GIT_CONFIG_KEY_0"] = "core.hooksPath", ["GIT_CONFIG_KEY_0"] = "core.hooksPath",
["GIT_CONFIG_VALUE_0"] = 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 Dictionary<string, string?> BuildDispatchedGitGateEnv() => BuildDispatchedGitHookEnv();
private static string ResolveExecutable(string command) private static string ResolveExecutable(string command)
@ -1478,7 +1502,7 @@ import('./tools/bifrost-crossing-documents.mjs')
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = "where.exe", FileName = OperatingSystem.IsWindows() ? "where.exe" : "which",
WorkingDirectory = RepoRoot, WorkingDirectory = RepoRoot,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,

View file

@ -0,0 +1,562 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
public sealed class ConceptRankedExportTests : IClassFixture<ConceptRankedExportTests.RankedFactory>
{
private const string AllowedOrigin = "https://aetheria.gamecult.org";
private static readonly DateTimeOffset Epoch = new(2026, 11, 1, 0, 0, 0, TimeSpan.Zero);
private readonly RankedFactory _factory;
public ConceptRankedExportTests(RankedFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Net_ranks_up_minus_down_and_ties_break_by_key()
{
var c = await Seed();
var keys = Keys(await GetOk(c, "?sort=net"));
Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], keys);
}
[Fact]
public async Task Default_order_is_net_and_the_default_page_is_everything_under_the_cap()
{
var c = await Seed();
var body = await GetOk(c, "");
Assert.Equal(Keys(await GetOk(c, "?sort=net")), Keys(body));
Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind);
}
[Fact]
public async Task Total_ranks_up_plus_down()
{
var c = await Seed();
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Keys(await GetOk(c, "?sort=total")));
}
[Fact]
public async Task Newest_ranks_by_registration_time()
{
var c = await Seed();
Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], Keys(await GetOk(c, "?sort=newest")));
}
[Fact]
public async Task Items_carry_counts_and_title_only()
{
var c = await Seed();
var first = (await GetOk(c, "?limit=1")).GetProperty("items")[0];
Assert.Equal(["down", "key", "members", "title", "up"], first.EnumerateObject().Select(p => p.Name).Order().ToArray());
Assert.Equal(["down", "up"], first.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray());
}
[Theory]
[InlineData("net")]
[InlineData("total")]
[InlineData("newest")]
public async Task Paging_walks_every_subject_once_in_the_same_order_as_one_big_page(string sort)
{
var c = await Seed();
var whole = Keys(await GetOk(c, $"?sort={sort}"));
var walked = new List<string>();
string? cursor = null;
var pages = 0;
do
{
var body = await GetOk(c, $"?sort={sort}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor));
walked.AddRange(Keys(body));
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
pages++;
}
while (cursor is not null && pages < 10);
Assert.Equal(whole, walked);
Assert.Equal(3, pages);
}
[Fact]
public async Task Ties_break_by_ordinal_key_even_when_keys_hold_hyphens_and_were_registered_out_of_order()
{
var c = "c" + Guid.NewGuid().ToString("N");
string[] ordinal = ["a-b", "a-c", "ab", "b", "b-a"];
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
foreach (var key in ordinal.Reverse())
{
db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key, RegisteredAtUtc = Epoch });
}
await db.SaveChangesAsync();
}
var walked = new List<string>();
string? cursor = null;
do
{
var body = await GetOk(c, "?limit=1" + (cursor is null ? "" : "&cursor=" + cursor));
walked.AddRange(Keys(body));
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
}
while (cursor is not null && walked.Count < 10);
Assert.Equal(ordinal, walked);
Assert.Equal(ordinal, Keys(await GetOk(c, "?sort=newest")));
}
[Fact]
public async Task Unvoted_without_a_client_address_still_pages()
{
var c = await Seed();
var first = await GetOk(c, "?unvoted=network&limit=2");
var second = await GetOk(c, "?unvoted=network&limit=2&cursor=" + first.GetProperty("next_cursor").GetString());
Assert.Equal(["bravo", "alpha"], Keys(first));
Assert.Equal(["charlie", "delta"], Keys(second));
}
[Fact]
public async Task A_vote_between_pages_never_repeats_the_cursors_own_row()
{
var c = await Seed();
var first = await GetOk(c, "?limit=2");
var seen = Keys(first);
await Cast(c, "echo", "mover-1", ConceptStance.Up);
await Cast(c, "echo", "mover-2", ConceptStance.Up);
await Cast(c, "echo", "mover-3", ConceptStance.Up);
await Cast(c, "echo", "mover-4", ConceptStance.Up);
var second = Keys(await GetOk(c, "?limit=5&cursor=" + first.GetProperty("next_cursor").GetString()));
Assert.Empty(seen.Intersect(second));
}
[Theory]
[InlineData("?limit=0")]
[InlineData("?limit=101")]
[InlineData("?limit=-1")]
[InlineData("?limit=+5")]
[InlineData("?limit=05")]
[InlineData("?limit=5.0")]
[InlineData("?limit=%205")]
[InlineData("?limit=")]
[InlineData("?limit=abc")]
[InlineData("?limit=99999999999999999999")]
[InlineData("?sort=")]
[InlineData("?sort=oldest")]
[InlineData("?sort=Net")]
[InlineData("?sort=net&sort=total")]
[InlineData("?limit=1&limit=2")]
[InlineData("?unvoted=")]
[InlineData("?unvoted=all")]
[InlineData("?unvoted=network&unvoted=network")]
[InlineData("?Sort=net")]
[InlineData("?tag=abc")]
[InlineData("?networkTag=abc")]
[InlineData("?page=2")]
public async Task A_query_outside_the_closed_shape_is_a_400(string query)
{
var c = await Seed();
using var response = await Get(c, query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("request_invalid", JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("error").GetString());
}
[Fact]
public async Task The_page_cap_is_one_hundred_and_one_hundred_is_allowed()
{
var c = await Seed();
using var ok = await Get(c, "?limit=100");
using var over = await Get(c, "?limit=101");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, over.StatusCode);
}
public static IEnumerable<object[]> HostileCursors()
{
static string B64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
yield return ["x"];
yield return ["%00"];
yield return ["not base64!"];
yield return ["AAAA"];
yield return [new string('A', 201)];
// 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")];
yield return [B64("2|0|0|5|alpha")];
yield return [B64("1|3|0|5|alpha")];
yield return [B64("1|-1|0|5|alpha")];
yield return [B64("1|0|2|5|alpha")];
yield return [B64("1|0|0|abc|alpha")];
yield return [B64("1|0|0|+5|alpha")];
yield return [B64("1|0|0|-0|alpha")];
yield return [B64("1|0|0|05|alpha")];
yield return [B64("1|0|0|99999999999999999999|alpha")];
yield return [B64("1|0|0|5|Alpha")];
yield return [B64("1|0|0|5|al pha")];
yield return [B64("1|0|0|5|alpha\n")];
yield return [B64("1|0|0|5|'; drop table")];
yield return [B64("1|0|0|5|../x")];
yield return [B64("1|0|0|5|" + new string('a', 81))];
yield return [B64("1|0|0|5|")];
yield return [Convert.ToBase64String(Encoding.UTF8.GetBytes("1|0|0|5|alpha"))];
yield return [B64("1|0|0|5|alpha") + "="];
yield return [Convert.ToBase64String([0xFF, 0xFE, 0xFD, 0xFC]).Replace('+', '-').Replace('/', '_').TrimEnd('=')];
}
[Theory]
[MemberData(nameof(HostileCursors))]
public async Task A_hostile_cursor_is_a_400_and_never_a_query(string cursor)
{
var c = await Seed();
using var response = await Get(c, "?cursor=" + Uri.EscapeDataString(cursor));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task A_cursor_is_bound_to_the_sort_and_the_filter_it_was_issued_for()
{
var c = await Seed();
var net = (await GetOk(c, "?limit=1")).GetProperty("next_cursor").GetString()!;
var unvoted = (await GetOk(c, "?limit=1&unvoted=network", client: "198.51.100.9")).GetProperty("next_cursor").GetString()!;
using var otherSort = await Get(c, "?sort=total&cursor=" + net);
using var dropsFilter = await Get(c, "?cursor=" + unvoted);
using var addsFilter = await Get(c, "?unvoted=network&cursor=" + net, client: "198.51.100.9");
using var same = await Get(c, "?limit=1&cursor=" + net);
Assert.Equal(HttpStatusCode.BadRequest, otherSort.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, dropsFilter.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, addsFilter.StatusCode);
Assert.Equal(HttpStatusCode.OK, same.StatusCode);
}
[Fact]
public async Task A_well_formed_cursor_past_every_row_is_an_empty_last_page()
{
var c = await Seed();
var past = new RankCursor(RankOrder.Net, false, long.MinValue, "a").Encode();
var body = await GetOk(c, "?cursor=" + past);
Assert.Empty(Keys(body));
Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind);
}
[Theory]
[InlineData("Alpha")]
[InlineData("a_b")]
[InlineData("..")]
[InlineData("a%2Fb")]
[InlineData("a%0A")]
[InlineData("%27%3B--")]
[InlineData("nope")]
public async Task A_hostile_or_unknown_collection_is_a_404(string collection)
{
using var response = await Get(collection, "");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task A_collection_over_the_key_grammar_is_a_404()
{
using var response = await Get(new string('a', 81), "");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Retired_subjects_are_left_out()
{
var c = await Seed();
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
(await db.ConceptSubjects.SingleAsync(x => x.Collection == c && x.Key == "bravo")).RetiredAtUtc = Epoch;
await db.SaveChangesAsync();
}
Assert.DoesNotContain("bravo", Keys(await GetOk(c, "")));
}
[Fact]
public async Task Unvoted_leaves_out_what_the_callers_network_voted_and_not_what_others_did()
{
var c = await Seed();
var fromVoter = Keys(await GetOk(c, "?unvoted=network", client: Voter));
var fromStranger = Keys(await GetOk(c, "?unvoted=network", client: "198.51.100.77"));
Assert.Equal(["charlie", "delta", "echo"], fromVoter.Order().ToArray());
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], fromStranger.Order().ToArray());
}
[Fact]
public async Task Unvoted_pages_and_ranks_the_same_way()
{
var c = await Seed();
var walked = new List<string>();
string? cursor = null;
do
{
var body = await GetOk(c, "?unvoted=network&sort=newest&limit=1" + (cursor is null ? "" : "&cursor=" + cursor), client: "198.51.100.77");
walked.AddRange(Keys(body));
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
}
while (cursor is not null && walked.Count < 10);
Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], walked);
}
[Fact]
public async Task Unvoted_without_a_client_address_filters_nothing_and_matches_no_untagged_row()
{
var c = await Seed();
await CastUntagged(c, "echo", "ghost-device");
var keys = Keys(await GetOk(c, "?unvoted=network", client: null));
Assert.Equal(5, keys.Length);
}
[Fact]
public async Task Unvoted_and_plain_responses_expose_no_tag_or_voter_identity()
{
var c = await Seed();
string[] tags;
using (var scope = _factory.Services.CreateScope())
{
tags = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
.ConceptAppraisals.Where(x => x.NetworkTag != "").Select(x => x.NetworkTag).Distinct().ToArrayAsync();
}
Assert.NotEmpty(tags);
foreach (var query in new[] { "", "?unvoted=network", "?limit=2&unvoted=network" })
{
using var response = await Get(c, query, client: Voter);
var text = await response.Content.ReadAsStringAsync();
foreach (var forbidden in tags.Concat(["voter-", "device:", "account:", "networktag", "voterkey", Voter]))
{
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
}
}
// The cursor is position only: it carries no tag either.
var cursor = (await GetOk(c, "?limit=1&unvoted=network", client: Voter)).GetProperty("next_cursor").GetString()!;
var decoded = Encoding.UTF8.GetString(Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(cursor));
foreach (var tag in tags)
{
Assert.DoesNotContain(tag, decoded);
}
}
[Fact]
public async Task Nothing_in_the_request_can_name_a_tag_or_a_client()
{
var c = await Seed();
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{c}/ranked?unvoted=network");
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Client", "198.51.100.77");
request.Headers.Add("X-Network-Tag", "anything");
request.Headers.Add("X-Forwarded-For", Voter);
using var response = await client.SendAsync(request);
// The test connection address wins; the forwarded header is the proxy's to set, not the request's.
Assert.Equal(5, Keys(JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement).Length);
}
[Fact]
public async Task Cache_control_is_public_for_the_plain_read_and_never_cached_for_the_unvoted_one()
{
var c = await Seed();
using var plain = await Get(c, "");
using var unvoted = await Get(c, "?unvoted=network", client: Voter);
Assert.True(plain.Headers.CacheControl!.Public);
Assert.Equal(TimeSpan.FromSeconds(60), plain.Headers.CacheControl.MaxAge);
Assert.True(unvoted.Headers.CacheControl!.Private);
Assert.True(unvoted.Headers.CacheControl.NoStore);
Assert.False(unvoted.Headers.CacheControl.Public);
}
[Fact]
public async Task Cors_and_vary_follow_the_tally_export_policy_and_the_read_sets_no_cookie_and_writes_nothing()
{
var c = await Seed();
int rows, audits;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
rows = db.ConceptAppraisals.Count();
audits = db.AuditEvents.Count();
}
using var allowed = await Get(c, "", origin: AllowedOrigin);
using var other = await Get(c, "", origin: "https://evil.example");
using var bad = await Get(c, "?limit=0", origin: AllowedOrigin);
Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials"));
Assert.False(other.Headers.Contains("Access-Control-Allow-Origin"));
foreach (var response in new[] { allowed, other, bad })
{
Assert.Contains("Origin", response.Headers.Vary.ToArray());
Assert.False(response.Headers.Contains("Set-Cookie"));
}
using var after = _factory.Services.CreateScope();
var dbAfter = after.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Equal(rows, dbAfter.ConceptAppraisals.Count());
Assert.Equal(audits, dbAfter.AuditEvents.Count());
}
// The network that cast the votes in Seed.
private const string Voter = "203.0.113.7";
// alpha 3 up 2 down (net 1, total 5), bravo 2 up (net 2, total 2), charlie 1 up 1 down (net 0, total 2),
// delta and echo unvoted (net 0, total 0). Registered alpha first, echo last. Voter's network cast alpha and
// bravo; charlie's votes came from a second network.
private async Task<string> Seed()
{
var c = "c" + Guid.NewGuid().ToString("N");
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var i = 0;
foreach (var key in new[] { "alpha", "bravo", "charlie", "delta", "echo" })
{
db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key.ToUpperInvariant(), RegisteredAtUtc = Epoch.AddDays(i++) });
}
await db.SaveChangesAsync();
}
await Cast(c, "alpha", "voter-1", ConceptStance.Up);
await Cast(c, "alpha", "voter-2", ConceptStance.Up);
await Cast(c, "alpha", "voter-3", ConceptStance.Up);
await Cast(c, "alpha", "voter-4", ConceptStance.Down);
await Cast(c, "alpha", "voter-5", ConceptStance.Down);
await Cast(c, "bravo", "voter-1", ConceptStance.Up);
await Cast(c, "bravo", "voter-2", ConceptStance.Up);
await Cast(c, "charlie", "other-1", ConceptStance.Up, "192.0.2.50");
await Cast(c, "charlie", "other-2", ConceptStance.Down, "192.0.2.50");
return c;
}
private async Task Cast(string collection, string key, string device, ConceptStance stance, string client = Voter)
{
using var scope = _factory.Services.CreateScope();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!;
var result = await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
.CastAsync(collection, key, voter, stance, IPAddress.Parse(client), default);
Assert.Equal(CastResult.Recorded, result);
}
private async Task CastUntagged(string collection, string key, string device)
{
using var scope = _factory.Services.CreateScope();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!;
await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
.CastAsync(collection, key, voter, ConceptStance.Up, null, default);
}
private async Task<HttpResponseMessage> Get(string collection, string query, string? client = null, string? origin = null)
{
using var http = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/ranked{query}");
request.Headers.Add("X-Test-Anonymous", "true");
if (client is not null)
{
request.Headers.Add("X-Test-Client", client);
}
if (origin is not null)
{
request.Headers.TryAddWithoutValidation("Origin", origin);
}
return await http.SendAsync(request);
}
private async Task<JsonElement> GetOk(string collection, string query, string? client = null)
{
using var response = await Get(collection, query, client);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();
}
private static string[] Keys(JsonElement body) =>
body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("key").GetString()!).ToArray();
// The test host has no connection address; a header stands in for the one the proxy path would have resolved.
private sealed class ClientAddressFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
if (context.Request.Headers.TryGetValue("X-Test-Client", out var address))
{
context.Connection.RemoteIpAddress = IPAddress.Parse(address.ToString());
}
return nextMiddleware(context);
});
next(app);
};
}
public sealed class RankedFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services => services.AddTransient<IStartupFilter, ClientAddressFilter>());
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin,
["ConceptVotes:NetworkTagSecret"] = "ranked-test-tag-secret-0123456789-abcdef"
}));
}
}
}

View file

@ -516,6 +516,96 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLim
Assert.Equal(HttpStatusCode.OK, export.StatusCode); Assert.Equal(HttpStatusCode.OK, export.StatusCode);
} }
// The host itself, not the validator alone: Program runs StartupConfigurationValidator on every start, in every
// environment. IPv4 is 0-32 and IPv6 is 0-128 inclusive; a length outside its own family is refused by name.
[Theory]
[InlineData("Development", 33, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData("Development", -1, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData("Development", 32, 129, "ConceptVotes:IPv6PrefixLength")]
[InlineData("Development", 32, -1, "ConceptVotes:IPv6PrefixLength")]
[InlineData("Testing", 33, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData("Testing", -1, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData("Testing", 32, 129, "ConceptVotes:IPv6PrefixLength")]
[InlineData("Testing", 32, -1, "ConceptVotes:IPv6PrefixLength")]
[InlineData("Production", 33, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData("Production", -1, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData("Production", 32, 129, "ConceptVotes:IPv6PrefixLength")]
[InlineData("Production", 32, -1, "ConceptVotes:IPv6PrefixLength")]
public void Host_refuses_to_start_with_a_prefix_length_outside_its_address_family(
string environment, int v4, int v6, string key)
{
using var factory = WithPrefixes(environment, v4, v6);
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
Assert.Contains(key, Messages(failure));
}
// Boundary lengths start in Development and Testing and a cast from a real client address works. Production is
// not started here: it additionally demands a database, keys path and secrets this factory does not supply.
[Theory]
[InlineData("Development", 0, 0)]
[InlineData("Development", 32, 128)]
[InlineData("Testing", 0, 0)]
[InlineData("Testing", 32, 128)]
public async Task Boundary_prefix_lengths_start_and_a_cast_from_a_client_address_succeeds(
string environment, int v4, int v6)
{
using var factory = WithPrefixes(environment, v4, v6);
var collection = await NewSubject(factory);
using var v4Cast = await new Client(factory, "203.0.113.200").Post(collection, "Up");
using var v6Cast = await new Client(factory, "2001:db8:5::1").Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, v4Cast.StatusCode);
Assert.Equal(HttpStatusCode.Redirect, v6Cast.StatusCode);
}
[Fact]
public async Task A_zero_length_prefix_puts_every_address_of_the_family_in_one_bucket()
{
using var factory = WithPrefixes("Development", 0, 0);
var collection = await NewSubject(factory);
// One device (one cookie, one mint) arriving from changing addresses: only the cast bucket can refuse it.
var device = new Client(factory, "198.51.100.1");
for (var i = 0; i < 3; i++)
{
using var ok = await device.Post(collection, "Up", $"198.51.100.{i + 1}");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
using var denied = await device.Post(collection, "Up", "192.0.2.77");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
private WebApplicationFactory<Program> WithPrefixes(string environment, int v4, int v6) =>
_factory.WithWebHostBuilder(b =>
{
b.UseEnvironment(environment);
b.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:IPv4PrefixLength"] = v4.ToString(System.Globalization.CultureInfo.InvariantCulture),
["ConceptVotes:IPv6PrefixLength"] = v6.ToString(System.Globalization.CultureInfo.InvariantCulture)
}));
});
private static string Messages(Exception failure)
{
var lines = new List<string>();
for (var e = failure; e is not null; e = e.InnerException!)
{
lines.Add(e.Message);
if (e.InnerException is null)
{
break;
}
}
return string.Join("\n", lines);
}
private WebApplicationFactory<Program> Capped(int maxTrackedKeys) => private WebApplicationFactory<Program> Capped(int maxTrackedKeys) =>
_factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?> configuration.AddInMemoryCollection(new Dictionary<string, string?>
@ -531,10 +621,12 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLim
? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal)) ? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal))
: null; : null;
private async Task<string> NewSubject() private Task<string> NewSubject() => NewSubject(_factory);
private static async Task<string> NewSubject(WebApplicationFactory<Program> factory)
{ {
var collection = "c" + Guid.NewGuid().ToString("N"); var collection = "c" + Guid.NewGuid().ToString("N");
using var scope = _factory.Services.CreateScope(); using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>(); var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.ConceptSubjects.Add(new ConceptSubject db.ConceptSubjects.Add(new ConceptSubject
{ {

View file

@ -0,0 +1,159 @@
using System.Net;
using Bifrost.Web.Data;
using Bifrost.Web.Data.Migrations;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
/// <summary>Throws the store's refusal when a receipt is inserted and the flag is up, as the filtered unique index does to the loser of a race.</summary>
public sealed class UniqueViolationInterceptor : SaveChangesInterceptor
{
public volatile bool Armed;
/// <summary>The SqlState to wrap in the thrown exception.</summary>
public volatile string SqlState = Npgsql.PostgresErrorCodes.UniqueViolation;
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
if (Armed && eventData.Context!.ChangeTracker.Entries<ConsentReceipt>().Any(x => x.State == EntityState.Added))
{
throw new DbUpdateException(
"An error occurred while saving the entity changes.",
new Npgsql.PostgresException("store refused the insert", "ERROR", "ERROR", SqlState));
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
public sealed class UniqueViolationFactory : TestWebApplicationFactory
{
public UniqueViolationInterceptor Interceptor { get; } = new();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
// Wrap the options the base factory registered so the interceptor rides on the same in-memory store.
var original = services.Single(x => x.ServiceType == typeof(DbContextOptions<BifrostDbContext>));
services.Remove(original);
services.Add(ServiceDescriptor.Describe(
typeof(DbContextOptions<BifrostDbContext>),
sp => new DbContextOptionsBuilder<BifrostDbContext>((DbContextOptions<BifrostDbContext>)original.ImplementationFactory!(sp)).AddInterceptors(Interceptor).Options,
original.Lifetime));
});
}
}
public sealed class ConsentRaceTests(UniqueViolationFactory factory) : IClassFixture<UniqueViolationFactory>
{
private async Task<HttpResponseMessage> AcceptWithStoreFailureAsync(string sqlState, string identity)
{
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/accept")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["version"] = TrainingNotice.Version.ToString() })
};
request.Headers.Add("X-Test-Bifrost-Identity", identity);
request.Headers.Add("Origin", "https://localhost");
factory.Interceptor.SqlState = sqlState;
factory.Interceptor.Armed = true;
try
{
return await client.SendAsync(request);
}
finally
{
factory.Interceptor.Armed = false;
}
}
private Guid NewAccount(string identity)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity };
db.UserAccounts.Add(account);
db.SaveChanges();
return account.Id;
}
private bool HasReceipt(Guid accountId)
{
using var scope = factory.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>().ConsentReceipts.Any(x => x.UserAccountId == accountId);
}
[Fact]
public async Task The_loser_of_an_accept_race_gets_303_not_500_and_no_second_receipt()
{
var identity = $"race-{Guid.NewGuid():N}";
var accountId = NewAccount(identity);
using var response = await AcceptWithStoreFailureAsync(Npgsql.PostgresErrorCodes.UniqueViolation, identity);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/auth/bifrost/consent", response.Headers.Location!.OriginalString);
Assert.False(HasReceipt(accountId));
}
[Theory]
[InlineData(Npgsql.PostgresErrorCodes.ForeignKeyViolation)]
[InlineData(Npgsql.PostgresErrorCodes.DeadlockDetected)]
[InlineData(Npgsql.PostgresErrorCodes.NotNullViolation)]
public async Task Any_other_store_fault_on_accept_is_a_500_not_a_silent_redirect(string sqlState)
{
var identity = $"fault-{Guid.NewGuid():N}";
var accountId = NewAccount(identity);
using var response = await AcceptWithStoreFailureAsync(sqlState, identity);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.False(HasReceipt(accountId));
}
}
// The in-memory provider ignores index filters, so the model and the migration are checked directly.
public sealed class ConsentReceiptIndexTests
{
private const string Filter = "WithdrawnAtUtc\" IS NULL";
private static IIndex ReceiptIndex(IModel model) =>
model.FindEntityType(typeof(ConsentReceipt))!.GetIndexes()
.Single(x => x.Properties.Select(p => p.Name).SequenceEqual(["UserAccountId", "NoticeVersion"]));
[Fact]
public void The_design_time_model_and_the_snapshot_have_a_filtered_unique_index_on_live_receipts()
{
using var db = new BifrostDbContext(new DbContextOptionsBuilder<BifrostDbContext>().UseNpgsql("Host=unused").Options);
foreach (var model in new[] { db.GetService<IDesignTimeModel>().Model, db.GetService<Microsoft.EntityFrameworkCore.Migrations.IMigrationsAssembly>().ModelSnapshot!.Model })
{
var index = ReceiptIndex(model);
Assert.True(index.IsUnique);
Assert.Contains(Filter, index.GetFilter());
}
}
[Fact]
public void The_latest_consent_migration_creates_the_same_filter()
{
var up = new ConsentReceiptsOneLivePerVersion().UpOperations.OfType<CreateIndexOperation>()
.Single(x => x.Table == "ConsentReceipts");
Assert.True(up.IsUnique);
Assert.Contains(Filter, up.Filter);
Assert.Equal(["UserAccountId", "NoticeVersion"], up.Columns);
}
}

View file

@ -0,0 +1,452 @@
using System.Net;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
// The consent page and its posts, through HTTP, against receipt rows in the database.
public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
{
private const string Origin = "https://localhost";
private static readonly DateTimeOffset Agreed = new(2026, 11, 1, 12, 30, 5, TimeSpan.Zero);
private static readonly DateTimeOffset Withdrew = new(2026, 11, 2, 8, 0, 9, TimeSpan.Zero);
private static string NewIdentity() => $"consent-{Guid.NewGuid():N}";
private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
private void Seed(string identity, params (int Version, DateTimeOffset Accepted, DateTimeOffset? Withdrawn)[] receipts)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity };
db.UserAccounts.Add(account);
foreach (var (version, accepted, withdrawn) in receipts)
{
db.ConsentReceipts.Add(new ConsentReceipt { UserAccount = account, NoticeVersion = version, AcceptedAtUtc = accepted, WithdrawnAtUtc = withdrawn });
}
db.SaveChanges();
}
private List<ConsentReceipt> Receipts(string identity)
{
using var scope = factory.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>().ConsentReceipts.AsNoTracking()
.Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).OrderBy(x => x.AcceptedAtUtc).ToList();
}
private async Task<HttpResponseMessage> GetPageAsync(HttpClient client, string? identity)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/consent");
Sign(request, identity);
return await client.SendAsync(request);
}
private static void Sign(HttpRequestMessage request, string? identity)
{
if (identity is null)
{
request.Headers.Add("X-Test-Anonymous", "true");
}
else
{
request.Headers.Add("X-Test-Bifrost-Identity", identity);
}
}
private static async Task<HttpResponseMessage> PostAsync(
HttpClient client, string action, string? identity, Dictionary<string, string>? fields = null, string? origin = Origin, string? fetchSite = null)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/" + action)
{
Content = new FormUrlEncodedContent(fields ?? new Dictionary<string, string> { ["version"] = TrainingNotice.Version.ToString() })
};
Sign(request, identity);
if (origin is not null)
{
request.Headers.Add("Origin", origin);
}
if (fetchSite is not null)
{
request.Headers.Add("Sec-Fetch-Site", fetchSite);
}
return await client.SendAsync(request);
}
private async Task<string> PageHtmlAsync(string identity)
{
using var client = Client();
using var response = await GetPageAsync(client, identity);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadAsStringAsync();
}
[Fact]
public async Task Anonymous_is_sent_to_the_sign_in_chooser_with_the_page_as_return_target()
{
using var client = Client();
using var response = await GetPageAsync(client, null);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/auth/sign-in?returnUrl=%2Fauth%2Fbifrost%2Fconsent", response.Headers.Location!.OriginalString);
}
[Fact]
public async Task The_page_shows_the_notice_in_force_and_is_plain_server_html()
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var response = await GetPageAsync(client, identity);
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
Assert.Contains("no-store", response.Headers.CacheControl!.ToString());
foreach (var paragraph in TrainingNotice.Text.Split("\n\n"))
{
Assert.Contains(WebUtility.HtmlEncode(paragraph), html);
}
Assert.Contains("<html lang=\"en\">", html);
Assert.Contains("aria-live=\"polite\"", html);
Assert.DoesNotContain("<script", html);
Assert.DoesNotMatch("(src|href)=\"https?:", html);
}
[Fact]
public async Task No_agreement_shows_that_state_and_only_the_agree_button()
{
var identity = NewIdentity();
Seed(identity);
var html = await PageHtmlAsync(identity);
Assert.Contains("You have not agreed", html);
Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html);
Assert.DoesNotContain("/consent/withdraw", html);
Assert.Contains($"name=\"version\" value=\"{TrainingNotice.Version}\"", html);
}
[Fact]
public async Task The_page_states_what_agreement_is_for_before_the_agree_button()
{
var identity = NewIdentity();
Seed(identity);
var html = await PageHtmlAsync(identity);
var notice = html.IndexOf("to speak in a recorded room", StringComparison.Ordinal);
Assert.True(notice >= 0 && html.Contains("Lines you have already spoken stay"), "the notice says what agreement is for and that spoken lines stay");
Assert.True(notice < html.IndexOf("action=\"/auth/bifrost/consent/accept\"", StringComparison.Ordinal), "the notice comes before the button that agrees to it");
}
[Fact]
public async Task Agreeing_from_a_refused_chat_sign_in_returns_to_the_request_and_a_foreign_path_does_not_survive()
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/consent?returnUrl=" + Uri.EscapeDataString("/connect/authorize?request_uri=urn%3Aabc"));
Sign(request, identity);
using var page = await client.SendAsync(request);
Assert.Contains("name=\"returnUrl\" value=\"/connect/authorize?request_uri=urn%3Aabc\"", await page.Content.ReadAsStringAsync());
using var agreed = await PostAsync(client, "accept", identity, new Dictionary<string, string> { ["version"] = TrainingNotice.Version.ToString(), ["returnUrl"] = "/connect/authorize?request_uri=urn%3Aabc" });
Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode);
Assert.Equal("/connect/authorize?request_uri=urn%3Aabc", agreed.Headers.Location!.OriginalString);
using var foreign = await PostAsync(client, "accept", identity, new Dictionary<string, string> { ["version"] = TrainingNotice.Version.ToString(), ["returnUrl"] = "//evil.example/x" });
Assert.Equal(HttpStatusCode.SeeOther, foreign.StatusCode);
Assert.Equal("/App", foreign.Headers.Location!.OriginalString);
}
[Fact]
public async Task Agreement_to_the_current_version_shows_its_time_and_only_the_withdraw_button()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version, Agreed, null));
var html = await PageHtmlAsync(identity);
Assert.Contains($"You agreed to version {TrainingNotice.Version} on 2026-11-01T12:30:05Z", html);
Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html);
Assert.DoesNotContain("/consent/accept", html);
}
[Fact]
public async Task Agreement_to_an_older_version_says_it_no_longer_counts_and_offers_both_agree_and_withdraw()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version - 1, Agreed, null));
var html = await PageHtmlAsync(identity);
Assert.Contains($"You agreed to version {TrainingNotice.Version - 1} on 2026-11-01T12:30:05Z; version {TrainingNotice.Version} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it", html);
Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html);
Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html);
}
[Fact]
public async Task A_live_receipt_for_the_version_in_force_outranks_an_older_live_one()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version - 1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null));
var html = await PageHtmlAsync(identity);
Assert.Contains($"You agreed to version {TrainingNotice.Version} on 2026-11-02T12:30:05Z", html);
Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html);
}
[Fact]
public async Task A_withdrawal_shows_its_time_and_offers_agree()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version, Agreed, Withdrew));
var html = await PageHtmlAsync(identity);
Assert.Contains("You withdrew on 2026-11-02T08:00:09Z", html);
Assert.DoesNotContain("You agreed to version", html);
Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html);
Assert.DoesNotContain("/consent/withdraw", html);
}
[Fact]
public async Task Accept_creates_one_live_receipt_and_a_second_accept_adds_none()
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var first = await PostAsync(client, "accept", identity);
factory.Time.Advance(TimeSpan.FromHours(1));
using var second = await PostAsync(client, "accept", identity);
Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode);
Assert.Equal("/auth/bifrost/consent", first.Headers.Location!.OriginalString);
Assert.Equal(HttpStatusCode.SeeOther, second.StatusCode);
var receipt = Assert.Single(Receipts(identity));
Assert.Equal(TrainingNotice.Version, receipt.NoticeVersion);
Assert.Null(receipt.WithdrawnAtUtc);
Assert.Contains("You agreed to version", await PageHtmlAsync(identity));
}
[Fact]
public async Task Withdraw_stamps_once_and_a_second_withdraw_changes_nothing()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version, Agreed, null));
using var client = Client();
using var first = await PostAsync(client, "withdraw", identity);
var stamped = Assert.Single(Receipts(identity)).WithdrawnAtUtc;
factory.Time.Advance(TimeSpan.FromHours(1));
using var second = await PostAsync(client, "withdraw", identity);
Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode);
Assert.Equal(HttpStatusCode.SeeOther, second.StatusCode);
Assert.NotNull(stamped);
Assert.Equal(stamped, Assert.Single(Receipts(identity)).WithdrawnAtUtc);
}
[Fact]
public async Task Withdraw_stamps_every_live_receipt_including_an_older_version()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version - 1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null));
using var client = Client();
using var response = await PostAsync(client, "withdraw", identity);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.All(Receipts(identity), x => Assert.NotNull(x.WithdrawnAtUtc));
}
[Fact]
public async Task Withdraw_with_no_receipt_changes_nothing()
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var response = await PostAsync(client, "withdraw", identity);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Empty(Receipts(identity));
}
[Fact]
public async Task Accept_after_withdraw_makes_a_new_live_receipt_and_the_old_one_stays_withdrawn()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version, Agreed, Withdrew));
using var client = Client();
using var response = await PostAsync(client, "accept", identity);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
var receipts = Receipts(identity);
Assert.Equal(2, receipts.Count);
Assert.Single(receipts, x => x.WithdrawnAtUtc == Withdrew && x.AcceptedAtUtc == Agreed);
Assert.Single(receipts, x => x.WithdrawnAtUtc is null && x.NoticeVersion == TrainingNotice.Version);
}
[Fact]
public async Task Accept_next_to_an_older_live_receipt_adds_one_for_the_version_in_force()
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version - 1, Agreed, null));
using var client = Client();
using var response = await PostAsync(client, "accept", identity);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal([TrainingNotice.Version - 1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray());
}
[Theory]
[InlineData("0")]
[InlineData("2")]
[InlineData("3")]
[InlineData("abc")]
[InlineData("")]
public async Task Accept_with_the_wrong_version_is_refused_and_creates_nothing(string version)
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var response = await PostAsync(client, "accept", identity, new Dictionary<string, string> { ["version"] = version });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("role=\"alert\"", await response.Content.ReadAsStringAsync());
Assert.Empty(Receipts(identity));
}
[Fact]
public async Task Accept_with_no_version_field_is_refused_and_creates_nothing()
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var response = await PostAsync(client, "accept", identity, new Dictionary<string, string> { ["other"] = "x" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(Receipts(identity));
}
[Theory]
[InlineData("https://evil.example", null)]
[InlineData(null, "cross-site")]
[InlineData(null, "same-site")]
[InlineData(null, null)]
public async Task Accept_from_another_origin_or_with_no_fetch_metadata_is_forbidden_and_changes_nothing(string? origin, string? fetchSite)
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var response = await PostAsync(client, "accept", identity, origin: origin, fetchSite: fetchSite);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(Receipts(identity));
}
[Theory]
[InlineData("https://evil.example", null)]
[InlineData(null, "cross-site")]
[InlineData(null, "same-site")]
[InlineData(null, null)]
public async Task Withdraw_from_another_origin_or_with_no_fetch_metadata_is_forbidden_and_changes_nothing(string? origin, string? fetchSite)
{
var identity = NewIdentity();
Seed(identity, (TrainingNotice.Version, Agreed, null));
using var client = Client();
using var response = await PostAsync(client, "withdraw", identity, origin: origin, fetchSite: fetchSite);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(Assert.Single(Receipts(identity)).WithdrawnAtUtc);
}
[Fact]
public async Task Same_origin_by_fetch_metadata_alone_is_allowed()
{
var identity = NewIdentity();
Seed(identity);
using var client = Client();
using var response = await PostAsync(client, "accept", identity, origin: null, fetchSite: "same-origin");
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Single(Receipts(identity));
}
[Theory]
[InlineData("accept")]
[InlineData("withdraw")]
public async Task Anonymous_posts_are_unauthorized(string action)
{
using var client = Client();
using var response = await PostAsync(client, action, null);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task One_account_changing_its_consent_leaves_every_other_account_untouched()
{
var mine = NewIdentity();
var theirs = NewIdentity();
Seed(mine, (TrainingNotice.Version, Agreed, null));
Seed(theirs, (TrainingNotice.Version, Agreed, null));
using var client = Client();
(await PostAsync(client, "withdraw", mine)).Dispose();
(await PostAsync(client, "accept", mine)).Dispose();
Assert.Null(Assert.Single(Receipts(theirs)).WithdrawnAtUtc);
Assert.Equal(2, Receipts(mine).Count);
}
[Fact]
public async Task A_hostile_identity_is_not_echoed_unescaped_on_the_page()
{
// The page shows no account name at all, so a hostile display name cannot reach it.
var identity = NewIdentity();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = "<script>alert(1)</script>" });
db.SaveChanges();
}
var html = await PageHtmlAsync(identity);
Assert.DoesNotContain("<script", html);
Assert.DoesNotContain("alert(1)", html);
}
[Fact]
public async Task Registering_with_consent_then_withdrawing_and_agreeing_again_works_end_to_end()
{
using var client = Client();
var identity = NewIdentity();
using var registered = await RegistrationKit.RegisterAsync(client, identity, anonymous: true);
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
(await PostAsync(client, "withdraw", identity)).Dispose();
(await PostAsync(client, "accept", identity)).Dispose();
var receipts = Receipts(identity);
Assert.Equal(2, receipts.Count);
Assert.Single(receipts, x => x.WithdrawnAtUtc is null);
}
}

View file

@ -0,0 +1,295 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAnswerTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static async Task<(DeskWebApplicationFactory Factory, string Token)> ArrangeAsync()
{
var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
return (factory, factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
}
private static AnswerResult Stale(string forkId) => new(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, "Refused", null, null, "stale_fork", "oldhash", "no", null,
"2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
"00000001-0000-4000-8000-000000000001"),
null,
null);
[Fact]
public async Task Stale_fork_attaches_current_card_fresh_and_replayed()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
var target = DeskReplay.Target("answer", "gamecult-chat", null);
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "first card"));
var fresh = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
// The same stored answer is replayed after the card changed: nothing is stored for the card, so the reply carries the new one.
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "second card"));
var replayed = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, fresh.Status);
Assert.Equal("first card", (string?)JsonNode.Parse(fresh.Body)!["card"]!["title"]);
Assert.Equal(409, replayed.Status);
Assert.Equal("second card", (string?)JsonNode.Parse(replayed.Body)!["card"]!["title"]);
}
}
[Fact]
public async Task A_refusal_that_is_not_stale_carries_no_card_and_asks_no_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var closed = Stale(DeskReplay.ChatFork);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(closed with { Answer = closed.Answer with { Code = "fork_closed" } });
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, reply.Status);
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
Assert.DoesNotContain(factory.Door.Calls, c => c.Kind == "read");
}
}
[Fact]
public async Task A_stale_answers_card_read_of_an_oversize_fork_is_422_fork_too_large_naming_the_fork()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
var doc = JsonNode.Parse(reply.Body)!;
Assert.Equal(422, reply.Status);
Assert.Equal("fork_too_large", (string?)doc["code"]);
Assert.Contains(DeskReplay.ChatFork, (string)doc["error"]!);
Assert.Null(doc["card"]);
}
}
[Fact]
public async Task A_replayed_request_id_that_is_stale_again_takes_the_same_reread_and_gets_the_same_422()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
// The door's stored Refused(stale_fork) for this request_id, returned on every replay while the fork stays oversize.
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var body = DeskReplay.AnswerBody(DeskReplay.ChatFork);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
var first = await factory.SendAsync("POST", target, body, token);
var replay = await factory.SendAsync("POST", target, body, token);
Assert.Equal(422, first.Status);
Assert.Equal(first.Status, replay.Status);
Assert.Equal(first.Body, replay.Body);
Assert.Equal("fork_too_large", (string?)JsonNode.Parse(replay.Body)!["code"]);
Assert.Contains(DeskReplay.ChatFork, (string)JsonNode.Parse(replay.Body)!["error"]!);
Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "answer"));
Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "read"));
}
}
[Fact]
public async Task A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed()
{
var rows = DeskVectors.Rows("transport", "answerTooLarge").ToList();
Assert.Single(rows);
foreach (var row in rows)
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var request = row["request"]!;
var forkId = (string)row["fork_id"]!;
Assert.Equal(forkId, (string)request["fork_id"]!);
var stale = new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, (string)row["outcome"]!, null, null, (string)row["code"]!,
(string)request["options_hash"]!, (string?)request["answer"]!["choice"], (string?)request["answer"]!["note"],
(string)request["answered_at"]!, "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
(string)request["request_id"]!),
null,
null);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale);
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var body = DeskVectors.Bytes(request);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
var fresh = await factory.SendAsync("POST", target, body, token);
var replay = await factory.SendAsync("POST", target, body, token);
foreach (var reply in new[] { fresh, replay })
{
Assert.Equal((int)row["httpStatus"]!, reply.Status);
var doc = JsonNode.Parse(reply.Body);
Assert.True(DeskVectors.SameRefusal(doc, row["refusal"], forkId, null), (string)row["name"]!);
Assert.Null(doc!["card"]);
}
Assert.Equal(fresh.Body, replay.Body);
// The door got the client's bytes untouched, stored nothing counted, and was asked for the card twice.
Assert.All(factory.Door.Calls.Where(c => c.Kind == "answer"), c => Assert.Equal(body, c.Body));
Assert.Equal(forkId, factory.Door.Calls.First(c => c.Kind == "read").ForkId);
Assert.False((bool)row["counted"]!);
Assert.NotEqual("Counted", stale.Answer.Outcome);
}
}
}
[Fact]
public async Task Card_read_failure_is_unavailable()
{
foreach (var failure in new[] { "throws", "refuses" })
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ThrowOnRead = failure == "throws";
// "refuses": no fork held, so the read answers fork_unknown
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(503 == reply.Status, failure);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
}
[Fact]
public async Task The_body_reaches_the_door_byte_for_byte()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
// Whitespace, escapes and key spacing a re-serialiser would normalise.
var compact = Encoding.UTF8.GetString(DeskReplay.AnswerBody(DeskReplay.ChatFork));
var odd = compact.Replace("{", "{ \n\t", StringComparison.Ordinal).Replace(",\"", " ,\r\n \"", StringComparison.Ordinal)
.Replace("Keep", "\\u004beep", StringComparison.Ordinal) + " \n";
var bytes = Encoding.UTF8.GetBytes(odd);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), bytes, token);
Assert.Equal(200, reply.Status);
Assert.Equal(bytes, Assert.Single(factory.Door.Calls).Body);
}
}
[Fact]
public async Task The_door_context_comes_from_the_account_and_the_configuration_never_the_request()
{
var (factory, _) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Steward, true);
var token = factory.Token(DeskWebApplicationFactory.Steward.ToUpperInvariant(), "bifrost-desk", "desk.answer");
// Query strings and headers that name a decider or an answerer change nothing.
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "nibu", null) + "?deciders=" + DeskWebApplicationFactory.Member + "&answerer=x",
DeskReplay.AnswerBody("nibu:question:glow-order"), token,
configure: http => http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member);
Assert.Equal(200, reply.Status);
var call = Assert.Single(factory.Door.Calls);
Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject);
Assert.Equal("Steward", call.Context.Authority);
Assert.Equal("nibu", call.Context.Mind);
Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders);
}
}
[Fact]
public async Task Body_is_judged_after_membership_and_before_the_mind()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
// A bad body for an unknown Mind is the body's refusal; a non-Active member's bad body is forbidden.
var bad = Encoding.UTF8.GetBytes("{\"answerer\":\"x\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("body_forbidden_field", (string?)JsonNode.Parse(reply.Body)!["code"]);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
var forbidden = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("forbidden", (string?)JsonNode.Parse(forbidden.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task Membership_is_re_read_on_every_request()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
Assert.Equal(200, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
Assert.Equal(403, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
}
}
[Fact]
public async Task An_oversized_body_is_refused_before_it_is_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var big = Encoding.UTF8.GetBytes("{\"x\":\"" + new string('a', 17 * 1024) + "\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
// Chunked: no Content-Length to judge, so the cap applies while reading.
var chunked = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token,
configure: http => http.Request.ContentLength = null);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(chunked.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task A_door_that_sends_a_code_the_contract_does_not_name_is_unavailable()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
foreach (var script in new Func<DeskAnswerOutcome>[]
{
() => new DeskAnswerOutcome.NotStored("something_internal"),
() => new DeskAnswerOutcome.NotStored("forbidden"),
() => new DeskAnswerOutcome.Answered(Stale("x") with { Answer = Stale("x").Answer with { Code = "made_up" } }),
})
{
factory.Door.AnswerScript = (_, _, _) => script();
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(503, reply.Status);
}
}
}
}

View file

@ -0,0 +1,49 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAuthCaseTests
{
[Fact]
public async Task Vector_auth_cases()
{
var cases = DeskVectors.Rows("transport", "authCases").ToList();
Assert.Equal(22, cases.Count);
foreach (var row in cases)
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
factory.Door.Down = (bool)row["huginn_down"]!;
var token = await DeskReplay.TokenAsync(factory, row["token"]);
var route = (string)row["route"]!;
var reply = await factory.SendAsync(
route == "answer" ? "POST" : "GET", DeskReplay.Target(route, "gamecult-chat", DeskReplay.ChatFork),
route == "answer" ? DeskReplay.AnswerBody(DeskReplay.ChatFork) : null, token);
var name = (string)row["name"]!;
var expect = (string)row["expect"]!;
var body = JsonNode.Parse(reply.Body);
switch (expect)
{
case "ok":
Assert.True(200 == reply.Status, $"{name}: {reply.Status} {reply.Text}");
Assert.Single(factory.Door.Calls);
break;
case "unavailable":
Assert.True(503 == reply.Status, $"{name}: {reply.Status}");
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskUnavailables.Document(30), DeskJson.Options))), name);
break;
default:
Assert.True(DeskRefusals.StatusOf(expect) == reply.Status, $"{name}: {reply.Status}");
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))), name);
// Auth, scope and membership are judged before Huginn is asked.
Assert.True(factory.Door.Calls.Count == 0, $"{name}: the door was called");
break;
}
}
}
}

View file

@ -0,0 +1,267 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskBodyShapeTests
{
private const int Cap = 16 * 1024;
private static DeskBodyVerdict Judge(JsonNode body, int cap = Cap) => DeskBodyShape.Judge(DeskVectors.Bytes(body), cap);
private static DeskBodyVerdict Judge(string json, int cap = Cap) => DeskBodyShape.Judge(Encoding.UTF8.GetBytes(json), cap);
private static JsonObject ValidBody() =>
DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == "choice_and_note")["message"]!.DeepClone().AsObject();
private static string ExpectedCode(string name) => name switch
{
"answerer_key" or "authority_key" => DeskRefusals.BodyForbiddenField,
"answer_object_with_a_key" => DeskRefusals.BodyUnknownField,
_ => DeskRefusals.BodyInvalid,
};
public static IEnumerable<object[]> ValidRows() =>
DeskVectors.Rows("request", "valid").Select(row => new object[] { (string)row["name"]! });
public static IEnumerable<object[]> InvalidRows() =>
DeskVectors.Rows("request", "invalid").Select(row => new object[] { (string)row["name"]! });
[Theory]
[MemberData(nameof(ValidRows))]
public void Valid_request_vectors_pass(string name)
{
var message = DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == name)["message"]!;
var verdict = Judge(message);
Assert.Null(verdict.RefusalCode);
Assert.NotNull(verdict.Request);
// The parsed request is the document: same keys, same order, same values.
var echoed = JsonSerializer.SerializeToNode(verdict.Request, DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, echoed), $"{name} did not round-trip: {echoed}");
}
[Theory]
[MemberData(nameof(InvalidRows))]
public void Invalid_request_vectors_refuse_with_their_code(string name)
{
var message = DeskVectors.Rows("request", "invalid").First(row => (string)row["name"]! == name)["message"]!;
var verdict = Judge(message);
Assert.Null(verdict.Request);
Assert.Equal(ExpectedCode(name), verdict.RefusalCode);
}
[Fact]
public void Forbidden_key_at_any_depth_beats_unknown_key()
{
foreach (var key in new[] { "answerer", "authority" })
{
var nested = ValidBody();
nested["answer"]!["detail"] = new JsonObject { ["deep"] = new JsonArray(new JsonObject { [key] = "x" }) };
nested["extra"] = 1;
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(nested).RefusalCode);
var top = ValidBody();
top["unknown"] = 1;
top[key] = "someone";
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(top).RefusalCode);
// Written with an escape: the name is the same once decoded, and still refused whole.
var escaped = DeskVectors.Bytes(ValidBody()).ToArray();
var text = Encoding.UTF8.GetString(escaped).TrimEnd('}') + $",\"{key[..3]}\\u{(int)key[3]:x4}{key[4..]}\":\"x\"}}";
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(text).RefusalCode);
}
// The key name is matched exactly: a near miss is only an unknown key.
var near = ValidBody();
near["Answerer"] = "x";
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(near).RefusalCode);
}
[Fact]
public void Mind_and_deciders_in_the_body_are_unknown_fields()
{
foreach (var key in new[] { "mind", "deciders" })
{
var body = ValidBody();
body[key] = "x";
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(body).RefusalCode);
}
}
[Fact]
public void Illegal_retraction_forms_reach_the_door()
{
var forms = new (string Answer, string Retracted, string Retracts)[]
{
("null", "true", "null"), // a retraction with no target
("{\"choice\":\"no\",\"note\":null}", "true", "\"n1\""), // a retraction carrying an answer
("{\"choice\":\"no\",\"note\":null}", "false", "\"n1\""), // a plain answer with a target
("null", "true", "\"n1\""), // the legal retraction
};
foreach (var (answer, retracted, retracts) in forms)
{
var body = ValidBody();
body["answer"] = JsonNode.Parse(answer);
body["retracted"] = JsonNode.Parse(retracted);
body["retracts"] = JsonNode.Parse(retracts);
var verdict = Judge(body);
Assert.True(verdict.RefusalCode is null, $"answer={answer} retracted={retracted} retracts={retracts}: {verdict.RefusalCode}");
Assert.NotNull(verdict.Request);
}
}
[Fact]
public void Neither_answer_nor_retraction_is_body_invalid()
{
foreach (var retracts in new[] { "null", "\"n1\"" })
{
var body = ValidBody();
body["answer"] = null;
body["retracted"] = false;
body["retracts"] = JsonNode.Parse(retracts);
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Note_limits_are_utf8_bytes()
{
string Note(string text) => Judge(WithNote(text)).RefusalCode ?? "pass";
string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count));
var astral = char.ConvertFromUtf32(0x1F600); // 4 bytes
var combining = "e" + (char)0x0301; // 3 bytes
var twoByte = ((char)0xe9).ToString(); // 2 bytes
var spaces = new string(new[] { (char)9, (char)0xa0, (char)0x3000, (char)0x2028 });
Assert.Equal("pass", Note(new string('x', 4000)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(new string('x', 4001)));
Assert.Equal("pass", Note(Repeat(astral, 1000)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(astral, 1001)));
Assert.Equal("pass", Note(Repeat(combining, 1333) + "x"));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(combining, 1334)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(twoByte, 1999) + "xxx"));
Assert.Equal("pass", Note("x"));
Assert.Equal(DeskRefusals.BodyInvalid, Note(""));
Assert.Equal(DeskRefusals.BodyInvalid, Note(spaces));
Assert.Equal("pass", Note(" a "));
static JsonNode WithNote(string text)
{
var body = ValidBody();
body["answer"] = new JsonObject { ["choice"] = null, ["note"] = text };
return body;
}
}
[Fact]
public void Lone_surrogate_in_a_note_is_body_invalid()
{
var json = DeskVectors.Bytes(ValidBody()).ToArray();
var text = Encoding.UTF8.GetString(json).Replace("\"note\":\"", "\"note\":\"\\ud800");
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
}
[Fact]
public void Over_cap_refused_before_parse()
{
var body = ValidBody();
var bytes = DeskVectors.Bytes(body);
Assert.Null(DeskBodyShape.Judge(bytes, bytes.Length).RefusalCode);
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bytes, bytes.Length - 1).RefusalCode);
// Over the cap the forbidden key is never found, because the body is never parsed.
body["answerer"] = "x";
var forbidden = DeskVectors.Bytes(body);
Assert.Equal(DeskRefusals.BodyForbiddenField, DeskBodyShape.Judge(forbidden, forbidden.Length).RefusalCode);
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(forbidden, forbidden.Length - 1).RefusalCode);
}
[Theory]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("null")]
[InlineData("\"text\"")]
[InlineData("{")]
public void Non_object_or_broken_json_is_body_invalid(string json) =>
Assert.Equal(DeskRefusals.BodyInvalid, Judge(json).RefusalCode);
[Fact]
public void Invalid_utf8_is_body_invalid()
{
var bytes = DeskVectors.Bytes(ValidBody());
var bad = bytes.Take(bytes.Length - 2).Concat(new byte[] { 0xff }).Concat(bytes.Skip(bytes.Length - 2)).ToArray();
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bad, Cap).RefusalCode);
}
[Fact]
public void Repeated_key_is_body_invalid()
{
var text = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).TrimEnd('}') + ",\"fork_id\":\"other\"}";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
var answer = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).Replace("\"choice\":\"no\"", "\"choice\":\"no\",\"choice\":\"yes\"");
Assert.Equal(DeskRefusals.BodyInvalid, Judge(answer).RefusalCode);
}
[Fact]
public void Trailing_newline_does_not_rescue_a_bad_pattern()
{
var body = ValidBody();
body["request_id"] = ((string)body["request_id"]!) + "\n";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
body = ValidBody();
body["options_hash"] = ((string)body["options_hash"]!) + "\n";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
[Fact]
public void Wrong_schema_version_and_bad_retracts_are_body_invalid()
{
var body = ValidBody();
body["schemaVersion"] = "gamecult.fork.answer_request.v1";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
foreach (var retracts in new[] { "n0", "n01", "x1", "n" })
{
body = ValidBody();
body["answer"] = null;
body["retracted"] = true;
body["retracts"] = retracts;
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Missing_key_is_body_invalid_for_each_key()
{
foreach (var key in ValidBody().Select(p => p.Key).ToList())
{
var body = ValidBody();
body.Remove(key);
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Answer_object_must_hold_both_keys()
{
var body = ValidBody();
body["answer"] = new JsonObject { ["choice"] = "no" };
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}

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

@ -0,0 +1,94 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskDocumentTests
{
private static JsonNode? Write<T>(T document) => JsonSerializer.SerializeToNode(document, DeskJson.Options);
[Fact]
public void Refusal_and_unavailable_documents_match_vectors()
{
var rows = DeskVectors.Rows("doorRefusals").ToList();
Assert.Equal(10, rows.Count);
foreach (var row in rows)
{
var code = (string)row["message"]!["code"]!;
if (code == DeskRefusals.ForkTooLarge)
{
// It names its fork, so it is built, not looked up; the row is the list form at order 3.
Assert.True(DeskVectors.SameRefusal(Write(DeskRefusals.ForkTooLargeDocument("gamecult-chat:question:q3", 3)), row["message"], "gamecult-chat:question:q3", 3), $"refusal {code}");
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.ForkTooLargeStatus);
continue;
}
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}");
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code));
}
var unavailable = DeskVectors.Rows("unavailable").ToList();
Assert.Equal(2, unavailable.Count);
foreach (var row in unavailable)
{
var retry = (int?)row["message"]!["retryAfterSeconds"];
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskUnavailables.Document(retry))), (string)row["name"]!);
Assert.Equal(DeskUnavailables.Status, (int)row["httpStatus"]!);
}
}
[Fact]
public void Every_refusal_code_in_the_schema_has_a_document()
{
var codes = DeskVectors.Rows("doorRefusals").Select(row => (string)row["message"]!["code"]!).ToHashSet();
var written = typeof(DeskRefusals).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToHashSet();
Assert.Equal(codes, written);
}
[Fact]
public void Answer_results_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("results").ToList();
Assert.Equal(6, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
Assert.True(DeskVectors.SameShape(message, Write(result)), (string)row["name"]!);
}
}
[Fact]
public void Cards_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
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

@ -0,0 +1,327 @@
using System.Text;
using System.Text.Json.Nodes;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
// The rules the desk's edge keeps that a happy path never shows: whose decider list rides each call, the body cap
// to the byte, what the Retry-After comes from, the audience's case, no-store, and what a failing port becomes.
public sealed class DeskHardeningTests
{
private const string Nibu = "nibu";
private const string Chat = "gamecult-chat";
private const string NibuFork = "nibu:question:glow-order";
private static async Task<DeskWebApplicationFactory> ArrangeAsync(Dictionary<string, string?>? configuration = null)
{
var factory = new DeskWebApplicationFactory(configuration);
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
return factory;
}
private static string Token(DeskWebApplicationFactory factory, string audience = "bifrost-desk") =>
factory.Token(DeskWebApplicationFactory.Alice, audience, "desk.read", "desk.answer");
private static Task<DeskReply> SendRouteAsync(DeskWebApplicationFactory factory, string route, string mind, string token)
{
var fork = mind == Nibu ? NibuFork : DeskReplay.ChatFork;
return route switch
{
"answer" => factory.SendAsync("POST", DeskReplay.Target("answer", mind, null), DeskReplay.AnswerBody(fork), token),
"read" => factory.SendAsync("GET", DeskReplay.Target("read", mind, fork), null, token),
_ => factory.SendAsync("GET", DeskReplay.Target("list", mind, null), null, token),
};
}
private static readonly string[] Routes = ["answer", "read", "list"];
private static string Code(DeskReply reply) => (string)JsonNode.Parse(reply.Body)!["code"]!;
// (a) Each Mind's list is its own: a context built from another Mind's list changes the call.
[Fact]
public async Task Each_call_carries_the_route_minds_own_decider_list_and_envelope()
{
using var factory = await ArrangeAsync();
Assert.NotEqual(DeskWebApplicationFactory.DecidersOf(Chat), DeskWebApplicationFactory.DecidersOf(Nibu));
var token = Token(factory);
foreach (var mind in new[] { Chat, Nibu })
{
foreach (var route in Routes)
{
factory.Door.Calls.Clear();
var reply = await SendRouteAsync(factory, route, mind, token);
Assert.True(reply.Status == 200, $"{mind} {route}: {reply.Status}");
var call = Assert.Single(factory.Door.Calls);
Assert.True(DeskWebApplicationFactory.DecidersOf(mind).SequenceEqual(call.Context.Deciders), $"{mind} {route}: deciders");
Assert.Equal(mind, call.Context.Mind);
Assert.Equal(DeskWebApplicationFactory.SocketOf(mind), call.Socket);
Assert.Equal(DeskWebApplicationFactory.Alice, call.Context.Subject);
Assert.Equal("Operator", call.Context.Authority);
}
}
}
// (b) The cap to the byte, at the HTTP edge, however the length is announced.
[Theory]
[InlineData(0)]
[InlineData(4096)]
[InlineData(4097)]
[InlineData(8192)]
public async Task A_body_of_exactly_the_cap_reaches_the_shape_judge_and_one_more_byte_does_not(int cap)
{
var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork);
cap = Math.Max(cap, bare.Length);
var config = DeskWebApplicationFactory.DefaultConfiguration();
config["Desk:MaxBodyBytes"] = cap.ToString(System.Globalization.CultureInfo.InvariantCulture);
using var factory = await ArrangeAsync(config);
var token = Token(factory);
var target = DeskReplay.Target("answer", Chat, null);
byte[] Padded(int length) => [.. bare, .. Enumerable.Repeat((byte)' ', length - bare.Length)];
var modes = new (string Name, Action<HttpContext> Configure)[]
{
("content-length", _ => { }),
("no content-length", http => http.Request.ContentLength = null),
("chunked", http =>
{
http.Request.ContentLength = null;
http.Request.Headers.TransferEncoding = "chunked";
}),
};
foreach (var (name, configure) in modes)
{
factory.Door.Calls.Clear();
var exact = await factory.SendAsync("POST", target, Padded(cap), token, configure: configure);
Assert.True(exact.Status == 200, $"{name} exact: {exact.Status} {exact.Text}");
Assert.Equal(cap, Assert.Single(factory.Door.Calls).Body!.Length);
factory.Door.Calls.Clear();
var over = await factory.SendAsync("POST", target, Padded(cap + 1), token, configure: configure);
Assert.True(over.Status == 400 && Code(over) == "body_invalid", $"{name} over: {over.Status} {over.Text}");
Assert.Empty(factory.Door.Calls);
}
}
// A declared length over the cap is refused on the header alone, before any byte is read.
[Fact]
public async Task A_declared_length_over_the_cap_is_refused_before_reading()
{
var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork);
var config = DeskWebApplicationFactory.DefaultConfiguration();
config["Desk:MaxBodyBytes"] = bare.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
using var factory = await ArrangeAsync(config);
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", Chat, null), bare, Token(factory),
configure: http => http.Request.ContentLength = bare.Length + 1);
Assert.Equal("body_invalid", Code(reply));
Assert.Empty(factory.Door.Calls);
}
// (c) The wait an `unavailable` names is the configured one, in the document and in the header.
[Fact]
public async Task Retry_after_comes_from_configuration_in_the_document_and_the_header()
{
var config = DeskWebApplicationFactory.DefaultConfiguration();
config["Desk:RetryAfterSeconds"] = "7";
using var factory = await ArrangeAsync(config);
factory.Door.Down = true;
var token = Token(factory);
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, token);
Assert.True(reply.Status == 503, route);
Assert.Equal(7, (int)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]!);
Assert.Equal("7", reply.Headers.RetryAfter.ToString());
}
}
// (d) The audience is compared ordinally.
[Theory]
[InlineData("BIFROST-DESK")]
[InlineData("Bifrost-Desk")]
[InlineData("bifrost-desk ")]
[InlineData("bifrost-votes")]
public async Task The_audience_is_case_sensitive(string audience)
{
using var factory = await ArrangeAsync();
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory, audience));
Assert.True(reply.Status == 401 && Code(reply) == "unauthenticated", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// (e) No desk response is cacheable: answers, refusals, unavailable and an unrecognised target alike.
[Fact]
public async Task Every_desk_response_is_no_store()
{
using var factory = await ArrangeAsync();
var token = Token(factory);
var replies = new List<(string, DeskReply)>();
foreach (var route in Routes)
{
replies.Add((route + " 200", await SendRouteAsync(factory, route, Chat, token)));
replies.Add((route + " 401", await SendRouteAsync(factory, route, Chat, "")));
replies.Add((route + " 404", await SendRouteAsync(factory, route, "ghost", token)));
}
factory.Door.Down = true;
replies.Add(("503", await SendRouteAsync(factory, "list", Chat, token)));
replies.Add(("unrecognised", await factory.SendAsync("GET", "/api/desk/x", null, token)));
foreach (var (label, reply) in replies)
{
Assert.True(reply.Headers.CacheControl.ToString() == "no-store", $"{label}: '{reply.Headers.CacheControl}'");
}
Assert.Equal(200, replies[0].Item2.Status);
Assert.Equal(503, replies[^2].Item2.Status);
}
// (f) A cancelled request is not `unavailable`: the exception goes on to the host.
[Fact]
public async Task A_cancelled_request_is_not_called_unavailable()
{
using var factory = await ArrangeAsync();
using var cts = new CancellationTokenSource();
factory.Door.AnswerScript = (_, _, _) =>
{
cts.Cancel();
throw new IOException("the client went away");
};
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", Chat, null), DeskReplay.AnswerBody(DeskReplay.ChatFork), Token(factory),
configure: http => http.RequestAborted = cts.Token);
// The host's exception handling answers; the desk does not claim the door was merely unavailable.
Assert.NotEqual(503, reply.Status);
Assert.DoesNotContain("unavailable", reply.Text);
}
// (f) A stale_fork needs the current card; a card read that finds nothing, for any code, is `unavailable`.
[Theory]
[InlineData("fork_unknown")]
[InlineData("request_invalid")]
[InlineData("made_up")]
public async Task A_stale_fork_whose_card_read_finds_nothing_is_unavailable_never_a_stale_fork_without_a_card(string code)
{
using var factory = await ArrangeAsync();
var stale = new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Refused", null, null, "stale_fork", "oldhash", "no", null,
"2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
"00000001-0000-4000-8000-000000000001"),
null,
null);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale);
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
var reply = await SendRouteAsync(factory, "answer", Chat, Token(factory));
Assert.Equal(503, reply.Status);
Assert.Equal("unavailable", Code(reply));
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
Assert.Contains(factory.Door.Calls, c => c.Kind == "read");
}
// (h) A port that fails is `unavailable`, never a 500 and never a pass.
[Fact]
public async Task A_throwing_credential_port_is_unavailable_and_opens_nothing()
{
using var factory = await ArrangeAsync();
factory.Credential.Throws = true;
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_throwing_account_lookup_is_unavailable_and_opens_nothing()
{
using var factory = await ArrangeAsync();
// The request's database context is gone by the time the desk looks the account up (the database failed).
factory.Credential.OnAuthenticate = http => http.RequestServices.GetRequiredService<Bifrost.Web.Data.BifrostDbContext>().Dispose();
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// The runtime guard on the decider list (the startup validator is the first layer; this is the second).
private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D");
private static void SetDeciders(DeskWebApplicationFactory factory, string mind, IEnumerable<string> ids)
{
var list = factory.Services.GetRequiredService<IOptions<DeskOptions>>().Value.Minds.Single(m => m.Id == mind).Deciders;
list.Clear();
list.AddRange(ids);
}
[Fact]
public async Task The_door_is_called_with_64_deciders()
{
using var factory = await ArrangeAsync();
var ids = Enumerable.Range(1, 64).Select(Id).ToList();
SetDeciders(factory, Chat, ids);
foreach (var route in Routes)
{
factory.Door.Calls.Clear();
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 200, route);
Assert.Equal(ids, Assert.Single(factory.Door.Calls).Context.Deciders);
}
}
[Theory]
[InlineData("65")]
[InlineData("0")]
[InlineData("duplicate")]
public async Task A_decider_list_out_of_bounds_is_unavailable_without_a_door_call(string fault)
{
using var factory = await ArrangeAsync();
var ids = fault switch
{
"65" => Enumerable.Range(1, 65).Select(Id).ToList(),
"0" => [],
_ => [Id(1), Id(2), Id(1)],
};
SetDeciders(factory, Chat, ids);
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
// Only that Mind's list is at fault: the other Mind still answers.
var other = await SendRouteAsync(factory, "list", Nibu, Token(factory));
Assert.Equal(200, other.Status);
}
}

View file

@ -0,0 +1,91 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskMindRouteTests
{
private static async Task<(DeskReply Reply, DeskWebApplicationFactory Factory)> RunAsync(JsonObject row)
{
var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
var token = await DeskReplay.TokenAsync(factory, row["token"]);
var route = (string)row["route"]!;
var forkId = (string?)row["fork_id"];
var reply = await factory.SendAsync(
route == "answer" ? "POST" : "GET",
DeskReplay.Target(route, (string)row["mind"]!, forkId),
route == "answer" ? DeskReplay.AnswerBody(forkId!) : null,
token);
return (reply, factory);
}
[Fact]
public async Task Vector_route_cases()
{
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
Assert.Equal(57, cases.Count);
foreach (var row in cases)
{
var name = (string)row["name"]!;
var (reply, factory) = await RunAsync(row);
using (factory)
{
Assert.True((int)row["httpStatus"]! == reply.Status, $"{name}: {reply.Status} {reply.Text}");
var expect = (string)row["expect"]!;
if (expect != "ok")
{
Assert.True(
DeskVectors.SameShape(JsonNode.Parse(reply.Body), JsonNode.Parse(JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))),
name);
}
var opened = (string?)row["opened"];
if (opened is null)
{
Assert.True(factory.Door.Calls.Count == 0, $"{name}: opened {factory.Door.Calls.FirstOrDefault()?.Socket}");
}
else
{
var call = Assert.Single(factory.Door.Calls);
Assert.True(call.Socket.EndsWith("/" + opened, StringComparison.Ordinal), $"{name}: {call.Socket}");
}
var storedIn = (string?)row["stored_in"];
if (storedIn is null)
{
Assert.Empty(factory.Door.StoredIn);
}
else
{
Assert.Equal([DeskWebApplicationFactory.SocketOf(storedIn)], factory.Door.StoredIn);
}
}
}
}
[Fact]
public async Task Same_response_pairs_are_byte_equal()
{
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToDictionary(r => (string)r["name"]!);
var pairs = DeskVectors.Array("transport", "mindRoutes", "sameResponse");
Assert.Equal(4, pairs.Count);
foreach (var pair in pairs)
{
var first = cases[(string)pair![0]!];
var second = cases[(string)pair[1]!];
var (a, fa) = await RunAsync(first);
var (b, fb) = await RunAsync(second);
using (fa)
using (fb)
{
Assert.Equal(a.Status, b.Status);
Assert.Equal(a.Body, b.Body);
}
}
}
}

View file

@ -0,0 +1,125 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskMindRoutesTests
{
private static DeskMindRoutes Routes()
{
var minds = DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject();
return new DeskMindRoutes(minds.Select(m => new DeskMindOptions { Id = m.Key, Socket = "/run/huginn/" + (string)m.Value! }));
}
[Fact]
public void Every_vector_segment_resolves_or_misses()
{
var routes = Routes();
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
Assert.Equal(57, cases.Count);
foreach (var row in cases)
{
var mind = (string)row["mind"]!;
var opened = (string?)row["opened"];
var resolved = routes.Resolve(mind);
// A route that opens a socket resolves to the Mind whose socket it is; every other route is a miss.
// Auth-refused cases never reach the lookup, so only the cases that reach it are judged here.
var expect = (string)row["expect"]!;
if (expect is not ("ok" or "fork_unknown" or "unavailable"))
{
continue;
}
if (opened is null)
{
Assert.True(resolved is null, $"{row["name"]}: '{Short(mind)}' resolved to '{resolved?.Id}'");
}
else
{
Assert.True(resolved is not null, $"{row["name"]}: '{Short(mind)}' missed");
Assert.EndsWith(opened, resolved!.Socket, StringComparison.Ordinal);
}
}
}
[Fact]
public void Segments_that_do_not_name_a_configured_mind_miss()
{
var routes = Routes();
foreach (var mind in new[]
{
"ghost", ".", "..", "../x", "a/b", "a%2fb", "%2e%2e%2fx", "eureka%00", "nibu%20", "nibu ", " nibu",
"%ff", "%c3%28", "%", "%6", "%zz", "nibu%", "%256eibu", "", new string('a', 4096), "nïbu",
})
{
Assert.Null(routes.Resolve(mind));
}
}
[Fact]
public void Percent_encoded_configured_ids_are_decoded_once()
{
var routes = Routes();
Assert.Equal("nibu", routes.Resolve("%6eibu")!.Id);
Assert.Equal("nibu", routes.Resolve("%6Eibu")!.Id);
Assert.Equal("gamecult-chat", routes.Resolve("gamecult%2Dchat")!.Id);
}
[Fact]
public void Other_case_misses_despite_config_case_insensitivity()
{
var routes = Routes();
Assert.Null(routes.Resolve("GAMECULT-CHAT"));
Assert.Null(routes.Resolve("Nibu"));
Assert.Null(routes.Resolve("gamecult-chat%20"));
Assert.NotNull(routes.Resolve("gamecult-chat"));
}
[Fact]
public void Double_encoded_misses()
{
var routes = Routes();
Assert.Null(routes.Resolve("%256eibu"));
Assert.Null(routes.Resolve("gamecult%252Dchat"));
}
[Fact]
public void Configured_ids_that_differ_only_by_case_stay_distinct()
{
var routes = new DeskMindRoutes([
new DeskMindOptions { Id = "a", Socket = "/s/a" },
new DeskMindOptions { Id = "A", Socket = "/s/A" },
]);
Assert.Equal("/s/a", routes.Resolve("a")!.Socket);
Assert.Equal("/s/A", routes.Resolve("A")!.Socket);
}
[Fact]
public void A_decoding_failure_is_a_miss_even_when_the_failure_spells_a_configured_id()
{
var replacement = char.ConvertFromUtf32(0xFFFD);
var e = char.ConvertFromUtf32(0xE9);
var routes = new DeskMindRoutes([
new DeskMindOptions { Id = replacement, Socket = "/s/replacement" },
new DeskMindOptions { Id = e, Socket = "/s/e-acute" },
new DeskMindOptions { Id = "a%zz", Socket = "/s/bad-escape" },
new DeskMindOptions { Id = "100%", Socket = "/s/trailing" },
]);
Assert.Null(routes.Resolve("%ff")); // not UTF-8, not U+FFFD
Assert.Null(routes.Resolve(char.ConvertFromUtf32(0xC3) + char.ConvertFromUtf32(0xA9))); // UTF-8 bytes as raw chars
Assert.Null(routes.Resolve("a%zz"));
Assert.Null(routes.Resolve("100%"));
Assert.Equal("/s/replacement", routes.Resolve("%ef%bf%bd")!.Socket); // the real encoding still resolves
Assert.Equal("/s/e-acute", routes.Resolve("%c3%a9")!.Socket);
}
private static string Short(string value) => value.Length > 40 ? value[..40] + "..." : value;
}

View file

@ -0,0 +1,245 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class DeskOptionsTests
{
private const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
private const string Bob = "00000000-0000-4000-8000-000000000002";
private static Dictionary<string, string?> Good() => new()
{
["Desk:AllowedOrigin"] = "https://chat.gamecult.org",
["Desk:Minds:0:Id"] = "gamecult-chat",
["Desk:Minds:0:Socket"] = "/run/huginn/huginn-gamecult-chat.sock",
["Desk:Minds:0:Deciders:0"] = Alice,
["Desk:Minds:1:Id"] = "nibu",
["Desk:Minds:1:Socket"] = "/run/huginn/huginn-nibu.sock",
["Desk:Minds:1:Deciders:0"] = Bob,
["Desk:Attribution:Operator"] = Alice,
["Desk:Attribution:Stewards:0"] = Bob,
};
private static DeskOptions Bind(Dictionary<string, string?> values)
{
var options = new DeskOptions();
new ConfigurationBuilder().AddInMemoryCollection(values).Build().GetSection(DeskOptions.SectionName).Bind(options);
return options;
}
private static void Validate(Dictionary<string, string?> values, string environment = "Testing", bool oidc = true)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
new StartupConfigurationValidator(
configuration,
new StubEnvironment(environment),
Options.Create(new GitHubOAuthOptions()),
Options.Create(new GitHubAppOptions()),
Options.Create(new HeimdallOptions()),
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions()),
Options.Create(new ConceptVoteOptions()),
Options.Create(Bind(values)),
Options.Create(new RegistrationOptions()),
oidc ? new OidcIssuerOptions() : null).Validate();
}
private static void Refused(Dictionary<string, string?> values, string expectedFragment)
{
var failure = Assert.Throws<InvalidOperationException>(() => Validate(values));
Assert.Contains(expectedFragment, failure.Message);
}
[Fact]
public void A_good_desk_configuration_binds_by_position_and_passes()
{
var options = Bind(Good());
Assert.Equal(["gamecult-chat", "nibu"], options.Minds.Select(m => m.Id));
Assert.Equal([Alice], options.Minds[0].Deciders);
Assert.Equal([Bob], options.Minds[1].Deciders);
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()
{
Assert.False(Bind([]).IsConfigured);
Validate([]);
}
[Fact]
public void Mind_ids_keep_the_case_the_configuration_gave_them()
{
var values = Good();
values["Desk:Minds:0:Id"] = "GameCult-Chat";
Assert.Equal("GameCult-Chat", Bind(values).Minds[0].Id);
}
[Fact]
public void Startup_refuses_duplicate_ids() => Refused(Dup(), "Desk:Minds:1:Id repeats");
private static Dictionary<string, string?> Dup()
{
var values = Good();
values["Desk:Minds:1:Id"] = "gamecult-chat";
return values;
}
[Fact]
public void Startup_refuses_a_blank_id()
{
var values = Good();
values["Desk:Minds:1:Id"] = " ";
Refused(values, "Desk:Minds:1:Id must not be blank");
}
[Theory]
[InlineData("huginn.sock")]
[InlineData("run/huginn.sock")]
[InlineData("")]
public void Startup_refuses_relative_socket(string socket)
{
var values = Good();
values["Desk:Minds:1:Socket"] = socket;
Refused(values, "Desk:Minds:1:Socket must be an absolute path");
}
[Theory]
[InlineData("E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F")]
[InlineData("e0d1c2b3a495468788796a5b4c3d2e1f")]
[InlineData("{e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f}")]
[InlineData("not-a-guid")]
[InlineData("")]
public void Startup_refuses_bad_decider_id(string decider)
{
var values = Good();
values["Desk:Minds:0:Deciders:1"] = decider;
Refused(values, "Desk:Minds:0:Deciders entries must be lowercase D-format");
}
[Fact]
public void Startup_refuses_a_repeated_decider()
{
var values = Good();
values["Desk:Minds:0:Deciders:1"] = Alice;
Refused(values, "Desk:Minds:0:Deciders repeats");
}
[Fact]
public void Startup_refuses_operator_in_stewards()
{
var values = Good();
values["Desk:Attribution:Stewards:1"] = Alice;
Refused(values, "must not also be a steward");
}
[Fact]
public void Startup_refuses_bad_or_repeated_attribution_ids()
{
var bad = Good();
bad["Desk:Attribution:Operator"] = "ALICE";
Refused(bad, "Desk:Attribution:Operator must be a lowercase");
var badSteward = Good();
badSteward["Desk:Attribution:Stewards:1"] = "E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F";
Refused(badSteward, "Desk:Attribution:Stewards entries must be lowercase");
var repeated = Good();
repeated["Desk:Attribution:Stewards:1"] = Bob;
Refused(repeated, "Desk:Attribution:Stewards repeats");
}
private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D");
private static Dictionary<string, string?> WithDeciders(int count)
{
var values = Good();
for (var i = 0; i < count; i++)
{
values[$"Desk:Minds:0:Deciders:{i}"] = Id(i + 1);
}
if (count == 0)
{
values.Remove("Desk:Minds:0:Deciders:0");
}
return values;
}
[Fact]
public void Startup_takes_one_to_64_deciders_per_mind()
{
Validate(WithDeciders(1));
Validate(WithDeciders(64));
Refused(WithDeciders(65), "Desk:Minds:0:Deciders must hold 1 to 64");
Refused(WithDeciders(0), "Desk:Minds:0:Deciders must hold 1 to 64");
// The bound is per Mind: the second Mind is judged on its own list.
var second = Good();
second.Remove("Desk:Minds:1:Deciders:0");
Refused(second, "Desk:Minds:1:Deciders must hold 1 to 64");
}
[Fact]
public void Startup_refuses_two_minds_sharing_one_socket()
{
var values = Good();
values["Desk:Minds:1:Socket"] = values["Desk:Minds:0:Socket"];
Refused(values, "Desk:Minds:1:Socket repeats an earlier Mind's socket");
}
[Fact]
public void Desk_failures_are_reported_in_production_too_and_alongside_each_other()
{
var values = Good();
values["Desk:Minds:1:Socket"] = "relative";
values["Desk:Attribution:Stewards:1"] = Alice;
var failure = Assert.Throws<InvalidOperationException>(() => Validate(values, "Production"));
Assert.Contains("Desk:Minds:1:Socket", failure.Message);
Assert.Contains("must not also be a steward", failure.Message);
}
[Fact]
public void Attribution_is_operator_steward_member()
{
var attribution = Bind(Good()).Attribution;
Assert.Equal("Operator", attribution.AuthorityOf(Alice));
Assert.Equal("Steward", attribution.AuthorityOf(Bob));
Assert.Equal("Member", attribution.AuthorityOf("00000000-0000-4000-8000-000000000003"));
Assert.Equal("Member", attribution.AuthorityOf(Alice.ToUpperInvariant()));
Assert.Equal("Member", new DeskAttributionOptions().AuthorityOf(string.Empty));
}
private sealed class StubEnvironment(string name) : IHostEnvironment
{
public string EnvironmentName { get; set; } = name;
public string ApplicationName { get; set; } = "Bifrost.Web";
public string ContentRootPath { get; set; } = string.Empty;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}

View file

@ -0,0 +1,467 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskReadTests
{
private static string ReadToken(DeskWebApplicationFactory f) =>
f.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
private static Uri Unused => new("http://unused");
// A list_request invalid row names the query that cannot express it; the others are the shapes a query cannot hold.
private static readonly Dictionary<string, string> InvalidListQueries = new()
{
["limit_51"] = "?limit=51",
["limit_negative"] = "?limit=-1",
["status_all"] = "?status=all",
["cursor_malformed"] = "?cursor=next",
["cursor_zero"] = "?cursor=c0",
["unknown_key"] = "?sort=oldest",
};
[Fact]
public async Task List_and_read_request_invalid_vectors()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = ReadToken(factory);
var invalid = DeskVectors.Rows("transport", "listRequest", "invalid").Select(r => (string)r["name"]!).ToList();
foreach (var name in invalid.Where(n => n != "mind_empty"))
{
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + InvalidListQueries[name], null, token);
Assert.True(reply.Status == 400, $"{name}: {reply.Status}");
Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
// Also: repeated key, bad escape, query on a read, empty fork id.
foreach (var target in new[]
{
"/api/desk/gamecult-chat/forks?limit=1&limit=2", "/api/desk/gamecult-chat/forks?limit=%zz", "/api/desk/gamecult-chat/forks?limit=",
"/api/desk/gamecult-chat/forks?limit=+1", "/api/desk/gamecult-chat/forks?limit=1.0",
"/api/desk/gamecult-chat/forks/gamecult-chat%3Aquestion%3Aq1?x=1", "/api/desk/gamecult-chat/forks/", "/api/desk/gamecult-chat/forks/%ff",
})
{
var reply = await factory.SendAsync("GET", target, null, token);
Assert.True(reply.Status == 400, $"{target}: {reply.Status}");
}
// The invalid request is judged before the Mind: an unknown Mind with a bad query is the query's refusal.
var order = await factory.SendAsync("GET", "/api/desk/ghost/forks?limit=99", null, token);
Assert.Equal("request_invalid", (string?)JsonNode.Parse(order.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task Valid_list_queries_reach_the_door_as_typed_requests()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var row in DeskVectors.Rows("transport", "listRequest", "valid"))
{
var m = row["message"]!;
var query = $"?status={(string?)m["status"]}&cursor={(string?)m["cursor"]}&limit={(int)m["limit"]!}";
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
var call = factory.Door.Calls[^1];
Assert.Equal(new DeskListRequest("gamecult-chat", (string?)m["status"], (string?)m["cursor"], (int)m["limit"]!), call.List);
}
var bare = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
Assert.Equal(new DeskListRequest("nibu", null, null, 50), factory.Door.Calls[^1].List);
Assert.Equal(200, bare.Status);
}
[Fact]
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var ok = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, ReadToken(factory));
var entry = JsonSerializer.SerializeToNode(DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)), DeskJson.Options);
var missing = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", "gamecult-chat:question:ghost"), null, ReadToken(factory));
Assert.Equal(200, ok.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(ok.Body), entry));
Assert.Equal(DeskReplay.ChatFork, Assert.IsType<string>(factory.Door.Calls[0].ForkId));
Assert.Equal(404, missing.Status);
}
[Fact]
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_its_order()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:huge", 7);
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
var doc = JsonNode.Parse(reply.Body)!;
Assert.Equal(422, reply.Status);
Assert.Equal("fork_too_large", (string?)doc["code"]);
var error = (string)doc["error"]!;
Assert.Contains("nibu:question:huge", error);
Assert.Contains("order 7", error);
// At order 0 the text still names the fork and its order: the client reads "below 0" as the end.
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:first", 0);
var end = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
var endError = (string)JsonNode.Parse(end.Body)!["error"]!;
Assert.Equal(422, end.Status);
Assert.Contains("nibu:question:first", endError);
Assert.Contains("order 0", endError);
}
[Fact]
public async Task fork_too_large_as_a_bare_door_code_is_unavailable_only_the_typed_outcomes_are_the_refusal()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound("fork_too_large");
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed("fork_too_large");
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(503, reply.Status);
}
}
[Fact]
public async Task A_read_of_an_oversize_fork_is_the_same_422_naming_the_fork_with_no_order_or_cursor()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:huge"), null, ReadToken(factory));
var doc = JsonNode.Parse(reply.Body)!;
Assert.Equal(422, reply.Status);
Assert.Equal("fork_too_large", (string?)doc["code"]);
var error = (string)doc["error"]!;
Assert.Contains("nibu:question:huge", error);
Assert.DoesNotContain("order", error);
Assert.DoesNotContain("cursor", error);
}
[Fact]
public async Task List_short_pages_and_oversize_forks_follow_the_vectors()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listShortPages"))
{
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 name = (string)scenario["name"]!;
if (step["refusal"] is { } refusal)
{
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge((string)step["fork_id"]!, (int)step["order"]!);
}
else
{
var list = step["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(step["refusal"] is null ? 200 : (int)step["httpStatus"]!, reply.Status);
Assert.True(
step["refusal"] is null
? DeskVectors.SameShape(JsonNode.Parse(reply.Body), step["response"])
: DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), step["refusal"], (string)step["fork_id"]!, (int)step["order"]!),
name);
steps++;
}
}
Assert.Equal(6, steps);
}
[Fact]
public async Task A_read_past_the_cap_follows_the_vectors()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var rows = DeskVectors.Rows("transport", "readTooLarge").ToList();
Assert.Single(rows);
foreach (var row in rows)
{
var forkId = (string)row["fork_id"]!;
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", (string)row["request"]!["mind"]!, forkId), null, ReadToken(factory));
Assert.Equal((int)row["httpStatus"]!, reply.Status);
Assert.True(DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), row["refusal"], forkId, null), (string)row["name"]!);
}
}
[Fact]
public async Task Door_faults_follow_the_vectors_per_route()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var faults = DeskVectors.Rows("transport", "doorFaults").ToList();
Assert.Equal(3, faults.Count);
foreach (var row in faults)
{
var code = (string)row["doorCode"]!;
string method = "GET", target;
string? bearer = null;
byte[]? body = null;
switch ((string)row["route"]!)
{
case "read":
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
target = DeskReplay.Target("read", "nibu", "nibu:question:glow-order");
bearer = ReadToken(factory);
break;
case "list":
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
target = "/api/desk/nibu/forks";
bearer = ReadToken(factory);
break;
default:
method = "POST";
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
target = DeskReplay.Target("answer", "nibu", null);
body = DeskReplay.AnswerBody("nibu:question:glow-order");
bearer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
break;
}
var reply = await factory.SendAsync(method, target, body, bearer);
Assert.Equal((int)row["httpStatus"]!, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), row["response"]), (string)row["name"]!);
}
}
[Fact]
public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 3, 0, [], "c2"));
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks?limit=10", null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.Equal("c2", (string?)JsonNode.Parse(reply.Body)!["next_cursor"]);
}
[Fact]
public async Task A_door_that_refuses_a_read_or_list_for_a_body_field_such_as_authority_is_request_invalid_not_unavailable()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var code in new[] { "body_forbidden_field", "body_unknown_field", "body_invalid" })
{
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(400, reply.Status);
Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
}
[Fact]
public async Task request_id_conflict_is_the_answers_alone_on_read_and_list_it_is_a_door_fault()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(DeskRefusals.RequestIdConflict);
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(DeskRefusals.RequestIdConflict);
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(503, reply.Status);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
[Fact]
public async Task Door_failure_on_list_and_read_is_unavailable()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.Down = true;
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(503, reply.Status);
Assert.Equal(30, (int?)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]);
}
}
[Fact]
public async Task Every_door_call_carries_the_configured_deciders_and_nothing_in_the_request_can_change_them()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var hostile = "deciders=" + DeskWebApplicationFactory.Member;
Action<Microsoft.AspNetCore.Http.HttpContext> headers = http =>
{
http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member;
http.Request.Headers["Deciders"] = DeskWebApplicationFactory.Member;
};
var read = ReadToken(factory);
var answer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
await factory.SendAsync("GET", "/api/desk/nibu/forks", null, read, configure: headers);
var hostileList = await factory.SendAsync("GET", "/api/desk/nibu/forks?" + hostile, null, read, configure: headers);
Assert.Equal(400, hostileList.Status);
await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:glow-order"), null, read, configure: headers);
await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null) + "?" + hostile, DeskReplay.AnswerBody("nibu:question:glow-order"), answer, configure: headers);
// A body naming deciders never reaches the door.
var body = JsonNode.Parse(DeskReplay.AnswerBody("nibu:question:glow-order"))!.AsObject();
body["deciders"] = new JsonArray(DeskWebApplicationFactory.Member);
var named = await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null), DeskVectors.Bytes(body), answer);
Assert.Equal("body_unknown_field", (string?)JsonNode.Parse(named.Body)!["code"]);
Assert.Equal(["list", "read", "answer"], factory.Door.Calls.Select(c => c.Kind).ToArray());
Assert.All(factory.Door.Calls, c => Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), c.Context.Deciders));
}
private const string Secret = "the decider's own words";
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, 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()
{
var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork));
DeskAnswerDocument[] answers =
[
Doc("n1", "Counted", null, null, Secret),
Doc("n2", "Revision", "n1", null, Secret + " 2"),
Doc("n3", "Retraction", null, "n2", Secret + " 3"),
Doc("n4", "Refused", null, null, Secret + " 4"),
];
return entry with { Record = entry.Record with { CountedAnswerId = "n1", Answers = answers } };
}
private static bool AnyNote(JsonNode? node) => node switch
{
JsonObject o => o.Any(kv => (kv.Key == "note" && kv.Value is not null) || AnyNote(kv.Value)),
JsonArray a => a.Any(AnyNote),
_ => false,
};
private static async Task<(string Read, string List)> BodiesAsync(DeskWebApplicationFactory factory, string account)
{
var entry = NotedEntry();
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.Found(entry);
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(
new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry, entry], null));
var token = factory.Token(account, "bifrost-desk", "desk.read");
var read = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, token);
var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token);
Assert.Equal(200, read.Status);
Assert.Equal(200, list.Status);
return (System.Text.Encoding.UTF8.GetString(read.Body), System.Text.Encoding.UTF8.GetString(list.Body));
}
[Fact]
public async Task A_non_decider_reads_and_lists_no_notes_anywhere_and_the_body_keeps_the_contract_shape()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
Assert.DoesNotContain(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("gamecult-chat"));
var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Member);
Assert.DoesNotContain("decider's own words", read);
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
{
Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] },
}, DeskJson.Options);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected));
var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray();
Assert.Equal(new[] { "Counted", "Revision", "Retraction", "Refused" }, answers.Select(a => (string)a!["outcome"]!).ToArray());
Assert.All(answers, a => Assert.True(a!.AsObject().ContainsKey("note")));
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()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
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)));
}
[Fact]
public async Task Decider_status_is_per_mind_the_same_account_is_stripped_where_it_is_not_on_the_list()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
Assert.Contains(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("nibu"));
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(
new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [NotedEntry()], null));
var token = factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read");
var onNibu = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, token);
var onChat = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, token);
Assert.True(AnyNote(JsonNode.Parse(onNibu.Body)));
Assert.False(AnyNote(JsonNode.Parse(onChat.Body)));
}
}

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