Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
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
129 changed files with 31780 additions and 262 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

@ -36,7 +36,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 +73,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.

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.

View file

@ -23,7 +23,7 @@ That means Bifrost owns the operational meaning of the crossing:
It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain. It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain.
The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody. The native registration surface is `POST /auth/bifrost/register`, which takes `identity`, optional `displayName` and `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (from `GET /auth/bifrost/notice`). It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
## Ownership ## Ownership
@ -198,6 +198,62 @@ Bifrost's native Discord gateway is the conversation observer. A direct mention
Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-<persona>`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download/<server>/<mediaId>` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT/<persona>/<hex sha256 of the room id>/<hex sha256 of the bytes>[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `<room hash>/<file>` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. An ack posts nothing to Matrix. That Persona's public `m.read` receipt follows `gamecult.persona.chat_read.v1` (`[persona, room_alias, event_id, at_unix_ms]`), which Huginn sends when a delivery has gone into the model's input: the mouth posts `m.read` at that event id only, for an event it delivered to that Persona in that room (to the delivered room id, whatever alias the report named), never behind the last receipt that landed there, one report per room at a time, and only while her brake is released (re-read after the presence write; fire and forget on the endpoint: a failure is logged once and not retried; pending polls and acks post none). Her Matrix presence follows a turn and her brake: `online` while a read report, a send or an ack came within `presenceGraceMs` (5 minutes), `unavailable` after that, `offline` while the brake is held. Each `/sync` carries the state as `set_presence`, and a change is also `PUT /presence/<user>/status` at once (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds/<persona>/control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed. Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-<persona>`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download/<server>/<mediaId>` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT/<persona>/<hex sha256 of the room id>/<hex sha256 of the bytes>[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `<room hash>/<file>` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. An ack posts nothing to Matrix. That Persona's public `m.read` receipt follows `gamecult.persona.chat_read.v1` (`[persona, room_alias, event_id, at_unix_ms]`), which Huginn sends when a delivery has gone into the model's input: the mouth posts `m.read` at that event id only, for an event it delivered to that Persona in that room (to the delivered room id, whatever alias the report named), never behind the last receipt that landed there, one report per room at a time, and only while her brake is released (re-read after the presence write; fire and forget on the endpoint: a failure is logged once and not retried; pending polls and acks post none). Her Matrix presence follows a turn and her brake: `online` while a read report, a send or an ack came within `presenceGraceMs` (5 minutes), `unavailable` after that, `offline` while the brake is held. Each `/sync` carries the state as `set_presence`, and a change is also `PUT /presence/<user>/status` at once (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds/<persona>/control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed.
### Addressing facts (`chat_pending.v3`)
The mouth wakes no one; the heartbeat and each Persona session decide who is told a delivery (Huginn `address.rs`, ruling `eureka-body:ruling:addressed-delivery-only`). Telling every Persona every message made 71.5% of 4,351 Persona turns on 2026-11-10 do nothing. The rule stays in one place, Huginn's, because two of its inputs are not Matrix facts: the Persona's stewardship records (her projects' chat names) and the sender's role (operator or Mind). The mouth owns the other half: what Matrix itself says, per Persona, for the delivery. Matrix verifies the sender's identity and nothing else: `tag` and `room` are the sender's claim in `m.mentions` (the homeserver does not check the text against it, nor that the sender may use `@room`), and `reply` is checked against the event it targets, which the mouth reads as her account.
A client asks for the facts by the kind it polls with: `gamecult.persona.chat_pending_request.v2` (the v1 tuple, `[persona, after_seq]`) is answered with `gamecult.persona.chat_pending.v3`; a v1 request still gets the 12-field `chat_pending.v2` answer unchanged. A v3 delivery is the 12 fields of the v2 delivery, then:
| Field | Type | Meaning |
|---|---|---|
| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"` or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `<a href>` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event, a failure for a minute); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. |
| 13 `form` | `"message"`, `"notice"` or `"edit"` | `m.notice` is an automated line; `m.replace` is an edit of an earlier message. Only a `message` can address anyone. |
What the mouth cannot know, and so what a nil `basis` means: **unproven, not "not for her"**. Her handle and public name in the text (any casing, with or without `@`, `'s`), the names her stewardships list, a project's repo name, and whether the sender is the operator or a Mind are all matched by Huginn, not here. A reply whose target the homeserver will not give is nil and logged. The mouth also cannot see whether a message was meant for her without naming her. Every delivery is still delivered, claimed tags included: nothing is dropped in the mouth, so the Huginn rule cannot lose a message the mouth had no way to judge.
Proposed runtime half (follow-up `persona-wakes-on-address-only`):
1. Poll with `chat_pending_request.v2` and parse `chat_pending.v3`.
2. A delivery is *addressed* to a Persona when `form == "message"` and either `basis` is `direct`, `tag` or `reply`, or `address::addresses` matches her names or her projects' names on the text. The off-beat wake keeps the sender gate of `heartbeat.rs` `wakes` (the operator or another Mind of the roster): a stranger's tag, `@room` or DM is delivered but wakes no one off the beat. `basis == "room"` is the runtime's policy to weigh; the mouth only reports it.
3. A real-time wake, a live session's tell and the stagger apply to addressed deliveries only. A `notice` or an `edit` never wakes, whatever its basis.
4. A delivery that is not addressed is acked in sequence with the rest, so the position moves (the mouth stops reading at 500 unacked deliveries); it is not lost: Matrix is the record, and the next addressed delivery's `context` (up to 20 lines) and a beat's own room read show it. The room tail below gives a scheduled beat those lines without a Matrix read of its own; the mouth adds no endpoint for it.
5. `basis` and `form` are evidence, not authority: a sender's claim is weighed by the runtime's own rule (`address.rs` for names, the sender gate for who may wake her); the mouth reports what the event says and what only Matrix could look up (the author of a reply target).
### Room tail (`chat_pending.v3`, field 2)
The v3 answer is `[persona, deliveries, tail, tail_gaps]`. `tail` is a list of recent room lines, oldest first, for a reader that was not woken for them (a scheduled beat). It uses the same room read as a delivery's `context` (the same function, a separate read), as her account, with no new endpoint; the cost is one `/messages` call per speakable room per poll (15 seconds cached), including every DM. A `chat_pending_request.v1` is still answered with the two-field `chat_pending.v2`. `chat_pending.v3` has not been deployed, but a reader that decodes it as a two-field tuple fails on the third element (rmp-serde `LengthMismatch`), so Huginn must accept `tail` and `tail_gaps` (defaulted fields) before this mouth is deployed. The array is positional: a decoder of the earlier three-field shape fails on the fourth element with the same `LengthMismatch`, so `tail_gaps` has the same deploy precondition; a decoder that reads the first three elements and ignores the rest sees `persona`, `deliveries` and `tail` unchanged.
| Bound | Rule |
|---|---|
| Rooms | Only rooms the Persona may speak in: a configured room she has joined, or one of her DMs. The same rule `send` uses (`speakableRoomId`). A room she is in but cannot speak in, and any other room, is absent. |
| Lines | A text line outside threads, not an edit, newest `20` per room, newest `60` over all rooms. Trimmed oldest first if the reply byte budget (256 KiB, deliveries first) would be exceeded. |
| Cursor | None. The tail is each room's newest lines whether or not they were delivered or acked: a message the wake rule left unaddressed is acked and leaves `pending`, and the tail is where a beat still finds it. A reader removes the lines a delivery already shows (by event id). Her own lines are included. |
| Read | A room's read is kept 15 seconds, dropped when a sync brings the room news. A room that cannot be read within 10 seconds is logged and absent; the other rooms and the deliveries are unaffected. |
A line is the 7-field tuple `[room, event_id, at_unix_ms, sender, form, basis, body]`: `room` is the configured alias (the room id for a DM), `form` and `basis` as for a delivery, `body` clipped to 1000 bytes with the same typed text line for media a delivery has (nothing is fetched). `basis` is what Matrix says on the event itself (`direct` in a DM, `tag`, `room`) and nothing the mouth would have to look up, so a reply is nil here: **nil is unproven, not "not for her"**, and a line in a DM is `direct`. Huginn's rule that an `is_direct` delivery wakes with a nil `basis` is unchanged and lives with Huginn; the tail does not feed it.
The tail is **data, not instruction**. A line is what a sender typed in a room, and the mouth wakes no one, acks nothing and sends nothing because of it. A reader treats the body as untrusted text and weighs `basis` as evidence, as for a delivery. The tail does not replace a delivery: a message still reaches her as a delivery, with its own `seq`, and only `chat_ack` advances a position.
#### Gaps in the tail (`chat_pending.v3`, field 3, `tail_gaps`)
`tail_gaps` is a list of room names (the configured alias, or the room id for a DM, as `room` in a tail line), one for each speakable room whose lines are not all in `tail`. A reader renders such a room as **unknown**, not quiet. Absent or empty means every speakable room was read fully: a room with no lines because it is quiet, and a room whose few lines all fit, are never listed. Order is the order the mouth reads rooms (configured rooms in join order, then DMs), not time; a room appears once however many causes apply. Causes, all of them:
| Cause | Where |
|---|---|
| The room's read failed (the homeserver refused or errored) or did not answer within 10 seconds (`sendTimeoutMs`); the room is logged and contributes no lines | the mouth |
| The room had more than 20 lines outside threads (the per-room cap; her own lines count), so older ones were left out | the mouth |
| The read window came back full (100 raw events, before edits and thread replies are set aside), so real lines older than the window may exist and are unseen; a shorter chunk means the room's history was exhausted. A room whose history is exactly 100 events long is gapped too, since the mouth cannot tell. The rule assumes a homeserver returns the 100 events asked for whenever older history exists; a server that caps the limit lower turns a truncated read into a false "whole", and Bifrost does not detect that | the mouth |
| One of the room's lines was cut by the 60-line total cap (only the newest 60 over all rooms are kept) | the mouth |
| A line was dropped as malformed (no event id) | the mouth |
| The reply byte budget (256 KiB, deliveries first) cut the room's oldest lines, or a line would not encode and was skipped | the reply |
| The mouth has not started: every configured room is a gap (DMs are not yet known) | the mouth |
| The whole tail read failed: every speakable room is a gap, and the deliveries are still served | the endpoint |
A line a delivery already shows and a room she cannot speak in change nothing here; her own lines are lines like any other and count toward the caps. A read the mouth kept for 15 seconds counts as a read.
Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries two lines and no gap, `chat_pending.v3.tail-gaps` one line and two gap rooms; the earlier v3 samples gain an empty fourth element); Huginn pins the same bytes.
Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority.
`tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path. `tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path.

View file

@ -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

@ -75,12 +75,22 @@ All must hold before step 1. A missing one means the window is not used.
1. **Root window.** Someone with root on Yggdrasil is present for the whole deploy and the 30 minutes after (installing 1. **Root window.** Someone with root on Yggdrasil is present for the whole deploy and the 30 minutes after (installing
the unit, reading `journalctl`, restoring). The swarm brake is off for Bifrost, or the operator has said so. the unit, reading `journalctl`, restoring). The swarm brake is off for Bifrost, or the operator has said so.
2. **Verified database backup.** `pg_dump -Fc` as in the runbook section 8, taken inside the window, then verified: 2. **Verified database backup.** `pg_dump -Fc` as in the runbook section 8, taken inside the window to
`pg_restore --list <dump> | head` succeeds, and the dump restores into a scratch database, where the member count `/srv/bifrost/backups/bifrost-pre-<sha12>-<UTC>.dump`, **root-owned, mode 0600**, then verified. The `postgres` user
equals the live count. The `bifrost` database role has no CREATEDB, so create and drop the scratch database as the cannot read that file, so each check says who runs it:
superuser: `sudo -n -u postgres createdb -O bifrost bifrost_rehearsal`, then - `sudo -n pg_restore --list <dump> | head` succeeds, **as root** (it reads the file and touches no database).
`sudo -n -u postgres pg_restore --no-owner --role=bifrost -d bifrost_rehearsal <dump>`, and at the end - The restore reads a **postgres-readable copy**, never the real dump: with `S=/var/tmp/bifrost-restoretest-<UTC>`, root runs
`sudo -n -u postgres dropdb bifrost_rehearsal`. An unrestored dump is not a backup. `sudo -n install -d -m 0700 -o postgres -g postgres $S` and
`sudo -n install -m 0400 -o postgres -g postgres <dump> $S/restore.dump`. The `bifrost` database role has no
CREATEDB, so create and drop the scratch database as the superuser:
`sudo -n -u postgres createdb -O bifrost bifrost_restoretest_<UTC>`, then
`sudo -n -u postgres pg_restore --no-owner --role=bifrost -d bifrost_restoretest_<UTC> $S/restore.dump`. The scratch
member count must equal the live count.
- Cleanup, on success and on every stop: `sudo -n -u postgres dropdb bifrost_restoretest_<UTC>`, then
`sudo -n sh -c "shred -u $S/restore.dump && rmdir $S"`; readback `test ! -e $S && echo gone` prints `gone`. The real
dump stays root-only and is kept.
An unrestored dump is not a backup.
3. **Env, secrets and key-ring backup.** The runbook's `bifrost-env-*.tar`, plus the live ring 3. **Env, secrets and key-ring backup.** The runbook's `bifrost-env-*.tar`, plus the live ring
**`/srv/bifrost/.aspnet/DataProtection-Keys`** (read from the host 2026-11-09: `DataProtection__KeysPath` in **`/srv/bifrost/.aspnet/DataProtection-Keys`** (read from the host 2026-11-09: `DataProtection__KeysPath` in
`bifrost.env` names it; three key files at that reading, modes 600, owner `bifrost`, dated 2026-04-22, 2026-07-22 and `bifrost.env` names it; three key files at that reading, modes 600, owner `bifrost`, dated 2026-04-22, 2026-07-22 and
@ -217,7 +227,9 @@ Rollback signs members out again (the old release reads the old cookie names); s
3. Re-run the "before" checks from step 1: revision, `active`, counts equal. 3. Re-run the "before" checks from step 1: revision, `active`, counts equal.
4. The two new migrations stay applied. They add tables the old release ignores, as rehearsed in Precondition 4. 4. The two new migrations stay applied. They add tables the old release ignores, as rehearsed in Precondition 4.
5. Only if the schema or data is damaged (not merely new): the runbook section 8 restore, from the verified dump. This is 5. Only if the schema or data is damaged (not merely new): the runbook section 8 restore, from the verified dump. This is
the last resort and needs the same root window. the last resort and needs the same root window. The `postgres` superuser cannot read the root-owned 0600 dump, so make a
fresh postgres-readable copy as in Precondition 2 (`$S/restore.dump`, 0400, postgres-owned, new 0700 directory), run
`pg_restore` from it, then `shred -u` the copy and `rmdir $S`; readback `test ! -e $S`. The dump itself stays root-only.
6. Leave the failed release directory for diagnosis; do not delete it in the window. 6. Leave the failed release directory for diagnosis; do not delete it in the window.
## What this plan does not do ## What this plan does not do

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 sign-ins and token refresh (the authorize, token and userinfo
endpoints refuse a suspended account) but does not end a chat session that is already live, and the issuer logs one warning at
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";

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. 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.

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 ends everyone's chat token until they agree (their next chat sign-in lands on that page). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no.
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

@ -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

@ -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,196 @@
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 de8a062a) spells them: snake_case keys, every key written (nil where absent), declared in the
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
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);
public sealed record ForkOption(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
public sealed record ForkRef(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("id")] string Id);
public sealed record ForkCard(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("options")] ForkOption[] Options,
[property: JsonPropertyName("recommendation")] string Recommendation,
[property: JsonPropertyName("depends")] string[] Depends,
[property: JsonPropertyName("leans")] string? Leans,
[property: JsonPropertyName("flag")] string? Flag,
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
[property: JsonPropertyName("deciders")] string[] Deciders,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("ruling")] string? Ruling,
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
[property: JsonPropertyName("order")] long Order,
[property: JsonPropertyName("options_hash")] string OptionsHash);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[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,
[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("answers")] DeskAnswerDocument[] Answers);
public sealed record ForkEntry(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("card")] ForkCard 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")] ForkCard? 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);
}
// Notes are returned only to deciders. For everyone else Bifrost nils every note at the door: the key stays written
// (every key written, nil where absent), so the stripped body is still the contract's shape. Cards, outcomes, codes
// and counts are untouched; Huginn returns the full record.
public static class DeskNotes
{
public static ForkEntry Strip(ForkEntry entry) =>
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
public static ForkList Strip(ForkList list) => list with { Forks = [.. list.Forks.Select(Strip)] };
}

View file

@ -0,0 +1,456 @@
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.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 IServiceCollection AddBifrostDesk(this IServiceCollection services)
{
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 : DeskNotes.Strip(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 : DeskNotes.Strip(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 them (visibility ruling c).
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,70 @@
namespace Bifrost.Web.Features.Desk;
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
// peer-checked door. No production implementation is registered here: with the Desk section configured and a port
// missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// 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

@ -319,7 +319,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 +333,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!,

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,8 @@ 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.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 +21,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 +36,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 +51,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 +69,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);
@ -75,19 +93,27 @@ public static class OidcEndpoints
_ => [Destinations.AccessToken] _ => [Destinations.AccessToken]
}); });
// Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree.
// Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request
// (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it.
if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
{
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);
} }
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) 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 +127,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) 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 +143,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 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)
{ {
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 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,7 +13,9 @@ 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)
{ {
public void Validate() public void Validate()
{ {
@ -45,6 +48,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);
@ -116,6 +145,106 @@ 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)
{
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 && !string.IsNullOrEmpty(user.SuspensionReason))
{
<div class="muted" style="margin-top: 0.8rem;">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,23 @@ 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.";
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; } = [];
@ -102,7 +121,13 @@ public sealed class IndexModel(
.ThenInclude(x => x.RoleAssignments) .ThenInclude(x => x.RoleAssignments)
.SingleAsync(x => x.Id == userAccountId, cancellationToken); .SingleAsync(x => x.Id == userAccountId, cancellationToken);
user.Membership!.Status = MembershipStatus.Active; 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 +149,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);
@ -139,6 +185,19 @@ public sealed class IndexModel(
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 +212,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);
} }
@ -317,6 +378,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 +466,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

@ -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;
@ -76,6 +78,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;
@ -112,6 +118,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 +132,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>();
@ -252,6 +263,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();
}
var app = builder.Build(); var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
@ -273,6 +291,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 +316,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 +390,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,
@ -715,6 +882,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 +965,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

@ -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

@ -0,0 +1,561 @@
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)];
yield return [new string('A', 100_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,75 @@
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(5, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
}
}
}

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,235 @@
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")
{
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())).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 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,468 @@
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 List_scenarios_pass_through()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios"))
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var request = step["request"]!;
var response = step["response"]!.AsObject();
var list = response.Deserialize<ForkList>(DeskJson.Options)!;
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!);
steps++;
}
}
Assert.Equal(9, steps);
}
[Fact]
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
{
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, DeskWebApplicationFactory.Alice, "Steward", "r-" + id);
// 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)));
// 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 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(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)));
}
}

View file

@ -0,0 +1,188 @@
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 DeskScenarioTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static readonly string[] ConfiguredDeciders = DeskWebApplicationFactory.DecidersOf("gamecult-chat");
// Replays every answer step of every scenario. The door is scripted from the vector's response; what Bifrost
// must produce by itself (edge refusals, unavailable, the attached card) is compared with the vector.
private sealed record Counts(int Answers, int Edge, int Unavailable, int Skipped);
private static async Task<Counts> ReplayAsync(Action<string, DeskReply, DeskWebApplicationFactory, JsonObject>? observe = null)
{
int answers = 0, edge = 0, unavailable = 0, skipped = 0;
foreach (var scenario in DeskVectors.Rows("scenarios"))
{
using var factory = new DeskWebApplicationFactory();
var door = factory.Door;
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
var scenarioName = (string)scenario["name"]!;
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var kind = (string)step["kind"]!;
if (kind == "matrix_event")
{
skipped++; // no Matrix ingress exists, so there is no route a Matrix event could take
continue;
}
if (kind == "card")
{
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
continue;
}
var server = step["server"]!.AsObject();
var response = step["response"]!.AsObject();
var code = (string?)response["code"];
var answerer = (string?)server["answerer"];
var active = (bool?)server["active"] ?? true;
door.Calls.Clear();
door.StoredIn.Clear();
door.Down = false;
door.ThrowAfterStore = false;
door.AnswerScript = null;
if (answerer is not null)
{
await factory.EnsureAccountAsync(answerer, active);
}
var isDown = (bool?)server["door_down"] == true;
var stored = (bool?)server["stored"] == true;
if (isDown)
{
door.Down = !stored;
door.ThrowAfterStore = stored;
}
if (code is null)
{
var wire = response.Deserialize<AnswerResult>(DeskJson.Options)!;
// Huginn answers without the card; Bifrost attaches the current one for stale_fork.
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(wire with { Card = null });
}
else if (code is "request_id_conflict" or "fork_unknown")
{
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
}
var body = DeskVectors.Bytes(step["body"]!);
var token = answerer is null
? null
: factory.Token(answerer, "bifrost-desk", "desk.answer");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), body, token);
var label = $"{scenarioName}";
if (code == "unavailable")
{
unavailable++;
Assert.True(503 == reply.Status, $"{label}: {reply.Status}");
Assert.Equal("30", reply.Headers.RetryAfter.ToString());
Assert.Equal(stored ? [Socket] : [], door.StoredIn);
}
else if (code is not null && DeskReplay.IsEdgeRefusal(code))
{
edge++;
Assert.True(DeskRefusals.StatusOf(code) == reply.Status, $"{label}: {reply.Status}");
Assert.True(door.Calls.Count == 0, $"{label}: the door was called for an edge refusal");
}
else
{
answers++;
var call = Assert.Single(door.Calls.Where(c => c.Kind == "answer"));
Assert.Equal(body, call.Body);
Assert.Equal(answerer, call.Context.Subject);
Assert.Equal((string)server["authority"]!, call.Context.Authority);
Assert.Equal("gamecult-chat", call.Context.Mind);
Assert.Equal(ConfiguredDeciders, call.Context.Deciders);
Assert.Equal(Socket, call.Socket);
}
Assert.True(
DeskVectors.SameShape(JsonNode.Parse(reply.Body), response),
$"{label}: response differs\n{reply.Text}\n{response.ToJsonString()}");
observe?.Invoke(scenarioName, reply, factory, step);
}
}
return new Counts(answers, edge, unavailable, skipped);
}
[Fact]
public async Task Every_scenario_answer_step_replays_through_http()
{
var counts = await ReplayAsync();
Assert.Equal(3, counts.Skipped);
Assert.Equal(2, counts.Unavailable);
Assert.Equal(15, counts.Edge);
Assert.Equal(100, counts.Answers);
}
[Fact]
public async Task Edge_refusals_never_call_the_door()
{
var edges = 0;
await ReplayAsync((_, reply, factory, step) =>
{
var code = (string?)step["response"]!["code"];
if (code is not null && DeskReplay.IsEdgeRefusal(code))
{
edges++;
Assert.Empty(factory.Door.Calls);
}
});
Assert.Equal(15, edges);
}
[Fact]
public async Task Answer_results_pass_through_unchanged()
{
var checkedResults = 0;
foreach (var row in DeskVectors.Rows("results"))
{
using var factory = new DeskWebApplicationFactory();
var message = row["message"]!.AsObject();
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(answer.ForkId),
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
Assert.True((int)row["httpStatus"]! == reply.Status, $"{row["name"]}: {reply.Status}");
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), message), $"{row["name"]}");
checkedResults++;
}
Assert.Equal(6, checkedResults);
}
[Fact]
public async Task Unavailable_before_and_after_store()
{
// The scenarios hold both: door_down before the store, and door_down after it (the retry then replays).
var seen = new List<(bool Stored, int Status)>();
await ReplayAsync((_, reply, factory, step) =>
{
if ((string?)step["response"]!["code"] == "unavailable")
{
seen.Add(((bool?)step["server"]!["stored"] == true, reply.Status));
}
});
Assert.Equal([(false, 503), (true, 503)], seen.OrderBy(s => s.Stored).ToArray());
}
}

View file

@ -0,0 +1,149 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bifrost.Web.Tests;
public sealed class DeskSurfaceTests
{
private static readonly string AnswerTarget = DeskReplay.Target("answer", "gamecult-chat", null);
[Fact]
public async Task Not_mapped_without_config()
{
using var factory = new DeskWebApplicationFactory(configured: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync(
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
Assert.Equal(404, reply.Status);
Assert.Empty(factory.Door.Calls);
var routes = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
Assert.DoesNotContain(routes, e => e.RoutePattern.RawText!.StartsWith("/api/desk", StringComparison.Ordinal));
}
[Fact]
public void A_configured_desk_without_its_ports_stops_startup()
{
using var factory = new DeskWebApplicationFactory(registerPorts: false);
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
Assert.Contains("IDeskCredential", failure.ToString());
}
[Fact]
public async Task Cookie_session_does_not_authenticate()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
// The test host signs every request in as an Active-looking site member through the default scheme. The desk ignores it.
var reply = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork));
var withCookie = await factory.SendAsync(
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
configure: http => http.Request.Headers.Cookie = "bifrost_session=anything");
Assert.Equal(401, reply.Status);
Assert.Equal(401, withCookie.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task Cors_allows_exactly_the_configured_origin_without_credentials()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
var allowed = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin);
var refused = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, "https://evil.example");
var lookalike = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin + ".evil.example");
var preflight = await factory.SendAsync("OPTIONS", AnswerTarget, null, null, DeskWebApplicationFactory.Origin, http =>
{
http.Request.Headers["Access-Control-Request-Method"] = "POST";
http.Request.Headers["Access-Control-Request-Headers"] = "authorization,content-type";
});
var elsewhere = await factory.SendAsync("GET", "/healthz", null, null, DeskWebApplicationFactory.Origin);
Assert.Equal(DeskWebApplicationFactory.Origin, allowed.Headers.AccessControlAllowOrigin.ToString());
Assert.False(allowed.Headers.ContainsKey("Access-Control-Allow-Credentials"));
Assert.Contains("Origin", allowed.Headers.Vary.ToString());
Assert.False(refused.Headers.ContainsKey("Access-Control-Allow-Origin"));
Assert.False(lookalike.Headers.ContainsKey("Access-Control-Allow-Origin"));
Assert.Equal(DeskWebApplicationFactory.Origin, preflight.Headers.AccessControlAllowOrigin.ToString());
Assert.False(preflight.Headers.ContainsKey("Access-Control-Allow-Credentials"));
Assert.Contains("POST", preflight.Headers.AccessControlAllowMethods.ToString());
Assert.False(elsewhere.Headers.ContainsKey("Access-Control-Allow-Origin"));
}
[Fact]
public async Task An_unrecognised_target_is_fork_unknown_and_opens_nothing()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
foreach (var target in new[]
{
"/api/desk", "/api/desk/", "/api/desk/gamecult-chat", "/api/desk/gamecult-chat/answers/", "/api/desk/a/b/answers",
"/api/desk/a/../gamecult-chat/answers", "/api/desk/./gamecult-chat/answers", "/api/desk//answers/x",
"/API/DESK/gamecult-chat/answers", "/api/deskx/gamecult-chat/answers", "http://localhost/api/desk/gamecult-chat/answers",
})
{
var reply = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(reply.Status == 404, $"{target}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// Kestrel normalises Request.Path but leaves RawTarget as received. The desk parses RawTarget, so a dot
// segment can never be folded into a configured Mind's route.
[Fact]
public async Task Dot_segments_never_reach_the_route()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
await using var app = builder.Build();
app.Run(async http =>
{
var raw = http.Features.Get<IHttpRequestFeature>()!.RawTarget;
var text = $"{http.Request.Path}|{raw}";
http.Response.ContentLength = text.Length;
await http.Response.WriteAsync(text);
});
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
var port = new Uri(address).Port;
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", port);
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes("GET /api/desk/ghost/../gamecult-chat/answers HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"));
using var reader = new StreamReader(stream, Encoding.ASCII);
var text = await reader.ReadToEndAsync();
var echoed = text[(text.LastIndexOf("\r\n\r\n", StringComparison.Ordinal) + 4)..].Trim();
var parts = echoed.Split('|');
Assert.Equal("/api/desk/gamecult-chat/answers", parts[0]);
Assert.Equal("/api/desk/ghost/../gamecult-chat/answers", parts[1]);
Assert.Null(DeskRoute.Parse("POST", parts[1]));
Assert.NotNull(DeskRoute.Parse("POST", parts[0]));
}
}

View file

@ -0,0 +1,18 @@
using System.Security.Cryptography;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_de8a062a()
{
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
Assert.True(
DeskVectors.FileSha256 == sha,
$"fork-card-vectors.json is not CultLib {DeskVectors.SourceCommit}:contracts/cultnet/governance/fork-card-vectors.json (sha256 {sha}). " +
"Re-vendor from that commit or move the pin on purpose, with the whole contract.");
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,64 @@
// Runs the sign-up page's real inline script against a simulated browser and prints what it did as JSON.
// Usage: node registration-page-harness.cjs <page.html>
const fs = require("fs");
const vm = require("vm");
const { webcrypto } = require("crypto");
const html = fs.readFileSync(process.argv[2], "utf8");
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
if (scripts.length !== 1) {
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
}
const script = scripts[0][1];
const challenge = html.match(/<input type="hidden" name="challenge" value="([^"]*)"/)[1];
const difficulty = html.match(/data-difficulty="(\d+)"/)[1];
async function run(name, { withCrypto, submits, skipAfter = false }) {
const statuses = [];
let handler = null;
let submitted = 0;
let sawSubmit;
const done = new Promise((resolve) => (sawSubmit = resolve));
const form = {
elements: { challenge: { value: challenge }, nonce: { value: "" }, waited: { disabled: false } },
getAttribute: (n) => (n === "data-difficulty" ? difficulty : n === "data-wait" ? "30" : null),
addEventListener: (type, fn) => {
if (type === "submit") handler = fn;
},
submit() {
submitted++;
sawSubmit();
},
};
const status = {
set textContent(v) {
statuses.push(v);
},
};
const document = { getElementById: (id) => (id === "register" ? form : id === "status" ? status : null) };
const window = withCrypto ? { crypto: webcrypto, TextEncoder } : {};
vm.runInNewContext(script, { window, document, crypto: webcrypto, TextEncoder, Uint8Array, Promise, setTimeout, Math, parseInt, String });
if (!handler) return { name, listening: false, statuses, submitted, nonce: "", waitedDisabled: false, prevented: [], challenge, difficulty };
const prevented = [];
for (let i = 0; i < submits; i++) handler({ preventDefault: () => prevented.push(true) });
if (skipAfter) {
// The skip button pressed while the solve is in flight: the script must let this submit through and drop the solve.
const skipPrevented = [];
handler({ preventDefault: () => skipPrevented.push(true), submitter: { id: "skip" } });
await new Promise((r) => setTimeout(r, 200));
return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented: skipPrevented, challenge, difficulty };
}
await done;
await new Promise((r) => setTimeout(r, 20));
return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented, challenge, difficulty };
}
(async () => {
const out = [
await run("solves", { withCrypto: true, submits: 1 }),
await run("double-press", { withCrypto: true, submits: 2 }),
await run("skip-during-solve", { withCrypto: true, submits: 1, skipAfter: true }),
await run("no-subtle", { withCrypto: false, submits: 0 }),
];
console.log(JSON.stringify(out));
})();

View file

@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -864,6 +865,44 @@ public sealed class HeimdallCompletionTests
} }
} }
[Fact]
public async Task A_suspended_account_stays_suspended_and_gains_no_role_through_a_sign_in_with_the_member_claim()
{
using var host = new HeimdallHost();
var accountId = HeimdallShapes.NewAccountId();
Assert.True((await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"])).SignedIn);
await host.Db(async db =>
{
var account = await db.UserAccounts.Include(x => x.Membership).SingleAsync(x => x.HeimdallAccountId == accountId);
account.Membership!.Status = MembershipStatus.Suspended;
await db.SaveChangesAsync();
return 0;
});
var rolesBefore = (await host.Account(accountId))!.Membership!.RoleAssignments.Count;
var again = await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"]);
Assert.True(again.SignedIn);
var after = (await host.Account(accountId))!.Membership!;
Assert.Equal(MembershipStatus.Suspended, after.Status);
Assert.Equal(rolesBefore, after.RoleAssignments.Count);
}
[Fact]
public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member()
{
using var host = new HeimdallHost();
var accountId = HeimdallShapes.NewAccountId();
Assert.True((await Genuine(host, await host.StartAsync(member: false), accountId)).SignedIn);
Assert.Equal(MembershipStatus.Authenticated, (await host.Account(accountId))!.Membership!.Status);
Assert.True((await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"])).SignedIn);
var account = (await host.Account(accountId))!;
Assert.Equal(MembershipStatus.Active, account.Membership!.Status);
Assert.Contains(account.Membership.RoleAssignments, x => x.Role == MemberRole.StandardMember);
}
[Fact] [Fact]
public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access() public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access()
{ {

View file

@ -362,7 +362,9 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
MintPermits = mintPermits, MintPermits = mintPermits,
MintWindow = mintWindow ?? TimeSpan.FromHours(1), MintWindow = mintWindow ?? TimeSpan.FromHours(1),
MaxTrackedKeys = maxTrackedKeys MaxTrackedKeys = maxTrackedKeys
})).Validate(); }),
Options.Create(new DeskOptions()),
Options.Create(new RegistrationOptions())).Validate();
} }
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment

View file

@ -0,0 +1,202 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
/// <summary>A refused suspend or unsuspend tells the admin why on the page the redirect lands on, and shows only a fixed message.</summary>
public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
BaseAddress = new Uri("https://localhost"),
HandleCookies = true
});
private async Task<Guid> SeedMemberAsync(MembershipStatus status, bool withMembership = true)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var id = $"notice-{Guid.NewGuid():N}"[..20];
var account = new UserAccount
{
DisplayName = id, BifrostIdentity = id, NormalizedBifrostIdentity = id,
Membership = withMembership ? new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow } : null
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account.Id;
}
private async Task<Guid> SignedInAdminIdAsync(HttpClient client)
{
using var _ = await client.GetAsync("/App/Members"); // sign-in sync creates and promotes the bootstrap admin
using var scope = factory.Services.CreateScope();
return (await scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts.AsNoTracking().SingleAsync(x => x.GitHubUserId == 12345)).Id;
}
private static async Task<string> TokenAsync(HttpClient client)
{
var html = await (await client.GetAsync("/App/Members")).Content.ReadAsStringAsync();
return Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value;
}
private static async Task<string> PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason, string? role = null)
{
var token = await TokenAsync(client);
Assert.NotEmpty(token);
using var post = await client.PostAsync($"/App/Members?handler={handler}", new FormUrlEncodedContent(new Dictionary<string, string>
{
["userAccountId"] = target.ToString(),
["reason"] = reason,
["role"] = role ?? "Producer",
["__RequestVerificationToken"] = token
}));
Assert.Equal(HttpStatusCode.Redirect, post.StatusCode);
using var landed = await client.GetAsync(post.Headers.Location);
Assert.Equal(HttpStatusCode.OK, landed.StatusCode);
return await landed.Content.ReadAsStringAsync();
}
private static readonly Regex Alert = new("<p[^>]*role=\"alert\"[^>]*>(.*?)</p>", RegexOptions.Singleline);
[Fact]
public async Task A_suspend_without_a_reason_says_a_reason_is_required_and_changes_nothing()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "Suspend", member, " ");
Assert.Contains("a reason is required", Alert.Match(html).Groups[1].Value);
using var scope = factory.Services.CreateScope();
Assert.Equal(MembershipStatus.Active, (await scope.ServiceProvider.GetRequiredService<BifrostDbContext>().Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == member)).Status);
}
[Fact]
public async Task A_suspend_of_the_admin_themselves_says_it_is_protected()
{
using var client = Client();
var admin = await SignedInAdminIdAsync(client);
var html = await PostThenFollowAsync(client, "Suspend", admin, "oops");
Assert.Contains("cannot suspend yourself or another platform admin", Alert.Match(html).Groups[1].Value);
}
[Fact]
public async Task A_suspend_of_a_missing_account_says_so_and_never_echoes_the_submitted_text()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
const string canary = "<script>CANARY-notice-77</script>";
var html = await PostThenFollowAsync(client, "Suspend", Guid.NewGuid(), canary);
Assert.Contains("does not exist", Alert.Match(html).Groups[1].Value);
Assert.DoesNotContain("CANARY-notice-77", html);
}
[Fact]
public async Task An_unsuspend_of_an_active_account_says_it_is_already_in_that_state_and_the_notice_shows_once()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "Unsuspend", member, "appeal");
Assert.Contains("already in that state", Alert.Match(html).Groups[1].Value);
Assert.DoesNotContain("role=\"alert\"", await (await client.GetAsync("/App/Members")).Content.ReadAsStringAsync());
}
[Fact]
public async Task A_successful_suspend_shows_no_refusal()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "Suspend", member, "spam");
Assert.DoesNotContain("role=\"alert\"", html);
}
private async Task<(MembershipStatus Status, int Roles)> StateAsync(Guid user)
{
using var scope = factory.Services.CreateScope();
var m = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>().Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == user);
return (m.Status, m.RoleAssignments.Count);
}
[Fact]
public async Task Approving_a_suspended_account_says_only_unsuspend_lifts_it_and_leaves_it_suspended()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Suspended);
var html = await PostThenFollowAsync(client, "Approve", member, "");
Assert.Contains("only be lifted with Unsuspend", Alert.Match(html).Groups[1].Value);
Assert.Equal(MembershipStatus.Suspended, (await StateAsync(member)).Status);
}
[Fact]
public async Task Toggling_a_role_on_a_suspended_account_is_refused_with_a_notice_and_changes_nothing()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Suspended);
var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
Assert.Contains("roles cannot be changed until it is unsuspended", Alert.Match(html).Groups[1].Value);
Assert.Equal(0, (await StateAsync(member)).Roles);
}
[Fact]
public async Task Toggling_a_role_on_an_account_without_a_membership_says_so()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active, withMembership: false);
var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
Assert.Contains("has no membership yet", Alert.Match(html).Groups[1].Value);
}
[Fact]
public async Task An_admin_toggling_their_own_platform_admin_role_is_refused_and_keeps_the_role()
{
using var client = Client();
var admin = await SignedInAdminIdAsync(client);
var before = (await StateAsync(admin)).Roles;
var html = await PostThenFollowAsync(client, "ToggleRole", admin, "", role: "PlatformAdmin");
Assert.Contains("cannot remove or change your own platform admin role", Alert.Match(html).Groups[1].Value);
Assert.Equal(before, (await StateAsync(admin)).Roles);
}
[Fact]
public async Task A_role_toggle_on_an_active_account_still_works_without_a_refusal()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
Assert.DoesNotContain("role=\"alert\"", html);
Assert.True((await StateAsync(member)).Roles >= 1);
}
}

View file

@ -0,0 +1,196 @@
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;
namespace Bifrost.Web.Tests;
/// <summary>A suspension is an admin's decision: only the admin path sets or lifts it, with a receipt, and the members page's Approve cannot lift it.</summary>
public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public MembershipSuspensionTests(TestWebApplicationFactory factory) => _factory = factory;
private sealed record Seeded(Guid AdminId, Guid MemberId, Guid OrdinaryId, Guid OtherAdminId);
private async Task<Seeded> SeedAsync(MembershipStatus memberStatus = MembershipStatus.Active, bool approved = true)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
UserAccount Account(string name, MembershipStatus status, bool admin, bool wasApproved) => new()
{
DisplayName = name,
BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
Membership = new Bifrost.Web.Domain.Membership
{
Status = status,
IsPlatformAdmin = admin,
ApprovedAtUtc = wasApproved ? DateTimeOffset.UtcNow : null
}
};
var admin = Account("admin", MembershipStatus.Active, true, true);
var otherAdmin = Account("admin2", MembershipStatus.Active, true, true);
var member = Account("member", memberStatus, false, approved);
var ordinary = Account("ordinary", MembershipStatus.Active, false, true);
db.UserAccounts.AddRange(admin, otherAdmin, member, ordinary);
await db.SaveChangesAsync();
return new Seeded(admin.Id, member.Id, ordinary.Id, otherAdmin.Id);
}
private async Task<T> InScopeAsync<T>(Func<IServiceProvider, BifrostDbContext, Task<T>> body)
{
using var scope = _factory.Services.CreateScope();
return await body(scope.ServiceProvider, scope.ServiceProvider.GetRequiredService<BifrostDbContext>());
}
private Task<CurrentBifrostActor> ActorAsync(Guid id) => InScopeAsync(async (_, db) =>
new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == id)));
private Task<MembershipStatus> StatusAsync(Guid id) => InScopeAsync(async (_, db) =>
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Status);
private Task<string?> ReasonAsync(Guid id) => InScopeAsync(async (_, db) =>
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).SuspensionReason);
private Task<Guid> MembershipIdAsync(Guid id) => InScopeAsync(async (_, db) =>
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Id);
private Task<List<AuditEvent>> AuditAsync(string action) => InScopeAsync((_, db) =>
db.AuditEvents.AsNoTracking().Where(x => x.Action == action).ToListAsync());
private Task<SuspensionOutcome> SuspendAsync(Guid by, Guid target, string? reason) => InScopeAsync(async (sp, _) =>
await sp.GetRequiredService<MembershipSuspensionService>().SuspendAsync(await ActorAsync(by), target, reason, CancellationToken.None));
private Task<SuspensionOutcome> UnsuspendAsync(Guid by, Guid target, string? reason) => InScopeAsync(async (sp, _) =>
await sp.GetRequiredService<MembershipSuspensionService>().UnsuspendAsync(await ActorAsync(by), target, reason, CancellationToken.None));
[Fact]
public async Task Only_a_platform_admin_can_suspend_and_the_receipt_names_who_and_why()
{
var seed = await SeedAsync();
Assert.Equal(SuspensionOutcome.NotAdmin, await SuspendAsync(seed.OrdinaryId, seed.MemberId, "because"));
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId));
Assert.Equal(SuspensionOutcome.NoReason, await SuspendAsync(seed.AdminId, seed.MemberId, " "));
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId));
Assert.Equal(SuspensionOutcome.Done, await SuspendAsync(seed.AdminId, seed.MemberId, "spam in chat"));
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
var membershipId = (await MembershipIdAsync(seed.MemberId)).ToString();
var receipt = Assert.Single(await AuditAsync("membership.suspended"), x => x.EntityId == membershipId);
Assert.Equal(seed.AdminId, receipt.ActorUserAccountId);
Assert.Contains("Active -> Suspended", receipt.Detail);
Assert.DoesNotContain("spam in chat", receipt.Detail);
Assert.Equal("spam in chat", await ReasonAsync(seed.MemberId));
}
[Fact]
public async Task An_admin_cannot_suspend_themselves_or_another_platform_admin()
{
var seed = await SeedAsync();
Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.AdminId, "oops"));
Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.OtherAdminId, "coup"));
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.AdminId));
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.OtherAdminId));
}
[Fact]
public async Task A_suspension_reason_is_on_the_membership_row_not_the_audit_trail_or_the_member_dashboard_snapshot()
{
var seed = await SeedAsync();
await SuspendAsync(seed.AdminId, seed.MemberId, "private-reason-canary");
var snapshot = await InScopeAsync(async (sp, _) => await sp.GetRequiredService<DashboardSnapshotService>().GetAsync(seed.OrdinaryId, CancellationToken.None));
Assert.DoesNotContain(snapshot.RecentActivity, x => x.Detail.Contains("private-reason-canary"));
Assert.DoesNotContain(await AuditAsync("membership.suspended"), x => x.Detail.Contains("private-reason-canary"));
Assert.Equal("private-reason-canary", await ReasonAsync(seed.MemberId));
}
[Fact]
public async Task A_role_granted_admin_is_protected_like_a_flagged_one()
{
var seed = await SeedAsync();
await InScopeAsync(async (_, db) =>
{
var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == seed.MemberId);
db.RoleAssignments.Add(new RoleAssignment { MembershipId = membership.Id, Role = MemberRole.PlatformAdmin });
await db.SaveChangesAsync();
return 0;
});
Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.MemberId, "role admin"));
}
[Theory]
[InlineData(true, MembershipStatus.Active)]
[InlineData(false, MembershipStatus.Authenticated)]
public async Task Unsuspend_works_only_by_the_admin_path_and_with_a_reason(bool approved, MembershipStatus restored)
{
var seed = await SeedAsync(MembershipStatus.Suspended, approved);
Assert.Equal(SuspensionOutcome.NotAdmin, await UnsuspendAsync(seed.OrdinaryId, seed.MemberId, "please"));
Assert.Equal(SuspensionOutcome.NoReason, await UnsuspendAsync(seed.AdminId, seed.MemberId, null));
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
await InScopeAsync(async (_, db) =>
{
(await db.Memberships.SingleAsync(x => x.UserAccountId == seed.MemberId)).SuspensionReason = "old reason";
await db.SaveChangesAsync();
return 0;
});
Assert.Equal(SuspensionOutcome.Done, await UnsuspendAsync(seed.AdminId, seed.MemberId, "appeal upheld"));
Assert.Equal(restored, await StatusAsync(seed.MemberId));
Assert.Contains(await AuditAsync("membership.unsuspended"), x => x.ActorUserAccountId == seed.AdminId && !x.Detail.Contains("appeal upheld"));
Assert.Null(await ReasonAsync(seed.MemberId));
Assert.Equal(SuspensionOutcome.NotApplicable, await UnsuspendAsync(seed.AdminId, seed.MemberId, "again"));
}
[Fact]
public async Task The_members_page_Approve_cannot_lift_a_suspension()
{
var seed = await SeedAsync(MembershipStatus.Suspended);
var result = await InScopeAsync(async (sp, db) =>
{
var page = new IndexModel(
db, new FixedActor(await ActorAsync(seed.AdminId)), sp.GetRequiredService<AuditTrailService>(),
sp.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
return await page.OnPostApproveAsync(seed.MemberId, CancellationToken.None);
});
Assert.IsType<RedirectToPageResult>(result);
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
}
[Fact]
public async Task The_members_page_suspend_handler_forbids_a_non_admin_and_changes_nothing()
{
var seed = await SeedAsync();
var result = await InScopeAsync(async (sp, db) =>
{
var page = new IndexModel(
db, new FixedActor(await ActorAsync(seed.OrdinaryId)), sp.GetRequiredService<AuditTrailService>(),
sp.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
return await page.OnPostSuspendAsync(seed.MemberId, "nope", CancellationToken.None);
});
Assert.IsType<ForbidResult>(result);
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId));
}
private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor
{
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor);
}
}

View file

@ -24,6 +24,10 @@ public sealed class MigrationDiscoveryTests
"20261022163159_AddOpenIddict", "20261022163159_AddOpenIddict",
"20261109200000_AddSealedBallotMotions", "20261109200000_AddSealedBallotMotions",
"20261109210000_AddMindKeys", "20261109210000_AddMindKeys",
"20261111090000_AddConsentReceipts",
"20261111100000_ConsentReceiptsOneLivePerVersion",
"20261111110000_AddMembershipSuspensionReason",
"20261111120000_ClearLegacyConsentReceipts",
]; ];
// Database.GetMigrations() reads assembly metadata only; no connection is opened. // Database.GetMigrations() reads assembly metadata only; no connection is opened.
@ -53,7 +57,7 @@ public sealed class MigrationDiscoveryTests
} }
[Fact] [Fact]
public void All_sixteen_migrations_are_listed_in_order() public void All_migrations_are_listed_in_order()
{ {
Assert.Equal(ExpectedMigrations, Discovered()); Assert.Equal(ExpectedMigrations, Discovered());
} }

View file

@ -0,0 +1,132 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
// The site serves every script and stylesheet itself: a page that pulls code or styling from another origin hands
// that origin the visitor's session. Fails on any cross-origin <script src> / <link href> and on htmx remnants.
public sealed partial class NoThirdPartyAssetTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
// Known standing exception, not a ruling: the layout loads Google Fonts (fonts.googleapis.com, fonts.gstatic.com).
// Remove the lookahead when the fonts are self-hosted so the guard becomes absolute.
[GeneratedRegex(@"<(?:script|link)\b[^>]*\b(?:src|href)\s*=\s*[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)", RegexOptions.IgnoreCase)]
private static partial Regex CrossOriginAsset();
// An htmx remnant is an attribute (` hx-get="..."`); a bare "hx-" can occur inside a random token.
[GeneratedRegex(@"\shx-[a-z-]+\s*=", RegexOptions.IgnoreCase)]
private static partial Regex HtmxAttribute();
// Source-level: any element that fetches a resource (script/link/img/iframe/...) from an absolute or protocol-relative
// URL outside the Google Fonts allowance, in markup that may sit in C# strings (escaped or doubled quotes), plus CSS @import / url().
[GeneratedRegex(@"<(?:script|link|img|iframe|frame|source|embed|object|video|audio|input)\b[^>]*?\b(?:src|href|data|srcset)\s*=\s*(?:\\|"")?[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)|@import\s+(?:url\()?\s*(?:\\|"")?[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)|\burl\(\s*[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex CrossOriginSource();
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx")))
{
dir = dir.Parent;
}
return dir?.FullName ?? throw new InvalidOperationException("Bifrost.slnx not found above the test binary.");
}
// Every file that can emit HTML or CSS: all .cshtml, all .cs under src (the C# page builders), and the site's own css/js.
private static IEnumerable<string> MarkupSources(string root)
{
var src = Path.Combine(root, "src", "Bifrost.Web");
var sep = Path.DirectorySeparatorChar;
return Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories)
.Where(f => !f.Contains($"{sep}bin{sep}") && !f.Contains($"{sep}obj{sep}") && !f.Contains($"{sep}wwwroot{sep}lib{sep}"))
.Where(f => f.EndsWith(".cshtml") || f.EndsWith(".cs") || f.EndsWith(".css") || f.EndsWith(".js") || f.EndsWith(".html"));
}
[Fact]
public void No_source_that_emits_markup_loads_a_cross_origin_asset()
{
var files = MarkupSources(RepoRoot()).ToList();
Assert.True(files.Count(f => f.EndsWith(".cshtml")) >= 10, "the scan found too few views; the root is wrong");
Assert.Contains(files, f => f.EndsWith("HeimdallCompletePage.cs"));
Assert.Contains(files, f => f.EndsWith(Path.Combine("Membership", "Status.cshtml")));
var offenders = files.Where(f => CrossOriginSource().IsMatch(File.ReadAllText(f))).ToList();
Assert.True(offenders.Count == 0, "cross-origin asset in: " + string.Join(", ", offenders));
Assert.DoesNotContain(files, f => HtmxAttribute().IsMatch(File.ReadAllText(f)));
}
[Theory]
[InlineData("<script src=\"https://evil.example/x.js\"></script>", true)]
[InlineData("<script src='//evil.example/x.js'></script>", true)]
[InlineData("html.Append(\"<script src=\\\"https://evil.example/x.js\\\">\")", true)]
[InlineData("<script src=\"\"https://evil.example/x.js\"\">", true)]
[InlineData("<img src=\"http://evil.example/p.png\">", true)]
[InlineData("<iframe src=\"https://evil.example\"></iframe>", true)]
[InlineData("@import url('https://evil.example/a.css');", true)]
[InlineData("background: url(https://evil.example/a.png)", true)]
[InlineData("<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=X\">", false)]
[InlineData("<script src=\"/js/site.js\"></script>", false)]
[InlineData("<a href=\"https://github.com/GameCult\">repo</a>", false)]
public void The_source_pattern_catches_cross_origin_assets_and_allows_same_origin_and_fonts(string markup, bool flagged) =>
Assert.Equal(flagged, CrossOriginSource().IsMatch(markup));
[Theory]
[InlineData("<div hx-get=\"/x\">", true)]
[InlineData("<div class=a hx-post = \"/x\">", true)]
[InlineData("token=ab-hx-9f3", false)]
public void Htmx_is_matched_only_as_an_attribute(string markup, bool flagged) =>
Assert.Equal(flagged, HtmxAttribute().IsMatch(markup));
[Fact]
public async Task Real_pages_load_no_third_party_asset()
{
var collection = "c" + Guid.NewGuid().ToString("N");
var identity = $"assets-{Guid.NewGuid():N}";
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow });
db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity });
await db.SaveChangesAsync();
}
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") });
var pages = new (string Path, string? Identity, bool Anonymous)[]
{
("/", null, true),
($"/mill/{collection}/alpha", null, true),
($"/mill/{collection}/alpha", null, false),
("/App", null, false),
("/auth/sign-in", null, true),
("/auth/bifrost/register", null, true),
("/auth/bifrost/consent", identity, false),
};
foreach (var (path, who, anonymous) in pages)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
if (anonymous)
{
request.Headers.Add("X-Test-Anonymous", "true");
}
else if (who is not null)
{
request.Headers.Add("X-Test-Bifrost-Identity", who);
}
using var response = await client.SendAsync(request);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"{path} returned {response.StatusCode}");
var html = await response.Content.ReadAsStringAsync();
Assert.False(string.IsNullOrWhiteSpace(html), path);
Assert.DoesNotContain("unpkg.com", html, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("htmx", html, StringComparison.OrdinalIgnoreCase);
Assert.False(HtmxAttribute().IsMatch(html), $"{path} carries an hx- attribute");
Assert.False(CrossOriginAsset().IsMatch(html), $"{path} loads a cross-origin asset");
}
}
}

View file

@ -7,6 +7,7 @@ using System.Text.Json;
using Bifrost.Web.Domain; using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared; using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support; using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -19,7 +20,7 @@ namespace Bifrost.Web.Tests;
public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory> public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
{ {
private static readonly Uri Origin = new("https://localhost"); private static readonly Uri Origin = new("https://localhost");
private readonly OidcWebApplicationFactory _factory; private OidcWebApplicationFactory _factory;
public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory; public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory;
@ -106,20 +107,74 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
Assert.Equal("Second Name", second.Claim("name")); Assert.Equal("Second Name", second.Claim("name"));
} }
[Theory] [Fact]
[InlineData(MembershipStatus.Authenticated)] public async Task A_suspended_account_is_refused_with_access_denied_and_no_code()
[InlineData(MembershipStatus.PendingApproval)]
[InlineData(MembershipStatus.Suspended)]
public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status)
{ {
var (client, _) = await SignedInAsync(status, "Not Active"); var (client, _) = await SignedInAsync(MembershipStatus.Suspended, "Suspended One");
var request = NewRequest(); var request = NewRequest();
var final = await DriveAsync(client, request); var final = await DriveAsync(client, request);
var redirect = AssertRedirectToClient(final, request.State); var redirect = AssertRedirectToClient(final, request.State);
Assert.Equal("access_denied", redirect["error"]); Assert.Equal("access_denied", redirect["error"]);
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NotActiveDescription, redirect["error_description"]); Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.SuspendedDescription, redirect["error_description"]);
Assert.False(redirect.ContainsKey("code"));
}
[Theory]
[InlineData(MembershipStatus.Authenticated)]
[InlineData(MembershipStatus.PendingApproval)]
public async Task A_non_member_gets_a_chat_only_token_and_writes_no_membership_or_vote(MembershipStatus status)
{
var (client, identity) = await SignedInAsync(status, "Chat Only");
var accountId = _factory.AccountId(identity);
var before = await _factory.RowCountsAsync();
var tokens = await CompleteFlowAsync(client);
var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce);
Assert.Equal(accountId.ToString("D"), verified.Claim("sub"));
Assert.Equal(identity, verified.Claim("preferred_username"));
// The issuer wrote nothing about membership or votes, and the status is exactly what it was.
Assert.Equal(before, await _factory.RowCountsAsync());
Assert.Equal(status, await _factory.MembershipStatusAsync(identity));
Assert.Equal(0, (await _factory.RowCountsAsync()).Votes);
// Neither the session that signed in to chat nor its token opens a member or governance surface.
foreach (var path in new[] { "/eve/governance/surface", "/api/sealed-motions", "/App" })
{
using var viaSession = await client.GetAsync(path);
Assert.NotEqual(HttpStatusCode.OK, viaSession.StatusCode);
using var viaToken = new HttpRequestMessage(HttpMethod.Get, path);
viaToken.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
using var bearer = await NewClient().SendAsync(viaToken);
Assert.NotEqual(HttpStatusCode.OK, bearer.StatusCode);
}
using var desk = new HttpRequestMessage(HttpMethod.Get, "/api/desk/forks");
desk.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
using var deskResponse = await NewClient().SendAsync(desk);
Assert.NotEqual(HttpStatusCode.OK, deskResponse.StatusCode);
// Last, because userinfo answers 500 on a .NET 10 runtime (the six known failures), and the rest must still run there.
using var userinfo = await UserInfoAsync(tokens.AccessToken);
Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode);
var info = JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(["name", "preferred_username", "sub"], info.EnumerateObject().Select(x => x.Name).Order().ToArray());
}
[Fact]
public async Task A_chat_tier_account_with_a_reserved_identity_is_still_refused()
{
using var factory = new OidcFixedIdentityFactory("eureka", MembershipStatus.Authenticated);
using var client = factory.CreateClient(ClientOptions());
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}");
var request = NewRequest();
var redirect = AssertRedirectToClient(await DriveAsync(client, request), request.State);
Assert.Equal("access_denied", redirect["error"]);
Assert.False(redirect.ContainsKey("code")); Assert.False(redirect.ContainsKey("code"));
} }
@ -139,6 +194,290 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
Assert.False(redirect.ContainsKey("code")); Assert.False(redirect.ContainsKey("code"));
} }
[Theory]
[InlineData("meta")] // an operator's or a Mind's Matrix localpart
[InlineData("eureka")]
[InlineData("admin")]
[InlineData("system")] // a system name
[InlineData("Heimdall")] // the row's casing does not matter
public async Task An_active_member_whose_identity_is_a_reserved_localpart_gets_no_code(string reserved)
{
using var factory = new OidcFixedIdentityFactory(reserved);
using var client = factory.CreateClient(ClientOptions());
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); // any session: the actor is the fixed account
var request = NewRequest();
var final = await DriveAsync(client, request);
var redirect = AssertRedirectToClient(final, request.State);
Assert.Equal("access_denied", redirect["error"]);
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.ReservedIdentityDescription, redirect["error_description"]);
Assert.False(redirect.ContainsKey("code"));
}
[Theory]
[InlineData("meta2")] // look-alikes are ordinary names
[InlineData("admins")]
[InlineData("kiko-fan")]
public async Task A_name_that_only_resembles_a_reserved_localpart_is_given_a_code(string ordinary)
{
using var factory = new OidcFixedIdentityFactory(ordinary);
using var client = factory.CreateClient(ClientOptions());
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}");
await factory.AgreeAsync(factory.FixedAccountId);
var request = NewRequest();
var final = await DriveAsync(client, request);
var redirect = AssertRedirectToClient(final, request.State);
Assert.True(redirect.ContainsKey("code"));
Assert.False(redirect.ContainsKey("error"));
}
private static IReadOnlyDictionary<string, string?> Settings(params (string Key, string Value)[] pairs) =>
pairs.ToDictionary(p => $"Bifrost:Oidc:{p.Key}", p => (string?)p.Value);
[Fact]
public async Task A_flood_from_one_address_is_refused_with_429_and_a_retry_after()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "5")));
using var client = factory.CreateClient(ClientOptions());
var statuses = new List<HttpStatusCode>();
HttpResponseMessage? last = null;
for (var i = 0; i < 8; i++)
{
last?.Dispose();
last = await client.GetAsync(NewRequest().Path);
statuses.Add(last.StatusCode);
}
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(5));
Assert.All(statuses.Skip(5), status => Assert.Equal(HttpStatusCode.TooManyRequests, status));
Assert.True(int.Parse(last!.Headers.GetValues("Retry-After").Single()) >= 1);
using var tokenFlood = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>()));
Assert.Equal(HttpStatusCode.TooManyRequests, tokenFlood.StatusCode); // one budget over all three endpoints
using var userinfoFlood = await client.GetAsync("/connect/userinfo");
Assert.Equal(HttpStatusCode.TooManyRequests, userinfoFlood.StatusCode);
last.Dispose();
}
[Fact]
public async Task A_flood_naming_the_real_client_does_not_spend_anyone_elses_permits_and_a_trailing_slash_is_no_way_around()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "4")));
using var client = factory.CreateClient(ClientOptions());
var statuses = new List<HttpStatusCode>();
foreach (var path in new[] { NewRequest().Path, NewRequest().Path, "/connect/authorize/?client_id=x", "/connect/token/", "/connect/userinfo/" })
{
using var response = await client.GetAsync(path);
statuses.Add(response.StatusCode);
}
Assert.Equal(HttpStatusCode.TooManyRequests, statuses[4]); // the slashed forms spend the same budget
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(4));
}
[Theory]
[InlineData("/connect/authorize")]
[InlineData("/connect/authorize/")]
[InlineData("/connect/token/")]
[InlineData("/connect/userinfo/")]
[InlineData("/CONNECT/Userinfo")]
public async Task The_brake_holds_for_every_spelling_of_an_endpoint(string path)
{
var marker = Path.Combine(Path.GetTempPath(), $"oidc-brake-{Guid.NewGuid():N}");
File.WriteAllText(marker, "braked");
try
{
using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker)));
using var client = factory.CreateClient(ClientOptions());
using var response = await client.GetAsync(path == "/connect/authorize" || path == "/connect/authorize/" ? path + "?client_id=x" : path);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
finally
{
File.Delete(marker);
}
}
[Fact]
public async Task A_guard_that_cannot_decide_answers_503_and_never_passes_the_request_on()
{
var reached = false;
var options = new Bifrost.Web.Configuration.OidcIssuerOptions();
var middleware = new Bifrost.Web.Features.Oidc.OidcGuardMiddleware(
_ => { reached = true; return Task.CompletedTask; }, options,
new Bifrost.Web.Features.Oidc.OidcRateLimit(options, TimeProvider.System),
Microsoft.Extensions.Logging.Abstractions.NullLogger<Bifrost.Web.Features.Oidc.OidcGuardMiddleware>.Instance);
var http = new DefaultHttpContext();
http.Request.Path = "/connect/authorize";
http.Request.QueryString = new QueryString("?client_id=x");
http.Response.Body = new MemoryStream();
await middleware.InvokeAsync(http, System.Reflection.DispatchProxy.Create<OpenIddict.Abstractions.IOpenIddictTokenManager, ThrowingProxy>());
Assert.False(reached);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, http.Response.StatusCode);
}
/// <summary>Every call fails: the row count the guard asks of the token table cannot be answered.</summary>
public class ThrowingProxy : System.Reflection.DispatchProxy
{
protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) => throw new InvalidOperationException("the token table is down");
}
[Fact]
public async Task A_resumed_authorize_request_is_not_refused_by_the_row_cap_but_a_fresh_one_is()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3")));
_factory = factory;
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Resumer");
using var first = await client.GetAsync(NewRequest().Path); // parks one row, answers with a redirect carrying the request_uri
var resume = first.Headers.Location!.ToString();
Assert.Contains("request_uri", resume);
using var anonymous = factory.CreateClient(ClientOptions());
for (var i = 0; i < 2; i++)
{
using var filler = await anonymous.GetAsync(NewRequest().Path);
}
using var fresh = await anonymous.GetAsync(NewRequest().Path);
Assert.Equal(HttpStatusCode.ServiceUnavailable, fresh.StatusCode);
using var resumed = await client.GetAsync(resume);
Assert.NotEqual(HttpStatusCode.ServiceUnavailable, resumed.StatusCode);
}
[Fact]
public async Task A_mas_flow_fits_the_default_budgets_and_the_window_reopens()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "30")));
_factory = factory;
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Within Budget");
var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null);
Assert.Equal(identity, verified.Claim("preferred_username"));
for (var i = 0; i < 40; i++)
{
using var burst = await client.GetAsync("/connect/userinfo");
}
using var refused = await client.GetAsync("/connect/userinfo");
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
factory.Clock.Advance(TimeSpan.FromMinutes(1));
using var reopened = await client.GetAsync("/connect/userinfo");
Assert.NotEqual(HttpStatusCode.TooManyRequests, reopened.StatusCode);
}
[Fact]
public async Task The_brake_marker_stops_the_issuer_and_releasing_it_needs_no_restart()
{
var marker = Path.Combine(Path.GetTempPath(), $"bifrost-oidc-brake-{Guid.NewGuid():N}");
using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker)));
_factory = factory;
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Braked");
try
{
Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released: the flow passes");
await File.WriteAllTextAsync(marker, "held");
foreach (var path in new[] { NewRequest().Path, "/connect/userinfo" })
{
using var held = await client.GetAsync(path);
Assert.Equal(HttpStatusCode.ServiceUnavailable, held.StatusCode);
Assert.True(held.Headers.Contains("Retry-After"));
}
using var token = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>()));
Assert.Equal(HttpStatusCode.ServiceUnavailable, token.StatusCode);
using var discovery = await client.GetAsync("/.well-known/openid-configuration");
Assert.Equal(HttpStatusCode.OK, discovery.StatusCode); // the brake stops issuing, not the published keys
File.Delete(marker);
Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released again, same process");
}
finally
{
File.Delete(marker);
}
}
[Fact]
public async Task Anonymous_authorize_requests_cannot_park_more_than_the_row_cap_and_the_prune_gives_the_room_back()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3")));
_factory = factory;
using var client = factory.CreateClient(ClientOptions());
var parked = new List<HttpStatusCode>();
for (var i = 0; i < 5; i++)
{
using var response = await client.GetAsync(NewRequest().Path);
parked.Add(response.StatusCode);
}
Assert.Equal(3, await factory.TokenRowsAsync());
Assert.All(parked.Skip(3), status => Assert.Equal(HttpStatusCode.ServiceUnavailable, status));
Assert.All(parked.Take(3), status => Assert.NotEqual(HttpStatusCode.ServiceUnavailable, status));
factory.Clock.Advance(TimeSpan.FromMinutes(11)); // past the ten-minute parked lifetime
await factory.PruneAsync();
Assert.Equal(0, await factory.TokenRowsAsync());
using var again = await client.GetAsync(NewRequest().Path);
Assert.NotEqual(HttpStatusCode.ServiceUnavailable, again.StatusCode);
}
[Fact]
public async Task The_prune_removes_expired_rows_and_leaves_a_live_token_alone()
{
using var factory = new OidcSettingsFactory(Settings());
_factory = factory;
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Pruned");
var tokens = await CompleteFlowAsync(client); // a parked request, a code and an access token existed along the way
using (var anonymous = factory.CreateClient(ClientOptions()))
{
using var parked = await anonymous.GetAsync(NewRequest().Path); // a parked request that will expire
}
var before = await factory.TokenRowsAsync();
await factory.PruneAsync();
Assert.Equal(before, await factory.TokenRowsAsync()); // nothing has expired yet: nothing goes
factory.Clock.Advance(TimeSpan.FromMinutes(11)); // parked requests and codes are dead, the access token (an hour) is not
await factory.PruneAsync();
var after = await factory.TokenRowsAsync();
Assert.True(after < before, "expired rows went");
Assert.Contains("urn:ietf:params:oauth:token-type:access_token", await factory.TokenTypesAsync()); // the live one, not merely some row
using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
using var response = await factory.CreateClient(ClientOptions()).SendAsync(userinfo);
Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task The_prune_takes_a_revoked_authorization_with_its_tokens_and_touches_no_live_authorization()
{
using var factory = new OidcSettingsFactory(Settings());
_factory = factory;
var (revoked, _) = await SignedInAsync(MembershipStatus.Active, "Revoked");
await CompleteFlowAsync(revoked);
var (live, _) = await SignedInAsync(MembershipStatus.Active, "Live");
await CompleteFlowAsync(live);
Assert.Equal(2, await factory.AuthorizationRowsAsync());
Assert.Equal(6, await factory.TokensOfAuthorizationsAsync()); // a spent code, an access token and an id token each
await factory.RevokeFirstAuthorizationAsync();
factory.Clock.Advance(TimeSpan.FromMinutes(6)); // past the grace for a young row; the access tokens (an hour) are still alive
await factory.PruneAsync();
Assert.Equal(1, await factory.AuthorizationRowsAsync());
Assert.Equal(2, await factory.TokenRowsAsync()); // no orphan stays either: the revoked one's tokens went with it; the live one's access and id tokens stayed, spent codes go
}
[Theory] [Theory]
[InlineData("unknown-client", OidcTestKit.RedirectUri, true)] [InlineData("unknown-client", OidcTestKit.RedirectUri, true)]
[InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)] [InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)]
@ -335,11 +674,9 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
} }
[Theory] [Theory]
[InlineData(MembershipStatus.Authenticated)]
[InlineData(MembershipStatus.PendingApproval)]
[InlineData(MembershipStatus.Suspended)] [InlineData(MembershipStatus.Suspended)]
[InlineData(null)] [InlineData(null)]
public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status) public async Task An_account_suspended_or_gone_after_authorize_gets_no_tokens(MembershipStatus? status)
{ {
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone");
var request = NewRequest(); var request = NewRequest();
@ -356,11 +693,9 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
} }
[Theory] [Theory]
[InlineData(MembershipStatus.Authenticated)]
[InlineData(MembershipStatus.PendingApproval)]
[InlineData(MembershipStatus.Suspended)] [InlineData(MembershipStatus.Suspended)]
[InlineData(null)] [InlineData(null)]
public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status) public async Task Userinfo_refuses_an_access_token_whose_account_is_suspended_or_gone(MembershipStatus? status)
{ {
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone");
var tokens = await CompleteFlowAsync(client); var tokens = await CompleteFlowAsync(client);
@ -491,6 +826,164 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString()); Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString());
} }
// ---- chat needs a live receipt for the notice version in force; nothing else does ----
private const string ConsentPath = "/auth/bifrost/consent";
/// <summary>A form post as the consent page's own buttons send it: the site's own Origin, no antiforgery token.</summary>
private async Task<HttpResponseMessage> ConsentPostAsync(HttpClient client, string action, string? returnUrl = null)
{
var fields = new Dictionary<string, string> { ["version"] = Bifrost.Web.Features.Registration.TrainingNotice.Version.ToString() };
if (returnUrl is not null)
{
fields["returnUrl"] = returnUrl;
}
using var message = new HttpRequestMessage(HttpMethod.Post, $"{ConsentPath}/{action}") { Content = new FormUrlEncodedContent(fields) };
message.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority));
return await client.SendAsync(message);
}
[Theory]
[InlineData(ReceiptState.None)]
[InlineData(ReceiptState.Withdrawn)]
[InlineData(ReceiptState.OtherVersion)]
public async Task Chat_authorize_without_a_live_current_receipt_sends_the_member_to_the_consent_page_with_the_request_to_resume(ReceiptState receipt)
{
var (client, _) = await SignedInAsync(MembershipStatus.Active, "No Agreement", receipt);
var request = NewRequest();
var final = await DriveAsync(client, request);
Assert.Equal(HttpStatusCode.Redirect, final.StatusCode);
var location = final.Headers.Location!.OriginalString;
Assert.DoesNotContain("code=", location);
Assert.DoesNotContain("error=", location);
Assert.StartsWith(ConsentPath + "?returnUrl=", location);
var resume = ReturnUrl(final);
Assert.StartsWith("/connect/authorize?", resume);
Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); // short enough to survive as a local return path
using var page = await client.GetAsync(location);
var html = await page.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html);
Assert.Contains("to speak in a recorded room", html);
}
[Fact]
public async Task Agreeing_on_the_consent_page_resumes_the_refused_request_and_chat_works()
{
var (client, identity) = await SignedInAsync(MembershipStatus.Authenticated, "Late Agreer", ReceiptState.None);
var request = NewRequest();
var refused = await DriveAsync(client, request);
var resume = ReturnUrl(refused);
using var agreed = await ConsentPostAsync(client, "accept", resume);
Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode);
Assert.Equal(resume, agreed.Headers.Location!.OriginalString);
using var resumed = await client.GetAsync(resume);
var code = AssertRedirectToClient(resumed, request.State)["code"];
Assert.NotNull(code);
var tokens = await ExchangeAsync(client, request, code!);
using var userinfo = await UserInfoAsync(tokens.AccessToken);
Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode);
Assert.Equal(identity, JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement.GetProperty("preferred_username").GetString());
}
[Theory]
[InlineData(ReceiptState.Withdrawn)]
[InlineData(ReceiptState.OtherVersion)]
public async Task A_code_and_a_token_issued_before_the_agreement_went_stop_working(ReceiptState gone)
{
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Silent");
var pendingRequest = NewRequest();
var pendingCode = await CodeAsync(client, pendingRequest);
var tokens = await CompleteFlowAsync(client);
using (var before = await UserInfoAsync(tokens.AccessToken))
{
Assert.Equal(HttpStatusCode.OK, before.StatusCode);
}
await _factory.SetReceiptAsync(identity, gone);
using var code = await TokenAsync(client, pendingRequest, pendingCode);
var codeBody = await code.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, code.StatusCode);
Assert.Contains("invalid_grant", codeBody);
Assert.DoesNotContain("access_token", codeBody);
using var after = await UserInfoAsync(tokens.AccessToken);
var afterBody = await after.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode);
Assert.Contains("insufficient_access", string.Join(" ", after.Headers.WwwAuthenticate.Select(x => x.ToString())));
Assert.DoesNotContain(identity, afterBody);
Assert.DoesNotContain("preferred_username", afterBody);
}
[Fact]
public async Task Withdrawing_on_the_consent_page_ends_chat_and_agreeing_again_restores_it()
{
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Changes Mind");
var tokens = await CompleteFlowAsync(client);
using (var withdrawn = await ConsentPostAsync(client, "withdraw"))
{
Assert.Equal(HttpStatusCode.SeeOther, withdrawn.StatusCode);
}
using (var silenced = await UserInfoAsync(tokens.AccessToken))
{
Assert.Equal(HttpStatusCode.Forbidden, silenced.StatusCode);
}
var request = NewRequest();
Assert.StartsWith(ConsentPath, (await DriveAsync(client, request)).Headers.Location!.OriginalString);
using (var again = await ConsentPostAsync(client, "accept"))
{
Assert.Equal(HttpStatusCode.SeeOther, again.StatusCode);
}
var fresh = await CompleteFlowAsync(client);
using var restored = await UserInfoAsync(fresh.AccessToken);
Assert.Equal(HttpStatusCode.OK, restored.StatusCode);
}
[Fact]
public async Task Without_a_receipt_account_sign_in_the_members_page_and_votes_still_work()
{
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Member Only", ReceiptState.None);
var accountId = _factory.AccountId(identity);
foreach (var path in new[] { "/App", "/App/Members", ConsentPath })
{
using var page = await client.GetAsync(path);
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
}
var collection = "c" + Guid.NewGuid().ToString("N");
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<Bifrost.Web.Data.BifrostDbContext>();
db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow });
await db.SaveChangesAsync();
}
using var vote = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up") { Content = new FormUrlEncodedContent([]) };
vote.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority));
using var voted = await client.SendAsync(vote);
Assert.Equal(HttpStatusCode.Redirect, voted.StatusCode);
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<Bifrost.Web.Data.BifrostDbContext>();
Assert.Single(db.ConceptAppraisals.AsNoTracking().Where(x => x.Subject!.Collection == collection && x.UserAccountId == accountId));
}
Assert.Empty(_factory.ReceiptsOf(identity)); // none of that wrote an agreement
}
// ---- helpers ---- // ---- helpers ----
private static WebApplicationFactoryClientOptions ClientOptions() => new() private static WebApplicationFactoryClientOptions ClientOptions() => new()
@ -502,17 +995,22 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); private HttpClient NewClient() => _factory.CreateClient(ClientOptions());
private static async Task<string> RegisterAsync(HttpClient client, string identity) private static async Task<string> RegisterAsync(HttpClient client, string identity, bool agree = true)
{ {
using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity }); using var response = await RegistrationKit.RegisterAsync(client, identity, agree: agree);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return identity.ToLowerInvariant(); return identity.ToLowerInvariant();
} }
private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName) private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName, ReceiptState receipt = ReceiptState.Current)
{ {
var client = NewClient(); var client = NewClient();
var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}"); var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}", agree: receipt != ReceiptState.None);
if (receipt is ReceiptState.Withdrawn or ReceiptState.OtherVersion)
{
await _factory.SetReceiptAsync(identity, receipt);
}
await _factory.SetMembershipAsync(identity, status); await _factory.SetMembershipAsync(identity, status);
await _factory.SetDisplayNameAsync(identity, displayName); await _factory.SetDisplayNameAsync(identity, displayName);
return (client, identity); return (client, identity);

View file

@ -0,0 +1,50 @@
using Bifrost.Web.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Tests;
/// <summary>Without a back-channel logout URL suspension cannot end live chat sessions, and the host says so once at startup.</summary>
public sealed class OidcLogoutReachWarningTests
{
private const string Marker = "does not end its existing chat sessions";
private static int Warnings(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> factory, CapturingLoggerProvider logs)
{
_ = factory.Services; // starts the host and its hosted services
return logs.Lines.Count(x => x.Contains(Marker));
}
private static Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> WithLogs(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> factory, CapturingLoggerProvider logs) =>
factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
[Fact]
public void OIDC_configured_without_the_logout_url_warns_exactly_once()
{
var logs = new CapturingLoggerProvider();
using var plain = new OidcWebApplicationFactory();
using var factory = WithLogs(plain, logs);
Assert.Equal(1, Warnings(factory, logs));
}
[Fact]
public void OIDC_with_the_logout_url_does_not_warn()
{
var logs = new CapturingLoggerProvider();
using var plain = new OidcLogoutFactory();
using var factory = WithLogs(plain, logs);
Assert.Equal(0, Warnings(factory, logs));
}
[Fact]
public void OIDC_unconfigured_does_not_warn()
{
var logs = new CapturingLoggerProvider();
using var plain = new TestWebApplicationFactory();
using var factory = WithLogs(plain, logs);
Assert.Equal(0, Warnings(factory, logs));
}
}

View file

@ -37,9 +37,7 @@ public sealed class OidcLogoutTests
[Theory] [Theory]
[InlineData(MembershipStatus.Suspended)] [InlineData(MembershipStatus.Suspended)]
[InlineData(MembershipStatus.PendingApproval)] public async Task A_suspended_account_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
[InlineData(MembershipStatus.Authenticated)]
public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
{ {
using var factory = new OidcLogoutFactory(); using var factory = new OidcLogoutFactory();
var (identity, idToken) = await factory.GrantAsync(status); var (identity, idToken) = await factory.GrantAsync(status);
@ -78,6 +76,25 @@ public sealed class OidcLogoutTests
Assert.Single(factory.Mas.Bodies); Assert.Single(factory.Mas.Bodies);
} }
[Theory]
[InlineData(ReceiptState.Withdrawn)]
[InlineData(ReceiptState.OtherVersion)]
[InlineData(ReceiptState.None)]
public async Task An_account_whose_agreement_has_gone_is_logged_out_while_one_that_agreed_is_left_alone(ReceiptState gone)
{
using var factory = new OidcLogoutFactory();
var (silenced, _) = await factory.GrantAsync(MembershipStatus.Active);
var (agreed, _) = await factory.GrantAsync(MembershipStatus.Active);
await factory.SetReceiptAsync(silenced, gone);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
var token = new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens()));
Assert.Equal(factory.AccountId(silenced).ToString("D"), token.Subject);
Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(silenced).ToString("D")));
Assert.True(factory.ValidAuthorizations(factory.AccountId(agreed).ToString("D")) > 0);
}
[Fact] [Fact]
public async Task Two_logouts_for_one_subject_carry_different_jti() public async Task Two_logouts_for_one_subject_carry_different_jti()
{ {
@ -112,6 +129,23 @@ public sealed class OidcLogoutTests
Assert.Equal(before, factory.ValidAuthorizations(activeSubject)); Assert.Equal(before, factory.ValidAuthorizations(activeSubject));
} }
[Theory]
[InlineData(MembershipStatus.Authenticated)]
[InlineData(MembershipStatus.PendingApproval)]
public async Task A_chat_tier_non_member_is_never_sent_a_logout_and_keeps_the_authorizations(MembershipStatus status)
{
using var factory = new OidcLogoutFactory();
var (chat, _) = await factory.GrantAsync(status);
var subject = factory.AccountId(chat).ToString("D");
var before = factory.ValidAuthorizations(subject);
Assert.True(before > 0);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Empty(factory.Mas.LogoutTokens());
Assert.Equal(before, factory.ValidAuthorizations(subject));
}
[Fact] [Fact]
public async Task One_subject_that_cannot_be_delivered_does_not_block_another() public async Task One_subject_that_cannot_be_delivered_does_not_block_another()
{ {

View file

@ -45,16 +45,8 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
public async Task Bifrost_native_registration_creates_authenticated_identity_without_oauth() public async Task Bifrost_native_registration_creates_authenticated_identity_without_oauth()
{ {
using var client = _factory.CreateClient(); using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register"); using var response = await RegistrationKit.RegisterAsync(
request.Headers.Add("X-Test-Anonymous", "true"); client, $"native-{Guid.NewGuid():N}", "Native Bifrost User", "/App", anonymous: true);
request.Content = JsonContent.Create(new
{
identity = $"native-{Guid.NewGuid():N}",
displayName = "Native Bifrost User",
returnUrl = "/App"
});
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(); var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
@ -79,15 +71,8 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
using var client = _factory.CreateClient(); using var client = _factory.CreateClient();
var identity = $"native-{Guid.NewGuid():N}"; var identity = $"native-{Guid.NewGuid():N}";
using var firstRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register"); using var firstResponse = await RegistrationKit.RegisterAsync(client, identity, "First", anonymous: true);
firstRequest.Headers.Add("X-Test-Anonymous", "true"); using var secondResponse = await RegistrationKit.RegisterAsync(client, identity.ToUpperInvariant(), "Second", anonymous: true);
firstRequest.Content = JsonContent.Create(new { identity, displayName = "First" });
using var firstResponse = await client.SendAsync(firstRequest);
using var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
secondRequest.Headers.Add("X-Test-Anonymous", "true");
secondRequest.Content = JsonContent.Create(new { identity = identity.ToUpperInvariant(), displayName = "Second" });
using var secondResponse = await client.SendAsync(secondRequest);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode);

View file

@ -0,0 +1,957 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public class RegistrationFormFactory : TestWebApplicationFactory
{
public ManualClock Time { get; } = new();
protected virtual string FallbackPermits => "100";
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Bifrost:Registration:Captcha:FallbackWaitSeconds"] = "30",
["Bifrost:Registration:Limits:FallbackPermits"] = FallbackPermits
}));
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Time);
});
}
}
public sealed class RegistrationFallbackBudgetFactory : RegistrationFormFactory
{
protected override string FallbackPermits => "2";
}
internal static class FormKit
{
public static string Hidden(string html, string name)
{
var match = Regex.Match(html, $"<input type=\"hidden\" name=\"{name}\" value=\"([^\"]*)\"");
Assert.True(match.Success, $"no hidden field {name}");
return WebUtility.HtmlDecode(match.Groups[1].Value);
}
public static async Task<string> PageAsync(HttpClient client, string query = "")
{
using var response = await client.GetAsync("/auth/bifrost/register" + query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadAsStringAsync();
}
public const string SiteOrigin = "https://localhost";
// A browser on the site's own page sends its Origin; the guard refuses a post without one.
public static Task<HttpResponseMessage> PostAsync(HttpClient client, Dictionary<string, string> fields, string? origin = SiteOrigin, string? peer = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = new FormUrlEncodedContent(fields) };
if (origin is not null)
{
request.Headers.Add("Origin", origin);
}
if (peer is not null)
{
request.Headers.Add("X-Test-Peer", peer);
}
return client.SendAsync(request);
}
public static Dictionary<string, string> Fields(string html, string identity, bool consent = true, string? nonce = null, bool waited = false, string? returnUrl = null)
{
var fields = new Dictionary<string, string>
{
["identity"] = identity,
["displayName"] = "",
["challenge"] = Hidden(html, "challenge"),
["acceptedNoticeVersion"] = Hidden(html, "acceptedNoticeVersion"),
["returnUrl"] = returnUrl ?? Hidden(html, "returnUrl")
};
if (consent)
{
fields["consent"] = "yes";
}
if (nonce is not null)
{
fields["nonce"] = nonce;
}
if (waited)
{
fields["waited"] = "true";
}
return fields;
}
/// <summary>A nonce that provably fails this page's challenge: its hash lacks the leading zero bits the difficulty needs.</summary>
public static string Unsolved(string html)
{
var challenge = Hidden(html, "challenge");
var difficulty = int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value);
// Starts at a long marker value so a test can check the nonce is never echoed.
for (var nonce = 7777777777L; ; nonce++)
{
var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + text))) < difficulty)
{
return text;
}
}
}
public static string Solved(string html) => RegistrationKit.Solve(Hidden(html, "challenge"), int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value));
}
public sealed class RegistrationFormTests(RegistrationFormFactory factory) : IClassFixture<RegistrationFormFactory>
{
private static string NewIdentity() => $"form-{Guid.NewGuid():N}";
private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
private (int Accounts, int Receipts) Counts()
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return (db.UserAccounts.Count(), db.ConsentReceipts.Count());
}
private bool Exists(string identity)
{
using var scope = factory.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts.Any(x => x.NormalizedBifrostIdentity == identity);
}
private (bool Account, bool Receipt) Created(string identity)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.SingleOrDefault(x => x.NormalizedBifrostIdentity == identity);
return (account is not null, account is not null && db.ConsentReceipts.Any(x => x.UserAccountId == account.Id && x.NoticeVersion == TrainingNotice.Version));
}
[Fact]
public async Task The_page_shows_the_whole_notice_beside_an_unchecked_consent_and_touches_nothing_outside()
{
using var client = Client();
using var response = await client.GetAsync("/auth/bifrost/register");
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
foreach (var paragraph in TrainingNotice.Text.Split("\n\n"))
{
Assert.Contains(WebUtility.HtmlEncode(paragraph), html);
}
Assert.Contains($"version {TrainingNotice.Version}", html);
Assert.Contains("<html lang=\"en\">", html);
Assert.Matches("<input type=\"checkbox\" id=\"consent\" name=\"consent\" value=\"yes\" [^>]*/>", html);
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\bchecked\\b", html);
Assert.Contains("I agree to this notice, to speak in a recorded room", html);
Assert.Contains("Withdrawing ends future speaking there; lines you have already spoken stay.", html);
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\brequired\\b", html);
foreach (var id in new[] { "identity", "displayName", "consent" })
{
Assert.Matches($"<label[^>]*for=\"{id}\"", html);
}
Assert.Contains("<fieldset>", html);
Assert.Contains("<legend>", html);
Assert.Contains("aria-live=\"polite\"", html);
Assert.Equal(TrainingNotice.Version.ToString(), FormKit.Hidden(html, "acceptedNoticeVersion"));
Assert.False(string.IsNullOrWhiteSpace(FormKit.Hidden(html, "challenge")));
Assert.Matches("wait \\d+ seconds", html);
Assert.Matches("<button type=\"submit\" id=\"skip\"[^>]*>Skip the automatic check and wait \\d+ seconds instead</button>", html);
Assert.DoesNotContain("a few seconds", html);
Assert.DoesNotContain("nothing is lost", html);
Assert.Contains("tick it again if you want to agree", html);
Assert.DoesNotContain("<noscript", html);
Assert.DoesNotContain("http://", html);
Assert.DoesNotContain("https://", html);
foreach (Match url in Regex.Matches(html, "(?:src|href|action)=\"([^\"]*)\""))
{
Assert.Matches("^(/(?!/)|#)", url.Groups[1].Value);
}
}
[Fact]
public async Task A_solved_form_creates_the_account_and_receipt_signs_in_and_redirects_to_the_return_path()
{
using var client = Client();
var html = await FormKit.PageAsync(client, "?returnUrl=%2FTasks%3Fx%3D1");
Assert.Equal("/Tasks?x=1", FormKit.Hidden(html, "returnUrl"));
var identity = NewIdentity();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: FormKit.Solved(html)));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/Tasks?x=1", response.Headers.Location!.OriginalString);
Assert.True(response.Headers.Contains("Set-Cookie"));
Assert.Equal((true, true), Created(identity));
}
[Fact]
public async Task The_default_return_path_is_App()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: ""));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/App", response.Headers.Location!.OriginalString);
}
[Theory]
[InlineData("//evil.example")]
[InlineData("https://evil.example/App")]
[InlineData("/\\evil.example")]
[InlineData("\\\\evil.example")]
public async Task A_return_path_that_leaves_the_site_lands_on_App(string probe)
{
using var client = Client();
var page = await FormKit.PageAsync(client, "?returnUrl=" + Uri.EscapeDataString(probe));
Assert.Equal("/App", FormKit.Hidden(page, "returnUrl"));
var html = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: probe));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/App", response.Headers.Location!.OriginalString);
}
[Fact]
public async Task A_non_ASCII_return_path_never_reaches_the_Location_header()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: "/Tasks\u00e9"));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/App", response.Headers.Location!.OriginalString);
}
[Fact]
public async Task Without_the_consent_box_the_account_is_created_with_no_receipt()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var identity = NewIdentity();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, consent: false, nonce: FormKit.Solved(html)));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.True(Exists(identity));
Assert.False(Created(identity).Receipt);
using var scope = factory.Services.CreateScope();
Assert.Empty(scope.ServiceProvider.GetRequiredService<BifrostDbContext>().ConsentReceipts.Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity));
}
[Fact]
public async Task The_consent_box_with_a_stale_version_returns_the_page_with_an_error_and_nothing_created()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var identity = NewIdentity();
var before = Counts();
var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html));
fields["acceptedNoticeVersion"] = (TrainingNotice.Version + 1).ToString();
using var response = await FormKit.PostAsync(client, fields);
var again = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Matches("role=\"alert\"", again);
Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
Assert.Contains("<a href=\"#consent\">", again);
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\bchecked\\b", again);
Assert.Contains($"value=\"{identity}\"", again);
Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge"));
Assert.Equal(before, Counts());
}
[Theory]
[InlineData("abc")]
[InlineData("02")]
[InlineData("002")]
[InlineData("+2")]
[InlineData(" 2")]
public async Task The_consent_box_with_a_version_that_is_not_the_canonical_decimal_is_refused_not_read_as_no_consent(string posted)
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var identity = NewIdentity();
var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html));
fields["acceptedNoticeVersion"] = posted;
using var response = await FormKit.PostAsync(client, fields);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.False(Exists(identity));
}
[Fact]
public async Task A_wrong_nonce_fails_the_challenge_and_the_error_never_echoes_the_challenge_or_nonce()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var identity = NewIdentity();
var nonce = FormKit.Unsolved(html);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
var again = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Registration challenge failed.", again);
Assert.DoesNotContain(challenge, again);
Assert.DoesNotContain(nonce, again);
Assert.False(Exists(identity));
}
[Fact]
public async Task An_identity_with_markup_is_escaped_when_the_page_comes_back()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
const string hostile = "<script>alert(1)</script>\"><img src=x>";
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, hostile, consent: false));
var again = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("<script>alert(1)", again);
Assert.DoesNotContain("<img src=x>", again);
Assert.Contains("&lt;script&gt;alert(1)&lt;/script&gt;", again);
Assert.Single(Regex.Matches(again, "<script>"));
}
[Fact]
public async Task A_taken_identity_marks_the_identity_field_invalid()
{
using var client = Client();
var identity = NewIdentity();
var first = await FormKit.PageAsync(client);
using (await FormKit.PostAsync(client, FormKit.Fields(first, identity, nonce: FormKit.Solved(first)))) { }
var second = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(second, identity, nonce: FormKit.Solved(second)));
var again = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("already registered", again);
Assert.Matches("id=\"identity\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
Assert.Contains($"value=\"{identity}\"", again);
Assert.Contains("<a href=\"#identity\">", again);
Assert.Equal(FormKit.Hidden(second, "challenge"), FormKit.Hidden(again, "challenge"));
}
[Fact]
public async Task The_time_lock_refuses_before_the_wait_with_the_seconds_left_keeps_the_challenge_and_accepts_after()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var identity = NewIdentity();
var before = Counts();
using var early = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true));
var earlyPage = await early.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode);
Assert.Contains("30 more seconds", earlyPage);
Assert.Equal(challenge, FormKit.Hidden(earlyPage, "challenge"));
Assert.Equal(before, Counts());
factory.Time.Advance(TimeSpan.FromSeconds(10));
using var stillEarly = await FormKit.PostAsync(client, FormKit.Fields(earlyPage, identity, waited: true));
Assert.Contains("20 more seconds", await stillEarly.Content.ReadAsStringAsync());
Assert.Equal(before, Counts());
factory.Time.Advance(TimeSpan.FromSeconds(20));
using var ready = await FormKit.PostAsync(client, FormKit.Fields(earlyPage, identity, waited: true));
Assert.Equal(HttpStatusCode.SeeOther, ready.StatusCode);
Assert.Equal((true, true), Created(identity));
}
[Fact]
public async Task A_spent_time_lock_challenge_cannot_be_used_again_by_either_path()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
factory.Time.Advance(TimeSpan.FromSeconds(31));
using (var first = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), waited: true)))
{
Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode);
}
var replayWaited = NewIdentity();
using var again = await FormKit.PostAsync(client, FormKit.Fields(html, replayWaited, waited: true));
var replayPow = NewIdentity();
using var viaWork = await FormKit.PostAsync(client, FormKit.Fields(html, replayPow, nonce: FormKit.Solved(html)));
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, viaWork.StatusCode);
Assert.False(Exists(replayWaited));
Assert.False(Exists(replayPow));
}
[Fact]
public async Task A_nonce_together_with_waited_is_refused_and_spends_nothing()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var identity = NewIdentity();
factory.Time.Advance(TimeSpan.FromSeconds(31));
var nonce = FormKit.Solved(html);
using var both = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce, waited: true));
Assert.Equal(HttpStatusCode.BadRequest, both.StatusCode);
Assert.Contains("not both", await both.Content.ReadAsStringAsync());
Assert.False(Exists(identity));
using var json = await RegistrationKit.PostAsync(client, new
{
identity,
challenge = FormKit.Hidden(html, "challenge"),
nonce,
waited = true,
acceptedNoticeVersion = TrainingNotice.Version
}, anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, json.StatusCode);
Assert.False(Exists(identity));
// The challenge was not spent by either refusal.
using var ok = await RegistrationKit.PostAsync(client, new
{
identity,
challenge = FormKit.Hidden(html, "challenge"),
nonce,
acceptedNoticeVersion = TrainingNotice.Version
}, anonymous: true);
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
[Fact]
public async Task The_JSON_endpoint_takes_waited_true_with_the_same_time_lock()
{
using var client = Client();
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var challenge = issued.GetProperty("challenge").GetString()!;
var identity = NewIdentity();
object Body() => new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version };
using var early = await RegistrationKit.PostAsync(client, Body(), anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode);
Assert.Contains("more second", await early.Content.ReadAsStringAsync());
Assert.False(Exists(identity));
factory.Time.Advance(TimeSpan.FromSeconds(30));
using var ready = await RegistrationKit.PostAsync(client, Body(), anonymous: true);
Assert.Equal(HttpStatusCode.OK, ready.StatusCode);
Assert.Equal((true, true), Created(identity));
}
[Fact]
public async Task A_tampered_challenge_fails_the_time_lock()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var fields = FormKit.Fields(html, NewIdentity(), waited: true);
fields["challenge"] = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
factory.Time.Advance(TimeSpan.FromSeconds(31));
using var response = await FormKit.PostAsync(client, fields);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Registration challenge failed.", await response.Content.ReadAsStringAsync());
Assert.False(Exists(fields["identity"]));
}
[Fact]
public async Task A_form_must_be_urlencoded()
{
using var client = Client();
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = JsonContent.Create(new { identity = "x" }) };
request.Headers.Add("Origin", FormKit.SiteOrigin);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
public static TheoryData<string?, string?> CrossSite => new()
{
{ "https://evil.example", null },
{ "https://evil.example", "same-origin" },
{ "null", null },
{ "https://localhost.evil.example", null },
{ null, "cross-site" },
{ null, "same-site" },
{ null, "none" }
};
[Theory]
[MemberData(nameof(CrossSite))]
public async Task A_cross_site_or_unattributed_post_is_refused_before_anything_is_read_or_spent(string? origin, string? fetchSite)
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var identity = NewIdentity();
var before = Counts();
factory.Time.Advance(TimeSpan.FromSeconds(31));
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
{
Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, waited: true))
};
if (origin is not null)
{
request.Headers.Add("Origin", origin);
}
if (fetchSite is not null)
{
request.Headers.Add("Sec-Fetch-Site", fetchSite);
}
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
Assert.Equal(before, Counts());
// The challenge was not spent by the refusal.
using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true));
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
}
private async Task<HttpResponseMessage> PostWithAsync(HttpClient client, string html, string identity, string? origin, string? fetchSite, string? cookie)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
{
Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, nonce: FormKit.Solved(html)))
};
if (origin is not null) request.Headers.Add("Origin", origin);
if (fetchSite is not null) request.Headers.Add("Sec-Fetch-Site", fetchSite);
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return await client.SendAsync(request);
}
[Fact]
public async Task A_text_browser_sending_neither_header_is_accepted_only_without_a_session_cookie()
{
const string session = "__Host-bifrost_session=abc";
using var client = Client();
var html = await FormKit.PageAsync(client);
var plain = NewIdentity();
using (var ok = await PostWithAsync(client, html, plain, null, null, null))
{
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
Assert.Equal((true, true), Created(plain));
}
var second = await FormKit.PageAsync(client);
var withSession = NewIdentity();
using (var refused = await PostWithAsync(client, second, withSession, null, null, session))
{
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
Assert.False(refused.Headers.Contains("Set-Cookie"));
Assert.False(Exists(withSession));
}
// Any header present is judged as before, cookie or not; same-origin with a session passes.
using (var crossNoCookie = await PostWithAsync(client, second, withSession, null, "cross-site", null))
{
Assert.Equal(HttpStatusCode.Forbidden, crossNoCookie.StatusCode);
}
using var sameOrigin = await PostWithAsync(client, second, withSession, FormKit.SiteOrigin, null, session);
Assert.Equal(HttpStatusCode.SeeOther, sameOrigin.StatusCode);
}
[Fact]
public async Task An_identity_error_carries_the_challenge_on_only_while_it_is_live()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var live = FormKit.Hidden(html, "challenge");
// Live: kept.
using (var kept = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1")))
{
Assert.Equal(live, FormKit.Hidden(await kept.Content.ReadAsStringAsync(), "challenge"));
}
// Garbage: replaced by a fresh live one, never echoed.
var garbage = FormKit.Fields(html, "a!", nonce: "1");
garbage["challenge"] = "garbage-token";
using (var replaced = await FormKit.PostAsync(client, garbage))
{
var page = await replaced.Content.ReadAsStringAsync();
Assert.DoesNotContain("garbage-token", page);
Assert.True(factory.Services.GetRequiredService<RegistrationChallengeService>().IsLive(FormKit.Hidden(page, "challenge")));
}
// Spent: replaced.
var spentPage = await FormKit.PageAsync(client);
using (var spend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, NewIdentity(), nonce: FormKit.Solved(spentPage))))
{
Assert.Equal(HttpStatusCode.SeeOther, spend.StatusCode);
}
using (var afterSpend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, "a!", nonce: "1")))
{
Assert.NotEqual(FormKit.Hidden(spentPage, "challenge"), FormKit.Hidden(await afterSpend.Content.ReadAsStringAsync(), "challenge"));
}
// Expired: replaced.
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
using var afterExpiry = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1"));
var expiredPage = await afterExpiry.Content.ReadAsStringAsync();
Assert.NotEqual(live, FormKit.Hidden(expiredPage, "challenge"));
Assert.True(factory.Services.GetRequiredService<RegistrationChallengeService>().IsLive(FormKit.Hidden(expiredPage, "challenge")));
}
[Fact]
public async Task A_same_origin_post_without_an_Origin_header_is_accepted_on_Sec_Fetch_Site()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
{
Content = new FormUrlEncodedContent(FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html)))
};
request.Headers.Add("Sec-Fetch-Site", "same-origin");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
}
[Fact]
public async Task A_form_body_over_the_cap_is_refused_with_413_and_creates_nothing()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var fields = FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html));
fields["padding"] = new string('x', 17 * 1024);
var before = Counts();
using var response = await FormKit.PostAsync(client, fields);
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
Assert.Equal(before, Counts());
}
[Theory]
[InlineData("support", "reserved")]
[InlineData("a!", "3-80 characters")]
public async Task A_bad_identity_is_refused_without_spending_the_challenge(string identity, string fragment)
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var nonce = FormKit.Solved(html);
using var bad = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
var again = await bad.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
Assert.Contains(fragment, again);
Assert.Equal(FormKit.Hidden(html, "challenge"), FormKit.Hidden(again, "challenge"));
var good = NewIdentity();
using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, good, nonce: nonce));
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
Assert.Equal((true, true), Created(good));
}
[Theory]
[InlineData(3)]
[InlineData(5)]
public async Task A_challenge_payload_of_another_shape_is_refused(int parts)
{
using var client = Client();
var protector = factory.Services.GetRequiredService<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>()
.CreateProtector(RegistrationChallengeService.Purpose);
var now = factory.Time.GetUtcNow();
var expiry = (now + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var issued = (now - TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var fields = new[] { Guid.NewGuid().ToString("N"), expiry.ToString(), "1", issued.ToString(), "extra" };
var challenge = protector.Protect(string.Join('.', fields.Take(parts)));
var identity = NewIdentity();
using var pow = await RegistrationKit.PostAsync(client, new { identity, challenge, nonce = RegistrationKit.Solve(challenge, 1), acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true);
using var waited = await RegistrationKit.PostAsync(client, new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, pow.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, waited.StatusCode);
Assert.False(Exists(identity));
}
[Fact]
public async Task The_page_solver_script_finds_a_nonce_the_server_accepts_under_node()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-register-{Guid.NewGuid():N}.html");
await File.WriteAllTextAsync(pagePath, html);
try
{
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "registration-page-harness.cjs"));
start.ArgumentList.Add(pagePath);
Process process;
try
{
process = Process.Start(start)!;
}
catch (Win32Exception error)
{
throw new InvalidOperationException("Node must be on PATH to run the sign-up page's script: " + error.Message, error);
}
using (process)
{
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await errors);
var runs = JsonDocument.Parse(await output).RootElement.EnumerateArray().ToDictionary(x => x.GetProperty("name").GetString()!);
var solved = runs["solves"];
Assert.Equal(1, solved.GetProperty("submitted").GetInt32());
Assert.True(solved.GetProperty("waitedDisabled").GetBoolean());
var first = solved.GetProperty("statuses")[0].GetString()!;
Assert.StartsWith("Checking.", first);
Assert.Contains("Skip the automatic check", first);
Assert.Contains("slow device", first);
Assert.DoesNotContain("a few seconds", first);
var nonce = solved.GetProperty("nonce").GetString()!;
var challenge = FormKit.Hidden(html, "challenge");
Assert.True(RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(challenge + ":" + nonce))) >= int.Parse(solved.GetProperty("difficulty").GetString()!));
// The server accepts the script's own nonce.
var identity = NewIdentity();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal((true, true), Created(identity));
var skip = runs["skip-during-solve"];
Assert.Equal(0, skip.GetProperty("submitted").GetInt32());
Assert.Equal("", skip.GetProperty("nonce").GetString());
Assert.False(skip.GetProperty("waitedDisabled").GetBoolean());
Assert.Equal(0, skip.GetProperty("prevented").GetArrayLength());
Assert.Equal(1, runs["double-press"].GetProperty("submitted").GetInt32());
Assert.False(runs["no-subtle"].GetProperty("listening").GetBoolean());
}
}
finally
{
File.Delete(pagePath);
}
}
}
public sealed class RegistrationFallbackBudgetTests(RegistrationFallbackBudgetFactory factory) : IClassFixture<RegistrationFallbackBudgetFactory>
{
[Fact]
public async Task Past_the_fallback_budget_the_time_lock_gets_429_with_retry_after_and_work_still_passes()
{
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var pages = new List<string>();
for (var i = 0; i < 4; i++)
{
pages.Add(await FormKit.PageAsync(client));
}
factory.Time.Advance(TimeSpan.FromSeconds(31));
var created = new List<string>();
var statuses = new List<HttpStatusCode>();
HttpResponseMessage? third = null;
for (var i = 0; i < 3; i++)
{
var identity = $"fb-{Guid.NewGuid():N}";
created.Add(identity);
var response = await FormKit.PostAsync(client, FormKit.Fields(pages[i], identity, waited: true));
statuses.Add(response.StatusCode);
third = response;
}
Assert.Equal([HttpStatusCode.SeeOther, HttpStatusCode.SeeOther, HttpStatusCode.TooManyRequests], statuses);
Assert.True(third!.Headers.RetryAfter is not null);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Equal(2, db.UserAccounts.Count(x => created.Contains(x.NormalizedBifrostIdentity!)));
}
// The refused challenge was not spent, and proof of work is not under the fallback budget.
var viaWork = $"fb-{Guid.NewGuid():N}";
using var ok = await FormKit.PostAsync(client, FormKit.Fields(pages[2], viaWork, nonce: FormKit.Solved(pages[2])));
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
}
}
public sealed class RegistrationPageBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
[Fact]
public async Task The_page_spends_the_same_per_address_budget_as_the_challenge_endpoint()
{
using var client = factory.CreateClient();
var statuses = new List<HttpStatusCode>();
foreach (var path in new[] { "/auth/bifrost/register", "/auth/bifrost/challenge", "/auth/bifrost/register", "/auth/bifrost/register" })
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("X-Test-Peer", "203.0.113.99");
using var response = await client.SendAsync(request);
statuses.Add(response.StatusCode);
}
Assert.Equal([HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests], statuses);
}
}
public sealed class RegistrationFallbackOptionsTests
{
private sealed class StubEnvironment(string name) : Microsoft.Extensions.Hosting.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!;
}
private static void Validate(RegistrationOptions options) =>
new StartupConfigurationValidator(
new ConfigurationBuilder().Build(),
new StubEnvironment("Testing"),
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(new DeskOptions()),
Options.Create(options)).Validate();
[Fact]
public void The_defaults_pass() => Validate(new RegistrationOptions());
[Theory]
[InlineData(4)]
[InlineData(301)]
[InlineData(300)]
public void A_wait_outside_5_to_299_seconds_is_refused(int seconds)
{
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { FallbackWaitSeconds = seconds } };
Assert.Contains("FallbackWaitSeconds", Assert.Throws<InvalidOperationException>(() => Validate(options)).Message);
}
[Theory]
[InlineData(0, 3600)]
[InlineData(3, 0)]
public void A_fallback_budget_that_cannot_be_spent_is_refused(int permits, int windowSeconds)
{
var options = new RegistrationOptions { Limits = new RegistrationLimitsOptions { FallbackPermits = permits, FallbackWindow = TimeSpan.FromSeconds(windowSeconds) } };
Assert.Contains("FallbackPermits", Assert.Throws<InvalidOperationException>(() => Validate(options)).Message);
}
}
public sealed class RegistrationValidationBudgetFactory : RegistrationFormFactory
{
protected override string FallbackPermits => "2";
}
public sealed class RegistrationValidationBudgetTests(RegistrationValidationBudgetFactory factory) : IClassFixture<RegistrationValidationBudgetFactory>
{
[Fact]
public async Task Identity_refusals_spend_neither_a_fallback_permit_nor_the_challenge()
{
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var first = await FormKit.PageAsync(client);
var second = await FormKit.PageAsync(client);
var third = await FormKit.PageAsync(client);
factory.Time.Advance(TimeSpan.FromSeconds(31));
var taken = $"vb-{Guid.NewGuid():N}";
using (var one = await FormKit.PostAsync(client, FormKit.Fields(first, taken, waited: true)))
{
Assert.Equal(HttpStatusCode.SeeOther, one.StatusCode);
}
foreach (var bad in new[] { taken, "support", "a!" })
{
using var refused = await FormKit.PostAsync(client, FormKit.Fields(second, bad, waited: true));
Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
}
using (var fresh = await FormKit.PostAsync(client, FormKit.Fields(second, $"vb-{Guid.NewGuid():N}", waited: true)))
{
Assert.Equal(HttpStatusCode.SeeOther, fresh.StatusCode);
}
// Two permits, two registrations: the three refusals above cost nothing.
using var over = await FormKit.PostAsync(client, FormKit.Fields(third, $"vb-{Guid.NewGuid():N}", waited: true));
Assert.Equal(HttpStatusCode.TooManyRequests, over.StatusCode);
}
}
public sealed class RegistrationFormBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
[Fact]
public async Task The_general_budget_is_spent_before_the_body_is_read_and_cross_site_posts_spend_nothing()
{
using var client = factory.CreateClient();
const string peer = "203.0.113.150";
var fields = new Dictionary<string, string> { ["identity"] = "x", ["padding"] = new string('x', 17 * 1024) };
for (var i = 0; i < 5; i++)
{
using var crossSite = await FormKit.PostAsync(client, fields, origin: "https://evil.example", peer: peer);
Assert.Equal(HttpStatusCode.Forbidden, crossSite.StatusCode);
}
// Three permits: the over-cap posts spend them (413), the next is 429 whatever its body.
for (var i = 0; i < 3; i++)
{
using var big = await FormKit.PostAsync(client, fields, peer: peer);
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, big.StatusCode);
}
using var refused = await FormKit.PostAsync(client, fields, peer: peer);
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
}
}

View file

@ -0,0 +1,304 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class RegistrationGateFactory : TestWebApplicationFactory
{
public ManualClock Time { get; } = new();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Time);
});
}
}
// The anonymous register is gated by consent and a proof of work; every refusal must create nothing.
public sealed class RegistrationGateTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
{
private static string NewIdentity() => $"gate-{Guid.NewGuid():N}";
private HttpClient Client() => factory.CreateClient();
private (int Accounts, int Receipts) Counts()
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return (db.UserAccounts.Count(), db.ConsentReceipts.Count());
}
private async Task<(string Challenge, string Nonce)> SolvedAsync(HttpClient client) =>
await RegistrationKit.SolveAsync(client);
private async Task AssertRefusedCreatesNothingAsync(HttpClient client, object body, string? expectedFragment = null)
{
var before = Counts();
using var response = await RegistrationKit.PostAsync(client, body, anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
if (expectedFragment is not null)
{
Assert.Contains(expectedFragment, await response.Content.ReadAsStringAsync());
}
Assert.Equal(before, Counts());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task No_consent_registers_the_account_with_no_receipt(bool explicitNull)
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
var identity = NewIdentity();
object body = explicitNull
? new { identity, challenge, nonce, acceptedNoticeVersion = (int?)null }
: new { identity, challenge, nonce };
using var response = await RegistrationKit.PostAsync(client, body, anonymous: true);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
Assert.Empty(db.ConsentReceipts.Where(x => x.UserAccountId == account.Id));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(2)]
[InlineData(3)]
public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version)
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = version });
}
[Fact]
public async Task A_missing_challenge_is_refused_and_creates_nothing()
{
using var client = Client();
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), acceptedNoticeVersion = TrainingNotice.Version });
}
[Theory]
[InlineData("garbage")]
[InlineData("")]
public async Task A_garbage_challenge_is_refused_and_creates_nothing(string challenge)
{
using var client = Client();
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce = "1", acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task A_tampered_challenge_is_refused_and_creates_nothing()
{
using var client = Client();
var (challenge, _) = await SolvedAsync(client);
var tampered = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
var nonce = RegistrationKit.Solve(tampered, 4);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge = tampered, nonce, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task A_wrong_nonce_is_refused_and_creates_nothing()
{
using var client = Client();
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var challenge = issued.GetProperty("challenge").GetString()!;
var difficulty = issued.GetProperty("difficulty").GetInt32();
var correct = RegistrationKit.Solve(challenge, difficulty);
// The next nonce that does not meet the difficulty.
var wrong = Enumerable.Range(0, int.MaxValue)
.Select(i => i.ToString())
.First(n => n != correct && RegistrationChallengeService.LeadingZeroBits(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) < difficulty);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce = wrong, acceptedNoticeVersion = TrainingNotice.Version });
}
// Each malformed shape is padded until it meets the difficulty, so only the nonce-format guard can refuse it.
[Theory]
[InlineData("x", "")]
[InlineData("-", "")]
[InlineData("", "e")]
[InlineData("000000000000000000000", "")]
public async Task A_malformed_nonce_is_refused_even_when_its_hash_would_pass(string prefix, string suffix)
{
using var client = Client();
var (challenge, _) = await SolvedAsync(client);
var nonce = Enumerable.Range(0, int.MaxValue)
.Select(i => prefix + i + suffix)
.First(n => RegistrationChallengeService.LeadingZeroBits(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) >= 4);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task An_expired_challenge_is_refused_and_creates_nothing()
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task A_consumed_challenge_cannot_be_replayed()
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
var first = NewIdentity();
var body = (string identity) => new { identity, challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version };
using var accepted = await RegistrationKit.PostAsync(client, body(first), anonymous: true);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
var before = Counts();
await AssertRefusedCreatesNothingAsync(client, body(NewIdentity()));
Assert.Equal(before, Counts());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Single(db.UserAccounts.Where(x => x.NormalizedBifrostIdentity == first));
}
[Fact]
public async Task A_nonce_solved_for_one_challenge_does_not_pass_another()
{
using var client = Client();
var (_, nonceForFirst) = await SolvedAsync(client);
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var second = issued.GetProperty("challenge").GetString()!;
var difficulty = issued.GetProperty("difficulty").GetInt32();
// Skip the rare case where the nonce happens to also solve the second challenge.
if (RegistrationChallengeService.LeadingZeroBits(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(second + ":" + nonceForFirst))) >= difficulty)
{
return;
}
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge = second, nonce = nonceForFirst, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task Success_stores_the_receipt_with_the_account()
{
using var client = Client();
var identity = NewIdentity();
using var response = await RegistrationKit.RegisterAsync(client, identity, anonymous: true);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id);
Assert.Equal(TrainingNotice.Version, receipt.NoticeVersion);
Assert.Null(receipt.WithdrawnAtUtc);
Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task The_notice_endpoint_names_the_version_and_the_discord_exclusion()
{
using var client = Client();
var notice = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/notice");
Assert.Equal(TrainingNotice.Version, notice.GetProperty("version").GetInt32());
Assert.Contains("Discord", notice.GetProperty("text").GetString());
}
[Fact]
public async Task The_challenge_response_is_not_cached()
{
using var client = Client();
using var response = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(18, true)]
[InlineData(24, true)]
[InlineData(25, false)]
public void Difficulty_outside_one_to_twenty_four_is_refused_at_startup(int difficulty, bool valid)
{
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { Difficulty = difficulty } };
Action validate = () => new StartupConfigurationValidator(
new ConfigurationBuilder().Build(),
new TestingEnvironment(),
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(new DeskOptions()),
Options.Create(options)).Validate();
if (valid)
{
validate();
}
else
{
Assert.Contains("Difficulty", Assert.Throws<InvalidOperationException>(validate).Message);
}
Assert.Equal(18, new RegistrationOptions().Captcha.Difficulty);
}
private sealed class TestingEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Testing";
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,239 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
// The challenge service on its own: single use under contention, the bounded set, the purpose, and the difficulty it issued.
public sealed class RegistrationChallengeServiceTests
{
private sealed class MutableOptions(int difficulty) : IOptions<RegistrationOptions>
{
public RegistrationOptions Value { get; set; } = With(difficulty);
public static RegistrationOptions With(int d) => new() { Captcha = new RegistrationCaptchaOptions { Difficulty = d } };
}
private static RegistrationChallengeService NewService(IDataProtectionProvider provider, IOptions<RegistrationOptions> options, TimeProvider clock) =>
new(provider, options, clock);
[Fact]
public async Task Concurrent_consumes_of_one_challenge_yield_exactly_one_success()
{
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), new ManualClock());
for (var round = 0; round < 300; round++)
{
var challenge = service.Issue()!;
var nonce = RegistrationKit.Solve(challenge.Challenge, challenge.Difficulty);
using var start = new ManualResetEventSlim();
var tasks = Enumerable.Range(0, 16).Select(_ => Task.Factory.StartNew(
() => { start.Wait(); return service.TryConsume(challenge.Challenge, nonce); },
TaskCreationOptions.LongRunning)).ToArray();
start.Set();
var results = await Task.WhenAll(tasks);
Assert.Equal(1, results.Count(x => x));
}
}
[Fact]
public void A_full_consumed_set_refuses_to_issue_and_to_consume_until_entries_expire()
{
var clock = new ManualClock();
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), clock);
var pending = new List<(string, string)>();
for (var i = 0; i < RegistrationChallengeService.MaxConsumed + 1; i++)
{
var c = service.Issue()!;
pending.Add((c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
}
foreach (var (c, n) in pending.Take(RegistrationChallengeService.MaxConsumed))
{
Assert.True(service.TryConsume(c, n));
}
Assert.Null(service.Issue());
Assert.False(service.TryConsume(pending[^1].Item1, pending[^1].Item2));
clock.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
Assert.NotNull(service.Issue());
}
[Fact]
public void A_token_protected_for_another_purpose_is_refused()
{
var provider = new EphemeralDataProtectionProvider();
var clock = new ManualClock();
var service = NewService(provider, new MutableOptions(1), clock);
var expiry = (clock.GetUtcNow() + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var forged = provider.CreateProtector("Bifrost.Something.Else.v1").Protect($"{Guid.NewGuid():N}.{expiry}.1");
var nonce = RegistrationKit.Solve(forged, 1);
Assert.False(service.TryConsume(forged, nonce));
}
[Fact]
public void A_challenge_verifies_at_the_difficulty_it_was_issued_with()
{
var options = new MutableOptions(1);
var service = NewService(new EphemeralDataProtectionProvider(), options, new ManualClock());
var easy = service.Issue()!;
var easyNonce = RegistrationKit.Solve(easy.Challenge, 1);
// A nonce that meets 1 bit but not 12: only the embedded difficulty lets it through after the option rises.
var weak = Enumerable.Range(0, int.MaxValue).Select(i => i.ToString()).First(n =>
{
var bits = RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(easy.Challenge + ":" + n)));
return bits >= 1 && bits < 12;
});
options.Value = MutableOptions.With(12);
Assert.Equal(1, easy.Difficulty);
Assert.True(service.TryConsume(easy.Challenge, weak));
Assert.Equal(12, service.Issue()!.Difficulty);
_ = easyNonce;
}
}
public sealed class RegistrationBudgetFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Bifrost:Registration:Limits:PerAddressPermits"] = "3"
}));
builder.ConfigureServices(services => services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, PeerFilter>());
}
private sealed class PeerFilter : Microsoft.AspNetCore.Hosting.IStartupFilter
{
public Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "198.51.100.1");
return nextMiddleware();
});
next(app);
};
}
}
public sealed class RegistrationBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
private static async Task<HttpResponseMessage> ChallengeAsync(HttpClient client, string peer)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/challenge");
request.Headers.Add("X-Test-Peer", peer);
return await client.SendAsync(request);
}
[Fact]
public async Task The_request_past_the_address_budget_gets_429_and_another_address_is_unaffected()
{
using var client = factory.CreateClient();
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, "203.0.113.5");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var refused = await ChallengeAsync(client, "203.0.113.5");
using var other = await ChallengeAsync(client, "203.0.113.6");
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
Assert.True(refused.Headers.RetryAfter is not null);
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
}
[Fact]
public async Task An_IPv6_caller_owns_its_slash_64_and_a_mapped_IPv4_shares_its_IPv4_key()
{
using var client = factory.CreateClient();
string[] sameSlash64 = ["2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2:aaaa::3", "2001:db8:1:2:ffff:ffff:ffff:ffff", "2001:db8:1:2::5"];
var statuses = new List<HttpStatusCode>();
foreach (var peer in sameSlash64)
{
using var response = await ChallengeAsync(client, peer);
statuses.Add(response.StatusCode);
}
Assert.Equal(
[HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests],
statuses);
using var otherSlash64 = await ChallengeAsync(client, "2001:db8:1:3::1");
Assert.Equal(HttpStatusCode.OK, otherSlash64.StatusCode);
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, "203.0.113.50");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var mapped = await ChallengeAsync(client, "::ffff:203.0.113.50");
Assert.Equal(HttpStatusCode.TooManyRequests, mapped.StatusCode);
}
[Fact]
public async Task Challenge_and_register_spend_one_budget_and_a_refused_register_creates_nothing()
{
using var client = factory.CreateClient();
const string peer = "203.0.113.7";
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, peer);
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
{
Content = JsonContent.Create(new { identity = $"budget-{Guid.NewGuid():N}", acceptedNoticeVersion = TrainingNotice.Version })
};
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Peer", peer);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
using var scope = factory.Services.CreateScope();
Assert.DoesNotContain(scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts, x => x.NormalizedBifrostIdentity!.StartsWith("budget-"));
}
}
public sealed class RegistrationFullSetTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
{
[Fact]
public async Task A_full_consumed_set_makes_the_challenge_endpoint_answer_503()
{
var service = factory.Services.GetRequiredService<RegistrationChallengeService>();
for (var i = 0; i < RegistrationChallengeService.MaxConsumed; i++)
{
var c = service.Issue()!;
Assert.True(service.TryConsume(c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
}
using var client = factory.CreateClient();
using var response = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
using var after = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
}

View file

@ -28,7 +28,7 @@ public sealed class ReservedIdentityTests
{ {
var service = NewService(out var db); var service = NewService(out var db);
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default)); var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
Assert.Equal("That Bifrost identity is reserved.", ex.Message); Assert.Equal("That Bifrost identity is reserved.", ex.Message);
Assert.Empty(db.UserAccounts); Assert.Empty(db.UserAccounts);
@ -42,7 +42,7 @@ public sealed class ReservedIdentityTests
{ {
var service = NewService(out var db); var service = NewService(out var db);
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default)); await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
Assert.Empty(db.UserAccounts); Assert.Empty(db.UserAccounts);
} }
@ -56,7 +56,7 @@ public sealed class ReservedIdentityTests
{ {
var service = NewService(out var db); var service = NewService(out var db);
var account = await service.RegisterNativeAsync(name, null, default); var account = await service.RegisterNativeAsync(name, null, 1, default);
Assert.Equal(name, account.NormalizedBifrostIdentity); Assert.Equal(name, account.NormalizedBifrostIdentity);
Assert.Single(db.UserAccounts); Assert.Single(db.UserAccounts);
@ -112,7 +112,7 @@ public sealed class ReservedIdentityTests
{ {
var service = NewService(out var db); var service = NewService(out var db);
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default)); var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
Assert.Equal("That Bifrost identity is reserved.", ex.Message); Assert.Equal("That Bifrost identity is reserved.", ex.Message);
Assert.Empty(db.UserAccounts); Assert.Empty(db.UserAccounts);
@ -147,7 +147,7 @@ public sealed class ReservedIdentityTests
public async Task Mixed_case_input_is_refused_by_register_and_suffixed_when_derived(string input) public async Task Mixed_case_input_is_refused_by_register_and_suffixed_when_derived(string input)
{ {
var service = NewService(out var db); var service = NewService(out var db);
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, default)); await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, 1, default));
Assert.Empty(db.UserAccounts); Assert.Empty(db.UserAccounts);
var account = new UserAccount { DisplayName = input }; var account = new UserAccount { DisplayName = input };

View file

@ -0,0 +1,75 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
// Shared plumbing for replaying CultLib's transport vectors through HTTP.
public static class DeskReplay
{
public const string ChatFork = "gamecult-chat:question:slot-mark-consent";
private static JsonNode BaseCard => DeskVectors.Root["scenarios"]![0]!["card"]!;
public static ForkCard Card(string forkId, string title = "Slot mark consent")
{
var card = JsonNode.Parse(BaseCard.ToJsonString())!.AsObject();
card["fork_id"] = forkId;
card["title"] = title;
return card.Deserialize<ForkCard>(DeskJson.Options)!;
}
public static ForkEntry Entry(ForkCard card) => new(
"gamecult.fork.entry.v0",
card,
new ForkRecord("gamecult.fork.record.v0", card.ForkId, card.OptionsHash, null, []));
// Every fork the vectors say each Mind holds, on that Mind's socket.
public static void HoldVectorForks(FakeDeskDoor door)
{
var forks = DeskVectors.Root["transport"]!["mindRoutes"]!["forks"]!.AsObject();
foreach (var (mind, ids) in forks)
{
foreach (var id in ids!.AsArray())
{
door.Forks[(DeskWebApplicationFactory.SocketOf(mind), (string)id!)] = Entry(Card((string)id!));
}
}
}
public static byte[] AnswerBody(string forkId, string requestId = "00000001-0000-4000-8000-000000000001")
{
var body = JsonNode.Parse(DeskVectors.Root["scenarios"]![0]!["steps"]![0]!["body"]!.ToJsonString())!.AsObject();
body["fork_id"] = forkId;
body["request_id"] = requestId;
return DeskVectors.Bytes(body);
}
// The raw segment as it would travel in one path segment: a literal '/' cannot, so it is sent as %2F.
public static string SegmentOnWire(string rawMind) => rawMind.Replace("/", "%2F", StringComparison.Ordinal);
public static string Target(string route, string rawMind, string? forkId) => route switch
{
"answer" => $"/api/desk/{SegmentOnWire(rawMind)}/answers",
"list" => $"/api/desk/{SegmentOnWire(rawMind)}/forks",
"read" => $"/api/desk/{SegmentOnWire(rawMind)}/forks/{Uri.EscapeDataString(forkId!)}",
_ => throw new ArgumentOutOfRangeException(nameof(route)),
};
// Seeds the account the vector token stands for and returns the header value (null for no token).
public static async Task<string?> TokenAsync(DeskWebApplicationFactory factory, JsonNode? token)
{
if (token is null)
{
return null;
}
var active = (bool)token["active"]!;
var subject = active ? DeskWebApplicationFactory.Alice : DeskWebApplicationFactory.Member;
await factory.EnsureAccountAsync(subject, active);
return factory.Token(subject, (string)token["audience"]!, token["scopes"]!.AsArray().Select(s => (string)s!).ToArray());
}
public static bool IsEdgeRefusal(string code) =>
code is "unauthenticated" or "insufficient_scope" or "forbidden" or "body_forbidden_field" or "body_unknown_field" or "body_invalid";
}

View file

@ -0,0 +1,66 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Bifrost.Web.Tests.Support;
// CultLib's fork-card vectors, vendored at commit de8a062ab9d8c510c8b36e634fae8f108a570aff.
public static class DeskVectors
{
public const string SourceCommit = "de8a062ab9d8c510c8b36e634fae8f108a570aff";
public const string FileSha256 = "a7129eee00cb38c602481b0de7de0182f83122685b69ff441e3123adc3e0f774";
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");
public static JsonNode Root { get; } = JsonNode.Parse(File.ReadAllBytes(Path))!;
public static JsonArray Array(params string[] path)
{
JsonNode node = Root;
foreach (var step in path)
{
node = node[step]!;
}
return node.AsArray();
}
public static IEnumerable<JsonObject> Rows(params string[] path) => Array(path).Select(row => row!.AsObject());
// True when both are the same JSON with the same keys in the same order.
public static bool SameShape(JsonNode? left, JsonNode? right)
{
switch (left, right)
{
case (null, null):
return true;
case (JsonObject a, JsonObject b):
return a.Select(p => p.Key).SequenceEqual(b.Select(p => p.Key)) &&
a.All(p => SameShape(p.Value, b[p.Key]));
case (JsonArray a, JsonArray b):
return a.Count == b.Count && a.Zip(b).All(pair => SameShape(pair.First, pair.Second));
case (JsonValue, JsonValue):
return JsonNode.DeepEquals(left, right);
default:
return false;
}
}
// The vectors' refusal `error` text is illustrative (Libby): the contract pins the code, the key set, the status
// (asserted by the caller) and that the text names the fork id, and its order on a list.
public static bool SameRefusal(JsonNode? actual, JsonNode? vector, string forkId, int? order)
{
if (actual is not JsonObject a || vector is not JsonObject v ||
!a.Select(p => p.Key).SequenceEqual(v.Select(p => p.Key)) ||
(string?)a["schemaVersion"] != (string?)v["schemaVersion"] || (string?)a["code"] != (string?)v["code"])
{
return false;
}
var error = (string?)a["error"] ?? string.Empty;
return error.Contains(forkId, StringComparison.Ordinal) &&
(order is null || error.Contains($"order {order}", StringComparison.Ordinal));
}
public static byte[] Bytes(JsonNode node) => System.Text.Encoding.UTF8.GetBytes(node.ToJsonString());
}

View file

@ -0,0 +1,182 @@
using System.Text;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Desk;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests.Support;
public sealed record DeskReply(int Status, string ContentType, byte[] Body, IHeaderDictionary Headers)
{
public string Text => Encoding.UTF8.GetString(Body);
}
// The app with the Desk section configured and the two ports faked. `Configuration` is the Desk section as
// key/value pairs; null leaves the Desk section out, `RegisterPorts` false leaves the ports unregistered.
public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
{
public const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
public const string Steward = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a";
public const string Member = "11111111-2222-4333-8444-555555555555";
public const string Origin = "https://chat.gamecult.org";
private readonly Dictionary<string, string?>? configuration;
private readonly bool registerPorts;
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true)
{
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
this.registerPorts = registerPorts;
}
public FakeDeskDoor Door { get; } = new();
public FakeDeskCredential Credential { get; } = new();
public static Dictionary<string, string?> DefaultConfiguration()
{
var values = new Dictionary<string, string?>
{
["Desk:AllowedOrigin"] = Origin,
["Desk:RetryAfterSeconds"] = "30",
["Desk:Attribution:Operator"] = Alice,
["Desk:Attribution:Stewards:0"] = Steward,
};
var i = 0;
foreach (var mind in DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject())
{
values[$"Desk:Minds:{i}:Id"] = mind.Key;
values[$"Desk:Minds:{i}:Socket"] = SocketOf(mind.Key);
var deciders = DecidersOf(mind.Key);
for (var d = 0; d < deciders.Length; d++)
{
values[$"Desk:Minds:{i}:Deciders:{d}"] = deciders[d];
}
i++;
}
return values;
}
// A distinct list per Mind (different members, order and length), so a door context that carries another
// Mind's list cannot pass for the route Mind's.
public static string[] DecidersOf(string mindId) =>
mindId == "nibu" ? [Steward, Alice, Member] : [Alice, Steward];
public static string SocketOf(string mindId) =>
"/run/huginn/" + (string)DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]![mindId]!;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
if (configuration is not null)
{
foreach (var (key, value) in configuration)
{
builder.UseSetting(key, value);
}
}
if (registerPorts)
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IDeskCredential>(Credential);
services.AddSingleton<IDeskDoor>(Door);
});
}
}
// Upserts the account with an Active (or Suspended) membership.
public async Task EnsureAccountAsync(string id, bool active)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var guid = Guid.Parse(id);
var account = await db.UserAccounts.FindAsync(guid);
if (account is null)
{
account = new UserAccount { Id = guid, GitHubLogin = "desk-" + id[..8], NormalizedGitHubLogin = "desk-" + id[..8], BifrostIdentity = "desk-" + id, NormalizedBifrostIdentity = "desk-" + id };
db.UserAccounts.Add(account);
db.Memberships.Add(new Domain.Membership { UserAccountId = guid, Status = active ? MembershipStatus.Active : MembershipStatus.Suspended });
}
else
{
var membership = db.Memberships.Single(m => m.UserAccountId == guid);
membership.Status = active ? MembershipStatus.Active : MembershipStatus.Suspended;
}
await db.SaveChangesAsync();
}
public string Token(string? subject, string? audience, params string[] scopes) =>
FakeDeskCredential.Encode(subject, audience, scopes);
// What routing would see for a raw path. Kestrel refuses a NUL or a malformed escape before routing; the
// placeholder keeps the request inside /api/desk so the handler's own judgment is what the test observes.
private static PathString RoutedPath(string rawPath)
{
var schemeEnd = rawPath.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd > 0)
{
var pathStart = rawPath.IndexOf('/', schemeEnd + 3);
rawPath = pathStart < 0 ? "/" : rawPath[pathStart..];
}
try
{
return PathString.FromUriComponent(rawPath);
}
catch (Exception)
{
return new PathString("/api/desk/_");
}
}
// Sends one request with the RAW target set exactly as given (TestServer's HttpClient sets none). `path` is
// what routing sees; production Kestrel would hand over the decoded path the same way.
public async Task<DeskReply> SendAsync(
string method, string rawTarget, byte[]? body = null, string? token = null, string? origin = null,
Action<HttpContext>? configure = null)
{
var context = await Server.SendAsync(http =>
{
http.Request.Method = method;
http.Request.Scheme = "https";
http.Request.Host = new HostString("localhost");
var query = rawTarget.IndexOf('?');
var rawPath = query >= 0 ? rawTarget[..query] : rawTarget;
http.Request.Path = RoutedPath(rawPath);
http.Request.QueryString = query >= 0 ? new QueryString(rawTarget[query..]) : QueryString.Empty;
http.Features.GetRequiredFeature<IHttpRequestFeature>().RawTarget = rawTarget;
if (token is not null)
{
http.Request.Headers[FakeDeskCredential.Header] = token;
}
if (origin is not null)
{
http.Request.Headers.Origin = origin;
}
if (body is not null)
{
http.Request.Body = new MemoryStream(body);
http.Request.ContentLength = body.Length;
http.Request.ContentType = "application/json";
}
configure?.Invoke(http);
});
using var buffer = new MemoryStream();
await context.Response.Body.CopyToAsync(buffer);
return new DeskReply(context.Response.StatusCode, context.Response.ContentType ?? string.Empty, buffer.ToArray(), context.Response.Headers);
}
}

View file

@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
// A header-driven stand-in for the bearer validation port. X-Test-Desk-Token carries {sub, aud, scopes}; no header
// means no valid token. It validates nothing: the desk's own judgments (audience, scope, membership) are the subject.
public sealed class FakeDeskCredential : IDeskCredential
{
public const string Header = "X-Test-Desk-Token";
public static string Encode(string? subject, string? audience, IEnumerable<string> scopes) =>
JsonSerializer.Serialize(new { sub = subject, aud = audience, scopes });
// The port fails (its key source is down), instead of answering.
public bool Throws { get; set; }
// Runs when a request reaches the port, after the host's own middleware: a test breaks what comes next.
public Action<HttpContext>? OnAuthenticate { get; set; }
public ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken)
{
if (Throws)
{
throw new InvalidOperationException("the credential port is down");
}
OnAuthenticate?.Invoke(context);
if (!context.Request.Headers.TryGetValue(Header, out var raw))
{
return ValueTask.FromResult<DeskToken?>(null);
}
using var document = JsonDocument.Parse(raw.ToString());
var root = document.RootElement;
return ValueTask.FromResult<DeskToken?>(new DeskToken(
root.GetProperty("sub").GetString(),
root.GetProperty("aud").GetString(),
root.GetProperty("scopes").EnumerateArray().Select(s => s.GetString()!).ToArray()));
}
}

View file

@ -0,0 +1,125 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
public sealed record DeskDoorCall(string Kind, string Socket, DeskDoorContext Context, byte[]? Body, string? ForkId, DeskListRequest? List = null);
// The door as a test double: it records every call, holds the forks each socket's Mind holds, and by default stores
// an answer to a held fork as Counted. A test may script the outcome or make the door fail before or after storing.
public sealed class FakeDeskDoor : IDeskDoor
{
private readonly object gate = new();
public List<DeskDoorCall> Calls { get; } = [];
public List<string> StoredIn { get; } = [];
public Dictionary<(string Socket, string ForkId), ForkEntry> Forks { get; } = [];
public Func<string, DeskDoorContext, byte[], DeskAnswerOutcome>? AnswerScript { get; set; }
// Every call throws (Huginn is down).
public bool Down { get; set; }
// The answer is stored, then the door throws (a timeout after send).
public bool ThrowAfterStore { get; set; }
public bool ThrowOnRead { get; set; }
public Func<string, string, DeskReadOutcome>? ReadScript { get; set; }
public Func<string, DeskListRequest, DeskListOutcome>? ListScript { get; set; }
public Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
{
lock (gate)
{
Calls.Add(new DeskDoorCall("list", socket, context, null, null, request));
}
if (Down)
{
throw new IOException("huginn is down");
}
return Task.FromResult(ListScript?.Invoke(socket, request)
?? new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", request.Mind, "s1", 0, 0, [], null)));
}
public Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
var bytes = body.ToArray();
lock (gate)
{
Calls.Add(new DeskDoorCall("answer", socket, context, bytes, null));
}
if (Down)
{
throw new IOException("huginn is down");
}
var outcome = AnswerScript?.Invoke(socket, context, bytes) ?? HeldForkOutcome(socket, context, bytes);
if (outcome is DeskAnswerOutcome.Answered)
{
lock (gate)
{
StoredIn.Add(socket);
}
}
if (ThrowAfterStore)
{
throw new TimeoutException("the door timed out after send");
}
return Task.FromResult(outcome);
}
public Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
lock (gate)
{
Calls.Add(new DeskDoorCall("read", socket, context, null, forkId));
}
if (Down || ThrowOnRead)
{
throw new IOException("huginn is down");
}
if (ReadScript is not null)
{
return Task.FromResult(ReadScript(socket, forkId));
}
return Task.FromResult<DeskReadOutcome>(Forks.TryGetValue((socket, forkId), out var entry)
? new DeskReadOutcome.Found(entry)
: new DeskReadOutcome.NotFound(DeskRefusals.ForkUnknown));
}
private DeskAnswerOutcome HeldForkOutcome(string socket, DeskDoorContext context, byte[] body)
{
var request = JsonNode.Parse(body)!.AsObject();
var forkId = (string)request["fork_id"]!;
if (!Forks.ContainsKey((socket, forkId)))
{
return new DeskAnswerOutcome.NotStored(DeskRefusals.ForkUnknown);
}
var answer = request["answer"]?.AsObject();
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, "Counted", null, null, null,
(string)request["options_hash"]!, (string?)answer?["choice"], (string?)answer?["note"],
(string)request["answered_at"]!, "2026-11-12T19:02:05Z", false,
context.Subject, context.Authority, (string)request["request_id"]!),
null,
"n1"));
}
}

View file

@ -359,6 +359,11 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
public FakeMasHandler Mas { get; } = new(); public FakeMasHandler Mas { get; } = new();
/// <summary>The raw token response and access token of the last <see cref="GrantAsync"/>.</summary>
public string LastTokenResponse { get; private set; } = string.Empty;
public string LastAccessToken { get; private set; } = string.Empty;
public ManualClock Time { get; } = new(); public ManualClock Time { get; } = new();
public OidcLogoutReconciler Reconciler => Services.GetServices<IHostedService>().OfType<OidcLogoutReconciler>().Single(); public OidcLogoutReconciler Reconciler => Services.GetServices<IHostedService>().OfType<OidcLogoutReconciler>().Single();
@ -399,16 +404,24 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
}; };
/// <summary>Registers a member, sets <paramref name="status"/>, and completes one authorization-code flow for them.</summary> /// <summary>Registers a member, sets <paramref name="status"/>, and completes one authorization-code flow for them.</summary>
public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active) public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active, string? suspensionReason = null)
{ {
using var client = CreateClient(HostClientOptions()); using var client = CreateClient(HostClientOptions());
var identity = $"member-{Guid.NewGuid():N}"; var identity = $"member-{Guid.NewGuid():N}";
using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity })) using (var register = await RegistrationKit.RegisterAsync(client, identity))
{ {
Assert.Equal(HttpStatusCode.OK, register.StatusCode); Assert.Equal(HttpStatusCode.OK, register.StatusCode);
} }
await SetMembershipAsync(identity, MembershipStatus.Active); await SetMembershipAsync(identity, MembershipStatus.Active);
if (suspensionReason is not null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
db.Memberships.Single(x => x.UserAccountId == account.Id).SuspensionReason = suspensionReason;
await db.SaveChangesAsync();
}
var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48));
var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24));
@ -444,7 +457,10 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
"Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}"))); "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}")));
using var tokens = await bare.SendAsync(exchange); using var tokens = await bare.SendAsync(exchange);
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode); Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
var idToken = (await tokens.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id_token").GetString()!; var body = await tokens.Content.ReadFromJsonAsync<JsonElement>();
var idToken = body.GetProperty("id_token").GetString()!;
LastTokenResponse = body.GetRawText();
LastAccessToken = body.GetProperty("access_token").GetString()!;
if (status != MembershipStatus.Active) if (status != MembershipStatus.Active)
{ {

View file

@ -5,6 +5,7 @@ using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -15,6 +16,15 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bifrost.Web.Tests.Support; namespace Bifrost.Web.Tests.Support;
/// <summary>The consent receipt an account holds, from a consumer's side: current, none, withdrawn, or for a version other than the one in force.</summary>
public enum ReceiptState
{
Current,
None,
Withdrawn,
OtherVersion
}
/// <summary>What is wrong with the signing key file a test host is started with.</summary> /// <summary>What is wrong with the signing key file a test host is started with.</summary>
public enum OidcKeyFault public enum OidcKeyFault
{ {
@ -92,7 +102,9 @@ public sealed class OidcTestKit : IDisposable
["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath, ["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath,
["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId, ["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId,
["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath, ["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath,
["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri ["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri,
// The shared test client has no remote address, so every request is one caller: the budgets are out of the way unless a test sets them.
["Bifrost:Oidc:Limits:PerAddressPermits"] = "100000",
}; };
public void Dispose() public void Dispose()
@ -114,6 +126,11 @@ public sealed class OidcTestClock : TimeProvider
public override DateTimeOffset GetUtcNow() => _now; public override DateTimeOffset GetUtcNow() => _now;
// The monotonic reading follows the moved time, so a budget's window can be aged the same way as a parked request.
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _now.UtcTicks;
public void Advance(TimeSpan by) => _now += by; public void Advance(TimeSpan by) => _now += by;
} }
@ -171,6 +188,24 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory
} }
} }
public async Task<MembershipStatus?> MembershipStatusAsync(string identity)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
return (await db.Memberships.AsNoTracking().SingleOrDefaultAsync(x => x.UserAccountId == account.Id))?.Status;
}
public sealed record RowCounts(int Accounts, int Memberships, int Votes, int RoleAssignments);
/// <summary>What the issuer must leave alone: accounts, memberships, role grants and votes.</summary>
public async Task<RowCounts> RowCountsAsync()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return new RowCounts(await db.UserAccounts.CountAsync(), await db.Memberships.CountAsync(), await db.Votes.CountAsync(), await db.RoleAssignments.CountAsync());
}
/// <summary>Sets the membership of the account registered under <paramref name="identity"/>.</summary> /// <summary>Sets the membership of the account registered under <paramref name="identity"/>.</summary>
public async Task SetMembershipAsync(string identity, MembershipStatus status) public async Task SetMembershipAsync(string identity, MembershipStatus status)
{ {
@ -199,6 +234,48 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
/// <summary>Leaves the account with exactly the receipt state a test names: none, or one receipt for the version in force, a withdrawn one, or one for another version.</summary>
public async Task SetReceiptAsync(string identity, ReceiptState state)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id;
db.ConsentReceipts.RemoveRange(db.ConsentReceipts.Where(x => x.UserAccountId == id));
await db.SaveChangesAsync();
await AddReceiptAsync(db, id, state);
}
public List<ConsentReceipt> ReceiptsOf(string identity)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).ToList();
}
/// <summary>A receipt for an account that exists only as an actor fixture.</summary>
public async Task AgreeAsync(Guid accountId)
{
using var scope = Services.CreateScope();
await AddReceiptAsync(scope.ServiceProvider.GetRequiredService<BifrostDbContext>(), accountId, ReceiptState.Current);
}
private static async Task AddReceiptAsync(BifrostDbContext db, Guid accountId, ReceiptState state)
{
if (state != ReceiptState.None)
{
var now = DateTimeOffset.UtcNow;
db.ConsentReceipts.Add(new ConsentReceipt
{
UserAccountId = accountId,
// Another version stands for a notice that has since been bumped (or, in a future bump, the one before it).
NoticeVersion = state == ReceiptState.OtherVersion ? Bifrost.Web.Features.Registration.TrainingNotice.Version - 1 : Bifrost.Web.Features.Registration.TrainingNotice.Version,
AcceptedAtUtc = now,
WithdrawnAtUtc = state == ReceiptState.Withdrawn ? now : null
});
await db.SaveChangesAsync();
}
}
public Guid AccountId(string identity) public Guid AccountId(string identity)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
@ -231,6 +308,93 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory
} }
} }
/// <summary>
/// An OIDC host whose actor accessor reports an Active account holding the given Bifrost identity: how a row minted before
/// reserved names were refused at creation (or any identity the test needs) reaches the authorize endpoint, since
/// registration itself now refuses a reserved name.
/// </summary>
public sealed class OidcFixedIdentityFactory(string identity, MembershipStatus status = MembershipStatus.Active) : OidcWebApplicationFactory
{
public Guid FixedAccountId { get; } = Guid.NewGuid();
protected override void ConfigureActors(IServiceCollection services)
{
services.RemoveAll<ICurrentBifrostActorAccessor>();
services.AddScoped<ICurrentBifrostActorAccessor>(_ => new FixedIdentity(FixedAccountId, identity, status));
}
private sealed class FixedIdentity(Guid id, string identity, MembershipStatus status) : ICurrentBifrostActorAccessor
{
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) =>
Task.FromResult(new CurrentBifrostActor(true, new UserAccount
{
Id = id,
BifrostIdentity = identity,
NormalizedBifrostIdentity = identity, // as given: the guard, not the fixture, must fold case
DisplayName = "Fixed Identity",
Membership = new Bifrost.Web.Domain.Membership { Status = status }
}));
}
}
/// <summary>An OIDC host with extra settings (limits, a brake path) applied after the kit's own.</summary>
public sealed class OidcSettingsFactory(IReadOnlyDictionary<string, string?> settings) : OidcWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
foreach (var (key, value) in settings)
{
builder.UseSetting(key, value);
}
}
public Task PruneAsync() => Services.GetRequiredService<Bifrost.Web.Features.Oidc.OidcPruner>().PruneAsync(CancellationToken.None);
public async Task<List<string?>> TokenTypesAsync()
{
using var scope = Services.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictTokenManager>();
var types = new List<string?>();
await foreach (var token in manager.ListAsync())
{
types.Add(await manager.GetTypeAsync(token));
}
return types;
}
public async Task<int> AuthorizationRowsAsync()
{
using var scope = Services.CreateScope();
return (int)await scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>().CountAsync();
}
/// <summary>Tokens that belong to some authorization (the request and code rows carry none once spent).</summary>
public async Task<int> TokensOfAuthorizationsAsync()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return await db.Set<OpenIddictEntityFrameworkCoreToken>().CountAsync(token => token.Authorization != null);
}
public async Task RevokeFirstAuthorizationAsync()
{
using var scope = Services.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>();
await foreach (var authorization in manager.ListAsync(count: 1, offset: 0))
{
Assert.True(await manager.TryRevokeAsync(authorization));
}
}
public async Task<int> TokenRowsAsync()
{
using var scope = Services.CreateScope();
return (int)await scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictTokenManager>().CountAsync();
}
}
/// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary> /// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary>
public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory
{ {

View file

@ -0,0 +1,64 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Features.Registration;
namespace Bifrost.Web.Tests.Support;
/// <summary>Registers through the real gate: fetch a challenge, solve it, post with the notice version.</summary>
public static class RegistrationKit
{
public static async Task<(string Challenge, string Nonce)> SolveAsync(HttpClient client)
{
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var challenge = issued.GetProperty("challenge").GetString()!;
return (challenge, Solve(challenge, issued.GetProperty("difficulty").GetInt32()));
}
public static string Solve(string challenge, int difficulty)
{
for (long nonce = 0; ; nonce++)
{
var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + text))) >= difficulty)
{
return text;
}
}
}
public static async Task<HttpResponseMessage> RegisterAsync(
HttpClient client,
string identity,
string? displayName = null,
string? returnUrl = null,
bool anonymous = false,
bool agree = true)
{
var (challenge, nonce) = await SolveAsync(client);
return await PostAsync(client, new
{
identity,
displayName = displayName ?? identity,
returnUrl,
challenge,
nonce,
acceptedNoticeVersion = agree ? TrainingNotice.Version : (int?)null
}, anonymous);
}
public static async Task<HttpResponseMessage> PostAsync(HttpClient client, object body, bool anonymous = false)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
{
Content = JsonContent.Create(body)
};
if (anonymous)
{
request.Headers.Add("X-Test-Anonymous", "true");
}
return await client.SendAsync(request);
}
}

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