Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e03ad8e8 | ||
|
|
d5dd0c2a94 |
313 changed files with 2995 additions and 34296 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -80,15 +80,3 @@ Then start the first implementation milestone:
|
|||
- work items
|
||||
- motions
|
||||
- ledgers
|
||||
|
||||
## Live Postgres tests
|
||||
|
||||
Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the one-current-Patron-snapshot index under racing restages, the intake journal `Seq`, the write gate's shared lock racing a merge's freeze and commit, the migration's roll-forward-only guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server:
|
||||
|
||||
```bash
|
||||
BIFROST_TEST_PG='Host=127.0.0.1;Port=54329;Username=postgres' dotnet test tests/Bifrost.Web.Tests --filter FullyQualifiedName~AccountMergePostgresTests
|
||||
```
|
||||
|
||||
## Account merges are roll-forward only
|
||||
|
||||
Once any `AccountMerges` row exists, do not deploy a build older than the `AddAccountMerges` migration, and do not revert that migration: an older build cannot read the `Merged` membership status and counts voided votes again. `Down` refuses while any merge row exists. Fix forward (`heimdall-body:ruling:merge-roll-forward-only`, `docs/account-merge.md` Rollback). Any deploy list that names a rollback must say so.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
|
|||
- Razor Pages app under `src/Bifrost.Web`
|
||||
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
|
||||
- Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating
|
||||
- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a strong proof: a WebAuthn passkey (enrolled behind a fresh sign-in, 48 hour cooling-off), or a fresh Google step-up for an account with none ([docs/linked-accounts.md](docs/linked-accounts.md), [docs/passkeys.md](docs/passkeys.md))
|
||||
- explicit member roles and admin-managed patron or contributor tier snapshots
|
||||
- patron support event recording with derived patron point summaries and tier refresh for voting weight
|
||||
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
|
||||
|
|
@ -90,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
|||
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
|
||||
- default connection string points at local PostgreSQL
|
||||
- build with `dotnet build Bifrost.slnx`
|
||||
- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT`
|
||||
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
|
||||
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
|
||||
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
|
||||
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
# Account merge ledger
|
||||
|
||||
What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. The ledger owns what a merge does to Bifrost records; the saga (below) owns the merge's lifecycle around it and every Heimdall call. The member's pages are in Pages and flags below; with `AccountMerge:Enabled` false (the default) no page can start a merge.
|
||||
|
||||
## Owner
|
||||
|
||||
`Features/Membership/AccountMergeLedger.cs` is the one owner of merge effects. Outside it nothing writes `VoidedByMergeId`, `MembershipStatus.Merged`, `AccountMergeEntry`, or the states Committed, Final and Undone, and nothing but the ledger calls `IgnoreQueryFilters` on `Vote` or `ConceptAppraisal`. The ledger never reads an email, display name or login string; accounts are matched by id.
|
||||
|
||||
## States
|
||||
|
||||
`AccountMerge.State` (stored as a string) declares the whole lifecycle once: `Proposed`, `Confirmed`, `HeimdallMoved`, `Committed`, `Final`, `UndoRequested`, `HeimdallReversed`, `Undone`, `Failed`. Later cuts add columns, not states.
|
||||
|
||||
An account's standing is derived from that state on every request; there is no `MergedInto` column and `Membership.Status = Merged` is never read to decide it.
|
||||
|
||||
| State of the merge the account is folded in | `StandingAsync` |
|
||||
| --- | --- |
|
||||
| Confirmed, HeimdallMoved | `Frozen(mergeId)` |
|
||||
| Committed, Final, UndoRequested, HeimdallReversed | `Folded(mergeId, survivorId)` |
|
||||
| Proposed, Failed, Undone | none |
|
||||
|
||||
An alias is one hop: a landed merge's survivor never folds into a third account (the `folded_is_survivor` block), so a folded account's survivor is always the account that carries the person. Every patron event is recorded through `PatronageService.RecordSupportEventAsync`, which writes through the gate below: an event for a folded account (Heimdall intake, Stripe webhook, the admin Ledger's manual record) records on the survivor and is journalled in the same save, so an undo moves it back; a provider retry returns the stored event and journals it once if it sits on the survivor.
|
||||
|
||||
`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. The same standing is read at the other doors: the desk bearer door (`ActiveMemberIdAsync`) refuses a Frozen or Folded subject, the linked-accounts page and the link step of Heimdall completion refuse one (no login linked or unlinked, no passkey enrolled while the saga moves them), and every writer reads it again at the write, through the gate below, so an actor resolved before the merge froze the account writes nothing after it.
|
||||
|
||||
## Plan, commit, undo, finalize
|
||||
|
||||
`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_is_survivor`, `sealed_ballot_pending`. The database backs the two overlap blocks the plan checks first (check-then-act): a partial unique index lets an account be folded by one live merge only (Confirmed through HeimdallReversed), and a trigger refuses a live merge whose survivor is another live merge's folded account, taking advisory locks on both accounts so racing transactions serialise.
|
||||
|
||||
`CommitAsync(mergeId)` needs `HeimdallMoved` (Committed and Final return the receipt; any other state throws). It is total: blocks were checked before the move, so it never refuses on them. It applies everything in one `SaveChanges` together with `State = Committed` and `GraceEndsUtc = CommittedUtc + 7 days`; a failed save leaves the merge `HeimdallMoved` and applies nothing, and a retry applies once.
|
||||
|
||||
What it reduces:
|
||||
|
||||
- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge <id>)`. A survivor without a membership row (the chat tier holds none) is given a Suspended one, journalled as `Standing`/`Created`, because an absent membership chats and the next member-access claim would create an Active one; undo removes it unless an admin has taken it over since.
|
||||
- **Membership.** The folded membership becomes `Merged` (weight 0 by the not-Active rule). Roles and admin flags of the folded membership are never copied.
|
||||
- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's cast time and takes the survivor's weight, derived from the survivor's tiers after the patron events moved (the folded weight is journalled for undo); a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched.
|
||||
- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:<survivor>` as a Member voter if the survivor is Active, else it is voided (the prior class and key are journalled).
|
||||
- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history (only `PatronageService` writes Patron snapshots, so a merge with no folded events leaves both tiers as they were).
|
||||
|
||||
Voiding stamps `VoidedByMergeId` and `VoidedUtc`; the model's query filter on `Vote` and `ConceptAppraisal` then removes the row from every tally, including future ones. Nothing is deleted.
|
||||
|
||||
Left untouched (counted by the plan): consent receipts, passkeys, ledger entries, points, work and revenue rows.
|
||||
|
||||
`UndoLocalAsync(mergeId)` needs `HeimdallReversed` (Undone is a no-op). In one save it replays the journal backward: it un-voids votes whose motion is still Open and appraisals, moves a moved vote or appraisal back only if the survivor did not re-cast it since, restores the folded status, restores the survivor's status only while it is still Suspended with this merge's reason, moves patron events back and derives both tiers again. What it keeps it records as `KeptAfterUndo`; nothing is updated or deleted in the journal, only appended to.
|
||||
|
||||
`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`, one save per merge; `AccountMergeSweeper` runs it every 30 seconds, after the saga's due steps (see Saga). `State` is the merge's concurrency token, so a transition computed from a stale read (the sweeper's Final over the saga's UndoRequested, or the reverse) fails its save and the other transition stands; the sweep skips a merge it lost. `UndoLocalAsync` also refuses a merge with `FinalizedUtc` set: undo is never allowed past Final.
|
||||
|
||||
## Saga
|
||||
|
||||
`Features/Membership/AccountMergeSaga.cs` is the one owner of the merge row's lifecycle outside the ledger's Committed, Final and Undone, and the only caller of `HeimdallIdentityMoveClient.MoveAsync` (whose closed `Moved`/`Refused`/`Unknown` answer it consumes as is; the client retries nothing). It writes Confirmed, HeimdallMoved, Failed, UndoRequested and HeimdallReversed, the return UndoRequested to Committed, the move ids and the sealed proofs; it never writes a vote, membership or journal row (it calls `CommitAsync` and `UndoLocalAsync`), reads no standing of its own, and never judges a proof's tier or freshness (Heimdall's).
|
||||
|
||||
| State | Written by | Key and proofs | A crash here |
|
||||
| --- | --- | --- | --- |
|
||||
| Confirmed | `ConfirmAsync`: one insert, the consent record | `HeimdallMoveId` minted (random, never derived from the merge id); both sides' Heimdall bearers sealed | Before the insert nothing exists. After it the sweep calls Heimdall with the stored id; a lost answer replays to the same receipt, because Heimdall answers a replay before it judges liveness. |
|
||||
| HeimdallMoved | `AdvanceAsync` on `Moved`, one save | proofs wiped; receipt tiers and moved login ids stored | `CommitAsync` is total and idempotent; the sweep retries it. |
|
||||
| Failed | `AdvanceAsync` on any `Refused` | proofs wiped; `FailureCode` is Heimdall's code | Terminal. Heimdall applied nothing, Bifrost changed nothing, the folded account stands as before. |
|
||||
| Committed | ledger `CommitAsync`, one save with every reduction | grace 7 days | A crash before the save leaves HeimdallMoved. |
|
||||
| UndoRequested | `RequestUndoAsync`: the survivor, fresh survivor proofs, inside the grace | `HeimdallReverseMoveId` minted; survivor bearers sealed | The sweep replays the reversal (past the grace too). A refused reversal returns the merge to Committed, proofs wiped, `LastOutcome` `undo_<code>`. |
|
||||
| HeimdallReversed | `AdvanceAsync` on the reversal's `Moved` | proofs wiped | `UndoLocalAsync` is retried. |
|
||||
| Undone, Final | ledger | | |
|
||||
|
||||
`AdvanceAsync(mergeId)` is the one step function: the confirm's inline advance, the sweeper's resume and an undo request all run it, forward move and reversal alike (same seal, same outcome handling, same backoff). Every transition is one save guarded by `State`, the concurrency token; the loser of a race clears its tracker, reloads and continues from the stored state, so two advances make one Heimdall move and one commit. `Proposed` is declared by the ledger and written by nobody in this cut.
|
||||
|
||||
`ConfirmAsync` refuses (writing nothing) a third-party actor (`NotParty`), a plan with blocks (`Blocked`, codes only), a plan other than the one the member saw (`PreviewStale`: `MergePlan.Digest(survivorId, foldedId)`, a SHA-256 over a length-prefixed canonical text of the plan), and any proof of another Heimdall account (`ProofAccountMismatch`). A second live merge folding the same account, or a chain, is refused by the database (the live-merge unique index and the chain trigger) and answered `Blocked(merge_in_flight)`. Confirm and the undo request run through `AccountMergeLedger.WriteAsAsync` for the acting member, like every writer that acts for an account.
|
||||
|
||||
**Heimdall down.** `Unknown` (a timeout, a 5xx, a 429, an unrecognised 4xx, `move_id_conflict`) never fails a merge: the saga counts `Attempts`, sets `NextAttemptUtc` (15 s, 1 min, 5 min, then 15 min) and replays the same stored id; the folded account stays Frozen until Heimdall answers. Heimdall's answer to a replay of a move it already stored does not depend on the proofs' age, but a move that never reached Heimdall does: an outage longer than the proofs' freshness (300 s for a login's weak proof, 600 s for a passkey's strong one) ends, when Heimdall is back, in `Refused proof_stale` and a Failed merge; the member confirms again with fresh sign-ins. A replay whose proofs no longer verify (Heimdall's key regenerated) answers `move_id_conflict` and stays Unknown; a receipt that names an account other than the destination is `receipt_mismatch` (proofs wiped, no retry); a sealed payload the key ring can no longer open is `proofs_unreadable` (retried in an hour, state unchanged, because Heimdall may already have moved). None of the three ends by itself: see Stuck merges.
|
||||
|
||||
**Sealed proofs.** The saga holds the two Heimdall bearers (`ruling merge-sealed-proofs-until-move-final`) only between Confirmed (or UndoRequested) and Heimdall's final answer: protected with Data Protection (purpose `Bifrost.AccountMerge.Proofs.v1`), never logged, wiped in the save that records Moved or Refused. Logs name the merge id, state, outcome class and refusal code; never a token, account id, identity id or provider user id. Proof coverage is Heimdall's rule (`move-proof-strict-passkey`): a proof through one login moves only that login, and only an asserted passkey speaks for the whole account, so a folded account's every login must be proved (or a passkey asserted) or Heimdall answers `identity_not_proved`. A `moveId` replayed with other identities returns the first receipt (`move-replay-first-receipt`).
|
||||
|
||||
`AccountMergeSweeper` is the one merge loop (30 s): per tick, in one scope, `MergeProofs.PruneExpired` (a proof bearer lives only in process memory and is dropped 270 s after its proof, on access and by this tick, so none outlives that plus one tick whether or not anyone opens the page; `heimdall-body:ruling:merge-proof-bearers-memory-bound`), then `AccountMergeSaga.AdvanceDueAsync` (every in-flight merge whose `NextAttemptUtc` is null or due, oldest first, a failure logged and skipped), then `FinalizeDueAsync`.
|
||||
|
||||
## Stuck merges
|
||||
|
||||
`/App/Merges` (platform admins only; the `/App` folder's ActiveMember policy, then `IsPlatformAdmin`) lists in-flight merges (Confirmed or UndoRequested) that stopped on `receipt_mismatch`, `proofs_unreadable` or `move_id_conflict`, or were retried eight times, with the merge id, state, last outcome, attempts and the stored move id of the leg in flight. It shows no Heimdall account id, login or account name, and calls no Heimdall. Only the first three outcomes can be ended (`AccountMergeSaga.SettleableOutcomes`); a merge listed for its attempts alone is still being retried.
|
||||
|
||||
Two actions (`heimdall-body:ruling:merge-stuck-end-only-typed-id`), both `AccountMergeSaga.SettleStuckAsync`, both needing the leg's stored move id typed back (the forward id for a Confirmed merge, the reverse id for an UndoRequested one) and both writing one `merge.settled` audit row that names the merge and the choice only:
|
||||
|
||||
| Action | Applies to | Result |
|
||||
| --- | --- | --- |
|
||||
| Fail the merge (`FailForward`) | Confirmed, stuck | `Failed`, `FailureCode` and `LastOutcome` `operator_failed`, proofs wiped. The folded account is free again; the pair can be confirmed anew. |
|
||||
| Give up the undo (`AbandonUndo`) | UndoRequested, stuck | back to `Committed`, `LastOutcome` `undo_operator_abandoned`, proofs wiped, the grace as it was. The merge stands; the logins stay with the survivor. |
|
||||
|
||||
HeimdallMoved and Committed are never settleable: Heimdall answered Moved with a matching receipt there, and the saga rolls forward. A settle and the sweeper's `AdvanceAsync` meet at the `State` concurrency token; the loser changes nothing and the other transition stands (answered `lost`). A settle does not fence a replay already in flight to Heimdall, so read `identity_moves` first and read it again after.
|
||||
|
||||
Before ending a merge, read Heimdall's `identity_moves` row by the stored move id. If Heimdall did move the logins, failing the merge leaves them moved with no Bifrost merge to match: repair or reverse at Heimdall first. For `receipt_mismatch`, repair Heimdall's or Bifrost's account link before failing. Adopting a move Heimdall made (writing HeimdallMoved from a receipt) stays manual SQL until follow-up `merge-operator-adopt-moved`; the page never does it.
|
||||
|
||||
## Pages and flags
|
||||
|
||||
`Pages/Membership/MergeAccounts` is the one page that creates a merge. It is `[Authorize]`, no-store, a 404 unless `AccountMergeOptions.Enabled` (section `AccountMerge`, default false; environment `AccountMerge__Enabled`) and forbidden to a frozen or folded account, as Linked accounts is. Linked accounts shows one "Merge two accounts" link, only when the flag is on and the session is Heimdall-backed.
|
||||
|
||||
Its stage is derived on every request from `MergeProofs.Snapshot` and the `keep` query; no cookie or form field carries progress except the chosen direction and the preview digest, which the saga re-checks.
|
||||
|
||||
1. Prove: the member names a provider for this account (`MergeProveSelf`) and for the other (`MergeProveOther`). Nothing about the other account is rendered until both proofs are held.
|
||||
2. Choose: both accounts are named by display name only, and `keep=self` or `keep=other` shows the preview. No survivor is preselected (the chat-history default needs `ChatFirstSignInUtc`, follow-up `merge-chat-default-survivor`; the chat paragraph carries the warning meanwhile).
|
||||
3. Prove the rest: one fresh sign-in for each login of the account that would be folded and is not yet proved (`ProveRemaining`). Proofs older than `MergeProofs.ProofLifetime` fall out and are asked again.
|
||||
4. Confirm: two checkboxes, one per account, the direction and the digest. `AccountMergeSaga.ConfirmAsync` inserts the Confirmed row, which is the consent record (actor, time, digest, proof providers); the page then runs `AdvanceAsync` once (a throw is logged and the sweeper resumes) and sends the member to Merge status. `MergeProofs.Clear` runs after a Confirmed outcome only: Blocked, PreviewStale, NotParty and ProofAccountMismatch come back as closed codes (`?merge=`), keep the proofs and write nothing.
|
||||
|
||||
`MergePreview` is the one wording of the plan (counts by entry kind and action, the nine block codes, the static statements about chat, connections, StreamPixels, roles, one vote per motion, the 7-day undo and ordinary sign-ins); the digest is `MergePlan.Digest` of the plan just rendered. `MergeWording.Confirm` holds the refusal words. The page writes no merge, vote, membership or journal row.
|
||||
|
||||
Deploy: set `AccountMerge__Enabled` only after Heimdall's identity-moves route is deployed and probed. A Confirmed merge against a Heimdall without it stays Unknown and keeps the folded account frozen.
|
||||
|
||||
## Notices
|
||||
|
||||
The chat host learns of merges by pulling, never by being pushed to: Bifrost has no event receiver and no outbox. `MergeNoticeFeed` derives the notices from the `AccountMerges` row on every read (merged: `CommittedUtc` set; undone: `UndoneUtc` set; final: `FinalizedUtc` set), so a notice cannot disagree with its merge, and a Failed, Frozen or Confirmed merge (no `CommittedUtc`) yields none. The only stored fact is the reader's ack, one `AccountMergeNoticeAcks` row per (merge, kind, target), written by nothing else (migration `AddAccountMergeNoticeAcks`; the table holds only acks, so its `Down` drops it). The feed never writes a merge column.
|
||||
|
||||
```
|
||||
GET /api/merge-notices/chat?after=<iso>&limit=<n> -> 200 JSON array, oldest first by (occurred_at, merge_id, kind)
|
||||
POST /api/merge-notices/chat/ack {"merge_id": "...", "type": "merged"} -> 204
|
||||
```
|
||||
|
||||
Payload, exactly these keys: `type` (`merged`, `undone`, `final`), `merge_id`, `occurred_at` (the matching timestamp, ISO 8601 UTC), `grace_ends_at`, `old_subject` (the folded `UserAccount.Id`, format D, the OIDC `sub` the chat session knew), `new_subject` (the survivor's), `old_preferred_username`, `new_preferred_username` (`NormalizedBifrostIdentity`). `limit` defaults to 50 and is capped at 200. Acks are the cursor: a read returns every unacked notice; `after` only narrows the scan to notices strictly later than that time, so a reader must not skip unacked ones by it. An ack is idempotent (a repeat is 204); an ack for a (merge, kind) the feed would not emit now, such as `undone` for a merge not undone, is 404; a malformed body or unknown `type` is 400.
|
||||
|
||||
Auth: `Authorization: Bearer <token>`, compared in constant time after SHA-256 of both sides against `ChatNotices:Token` (environment `ChatNotices__Token`). An empty token turns both routes off (404). A missing or wrong bearer is 401 with an empty body. No cookie, no CORS, `Cache-Control: no-store`. Startup does not require the token.
|
||||
|
||||
```
|
||||
curl -sS -H "Authorization: Bearer $TOKEN" "$BIFROST/api/merge-notices/chat?limit=50"
|
||||
curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"merge_id":"<merge_id>","type":"merged"}' "$BIFROST/api/merge-notices/chat/ack"
|
||||
```
|
||||
|
||||
The feed carries no email, display name, Heimdall id, Matrix id or history flag. It is information for the chat operator's side; back-channel logout (`OidcLogoutReconciler`) stays what ends chat sessions. Chat-side rules that bind Bifrost already hold: undo is refused past Final, and `preferred_username` never changes. A later reader (StreamPixels) adds a target constant and its own token over the same derivation.
|
||||
|
||||
Deploy: set `ChatNotices__Token` on Bifrost and hand the same value to the chat host's timer script (the secret's path is the chat steward's).
|
||||
|
||||
## Merged is terminal
|
||||
|
||||
Only the ledger writes `Membership.Status = Merged` or leaves it. The other status writers refuse a merged row (`Membership.IsMerged`): the bootstrap-admin promotion (startup and every GitHub sign-in, which is the one status write sign-in sync makes on an existing account) skips it, the Heimdall member-access claim does not activate it, member approval refuses it and the suspension service reports `NotApplicable`. Folded standing itself is still derived from the merge state, never from this status.
|
||||
|
||||
## Rollback
|
||||
|
||||
Bifrost is **roll-forward only** once a merge exists (`heimdall-body:ruling:merge-roll-forward-only`). A build older than `AddAccountMerges` cannot read the `Merged` membership status (EF throws on `/App/Members` and on the folded account's sign-in) and has no query filter, so it counts voided votes and appraisals again. Deploy the merge migration knowing that a code rollback past it is not available after the first merge lands; fix forward. The migration's `Down` refuses while any `AccountMerges` row exists, whatever state, so it cannot drop the merge record and journal under a `Merged` membership. No tombstone table is built: it would fix only the recount, not the enum.
|
||||
|
||||
## Not covered
|
||||
|
||||
`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. Every writer that acts for an account (votes, concept appraisals, sealed-ballot commit and reveal, patron events, the Heimdall sign-in's membership) passes through `AccountMergeLedger.WriteAsAsync`. On Postgres it opens a transaction, takes a shared advisory lock on the account (`pg_advisory_xact_lock_shared(hashtextextended(id::text, 0))`, the key the chain trigger takes exclusively when a merge row is inserted or changes state), reads standing, runs the writer's save and commits. `CommitAsync` takes the exclusive lock on the folded account before it reduces, then re-reads the merge. So: a writer whose standing read found no merge saves before a merge can freeze or fold the account, and the commit's reduction sees what it saved; a merge that is already frozen or folded when the writer reads is refused (votes, appraisals, ballots, sign-in membership) or routed (patron events). Guaranteed only for writes made through the gate, and only on Postgres: the in-memory provider has no lock or transaction (tests that rest on the lock run on live Postgres), and a writer that catches a database error inside the gate cannot continue, because the transaction is aborted. Not closed: writers outside the gate (nothing in the code base today; a new one must use it), and a writer's own stale reads taken before the gate opened (the motion a vote was loaded with), which concern the motion, not the account's standing. No operator void tool exists.
|
||||
|
||||
`UX_TierSnapshots_MembershipId_CurrentPatron` allows one current Patron snapshot per membership, so two restages of one survivor racing cannot both leave a current snapshot: the loser's save fails on the unique index, a merge commit rolls back whole and is retried by the saga, and a patron refresh re-reads and derives again.
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -41,7 +41,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only
|
|||
|
||||
### Data Protection on the live host
|
||||
|
||||
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
|
||||
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
|
||||
19. The service's working directory resolves to the release directory: `readlink /proc/<MainPID>/cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty).
|
||||
|
||||
### Proxy and client address
|
||||
|
|
|
|||
|
|
@ -54,16 +54,9 @@ permits, windows and `MaxTrackedKeys`.
|
|||
|
||||
New since the live release, and not read by the validator:
|
||||
|
||||
- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one
|
||||
trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never
|
||||
rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file
|
||||
stops startup in every environment, with a message that names the option and path and never the value. Ops steps:
|
||||
`docs/heimdall-app-secret-file.md`.
|
||||
- **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for
|
||||
Discord and Patreon sign-in. Outside Development and Testing startup fails when neither this nor
|
||||
`Heimdall__AppSecretFile` supplies a non-blank secret (the message names both options, never a value), because
|
||||
`HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` would otherwise be false and those sign-ins silently
|
||||
stop working (GitHub sign-in is unaffected). The value is Heimdall's app secret for slug `bifrost`
|
||||
- **`Heimdall__AppSecret`**: required for Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and
|
||||
`IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail,
|
||||
so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost`
|
||||
(`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
|
||||
from logs.
|
||||
- **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be
|
||||
|
|
@ -136,7 +129,7 @@ All must hold before step 1. A missing one means the window is not used.
|
|||
not a workstation.
|
||||
6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret`
|
||||
included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env`
|
||||
(mode 600, owner `bifrost`; a build with the empty-secret refusal does not start without it or `Heimdall__AppSecretFile`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
|
||||
(mode 600, owner `bifrost`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
|
||||
7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that
|
||||
everyone is signed out once (see "What moves").
|
||||
8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
# Desk feed: per-Mind open forks for a signed-in member (design, no code)
|
||||
|
||||
Status: proposal, 2026-11-11, Heimdall (Imagination pass) against `origin/main` e8e8fcc. Docs only; nothing here is built, ruled or deployed.
|
||||
Labels: **FACT** = read at the cited place. **INFERENCE** = my reading. **UNREACHED** = I could not read it.
|
||||
Reads: `src/Bifrost.Web/Features/Desk/*` (main), `docs/ratatoskr-voting-auth.md` (PR #37, e172869), `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, Ratatoskr `fbec032` (`src/app/features/desk/*`), `tools/persona-matrix-mouth.mjs`, `docs/bridge.md`. UNREACHED: CultLib `fork-card.md` (no checkout here; I rely on the Desk code that pins it at de8a062a), Huginn's `desk_wire.rs` (PR #31 is merged; the list/read half of the door wire is not defined in anything I read).
|
||||
|
||||
## 1. What is asked, and the finding that shapes it
|
||||
|
||||
Ratatoskr's Desk row per Mind is empty "until Bifrost feeds it" (FACT `src/app/features/desk/source.ts`: `deskFor(mind): ForkList | undefined`, slice 2 "replaces this with Bifrost's list reader"). The page already decodes `gamecult.fork.list.v0` (`forkContract.ts`).
|
||||
|
||||
**Finding: the feed route is already built.** `GET /api/desk/{mind}/forks?status=open&limit=N` is on main (PR #40, hardened #42-#45): list, per-Mind socket door, `open_count`, `stamp`, notes stripped for non-deciders. It serves nothing today because its two ports have no production implementation (`DeskPorts.cs`: `IDeskCredential`, `IDeskDoor`; `MapDesk` fails startup if either is missing). So the smallest honest feed is **not a new endpoint. It is the existing list route, read-only, with its two ports filled and a token that carries only `desk.read`.** A second route would need a second contract, vectors and owner for no gain.
|
||||
|
||||
## 2. The endpoint
|
||||
|
||||
- `GET https://<bifrost>/api/desk/{mind}/forks?status=open&limit=50[&cursor=c<N>]`, one call per Mind (the client already asks per Mind). `limit=0` returns counts and stamp only (FACT binding file, route table), usable for a badge.
|
||||
- Response: `gamecult.fork.list.v0` (`ForkList`: `mind`, `stamp`, `open_count`, `recorded_count`, `forks[]` of `ForkEntry`, `next_cursor`). **JSON, and why:** Ratatoskr is a browser client, the xenos boundary; the contract (CultLib `fork-card.md`, Transport) fixes JSON at the HTTP edge with every key written, and the documents are typed C# records (`DeskDocuments.cs`) mirroring CultLib's. Bifrost to Huginn is the typed door, not JSON.
|
||||
- The row the UI needs is derived by the client from each entry: `card.fork_id`, `card.title`, age (section 5), and the count is `open_count`. The page "derives nothing" (FACT `forkContract.ts` header), so it takes `open_count` as the one count.
|
||||
- Not offered: any POST (`/api/desk/{mind}/answers` stays unreachable to this client because its token has no `desk.answer`, so it is refused 403 `insufficient_scope` at step 4 of the pipeline, before the body), any vote, any write.
|
||||
|
||||
## 3. Auth: what a signed-in member can call today, and what the feed reuses
|
||||
|
||||
FACT today: a signed-in member holds only the cookie `__Host-bifrost_session`; the default scheme is Cookie (`Program.cs:189-200`); no `AddValidation` exists, so no access token authenticates any `/api/*` route; the desk handler is `.AllowAnonymous()` and judges its own bearer (`DeskEndpoints.cs`, `MapDesk`), and the cookie scheme cannot authenticate `/api/desk/*`. **A signed-in member can call nothing on the desk today.** Chat.gamecult.org never holds the `__Host-` cookie, so "the Heimdall session" cannot be sent by the browser to Bifrost from chat.
|
||||
|
||||
So the session is used as PR #37 uses it: the **sign-in leg of an OIDC code+PKCE flow** (`/connect/authorize` resumes on the Heimdall-backed session, FACT `OidcEndpoints.cs:44-70`, which admits any account that is not Suspended and has an identity: membership is neither required nor written, so the sign-in leg does not prove Active. It works for `desk.read` only because the desk re-checks Active on every request) mints a short access token. Reuse, exactly:
|
||||
1. Token: PR #37's second public PKCE client (Ratatoskr) and OpenIddict validation as a non-default scheme (built as `OidcDeskCredential` on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; the label `BifrostBearer` in PR #37 section 5 names the role, not the scheme string; handler `IDeskCredential` production impl, the credential port; H4 in the desk cut map, a label used only there). Request scope `desk.read` only, audience `bifrost-desk`. No `desk.answer`, no `votes.*`.
|
||||
2. Judgment: unchanged `DeskEndpoints.HandleAsync`: audience, scope, then `OidcEndpoints.ActiveMemberIdAsync(db, sub)` re-read every request (non-Active gets 403 `forbidden`).
|
||||
3. CORS: existing `DeskCors` (exactly `Desk:AllowedOrigin`, GET and POST, no credentials).
|
||||
Rejected: a cookie-authenticated same-origin JSON route (works for a Bifrost page, not for the chat origin; a second auth path on the same data), and letting chat present its Matrix/MAS token (Bifrost does not validate it; INFERENCE per PR #37 section 1).
|
||||
|
||||
## 4. Where the data comes from (compare, recommend one)
|
||||
|
||||
Huginn is the truth. A fork is a question record with options, a derived status, deciders and a server-computed `options_hash` (FACT binding file, "Cards are a read-only projection"). Bifrost holds no fork store, no cache, no hash (FACT cut map authority map). Everything else is a cache or an unrelated plane. Options:
|
||||
|
||||
- **(a) A small Huginn endpoint Bifrost calls.** This is already the plan of record, not a new thing: `IDeskDoor.ListAsync(socket, context, request)` over the per-Mind peer-checked unix socket, `context` = {Mind, authority, subject, deciders} built by Bifrost alone (`DeskPorts.cs`). Huginn returns the list "as it projects it for this viewer".
|
||||
- **(b) A file the persona mouth tails or writes.** FACT the mouth holds one `cursor.cc` per Persona and it stores only the Matrix `/sync` position `next_batch` (`persona-matrix-mouth.mjs` ~:491, :906-912). `chat_pending` is Matrix deliveries to a Persona over loopback RUDP (credential-derived connection id), served *to* Huginn, which is the client; the mouth reads Matrix, not Huginn. It holds no fork, no question and no Huginn client. A file route means a new writer inside the Matrix actuator that copies private records out of the mind into the chat plane, a second owner of "what a member may see", a Node process rewriting Rust's projection, and staleness equal to its write period. The mouth would gain a job it was split away from.
|
||||
- **(c) Bifrost reads Huginn directly with a CultNet selection** (what `huginn open <campaign>` / `huginn blocked <spec>` do). Rejected: those return every in-force question and follow-up of a campaign (`epiphany.pipeline.question.v2`, `follow_up.v2`), most of which are not forks. Bifrost would need a general read of the mind and would decide fork-hood and visibility itself. Also Bifrost has no Huginn client at all (FACT: no CultNet/RUDP/unix-socket client in C#; `UnixDomainSocketEndPoint` appears nowhere).
|
||||
|
||||
**Recommend (a), as the existing desk door, list request only.** Reasons:
|
||||
- **Auth:** the door is peer-credential checked (the receiver's uid); Bifrost passes the verified subject; the member's token never reaches Huginn.
|
||||
- **Staleness:** none added. A live read, `Cache-Control: no-store` (FACT), with Huginn's `stamp` (`s<N>`, same stamp same state) as the change signal; a client polls, or `limit=0` for the cheap probe. Bifrost may cache by stamp later; it need not.
|
||||
- **Failure:** per Mind and per call. A dead socket, timeout or unknown outcome is 503 `unavailable` with `Retry-After` (FACT `WriteUnavailableAsync`); the other Minds' rows are unaffected because the client asks per Mind. The mouth being down is irrelevant. Nothing is served from a stale copy that could mislead.
|
||||
- **Mouth overlap:** none, which is the point. Chat plane (mouth) and fork plane (door) stay two owners.
|
||||
- **Not yet existing:** `UnixSocketDeskDoor` (the door port's production impl; H5 in the desk cut map) and the Huginn side of the list wire. Eureka owns that wire (binding file section 5, "The door wire is Eureka's"). I found no definition of the list/read request bytes; this note does not invent one.
|
||||
|
||||
## 5. Meaning of "open" and "age"
|
||||
|
||||
- **open** = the card's `status` is `open` as Huginn derives it (`ForkCard.status`; `?status=open` filter). Bifrost does not decide it. The count is `open_count` of the same response, so the number and the rows come from one stamp. `recorded_count` (closed with a ruling) is not part of the feed.
|
||||
- **age** = now minus when the question was raised. **The contract cannot give this today.** FACT `ForkCard` has `order` (a position), `raised_in` (a reference) and no timestamp; answers carry `answered_at`/`received_at` but an unanswered fork has none. Ruled 2026-11-11 by Libby (CultLib steward): the card gains `raised_at`, a REQUIRED key whose value is an RFC3339 string or nil (the card goes from 16 to 17 keys; its hex changes). Huginn emits the question's admission time, or nil when it has none. Bifrost's record gains one field that must accept string or nil. Age derives from `raised_at` and is computed by the client; Bifrost computes nothing; nil means age unknown and the row shows no age. Until the contract lands the feed ships without age (the fallback loses nothing else).
|
||||
|
||||
## 6. D1 (options_hash) and the feed
|
||||
|
||||
Two different "D1"s exist: the ruling cited for this feed is the fork-desk answer-binding D1 (2026-11-10: `options_hash` covers title, context, options with ids, recommendation and depends; `fork_id` is bound beside it). PR #37 also has a D1 (consent). This section is the first; section 7 the second.
|
||||
- Feed rows carry the card as Huginn returns it, `options_hash` included. Bifrost never computes or corrects a hash (FACT binding file). A row's identity is `(fork_id, options_hash)`: an edit to a shown title changes the hash, so the row changes with it.
|
||||
- The feed shows title, which the hash covers, so feed and detail cannot disagree about it.
|
||||
- **Age is not decision content.** Ruled by Libby (X2, my earlier "inside the hash" option is withdrawn): `raised_at` stays OUT of `options_hash`, which keeps exactly five members (title, context, options, recommendation, depends), so the hash digests and Void's `optionsHash.ts` vectors do not change. Only the card's own bytes change, by the added key.
|
||||
|
||||
## 7. PR #37 decisions, what this feed needs
|
||||
|
||||
| id | decision (brief) | for the feed |
|
||||
|---|---|---|
|
||||
| D1 | explicit consent for `votes.cast`, or first-party implicit | **not needed**: a `desk.read` grant casts and changes nothing; implicit read consent (PR #37 section 7) |
|
||||
| D2 | re-authentication at cast for high-stakes motions | **not needed**: no cast |
|
||||
| D3 | token lifetime | **decided** (PR #37 section 14 D3): no refresh token; `desk.read`-only requests get a 60-minute access token, so a polling feed needs no redirect per 10 minutes. Per-audience lifetime is not built; probe 7 is owed |
|
||||
| D4 | where a member lists and revokes grants | **not needed** while the grant is read-only; revisit with `desk.answer` |
|
||||
| D5 | runtime brake | **decided** (section 14 D5): the existing marker-file brake, held by the steward. It is NOT per-client and does not cover `/api/desk` until the guard's matching is extended; that and the desk-only deployment are the code PR's to settle |
|
||||
| D6 | rate limits | **decided** (section 14 D6): 120 requests/min per account for `desk.read`, in configuration. A 429 has no refusal code in the contract (Q3 of the desk cut map): not decided there |
|
||||
| D7 | order against the MAS rollout | **decided** (section 14 D7): independent; the Ratatoskr client is seeded only when configured. The feed still needs Bifrost OIDC live, which it is not (PR #37 section 1) |
|
||||
|
||||
Already ruled: the answer-binding D1 above, visibility ruling c (any `desk.read` holder sees cards and outcomes; notes only for deciders), read with Bifrost's requirement that the door returns only cards this member may see, D2/answer-binding (decider list authored in Bifrost admin, Mind read-only).
|
||||
|
||||
## 8. Privacy: what a member may see per Mind
|
||||
|
||||
- **Filter owner: Huginn.** The list and read calls carry the viewer's `subject` and that Mind's `deciders` (no `authority`; an answer carries it); Huginn projects "for this viewer". The policy is in Huginn's `docs/desk-feed-door.md` ("Listing", Huginn main 79d954c or later) and is not copied here: not listed unless a listing record is in force, deciders see every showable fork, a hidden fork reads as `fork_unknown`. That gate is not live on Huginn main yet (`cut-desk-feed-listing`), so no Mind that holds private questions goes behind a live receiver before it lands. Bifrost holds NO audience rule: no filter on which forks appear, no per-card visibility rule, no copy of the policy (two copies would drift). Bifrost's two new acts are the per-request Active check and the `desk.read` bearer (below), beside two already built: it nils every `note` for a non-decider (`DeskNotes.Strip`), and it resolves `{mind}` only against the configured list (unknown Mind and unknown fork return the same 404 bytes, so Minds are not probeable).
|
||||
- **Fields safe for a member:** `fork_id`, `title`, `options`, `recommendation`, `depends`, `status`, `order`, `options_hash`, counts, `stamp`, and `raised_at` (string or nil). **Not safe, and not in the feed:** `note` (stripped), the decider list beyond ids already on the card, anything from the mind that is not a fork card (follow-ups, findings, research, persona state, room text).
|
||||
- **Answerer ids (X3, closed by the steward in PR #37 section 14):** `ForkEntry.record.answers[]` carries each answerer's account id (a Bifrost `UserAccount.Id`) to any `desk.read` holder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not need `record` at all. SUPERSEDED by Eureka's ruling `eureka-substrate:ruling:desk-read-visibility-withheld-ids` (Huginn #56, 79d954c): the door, not Bifrost, withholds decider ids and answerer ids from a non-decider and returns counts. Bifrost masks nothing it cannot read (the card is opaque to it) and keeps no answerer mask; it still nils `note` for a non-decider. Until Huginn's cut and the CultLib change land, the door sends ids whole, so no receiver goes live on a Mind before then.
|
||||
- **Content privacy:** a fork's `context` and `title` are Mind-authored prose and may name people or private matter. The door is the only gate: Bifrost requires the list wire to carry only cards the member may see, passes them through; the door withholds answerer and decider ids. The audience policy is Huginn's door spec's, not here. The feed ships no free text Bifrost wrote.
|
||||
- **Consent and receipts:** reading creates no member record, so no receipt and no consent object; the member's act is the token grant (read-only, implicit). Answer receipts and the append-only record belong to the answer path and stay out. Audit: log client id and `sub`, not the response. The training-notice receipt gates the chat token only (Meta's ruling, 2026-11-11): a desk token issues for a member with no receipt.
|
||||
|
||||
## 9. Failure modes
|
||||
|
||||
Bearer unjudgeable: 503 `unavailable` (not a refusal of the caller). Not Active: 403. Unknown Mind: 404 `fork_unknown`. Oversize fork: 422 `fork_too_large` with a cursor to continue (FACT #42/#44; the client must handle it, not render nothing). Token expired: 401; the client restarts the redirect (D3). Door down: 503; the Desk row shows "unavailable, retry", never an empty list (an empty list must mean zero open forks). Huginn emits a decider list outside 1..64 for a configured Mind: 503 (config fault, FACT). Ratatoskr's source is synchronous; it needs a fetch-and-hold layer that keeps the last good `stamp` and marks it stale, which is Void's.
|
||||
|
||||
## 10. Tests that would prove it (consumer side)
|
||||
|
||||
1. Ratatoskr, against a stubbed list: a `ForkList` fixture from the vendored vectors renders the right rows and count; a 503 renders "unavailable", not an empty desk; `open_count` 0 with an empty page renders "none open".
|
||||
2. Bifrost, `DeskWebApplicationFactory` + `FakeDeskDoor`: a token with `desk.read` lists (200); no token 401; wrong audience 401; a `desk.read`-only token POSTing an answer is 403 `insufficient_scope` with the door never called; a non-Active subject is 403 with the door never called; a cookie alone gets 401.
|
||||
3. Privacy: a non-decider's response has every `note` nil (key present); Bifrost masks no `answerer` (Huginn's door withholds ids); a decider keeps notes; an unknown Mind and an unknown fork return identical bytes; Bifrost relays exactly the cards the door returns (a door double returning N cards yields N rows, none dropped or added) and applies no audience filter of its own.
|
||||
4. Hash: a row's `options_hash` equals the vector value, and Ratatoskr's `optionsHash.ts` recomputed over the rendered title/context/options matches it (pins D1 end to end).
|
||||
5. Probes owed by PR #37 section 11 (items 1, 3, 5): public client exchange, audience+scope enforced, bearer on `/App` still refused.
|
||||
6. Door contract: a `UnixSocketDeskDoor` against a Huginn double returns `Listed`, `NotListed`, `ForkTooLarge`, and an exception maps to 503.
|
||||
|
||||
## 11. Size estimate (lines, include tests)
|
||||
|
||||
| file | lines |
|
||||
|---|---|
|
||||
| Bifrost `Features/Desk/UnixSocketDeskDoor.cs` (list only; read/answer added by the answer cut) | ~180 |
|
||||
| Bifrost `Features/Oidc/*` bearer validation, `desk.read` scope, audience, second client seed (shared with PR #37) | ~150 |
|
||||
| Bifrost `IDeskCredential` production impl (validates the bearer, returns `DeskToken`) + wiring in `Program.cs`; no audience or visibility logic | ~60 |
|
||||
| Bifrost sweep revoking the Ratatoskr/desk client's authorizations for subjects failing the chat-tier test (PR #37 section 8; derived from membership, no outbound POST) | ~80 |
|
||||
| Bifrost `OidcGuard` match extended to `/api/desk` (D5 brake) and the desk-only deployment settlement | ~30 |
|
||||
| Bifrost per-audience token lifetime at authorize (D3: 60 minutes for `desk.read`-only; probe 7 first) | ~30 |
|
||||
| Bifrost `DeskNotes` extension: mask `answerer` for non-deciders (dropped: the door withholds ids) | 0 |
|
||||
| Bifrost re-vendor `tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json` (17-key card, new CultLib commit) + `DeskVectors.cs:9,11` pin + `ForkCard` 17th member `RaisedAt`, string or nil (`DeskDocuments.cs:64-80`) + `DeskDocumentTests`/`DeskScenarioTests` updates | ~60 |
|
||||
| CultLib `fork-card.md` + schema + card vectors for `raised_at` (17-key card; `options_hash` vectors unchanged) | ~40 |
|
||||
| Huginn: list wire handler and `raised_at` (Eureka's) | not estimated |
|
||||
| Ratatoskr list reader + token redirect + stale holder (Void's) | ~250 |
|
||||
| Bifrost tests (items 2, 3, 6) | ~250 |
|
||||
| This doc and `docs/oidc-issuer.md` note | ~40 |
|
||||
|
||||
Subtraction: no new route, no store, no migration, no DTO type beyond one field. The two shared ports (door, bearer) are the cost, and the answer path needs them anyway.
|
||||
|
||||
## 12. Open decisions
|
||||
|
||||
- **X1** (CultLib, ruled 2026-11-11, Libby): `raised_at` is a required card key, RFC3339 string or nil; 16 to 17 keys. Huginn's emission of it is Eureka's, still to build.
|
||||
- **X2** (withdrawn): `raised_at` is out of `options_hash`, digests unchanged (Libby).
|
||||
- **Nothing in this note is open for Bifrost's own design.** Decided and recorded elsewhere: `raised_at` (Libby, CultLib ruling 2026-11-11, X1/X2 closed); answerer ids (X3: withheld by Huginn's door, ruling `desk-read-visibility-withheld-ids`, supersedes the Bifrost mask); PR #37 D1-D7 (section 14); card visibility (the Huginn door's policy, `docs/desk-feed-door.md` in Huginn; Bifrost holds no audience rule).
|
||||
- Owed by others: Eureka owns Huginn's emission of `raised_at`, the listing gate (`cut-desk-feed-listing`) and the ops root-run that binds the sockets; the door wire is on Huginn main and its golden fixtures are the contract for `UnixSocketDeskDoor`.
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
# Feedback receiver
|
||||
|
||||
Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:chat-s3533-3533`, updated for
|
||||
`chat-s3549-3549`. It is the receiver half of Ratatoskr's Mind feedback pages. The page spec is Ratatoskr PR #24,
|
||||
`docs/mind-feedback-pages.md` at b146000d, which I take as final here; its section 7a names this doc. The rulings it
|
||||
builds on are Heimdall's drafts on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f).
|
||||
|
||||
Read at Bifrost main `b1725a1` (2026-11-12), line references re-read at `e58e953` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`,
|
||||
`src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*`, `Domain/BifrostModels.cs`, `Program.cs`, `docs/oidc-issuer.md`,
|
||||
`docs/ratatoskr-voting-auth.md`; the deployed trees under `/srv/bifrost` were last read at an earlier main (section 9). I could not read Eureka's receiver
|
||||
notes in #minds, and `huginn-feedback-kinds-proposal.md` was not in `/opt/commons/handoff` when I looked. Where
|
||||
PR #24 names a kind or a field it says the proposal wins, so I cite PR #24 and name the proposal's kinds only as PR #24 does.
|
||||
Where source disagrees with either, the section says so.
|
||||
|
||||
Rulings of Meta, 2026-11-12, now written into this doc: erase redacts the answer text in the receiver's store and keeps a text-free record
|
||||
(section 7a); the training notice covers any text entered into the app, bar a short exception list Eureka drafts (section 4a); the host may
|
||||
withhold an answer from its Mind in an emergency, with a logged receipt (section 6a); Minds authenticate to the receiver through an OIDC
|
||||
client with an audience, as the desk does (section 4b). Eureka's decisions on forks 2 to 5 of 2026-11-12 (`/opt/commons/handoff/bifrost-10-forks-2-5-recs-20261112.md`)
|
||||
are folded into sections 2, 4b, 6a, 7a and 9 and close those forks, except where section 9 says a point is still open.
|
||||
|
||||
## Rulings of the steward (Heimdall, 2026-11-12)
|
||||
|
||||
Two of this doc's forks are the Bifrost steward's. Meta's, Eureka's and Libby's items are not ruled here.
|
||||
|
||||
1. **Ratatoskr's client for people: reading 2a, with no third client.** The one public PKCE client the desk-credential cut seeds for Ratatoskr (only when `Bifrost:Oidc:Clients:Ratatoskr` is configured, so absent config changes nothing) also gets a scope `feedback.answer` that puts audience `bifrost-feedback-page` on the token. Authorization code with PKCE only, exact redirect URIs, no refresh token, the token only in an `Authorization` header and never in a URL, a query or a log. The audience is distinct from the Mind's `bifrost-feedback`, so a person's token never opens a Mind endpoint and a Mind's never opens the page (least privilege). Reading 2b, a Matrix token checked at the homeserver, is refused: it is a bearer for the person's whole Matrix account. The receiver writes a text-free receipt per accepted submission (receipts).
|
||||
2. **Token lifetimes are explicit, short, and set in the issuer change.** `feedback.answer` access token: 15 minutes, because the Bifrost session cookie re-issues it silently. The Mind's `client_credentials` token: 10 minutes, one call to renew. No refresh token for either. The desk's lifetime is its own decision and is not touched here. Until the issuer change lands the OpenIddict default applies; this doc no longer relies on it.
|
||||
|
||||
## 1. What the receiver owns
|
||||
|
||||
| Concern | Owner | Receiver's part |
|
||||
|---|---|---|
|
||||
| A question, its listing, status, hides, rulings | the owning Mind, in her own mind (Huginn) | reads them to build the projection; **never writes to Huginn** (an admission for another instance is refused `ForeignInstance`, PR #24 section 3) |
|
||||
| An answer's **text** | the receiver | stores it; it is never admitted into a mind |
|
||||
| The answer's **record** in the Mind | the owning Mind's Persona | she admits a text-free `feedback_answer` from the delivery, and acks after she has |
|
||||
| What `kind` an account is | Bifrost (`Membership`, later `Role`) | reads it at answer time and stamps it |
|
||||
| Delivery to the Mind | the receiver, at least once | keeps the record until the Persona acks |
|
||||
| Hiding, closing, ruling | the owning Mind | **never**; a hide only changes what the projection serves |
|
||||
| Erase | Bifrost's erase, executed here | redacts the text and name in the store, keeps a text-free record (section 7a) |
|
||||
| Withholding an answer from its Mind | the host (emergency only) | logs a text-free withhold record and skips delivery; changes nothing stored or ruled (section 6a) |
|
||||
| Who may answer | Bifrost: `Active`, a live training-notice receipt | checks both on every submission with text (section 4a) |
|
||||
|
||||
The page and the client write no record that carries identity (PR #24, section 3). A Matrix sender is a different identity
|
||||
authority and never becomes `answerer`.
|
||||
|
||||
## 2. The mouth's pending/ack path, read from source
|
||||
|
||||
Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs`; line numbers re-read at main `e58e953` (2026-11-12), where the mouth has grown by about 100 lines since `b1725a1` and nothing else in this section changed:
|
||||
|
||||
- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.156) is filled in one place, `syncOnce`
|
||||
(l.543; `pending.push(...made)` l.602), from Matrix `/sync` events. No function accepts a delivery from elsewhere.
|
||||
- **A request is non-consuming; an ack consumes.** The pending requests carry `afterSeq`; the mouth answers
|
||||
`pendingAfter(afterSeq)` (l.720) paged to 256 KiB (`pageOf`, l.1055-1084; `limits.replyBytes`, documents l.53). `chat_ack` carries `throughSeq`; `ack`
|
||||
(l.662-665) drops every delivery at or below it, releases stored images (`images?.acked`) and advances the Matrix cursor. An ack posts nothing to
|
||||
Matrix: her public `m.read` receipt follows a separate `chat_read` document (header l.10-13, `chat_read` branch l.1138). The endpoint serves a closed list of kinds
|
||||
(`served`, l.1103) and answers any other loudly with `chat_refusal` `UnknownKind` (l.1112); a request for another Persona gets `UnknownPersona`.
|
||||
- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.619-627), so a
|
||||
restart before the ack re-delivers from Matrix. `seqCounter` (l.155) is persisted whenever a round made deliveries (l.614), so
|
||||
sequence numbers never repeat across a restart.
|
||||
- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.63) `syncOnce` stops reading (l.550). A truncated round (pending cap l.578, reply-read cap l.579)
|
||||
leaves the cursor where it was (l.607).
|
||||
- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.1060-1062); the caller's
|
||||
next ack covers its seq, so it is dropped. Harmless for a chat line, wrong for a durable record.
|
||||
- **The endpoint is loopback RUDP.** `startChatEndpoint` (l.1091) refuses a non-loopback host (l.1092). It is another process
|
||||
from Bifrost.Web, which has no way to hand the mouth anything.
|
||||
- **The delivery tuple is closed and versioned.** `deliveryTuple` is twelve fields (the twelfth is an optional stored-image reference), the pending answer
|
||||
`gamecult.persona.chat_pending.v2` (`persona-chat-documents.mjs` l.216-224). v3 adds `basis` and `form` (14 fields) and a room tail (`chat_pending.v3`,
|
||||
asked for by `chat_pending_request.v2`); v4 adds `reply_to` (15 fields, `chat_pending_request.v3`, served only with `CHAT_PENDING_V4=1`, `replyLinkFromEnv` l.1185). No version has a source or
|
||||
kind field. A delivery cannot say it is not a Matrix message.
|
||||
- **A fetch kind exists, off by default.** `chat_fetch_request` is served only with `CHAT_FETCH=1` (`served`, l.1103; `fetchFromEnv`, l.1187): an optional served kind is a precedent for adding kinds
|
||||
to `served`, and the way a kind is withheld until its consumer is ready.
|
||||
- **The consumer is not in this repo.** The mouth's header (l.10-11) says the heartbeat polls with a non-consuming
|
||||
request and decides who runs. I could not confirm that from source.
|
||||
|
||||
What follows from it:
|
||||
|
||||
1. **Feedback cannot ride `pending` as it is.** There is no inlet, and the cursor logic ties acks to Matrix batches.
|
||||
2. **A framed text delivery is forgeable.** Any Matrix member can write a line that looks like a frame, and no tuple has a
|
||||
field to tell the Mind which one is real. The frame must be a typed difference in the wire, not a convention in `text`.
|
||||
3. **A record that is not in Matrix is not re-delivered by a restart.** Feedback needs its own durable store and its own
|
||||
delivery cursor.
|
||||
|
||||
PR #24 section 3 names the carrier: a new delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost, through
|
||||
the mouth's pending/ack path. Source adds one constraint on how it rides:
|
||||
|
||||
- **It cannot be a variant inside `chat_pending`.** Each version is a closed positional tuple (twelve, fourteen or fifteen fields) encoded by
|
||||
`pageOf` (l.1060), so a different tuple in that page breaks every current consumer, and a text frame in `text` is forgeable by any Matrix member.
|
||||
- **Option B, kept in shape:** the delivery document has its own request, page and ack kinds (`feedback_pending_request`, `feedback_pending`, `feedback_ack`), its own seq space and
|
||||
a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor and `MAX_PENDING` are untouched. Per the decision below the kinds are served by the receiver over
|
||||
HTTPS, not added to the mouth's `served` list (l.1103). Confidence medium: the Persona-side consumer, which I cannot see, must learn them.
|
||||
- **Why main's changes reinforce B.** A chat `ack` releases stored images and advances the Matrix `/sync` cursor (l.662-665). A feedback ack must do neither, so it cannot
|
||||
share the `chat_ack` kind or its `ack` function. The chat pending answer is already versioned three times, so a new field in that tuple is a new
|
||||
document version for every consumer, which a feedback record must not force.
|
||||
- Rejected: injecting records into `pending` as synthetic Matrix deliveries (no inlet, forgeable, lost on restart);
|
||||
posting to a Matrix room as a bot (wrong identity authority, spam and forgery, and the Mind would read it as chat).
|
||||
- **Decided (Eureka, 2026-11-12): feedback does not ride the mouth.** The endpoint is loopback RUDP with no bearer and trusts the persona id in the document, so
|
||||
routing feedback through it would make the Matrix organ a bearer-less deputy holding every Mind's authority and would put the receiver's own seq space and durable
|
||||
cursor in a Matrix process. A Mind pulls and acks **directly over HTTPS from her own process to the receiver, with the section 4b bearer**; she admits the text-free
|
||||
record into her own mind afterwards. The three kinds `feedback_pending_request`, `feedback_pending`, `feedback_ack` are therefore HTTP endpoints of the receiver, not
|
||||
kinds added to `served`; Option B's "same endpoint" clause is withdrawn and the mouth is untouched. The delivery document and its seq space are as above. This is a
|
||||
Bifrost HTTP service, a xenos boundary for CultNet, so JSON/HTTP there is the tolerated kind. Pre-deploy test: section 9.
|
||||
|
||||
## 3. Records
|
||||
|
||||
One CultCache file, owned and written only by the receiver (section 8). Append-only through the API: nothing edits or
|
||||
deletes a record except the redaction of section 7a. Every record has a `recordId` and a receiver-assigned `seq`
|
||||
per owning Mind (the delivery sequence). PR #24 names the answer row `<question-label>.a<N>`, N the receiver's per-question
|
||||
sequence; I use that for answers and the same shape for the others.
|
||||
|
||||
| Record | Fields | Notes |
|
||||
|---|---|---|
|
||||
| `answer` | `recordId`, `questionId`, `owner`, `choice?` (an option label), `text?` (at most 2000 characters), `answerer`, `kind`, `weightPermille`, `nameAtAnswer`, `clientRequestId`, `time` | at least one of `choice`/`text`; an unknown option label is refused; the request id makes a retry idempotent, as `txnId = request_id` does in the mouth; `weightPermille` is 0 for a guest and 1000 otherwise, stamped, and the page sums nothing |
|
||||
| `close_request` | `recordId`, `questionId`, `requester`, `kind`, `time` | refused for a guest and for an account not `active` at that moment (R4c) |
|
||||
| `report` | `recordId`, `targetId`, `reporter`, `kind`, `reason`, `time` | delivered to the Mind as input; never shown, never counted (R6b) |
|
||||
| `withhold`, `withholdLifted` | `recordId`, `answerRecordId`, `owner`, `withheldBy` or `liftedBy`, `time`, `reasonCode` (withhold only) | text-free log of section 6a |
|
||||
| delivery state | per owning Mind: highest seq acked; per record: `delivered`, `failed`, `withheld` | a cursor, not a record the page reads |
|
||||
|
||||
Invariants: `answerer`, `kind`, `weightPermille`, `time` and `nameAtAnswer` come from the verified token and Bifrost state, never from the
|
||||
request body, and the body schema is closed: an unknown key refuses the submission. The latest record per account is that
|
||||
account's standing answer, derived at read time. A submission is refused if the question has no listing in force or is hidden.
|
||||
An answer to a question that has settled is kept and the page says so (PR #24 section 8). A refused submission leaves nothing behind.
|
||||
|
||||
## 4. Credential and claims check
|
||||
|
||||
PR #24 section 7a rules **route 2** for people: Ratatoskr presents an OIDC access token that Bifrost issued, and Bifrost verifies it itself. Meta
|
||||
ruled on 2026-11-12 that Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (4b). This section records what
|
||||
Bifrost's source does on main `e58e953` (the Oidc, Desk and Registration files cited here are unchanged since `b1725a1`, bar `DeskEndpoints.cs:49`, which registers `UnixSocketDeskDoor`).
|
||||
|
||||
### What main does with tokens
|
||||
|
||||
| Token | Who may get it | Gate at issue | Gate on use |
|
||||
|---|---|---|---|
|
||||
| **Chat token** (scopes `openid`, `profile`; no audience) | an account that exists, has a Bifrost identity that is not reserved, and whose membership is not `Suspended`; membership is neither required nor written (`OidcEndpoints.cs:55-78`) | a live training-notice receipt: without one the member is redirected to the consent page and the parked request waits (`:104-111`) | the token and userinfo endpoints re-read it: `ChatRefusalAsync` refuses an absent or `Suspended` account and a chat token with no live receipt (`:164-180`, called at `:127` and `:144`) |
|
||||
| **Desk token** (every scope is `desk.read` or `desk.answer`, `DeskEndpoints.cs:21-26`) | the same sign-in, then only a client whose permissions allow the desk scope | **no receipt**: `IsDeskToken` is true only when every scope is a desk scope (`:118`), so a desk token skips the gate (`:104`, `:108`); `principal.SetResources(DeskEndpoints.Audience)` puts audience `bifrost-desk` on it (`:91-94`) | per request, in `DeskEndpoints.HandleAsync`: audience (`DeskEndpoints.cs:110`), scope (`:116-121`), then `Active` membership re-read through `OidcEndpoints.ActiveMemberIdAsync` (`:123-139`; `OidcEndpoints.cs:183-194`, which is `CurrentBifrostActor.IsActiveMember`, `Status == Active`, `CurrentBifrostActor.cs:17`) |
|
||||
|
||||
The token claims are unchanged: `sub` is `UserAccount.Id` in form `D`; `preferred_username` and `name` ride the token only with scope `profile`
|
||||
(`OidcEndpoints.cs:80-102`). No email, Heimdall id or GitHub id is in it. The access token is an encrypted OpenIddict token and its lifetime is OpenIddict's
|
||||
default, which I believe is one hour and have not verified (no lifetime override in `src`; `ratatoskr-voting-auth.md` section 14 D3 owes explicit values). There is no refresh token.
|
||||
|
||||
How a bearer is validated has changed: OpenIddict's validation handler is registered against the local server (`DeskEndpoints.cs:36-38`), reading the
|
||||
`Authorization` header only (`:40-44`), and `OidcDeskCredential` authenticates through it by its own scheme name and judges nothing else
|
||||
(`OidcDeskCredential.cs:14-27`; audience, scope and membership are the desk's, per its header comment). That path is registered only when `Bifrost:Oidc` is configured (`:30-33`).
|
||||
|
||||
**Only one client exists.** The seeder creates the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). MAS's permissions
|
||||
carry no desk scope (`:42-50`), so **no desk token can be issued on main**: the desk's second client is owed (`ratatoskr-voting-auth.md` section 14 D7). `AllowAuthorizationCodeFlow` is the only
|
||||
grant and the token endpoint takes `client_secret_basic` only (`OidcServiceCollectionExtensions.cs:39`, `:45-46`).
|
||||
|
||||
### 4a. What the receiver checks on an answer from a person
|
||||
|
||||
In this order; a submission that fails any step leaves nothing behind.
|
||||
|
||||
1. **The token opens and has not expired**, and `sub` is a Guid naming an existing `UserAccount`.
|
||||
2. **The account is `Active` now** (`ActiveMemberIdAsync` as the desk does). A chat token is issued to non-`Active` accounts (`OidcEndpoints.cs:55-62`), so the token alone proves nothing about standing. Under PR #9 `IsActiveMember` would also require
|
||||
`Role == Member` and exclude guests, so the receiver reads `Status == Active` itself and not that expression.
|
||||
3. **A live training-notice receipt, read by the receiver on every submission that carries text** (Meta's ruling, 2026-11-12): `ConsentEndpoints.HasLiveReceiptAsync`
|
||||
(`Features/Registration/ConsentEndpoints.cs:146`), the one reading of "may speak": a receipt for the notice version in force, not withdrawn. The notice covers any
|
||||
text entered into the app except a short designated exception list, which Eureka drafts. The receiver checks the receipt itself because the token's
|
||||
gate does not cover it: a chat token's receipt was checked at issue and again only at the token and userinfo endpoints (`:127`, `:144`), never at the receiver, and a token whose audience is
|
||||
not a chat token skips the gate altogether (`:104`). A receipt withdrawn after issue must stop the next submission. The check applies to answers, close requests and reports alike. **Until Eureka's list exists the gate applies to every submission with text**; the list narrows it, and only by being written down.
|
||||
4. The body is closed-schema, an option label must exist, the question must be in force and not hidden (section 3).
|
||||
|
||||
A refusal for a missing receipt is typed and carries the consent page's path, so the page can send the person to agree; it does not store the text.
|
||||
|
||||
What the receiver refuses: a token that does not open or has expired; a `sub` that is not a Guid or names no account; an account that is not `Active`; a missing receipt (above);
|
||||
a body carrying `answerer`, `kind`, `name`, `weight` or any key the closed schema does not name; an unknown option label; a question with no listing in force, or hidden; a close request from a guest.
|
||||
|
||||
What source says route 2 still has to answer:
|
||||
|
||||
1. **No client reaches Ratatoskr.** MAS holds the only client, and it holds the access token; Ratatoskr gets a Matrix session from MAS, not a Bifrost token. The reading that fits what the desk now does is **2a**: Ratatoskr as a
|
||||
second, public, PKCE client with its own audience and scope, issued by the same code path as the desk's (`ratatoskr-voting-auth.md` sections 3-4). The audience machinery it needs now exists on main; the client does not. Reading **2b** (check the Matrix token against the homeserver) hands the receiver a bearer good for the person's whole Matrix account; I recommend against. I lean to 2a, confidence medium.
|
||||
2. **The logout reconciler covers only MAS** (`OidcLogoutReconciler.cs:108`, `:180`); a second client's token lives to expiry. The receiver's own `Active` and receipt reads at every submission cover that.
|
||||
3. A token with an audience of its own would not be accepted from any other client, which closes the weakness an unaudienced chat token had here.
|
||||
|
||||
### 4b. How a Mind authenticates (Meta's ruling, 2026-11-12; names are proposed)
|
||||
|
||||
A Mind authenticates to the receiver through an OIDC client with an audience, the shape the desk uses: a scope at issue puts an audience on the token
|
||||
(`OidcEndpoints.cs:91-94`), the validation handler opens it (`OidcDeskCredential.cs:14-27`), and the endpoint judges audience, then scope, then subject
|
||||
(`DeskEndpoints.cs:110-139`). The analogous parts:
|
||||
|
||||
| Part | Desk (main) | Receiver (**proposed**) |
|
||||
|---|---|---|
|
||||
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | **`bifrost-feedback`** |
|
||||
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | **`feedback.read`** (pull deliveries and the Mind's own projection) and **`feedback.ack`** (ack a delivery; also the Mind's acts that pass through the receiver) |
|
||||
| Credential | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
|
||||
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | **the `sub` must be the owning Mind's configured client**, matched against the question's `owner`; a Mind is not a `UserAccount` and has no membership to read |
|
||||
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
|
||||
|
||||
**Decided (Eureka, 2026-11-12): OAuth 2.0 `client_credentials`, one confidential client per Mind.** `client_id = mind-<persona>`; scopes `feedback.read` and
|
||||
`feedback.ack` only; audience `bifrost-feedback`. The token's `sub` maps to the Mind's registered persona and is matched against the question's `owner`. The grant
|
||||
is RFC 6749 section 4.4 ("using only its client credentials"): short-lived, audience-bound, no refresh token, no cookie session. It is not extended to the desk in the
|
||||
same cut.
|
||||
|
||||
**The issuer today cannot issue it.** It enables one grant, `server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange()`
|
||||
(`OidcServiceCollectionExtensions.cs:39`); the token endpoint accepts `client_secret_basic` only (`:44-46`: `ClientAuthenticationMethods.Clear()`, then `ClientSecretBasic`);
|
||||
the seeder creates only the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). **Enabling `client_credentials`
|
||||
(`AllowClientCredentialsFlow`), seeding the per-Mind clients and the audience/scope registration is an issuer change this design owes**, with a matching edit to
|
||||
`docs/oidc-issuer.md`. Until it lands no Mind token can be issued on main. The audience and scope names are accepted as proposed.
|
||||
|
||||
**Client authentication: `private_key_jwt` with the Mind's Ed25519 key is not available on the pinned stack; use a secret.** Finding, read against the pinned packages
|
||||
(`src/Bifrost.Web/packages.lock.json`: `OpenIddict.Server`, `.Validation` and the rest at 7.7.1):
|
||||
|
||||
- *Supported:* OpenIddict.Server 7.7.1 has `private_key_jwt` client authentication. Its assembly carries the literal `private_key_jwt`, the `client_assertion` and
|
||||
`client_assertion_type` parameters, `OpenIddictServerOptions.ClientAssertionTypes`, and the handlers `ValidateClientAssertion`, `...Issuer`, `...Audience` and `...WellknownClaims`
|
||||
(`OpenIddict.Server.xml`); each application can carry a `JsonWebKeySet` (`OpenIddictApplicationDescriptor.JsonWebKeySet`, `IOpenIddictApplicationManager.GetJsonWebKeySetAsync`, `OpenIddict.Abstractions.xml`).
|
||||
Bifrost does not enable it today: the method list is cleared to `ClientSecretBasic` (`:44-46`).
|
||||
- *Not supported with Ed25519:* the assertion is a JWT checked by `Microsoft.IdentityModel` 8.19.2, the version the lock file pulls in. Scanning
|
||||
`Microsoft.IdentityModel.Tokens.dll` for algorithm identifiers finds only `HS256/384/512`, `RS256/384/512`, `PS256/384/512` and `ES256/384/512`; there is no `EdDSA`, `Ed25519`
|
||||
or `OKP` anywhere in it or in `Microsoft.IdentityModel.JsonWebTokens.dll`, and none in `OpenIddict.Server.dll` or `OpenIddict.Abstractions.dll`. So a Mind's existing Ed25519 key
|
||||
(the one moderator-registered through `MindIntakeService.cs`) cannot sign a `client_assertion` this server would accept. (Evidence is a string scan of the shipped assemblies, not a
|
||||
runtime test; I did not run an assertion against a live issuer. Confidence high that it fails, since the algorithm names are absent.)
|
||||
- *Consequence:* `private_key_jwt` would need a second, ECDSA P-256 (or RSA) key per Mind registered as the client's key set, which is a new key to custody and no improvement on a secret.
|
||||
Decision: **`client_secret_basic` with one secret per Mind**, the method the token endpoint already takes. If the stack gains EdDSA the choice reopens, and `private_key_jwt`
|
||||
with the existing key is then preferred because it removes the per-Mind secret (RFC 7523 section 2.2).
|
||||
|
||||
**The client secret lives only in a file the Mind's process reads** (for example a systemd credential in her own home, mode 0400), never in an environment variable, a command
|
||||
line or a log. The seeder stores the hash Bifrost holds; the plaintext is handed to the Mind once, through the same custody path as her other credentials.
|
||||
|
||||
| Part | Desk (main) | Receiver (decided) |
|
||||
|---|---|---|
|
||||
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | `bifrost-feedback` |
|
||||
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | `feedback.read` (pull deliveries, read the Mind's own projection) and `feedback.ack` (ack a delivery; also the Mind's acts that pass through the receiver) |
|
||||
| Credential port | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
|
||||
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | `sub` is the owning Mind's client; a Mind is not a `UserAccount` and has no membership to read |
|
||||
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
|
||||
|
||||
Rejected (Eureka): reusing the mouth's signed envelope per request. It is motion-bound (`verb` is commit/reveal, `MotionId` in the signed text) and would need a new verb set.
|
||||
Token lifetimes: ruled in Rulings of the steward (15 and 10 minutes, set explicitly in the issuer change).
|
||||
|
||||
## 5. Account-kind lookup
|
||||
|
||||
- The account is found by the token's `sub`, which is `UserAccount.Id` (section 4). **`answerer` is `UserAccount.Id`**, which survives role
|
||||
change and Heimdall unlink; the Heimdall account id is only an authenticator handle and is cleared on erase (guest-accounts design, PR #9).
|
||||
- On main there is only `Membership.Status` (`Authenticated | PendingApproval | Active | Suspended`) and no role
|
||||
(`BifrostModels.cs` l.5-10, l.335). The role field and `gamecult.bifrost.account.v1` are in PR #9 and unbuilt.
|
||||
Until then, `kind` can only be derived: `Active` is `member`, `Authenticated` and `PendingApproval` are `user`, and
|
||||
nobody is a `guest`. That is the PR #9 backfill, repeated here, and R4a-c have no guest to apply to until PR #9 lands.
|
||||
- **An answer needs `Status == Active`**, read at answer time, not `IsActiveMember` (section 4a). On main the chat token is issued to any account that is not
|
||||
suspended (`OidcEndpoints.cs:55-62`), so an `Authenticated` or `PendingApproval` account can hold a token and the receiver refuses it on its own read.
|
||||
- **`kind` is read from Bifrost's database**, never from the token: the session carries identity only.
|
||||
- **`mind` is not a Bifrost account** in main or PR #9. A Mind authenticates as an OIDC client with an audience (section 4b), not as an account; `kind` is stamped only on a person's answer.
|
||||
- **The display name must not be `CurrentBifrostActor.DisplayName`.** It falls back to `GitHubLogin`
|
||||
(`CurrentBifrostActor.cs:11-15`) and then to the literal "Guest". The receiver stores `nameAtAnswer` from
|
||||
`MemberProfile.Nickname` or `UserAccount.DisplayName` only, and "an account" when both are empty. Never a provider handle,
|
||||
GitHub login, Heimdall id or Matrix localpart.
|
||||
|
||||
## 6. Delivery as quoted data
|
||||
|
||||
- The receiver delivers each record as a `gamecult.persona.feedback_delivery.v1` document on the typed kinds of section 2. A feedback
|
||||
record is therefore never a `chat_pending` delivery and a Matrix sender cannot produce one.
|
||||
- The delivery carries its fields separately: `recordId`, `questionId`, `kind`, `nameAtAnswer`, `answerer`, `time`, `choice`, `text`. The
|
||||
**Persona's side renders the frame**, not the receiver, and puts `text` in a data position only: attributed, quoted, never in an instruction
|
||||
position, with the author's `kind` stated (R4d). The receiver does no keyword screening.
|
||||
- **She admits the text-free `feedback_answer` into her own mind and acks only after that** (PR #24 section 3). The text reaches her turn once, in the
|
||||
delivery, and is never admitted; she cites the record id, not the text. A restart redelivers the same `recordId`, which Huginn answers
|
||||
`AlreadyAdmitted`. So an ack means *admitted*, a stronger meaning than the mouth's chat ack, which means only *taken*.
|
||||
- **Untrusted by kind.** Text from `guest`, `user` and `patron` is untrusted input; so is `member` text, which differs only in weight. She records a
|
||||
ruling from an answer, never an action.
|
||||
- **A record that will not encode, or that she cannot admit, is marked `failed` and stays visible**, never acked past silently (the opposite of
|
||||
`pageOf`). The mouth's cap and backpressure do not apply: the receiver keeps accepting answers while a Mind has a backlog, because they are durable.
|
||||
- Close requests and reports are delivered the same way. A close request changes nothing until the Mind resolves the question; a report changes nothing.
|
||||
- **An erase notice is delivered the same way** (section 7a), and she answers it with a `feedback_hide` of reason `ErasedAuthor`.
|
||||
|
||||
## 6a. Withholding an answer from its Mind (Meta's ruling, 2026-11-12)
|
||||
|
||||
The host may withhold an answer from its Mind in an emergency, with a logged receipt. It changes **delivery only**.
|
||||
|
||||
- **The withhold record** is one more receiver record, append-only, text-free: `recordId` (its own), `answerRecordId` (the answer withheld), `owner` (the Mind),
|
||||
`withheldBy` (the host actor's `UserAccount.Id`, read from Bifrost state, never the body), `time`, `reasonCode` (closed, exactly `Emergency` (imminent harm), `PersonalData` (the answer contains someone's data) or `Legal` (counsel or a legal request); no free text, because free text is where content would get in; a fourth code needs a ruling) and, when it ends, a second record, `withholdLifted`, with `liftedBy` and `time`.
|
||||
**No answer text, no choice, no name of the answerer and no excerpt is in either.** It is the log the ruling asks for, readable by the host. **Decided (Eureka, 2026-11-12): the Mind and the member both see a text-free `withheld` marker carrying the code**, so silence is not read as "no answer"; the member sees the same marker on their feedback page. The marker carries no text, no choice and no withholder.
|
||||
- **What it changes:** the answer's delivery state becomes `withheld`, and the delivery cursor does not pass it silently. The receiver does not put it on the wire to the Mind
|
||||
while the withhold is in force. Lifting it makes the answer deliverable again, in its original seq order, unchanged.
|
||||
- **What it never changes:** the stored text (it is not redacted, edited or moved: only erase redacts, section 7a); the projection rules of section 7 (a withhold is not a hide);
|
||||
and the Mind's own authority over the question: she may still hide the answer, close the question or rule on it by her own acts, and a withhold takes none of that away. She cannot lift it; the host's act is the host's.
|
||||
- **Who may do it: `PlatformAdmin` only** (`CanManageMembership`, `CurrentBifrostActor.cs:30`, the check suspension uses; no new role). The act is refused to anyone else and to the Mind herself. **The withholder's identity (`withheldBy`, `liftedBy`) appears in the audit receipt only**, never on the marker the Mind or the member sees.
|
||||
Open: whether the member should be told in a safety case (for example a threat) is Meta's call (Eureka 60%); the default written here is that the member is told.
|
||||
- A withheld answer is not erased, and an erase of its author redacts the text as in 7a; the withhold record stays, because it carries none.
|
||||
|
||||
## 7. Projection read
|
||||
|
||||
The page polls the receiver, and the receiver reads the Mind's side through Huginn over RUDP and writes nothing to it
|
||||
(PR #24 section 3). The page does not talk to Huginn.
|
||||
|
||||
- Questions, their listing (`audience`, `close_grants`), status, rulings and hide acts come from the Mind's mind. Answer text and the name come from the receiver's store; an answer row is assembled from the receiver's record and the Mind's text-free `feedback_answer`.
|
||||
- **A hidden item's text is withheld on the server**, not collapsed in the client: author, kind, time and reason
|
||||
stay, the text does not leave the receiver (R6d). A hidden question's title, body, options and answers are withheld.
|
||||
- Questions and answers are filtered by the viewer's `kind` against the question's `audience` at read time (R4b).
|
||||
- The projection carries its own `as of`, so the page can say when it is stale.
|
||||
|
||||
## 7a. Erase
|
||||
|
||||
Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. **Ruled by Meta, 2026-11-12: erase redacts the answer text in the receiver's store and keeps a text-free record.** The receiver's part, as PR #24 section 7 fixes it:
|
||||
|
||||
1. Bifrost's erase records its intent in Postgres first, then the receiver applies it idempotently. The two stores are not one transaction, so
|
||||
erase is crash-replayable, as `docs/bridge.md` describes for the persona-feedback pump.
|
||||
2. For every record the account authored the receiver **drops the text and the name** and keeps **record id, kind and time only**. `choice`,
|
||||
`weightPermille`, `answerer` and `clientRequestId` are dropped too. The row shows "erased account". **No content hash is kept.** Redaction is in the store itself, not a flag over retained text.
|
||||
3. The receiver delivers an erase notice, and the Persona admits a `feedback_hide` of reason `ErasedAuthor`: the Mind's acknowledgement, not a moderation call.
|
||||
4. **The honest residue:** a Mind's own memory or ruling may already quote an answer, and Huginn cannot redact it. That the Persona cites record ids and
|
||||
never text is a prompt, not an invariant. Erase does not reach what she already holds.
|
||||
5. **Backups: 56 days, restores replay erasures** (Eureka, 2026-11-12). "Append-only" is the receiver's API contract, not a property of the file, so erased text survives in
|
||||
backups of it. The stated purge is: **erased text can survive in backups at most 56 days**, the existing Raven tier (newest 14 daily plus the newest of each of 8 Monday-weeks;
|
||||
`gamecult-ops` `runbooks/gamecult-state-backup.md`, read at `origin/main`). No backup is changed. The receiver keeps an **erased-id list** (account id and record ids, no text,
|
||||
no hash) beside its store, and **any restore must re-apply it over `receiver.cc` before the receiver serves, never trusting the two files to be the same generation (section 8)**. The notice to people says it (R2 of the exceptions draft). If Meta wants shorter, put the
|
||||
store outside the weekly tier (14 days), a small backup-config edit.
|
||||
*Is the store under a backed-up path? Yes, by choice (Eureka, 2026-11-12):* `/var/lib/gamecult/bifrost/feedback-receiver/` (section 8), under `/var/lib/gamecult`, which the nightly
|
||||
snapshot takes whole bar Idunn caches and Ghostlight acceptance output. It avoids the excluded `/srv/bifrost/persona-feedback/runtime`, `app`, `log` and `backups`. A file with a sibling
|
||||
`<name>.lock` is re-copied under a shared lock, so `receiver.cc` and `erased.cc` each carry a `.lock` and travel in one directory in every snapshot, the erased-id list included. The 56 days is read from the runbook, not measured.
|
||||
|
||||
## 8. Where it lives
|
||||
|
||||
**CultCache files in one directory, written only by the receiver, which is C# inside the Bifrost app** (`bifrost.service`, `Bifrost.Web`), sharing its issuer and auth (Eureka, 2026-11-12). Not Node,
|
||||
not the mouth, and not the `bifrost-feedback` user of `bifrost-persona-feedback.service`.
|
||||
|
||||
- **Path:** `/var/lib/gamecult/bifrost/feedback-receiver/` holding `receiver.cc` (records and delivery state), `erased.cc` (the erased-id list of section 7a) and a `.lock` file for each.
|
||||
Directory `bifrost:bifrost`, mode 0750, **created by root at install**: `/var/lib/gamecult/bifrost` is `root:root` 0755 today, so the `bifrost` user cannot create the child itself (or a
|
||||
`StateDirectory=gamecult/bifrost/feedback-receiver` in the unit that writes it). `bifrost.service` has no sandbox directives, so it needs nothing more; if it is later hardened with `ProtectSystem=strict`,
|
||||
add `ReadWritePaths=/var/lib/gamecult/bifrost/feedback-receiver`. The backup reads as root, so 0750 does not block it.
|
||||
- **Nothing else opens the files.** There is no Node mouth reader and no Rust writer. The receiver is the only process that opens `receiver.cc` or `erased.cc`; its delivery cursor lives in the same
|
||||
store. The cross-runtime refusals are real and are not worked around: the Rust runtime refuses to write until it has pulled, and the C# runtime refuses to open a store whose records it cannot read
|
||||
(`CultStoreUnreadableException`). The CultCache contract would allow a lock-free stale read or a shared-lock read by another process; **this design takes neither: Mind-side readers get no file
|
||||
access at all, they pull over HTTPS** (section 2).
|
||||
- **Two stores, two locks, no pair consistency.** `receiver.cc` and `erased.cc` take independent locks (`receiver.cc.lock`, `erased.cc.lock`; `AcquireLock`, `CultCache.cs:5164-5185`, `FileShare.None`, retry 30 s),
|
||||
so a backup copying under each shared lock can capture them at different generations. The design never trusts the pair to be consistent: **restore re-applies `erased.cc` over `receiver.cc` by
|
||||
design** (section 7a, item 5), whichever generation each came from, and the receiver does not serve until it has.
|
||||
- **The C# runtime fit is "needs work" (Eyes).** `CultLib` C# (`src/GameCult.Caching` and `GameCult.Caching.MessagePack`, about 10.6k lines, netstandard2.1, MIT) fits net8.0, but:
|
||||
- **No published package exists**: nuget.org has none, the `cultlib-v` tags stop at 1.0.32, and packaging is a PowerShell local feed (`CultLib` `docs/nuget-packaging.md`). Bifrost has no `nuget.config` and no vendor directory.
|
||||
**The consumption route (checkout `ProjectReference`, local feed, or vendoring) is an open fork for Eureka and Libby.**
|
||||
- **Version conflict (predicted, not run):** `Caching.MessagePack` needs System.Text.Json 9.0.10; Bifrost's Direct pin is `[8.0.5, )` (`packages.lock.json:108-112`), so restore likely fails with NU1605.
|
||||
- **It drags CultMath** (MIT AND MPL-2.0 AND Apache-2.0, shaders) into a web service.
|
||||
- **Writer precedent:** the Node sidecar already writes `/srv/bifrost/persona-feedback/deliveries.cc` (`docs/bridge.md:294`), so the receiver is the second `.cc` writer in the app and the first in C#.
|
||||
- **Gaps owed to Libby (CultLib steward); no local workaround is built here:** (1) no consumable C# package; (2) the System.Text.Json pin conflict; (3) the CultMath transitive closure;
|
||||
(4) `src/GameCult.Caching/README.md:310` says single-writer, no competing writers, which contradicts the store-composition contract's lock; (5) no documented stale `.tmp` / crash procedure;
|
||||
(6) no documented backup-while-writing contract for a pair of stores; (7) no cross-runtime test pairing a Linux C# writer with a Rust reader.
|
||||
- Session check, account state and role stay in Postgres, where they already are.
|
||||
|
||||
I withdraw my earlier lean to Postgres rows: it contradicts the ruled store, and the `.cc` witness it would have owed is what the file is.
|
||||
|
||||
Naming: `persona-feedback` already means the Discord-to-Epiphany ingress (`docs/bridge.md`, "Organizational Persona Feedback Ingress"). This
|
||||
receiver's records and kinds use `feedback_page` and `feedback_delivery`, not `persona_feedback`.
|
||||
|
||||
## 9. Gaps and open forks, plainly
|
||||
|
||||
Rulings of 2026-11-12 closed these, and they are no longer forks: whether erase redacts the receiver's text (it does, section 7a); whether a person's answer to a Mind needs the training-notice receipt (it does, bar a designated exception list, section 4a); whether the host can withhold an answer (it can, in an emergency, section 6a); whether a Mind authenticates through the issuer (it does, with an audience, section 4b).
|
||||
|
||||
**Closed by Eureka's decisions (2026-11-12), folded above:** how a Mind gets a token (`client_credentials`, a confidential client per Mind, secret in a file; section 4b); the carrier (HTTPS with the bearer, not the mouth; section 2); who may withhold, the reason codes and who is told (section 6a); the backup purge number (56 days, with the erased-id list; section 7a). Their remaining work is listed below.
|
||||
|
||||
**Open forks:**
|
||||
|
||||
1. **The exception list is owed.** The notice covers any text entered into the app except a short designated list, which Eureka drafts. This doc names no entries; until the list exists the receipt gate applies to every submission with text (section 4a).
|
||||
2. **Route 2 for people has no client.** Only MAS is seeded (`OidcClientSeeder.cs:35-75`); a Ratatoskr client with its own audience is owed, as is the desk's (section 4a).
|
||||
3. **Telling the member of a withhold** in a safety case (section 6a): Meta's call.
|
||||
4. **Erase is bounded** by what a Mind already holds; Huginn cannot redact it (section 7a, item 4).
|
||||
|
||||
**Owed by this design, not forks:**
|
||||
|
||||
- The issuer change of section 4b (`client_credentials`, per-Mind clients, `docs/oidc-issuer.md`).
|
||||
- **Pre-deploy test: a Mind's process can reach Bifrost over HTTPS, and get a token.** Done 2026-11-12 from a Self run (not a Persona turn's sandbox): `https://bifrost.gamecult.org/healthz` returns 200 with a verified certificate. `/.well-known/openid-configuration` returns 404 because the live app (`67072b2`) predates the issuer, so the token path is untested. **Re-run when the issuer ships**, adding discovery, a `client_credentials` token request and one authorised receiver call; and run `curl -sI https://bifrost.gamecult.org/healthz` once from inside a Persona turn, which has not been tested. No Mind-to-Bifrost HTTP call exists in the repo except the signed intake POST (`Program.cs:768`).
|
||||
- **Install step:** root creates `/var/lib/gamecult/bifrost/feedback-receiver/` as `bifrost:bifrost` 0750 (section 8).
|
||||
- **Retention: 56 days**, stated in the notice.
|
||||
|
||||
**Dependencies and unbuilt things:**
|
||||
|
||||
- **`gamecult.bifrost.account.v1` and `Membership.Role` are unbuilt** (PR #9, design only at last read). Without them `kind` is derived from `Status`, and no guest exists.
|
||||
- **The Huginn kinds are Eureka's.** PR #24 names `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`, owned by
|
||||
`huginn-feedback-kinds-proposal.md`, which I could not read. PR #24 also notes the proposal's `feedback_answer` lists a `content_hash` that must go.
|
||||
Their dependency order is Huginn readers, then the Persona organ, then this receiver, so nothing here can ship first.
|
||||
- **The Persona side must learn the three feedback kinds** and pull them over HTTPS with its bearer (section 2); the heartbeat that polls the mouth is outside this repo and is not the carrier.
|
||||
- **Deploy is behind main.** The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`, read at an earlier main; main has since added the image path, reply links, `CHAT_FETCH`
|
||||
and the v3/v4 pending answers. The deployed web app is `/srv/bifrost/app/current` at `67072b2`, which predates the desk, the chat-tier issuer change and the consent gate on main. I did not re-read `/srv/bifrost` for this pass. Anything in this design runs on a release that does not exist yet.
|
||||
- **Unread:** Eureka's receiver notes in #minds, `huginn-feedback-kinds-proposal.md`, MAS's side of the session, and Heimdall PR #6 (merged, `docs/answer-collector-consent.md`; it drops a sender with no linked member at l.197 and does not mention guests; the page's "guest answers are collected for nothing but delivery" is unchecked against it).
|
||||
- The receiver writes nothing to Matrix and nothing to Huginn.
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
# Guest accounts
|
||||
|
||||
Status: **design, nothing implemented.** Written for `eureka-body:follow_up:bifrost-guest-accounts` under
|
||||
`eureka-body:ruling:guest-arrival-bifrost` (Meta: guests need no Matrix account of their own; they get a GameCult
|
||||
account through Bifrost with a guest role, and move to member, user or patron on the same account seamlessly),
|
||||
building on `eureka-body:ruling:bifrost-owns-accounts-invite-only`. I could not `view` either ruling from the
|
||||
Heimdall mind, so their text here is the follow-up brief's wording. Eureka's review (PR comment, 2026-11-07) checked
|
||||
it against the rulings and found guest-arrival matches; the conflicts it found are folded in below. First guest:
|
||||
sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant list is hers to name.
|
||||
|
||||
Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/Membership`, `Features/Heimdall`,
|
||||
`Features/Oidc`, `Features/Motions`, `Features/ConceptVotes`, `Pages/App/Members`) and Heimdall `575be9c`
|
||||
(`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`). File and line references re-read at Bifrost main `e58e953` and Heimdall main
|
||||
`afa1b74` on 2026-11-12; where main moved the subject, the text says so. Nothing in this design has landed on main.
|
||||
|
||||
## Rulings of the steward (Heimdall, 2026-11-12)
|
||||
|
||||
These four are the Bifrost steward's; they replace the recommendations further down. Eureka's, Aqua's and Meta's items are not ruled here.
|
||||
|
||||
1. **Name and shape: a stored `Membership.Role` (enum `AccountRole`), not a boolean.** One stored field is one reading, and an account with no role is denied by default (least privilege). `MemberRole` keeps its name and meaning; renaming it is not needed, and nothing existing is removed.
|
||||
2. **Invite: a single-use code redeemed from a form, never a token in a URL.** The issuer is shown the code once; the guest pastes it into `POST /invite/guest` (a body, not a path or query), so it reaches no log, referrer or browser history. Hashed at rest, seven days, one use, receipted on issue, accept and revoke. The issuer may bind `ExpectedProvider` (optional, narrows the invite). The sign-in return path carries only the Heimdall attempt, never the code.
|
||||
3. **Provider lists: Bifrost narrows to the intersection with Heimdall's `bifrost` profile, `discord, patreon`; no widening.** Twitch cannot start in Heimdall, so dropping it removes nothing live; it logs a deprecation warning for one release before the entry goes (deprecate before remove). Google stays link-only (PRs #81, #82) and becomes a sign-in provider only when an issued invite needs it, by a profile change in Heimdall first. Which provider sletz uses is still the issuer's to learn.
|
||||
4. **Collector: a guest has no `member_id` and none of their messages are collected.** A sender with no linked member is already dropped (`docs/answer-collector-consent.md` l.197 in Heimdall). A `member_id`, and a consent record under it, exists only on promotion to `member`.
|
||||
|
||||
## The shape in one paragraph
|
||||
|
||||
A guest is a `UserAccount` like any other: same `Id` (the OIDC `sub`), same `NormalizedBifrostIdentity` (the Matrix
|
||||
localpart, minted once). What is new is one field, `Membership.Role`, with four values: `guest`, `user`, `patron`,
|
||||
`member`. Role is the single place that says what an account may do. A role change rewrites that field and writes a
|
||||
receipt; it never creates a second account, never re-mints the identity, so chat history, grants and the Matrix
|
||||
user stay attached.
|
||||
|
||||
## What exists today
|
||||
|
||||
- `UserAccount.Id` is the OIDC subject; `NormalizedBifrostIdentity` is `preferred_username` and, through MAS, the
|
||||
Matrix localpart (`docs/oidc-issuer.md`). `HeimdallAccountId` is only an authenticator handle.
|
||||
- `Membership.Status` is `Authenticated | PendingApproval | Active | Suspended`. `CurrentBifrostActor.IsActiveMember`
|
||||
(`CurrentBifrostActor.cs:17`, `Status == Active`) is the one test behind `/App`, bridge transport (`BridgeActionService.cs:492`), motion votes
|
||||
(`Membership.EffectiveVotingWeight`, `BifrostModels.cs:361`, read at `MotionGovernanceService.cs:72`), concept votes (`VoterIdentity.For`,
|
||||
`VoterIdentity.cs:20`, member versus account class), Mind moderation (`MindIntakeService.cs:241`) and the fork desk's subject check
|
||||
(`OidcEndpoints.ActiveMemberIdAsync`, `OidcEndpoints.cs:183-194`). The OIDC chat tier is not behind it: main lets any account that is
|
||||
not `Suspended` and holds a live training-notice receipt sign in to chat (`OidcEndpoints.cs:55-65` and `ChatRefusalAsync`, `:164-180`,
|
||||
called at `:127` and `:144`; `docs/oidc-issuer.md` l.72-77), and the back-channel logout reconciler (`OidcLogoutReconciler.cs`, 30 s
|
||||
interval, l.34) ends sessions of suspended or deleted subjects, never of a chat-tier account for lacking membership.
|
||||
- `MemberRole` (`StandardMember`, `Producer`, `Maintainer`, `LedgerReviewer`, `PlatformAdmin`) are capability
|
||||
roles a member holds in addition. They are not the new field.
|
||||
- Invites are `MembershipInvitation`, keyed by GitHub login (`MembershipSynchronizationService.cs:39-41`,
|
||||
`Members/Index.cshtml.cs:69-71`). A guest may have no GitHub login at all.
|
||||
- Heimdall sign-in (`HeimdallAuthService.StartAsync`) with `requireMemberAccess = false` already creates an
|
||||
identity-only account at `Authenticated`; with member access it creates `Active`.
|
||||
- Nothing exists for room grants, leaving, erasing, or any "estate" in code (estates are rulings and research
|
||||
only, `docs/charter-amendment-proposal.md`).
|
||||
|
||||
## Decision: one role field, status stays lifecycle
|
||||
|
||||
Two authorities cannot both say "may this account vote". Today `Status == Active` says it. The cut:
|
||||
|
||||
- `Membership.Status` keeps its meaning as lifecycle of the account: may it hold a session. Add `Left` and
|
||||
`Erased` (see Leave and erase). `Active` means admitted and in good standing, for any role.
|
||||
- `Membership.Role` (new, non-null, enum `AccountRole`) decides rights. `IsActiveMember` becomes
|
||||
`Status == Active && Role == Member`, and is the only place that expression exists. Every current
|
||||
`IsActiveMember` caller keeps working unchanged and a guest fails all of them without a per-site edit.
|
||||
- OIDC needs no change for guests: main already admits any non-`Suspended` account with a live training-notice receipt to chat (see
|
||||
What exists today), so a guest, user or patron holds a chat session, and a member demoted to `Suspended` loses it. The change this
|
||||
design owes is the two new statuses: `Left` and `Erased` must be refused at the same three places (`OidcEndpoints.cs:62`,
|
||||
`ChatRefusalAsync` `:174`) and ended by the reconciler, which today looks for `Suspended` only. That is the line to review.
|
||||
- Backfill: `Active` becomes `member`; `Authenticated` and `PendingApproval` become `user`; an account with a
|
||||
current `Patron` tier snapshot and not `Active` becomes `patron`. Nobody starts as `guest`.
|
||||
|
||||
Naming risk: `MemberRole` already exists for capability roles. If the collision bites, call the new field
|
||||
`Class`; the published field below follows whichever name is chosen. I use `Role` because that is the ruling's word.
|
||||
|
||||
Alternative considered and not recommended: a boolean `IsGuest` on `Membership` with guests kept at `Active`.
|
||||
Smaller to land, but `user` and `patron` stay implicit in status and tier snapshots, so "the role field Personas
|
||||
read" would have to be derived in three places. One stored field is one reading.
|
||||
|
||||
## Invite shape
|
||||
|
||||
New table `GuestInvitation`, separate from `MembershipInvitation` (which is GitHub-login-keyed and leads to
|
||||
approval, not to a guest). Fields:
|
||||
|
||||
| field | rule |
|
||||
|---|---|
|
||||
| `Id` | Guid. |
|
||||
| `CodeHash` | SHA-256 of a 256-bit random code. The code itself is shown to the issuer once and never stored. |
|
||||
| `IntendedIdentity` | the localpart the issuer picks (for example `sletz`), validated by `BifrostIdentityService.NormalizeIdentity`, reserved while the invite is open. Minted once on accept; a collision gets the usual `-2` suffix. |
|
||||
| `Label` | free note for the issuer ("sletz, Faust developer"). |
|
||||
| `RoomGrants` | the rooms to grant on accept (below), as a list of grant requests. |
|
||||
| `IssuedByUserAccountId`, `IssuedAtUtc` | issuer must be a platform admin (`CanManageMembership`), as for member invites. |
|
||||
| `ExpiresAtUtc` | default seven days. |
|
||||
| `AcceptedByUserAccountId`, `AcceptedAtUtc`, `RevokedAtUtc` | one use; revoke before accept ends it. |
|
||||
|
||||
The code is a bearer: whoever redeems it first is the guest. That is the cost of not needing sletz's
|
||||
provider handle in advance, and it is why the code is single-use and short-lived and why the accept is shown
|
||||
to the issuer. Ruled over binding the invite to a provider handle (which needs a Discord or Google id the
|
||||
issuer may not have); the issuer may still set `ExpectedProvider` (see Rulings). It is carried in a form body, never in a URL.
|
||||
|
||||
## Sign-in via Heimdall
|
||||
|
||||
1. Guest opens `/invite/guest`, pastes the code, and posts it. Bifrost checks hash, expiry, unused, unrevoked, and stores the invite id in
|
||||
the existing Heimdall attempt (`HeimdallAuthService` protects attempt state with the data protector).
|
||||
2. Bifrost starts Heimdall sign-in with `requireMemberAccess = false` (identity only, no entitlement policy).
|
||||
Heimdall authenticates; it does not issue the identity (`docs/jurisdiction-map.md`).
|
||||
3. On the callback, in one transaction: resolve `HeimdallAccountId`.
|
||||
- No account: create `UserAccount` with `IntendedIdentity`, `Membership { Status = Active, Role = Guest }`, write
|
||||
the room grants, mark the invite accepted, write the receipt.
|
||||
- Account exists: the invite never lowers an account. A `member`, `patron` or `user` keeps their role; the grants
|
||||
are added and the invite is spent. If the existing account is `Left` or `Erased`, refuse.
|
||||
4. Bifrost issues its own cookie as for any account. From there the guest reaches chat through the one OIDC client.
|
||||
|
||||
Gap, drift found while reading, still true on main: Bifrost's `AllowedProviders` is `discord, patreon, twitch`
|
||||
(`HeimdallAuthService.cs:37-42`), but Heimdall's `bifrost` app profile allows `discord, patreon, google`
|
||||
(`app-profiles.ts:152`, `bifrostProfile`). Twitch cannot start in Heimdall and Google is refused by Bifrost as a sign-in provider.
|
||||
Main's only Google path is the explicit link by an already signed-in member (`HeimdallAttemptPurpose.LinkAwaitGoogle`,
|
||||
`HeimdallAuthService.cs:672`, PRs #81 and #82); it never signs anyone in, so it does not close the gap. A guest with
|
||||
neither Discord, Patreon nor a Google account cannot sign in, and GitHub is not a Heimdall sign-in provider for
|
||||
this app. Which provider sletz uses is unknown to me; align the two lists before the first invite.
|
||||
|
||||
## Guest rights
|
||||
|
||||
A guest may: sign in, hold a Bifrost cookie, hold a chat session through OIDC (after accepting the training notice, as any chat account does on main, `ConsentEndpoints.HasLiveReceiptAsync`, `ConsentEndpoints.cs:146`), see and edit their own profile
|
||||
(`MemberProfile`), be in the rooms granted to them, and leave or erase (below). A guest may not:
|
||||
|
||||
- vote on motions: `EffectiveVotingWeight` is `0` for any role but `member` (today it is `Max(1, ...)` for
|
||||
`Active` (`BifrostModels.cs:361-363`), which would give every guest weight 1; that line must read the role);
|
||||
- cast concept-mill appraisals: `VoterIdentity.For` returns no voter for a guest (not even `VoterClass.Account`);
|
||||
- appear on any estate roll or in any tally. The charter proposal snapshots each estate's roll at notice (A6);
|
||||
`guest` is on no roll, so a guest cannot be counted, cannot make a quorum, and reclassifying a guest after a
|
||||
snapshot changes nothing for that snapshot;
|
||||
- use bridge transport, claim or log work, earn points or ledger credit, or hold tier snapshots (those make them
|
||||
a patron or member by changing role, not by decorating a guest);
|
||||
- hold a `MemberRole` or any of the `CanManage*` flags;
|
||||
- invite anyone.
|
||||
|
||||
This list is enforced by role in one place (`CurrentBifrostActor`), not by a check at each use.
|
||||
|
||||
## Per-room grants
|
||||
|
||||
Rooms are Matrix rooms in chat, not Bifrost objects. Bifrost owns the decision, so a grant is Bifrost state:
|
||||
|
||||
`GuestRoomGrant { Id, UserAccountId, RoomRef, GrantedByUserAccountId, GrantedAtUtc, ExpiresAtUtc?, RevokedAtUtc? }`
|
||||
|
||||
`RoomRef` is a room id or alias as chat names it. Aqua's `#aquasynth` is the first one if she says yes.
|
||||
|
||||
**Scope (per `guest-room-moderation-ruled`, `guest-room-authority-ruled`).** Project rooms are public-join and
|
||||
hand-moderated by the steward, with Eureka as backstop, so a guest who can reach a public room needs no grant. A
|
||||
grant exists only for a room its steward chooses to keep closed, and its grantor is that room's steward, not a
|
||||
platform admin (the invite itself is still issued by a platform admin; `GrantedByUserAccountId` records the
|
||||
steward). Room entry for public rooms is not Bifrost's decision.
|
||||
|
||||
The grant has to reach Matrix to mean anything. Today Bifrost's only Matrix writer is the Persona mouth, with
|
||||
per-Persona access tokens (`docs/bridge.md`), and it holds no admin or invite right.
|
||||
|
||||
Gap (largest in this doc): something must perform "invite this Matrix user to that room" on a grant and "remove
|
||||
them" on revoke or expiry. Candidates: a Bifrost-owned bot user that is a member with invite power in the granted
|
||||
rooms (reuses the Matrix client API, no Synapse admin credential), or the Synapse admin API. I recommend the bot
|
||||
user: narrower credential, and the room's power levels remain the real gate. Until it exists, the grant is a
|
||||
record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Whether the closed rooms a
|
||||
steward keeps are invite-only in Synapse is a chat configuration fact I have not verified.
|
||||
|
||||
What Matrix takes from the Bifrost account is exactly this effector: invite or remove on a grant, and the power
|
||||
level the effector sets on invite. Role never enters OIDC, so Matrix reads no role from the issuer
|
||||
(`bifrost-owns-accounts-invite-only`: grants flow from the account through the effector, not through claims).
|
||||
|
||||
**Guest DMs (`guest-dms-ruled`) conflict, open.** The mouth declines invites from other servers and accepts
|
||||
same-server DM invites (`tools/persona-matrix-mouth.mjs` `answerInvites`). The ruling's decline-for-now assumed
|
||||
federated guests; a Bifrost guest is a local `@sletz:<server>`, so the mouth would accept their DM. Either the mouth
|
||||
declines DMs from role `guest` (using the account document below), or the ruling is revisited. That is Eureka's and
|
||||
the ruling's owner's call, not this doc's; until it is made, the mouth's behaviour is the ruling's opposite for local
|
||||
guests.
|
||||
|
||||
Second gap: a guest has a full Matrix account once MAS provisions the localpart, so Matrix itself will let them
|
||||
create rooms or DM unless Synapse's room-creation and invite settings (`room_list_publication_rules`,
|
||||
`allow_public_rooms_*`, a spam-checker or module) say otherwise. These are global in Synapse, not per user, so a
|
||||
guest limit may have to be power levels in every room they are in. I did not find a per-user switch.
|
||||
|
||||
## Role change on the same account id, with a receipt
|
||||
|
||||
`ChangeRole(accountId, to, actor, reason)` is the only writer of `Membership.Role`. It runs in one transaction:
|
||||
|
||||
1. refuse `Erased` and `Left`; refuse a no-op;
|
||||
2. write `Role`, and for `member` also set `Status = Active`, `ApprovedAtUtc`, and the default `StandardMember`
|
||||
capability role (the same effects as `OnPostApproveAsync`); for `patron`, nothing beyond the role (tier
|
||||
snapshots come from support events as before);
|
||||
3. write `AccountRoleReceipt { Id, UserAccountId, From, To, ChangedAtUtc, ChangedByUserAccountId, Reason }`;
|
||||
4. leave `UserAccount.Id`, `NormalizedBifrostIdentity`, `HeimdallAccountId`, grants and profile untouched.
|
||||
|
||||
Who may change: a platform admin (`CanManageMembership`). Promoting to `member` is the existing approve path;
|
||||
this design routes it through `ChangeRole` so one writer exists. Promoting to `member` also applies whatever the
|
||||
charter says about joining an estate (not decided; see Questions). Demotion (member to guest) is allowed and
|
||||
receipted; it also removes the `MemberRole`s and capability flags in the same transaction.
|
||||
|
||||
The receipt is a Postgres row today, like `AuditEvent`. Bifrost's rule is that durable state has a CultCache `.cc`
|
||||
witness (`docs/jurisdiction-map.md`, Design Rules); the witness is owed (Gaps).
|
||||
|
||||
Seamless means: the guest's session cookie, OIDC subject, Matrix id, display name and room memberships do not
|
||||
change on promotion. A cookie issued before promotion carries identity claims only, never the role, so it needs
|
||||
no refresh; the actor is read from the database each request (`CurrentBifrostActorAccessor`).
|
||||
|
||||
## Leave and erase
|
||||
|
||||
Neither exists in Bifrost today. Two different acts:
|
||||
|
||||
**Leave** (guest or any role, self-service): sets `Status = Left`, revokes every grant, and expels the account
|
||||
from granted rooms through the same effector as grants. Once the reconciler and the chat refusals name `Left` (see Decision),
|
||||
it ends the MAS session within its 30 s interval, and the token and userinfo endpoints refuse. The account row and
|
||||
`Id` stay, so a later invite can restore it (a `Left` account re-accepting is a role change from itself, receipted).
|
||||
Nothing else about the person is deleted by leaving.
|
||||
|
||||
**Erase** (self-service, or admin on request): irreversible. In one transaction: blank `DisplayName`, `AvatarUrl`,
|
||||
`MemberProfile`, `GitHubLogin`/`GitHubUserId`, clear `HeimdallAccountId` (the authenticator link), set
|
||||
`Status = Erased`, keep `Id` and `NormalizedBifrostIdentity` as a tombstone so the localpart is never reissued to
|
||||
someone else (OIDC says it is minted once), keep receipts but with names stripped. The reason to keep a tombstone
|
||||
row is that receipts and audit events point at the id.
|
||||
|
||||
An erasing guest has no votes, ledger entries or points, because guests cannot create them. A member or patron
|
||||
who erases does, and those are accounting records: their erase is a separate question (Questions). Guest erase
|
||||
is the case this design covers.
|
||||
|
||||
Outside Bifrost, erase is incomplete without two effectors:
|
||||
|
||||
- Matrix: Synapse's deactivate-with-erase admin call and MAS deactivation for the user. Federation is disabled in
|
||||
the chat map (`gamecult-ops/docs/gamecult-chat-map.md`: federation paths 404), which helps: messages are
|
||||
not copied to other servers. Other members' clients, and any export or training copy, are not reached by an
|
||||
erase. Whether chat content is training data is `heimdall-body`'s consent rulings (Heimdall PR #6), which already
|
||||
define exit by redaction; a guest's messages follow whatever that doc says, not this one.
|
||||
- Heimdall: I found no account-delete or unlink path in Heimdall `src`. Clearing `HeimdallAccountId` on the Bifrost
|
||||
side leaves the Heimdall account and its provider link alive. Heimdall needs an erase call keyed by the account id
|
||||
before "erased" is true end to end.
|
||||
|
||||
## What Eureka reads
|
||||
|
||||
Personas read the role, not the account. Do not put it in OIDC: the issuer promises "no membership detail leaves
|
||||
Bifrost" (`docs/oidc-issuer.md`) and chat does not need it. Publish a typed CultMesh document under the
|
||||
`gamecult.bifrost.governance` family (the nearest existing schema namespace, `verse-service-contract.md` l.60), id
|
||||
`gamecult.bifrost.account.v1`, key = account id:
|
||||
|
||||
| field | type | note |
|
||||
|---|---|---|
|
||||
| `account_id` | uuid | OIDC `sub`; never changes. |
|
||||
| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart. |
|
||||
| `matrix_id` | string | the full mxid `@<identity>:<server>`, server name taken from MAS config at publish time so no reader assumes it. The lookup key for a Persona, which holds the Matrix sender id and not the account uuid. |
|
||||
| `display_name` | string | blank once erased. |
|
||||
| `role` | `guest` \| `user` \| `patron` \| `member` | the field Personas read. |
|
||||
| `status` | `authenticated` \| `pending_approval` \| `active` \| `suspended` \| `left` \| `erased` | |
|
||||
| `role_since` | UTC ms | from the latest `AccountRoleReceipt`. |
|
||||
| `rooms` | list of room refs | only for `guest`: the live grants. Others get rooms by estate. |
|
||||
|
||||
Contract the document carries, not left to each reader:
|
||||
|
||||
- **Lookup.** The document is queryable by `matrix_id`.
|
||||
- **Absence is `unknown`.** A sender with no document is not a Bifrost account (a bridge ghost or an unlinked user).
|
||||
Readers must treat it as `unknown`, never default it to `guest`, `user` or `member`. This reverses today's Persona
|
||||
reading (everyone unlisted is `member`), which narrows to the Bifrost `member` role.
|
||||
- **Status gates the role.** Only `status = active` yields a usable role; `suspended`, `left`, `erased`,
|
||||
`authenticated` and `pending_approval` yield none.
|
||||
- **Not Bifrost accounts.** Operator, the Persona's own id and Minds are typed by the reader's config, which takes
|
||||
precedence over the document. Minds are not Bifrost accounts and must not fall to `unknown`.
|
||||
- **Freshness.** A reader loads the document when it builds each turn, never once at start, so a role change
|
||||
applies from the next message; past messages keep the role they were read under. `role_since` lets a trace say
|
||||
which role was read. Publisher bound: a role or status change is published within 30 seconds, the same bound the
|
||||
OIDC reconciler already keeps for a non-`active` subject. No publisher exists, so this is the requirement it is
|
||||
built to, not a measurement.
|
||||
|
||||
Bifrost does not decide Persona behavior; it states the role.
|
||||
|
||||
No such publisher or witness exists; Bifrost has no `.cc` export for accounts today.
|
||||
|
||||
## Cut sequence (not specs)
|
||||
|
||||
1. `Membership.Role`, backfill, `IsActiveMember` and the OIDC tests moved to the new reading, vote and concept-vote
|
||||
exclusions, with tests that fail if a guest gets weight or a voter key.
|
||||
2. `GuestInvitation`, the accept path, the Heimdall provider list aligned.
|
||||
3. `AccountRoleReceipt` and `ChangeRole`, with `OnPostApproveAsync` routed through it.
|
||||
4. `GuestRoomGrant` and the Matrix effector.
|
||||
5. Leave, then erase (Bifrost side), then the Matrix and Heimdall effectors.
|
||||
6. The account document and its `.cc` witness.
|
||||
|
||||
## Gaps, plainly
|
||||
|
||||
- The Matrix effector for grants, leave and erase does not exist, and no credential for it is provisioned.
|
||||
- Synapse cannot cap what a guest does per user; room-creation and invite limits are global (unverified: I did not
|
||||
read the live Synapse config).
|
||||
- Heimdall has no erase path (re-checked at `afa1b74`: no account-delete or unlink route in `src`, and `docs/answer-collector-consent.md`, merged as Heimdall PR #6, redacts by exit and says nothing of guests). Bifrost and Heimdall disagree on provider lists today.
|
||||
- No `.cc` witness or CultMesh publisher for accounts or receipts. Bifrost's account state is Postgres only.
|
||||
- Matrix id shape (`@<identity>:<server>`) depends on MAS importing `preferred_username` as localpart; the OIDC doc
|
||||
says so, I did not check the deployed MAS.
|
||||
- Tie to the answer-collector consent doc (Heimdall PR #6, merged; `docs/answer-collector-consent.md`): a guest posting in a room may be posting to a sample
|
||||
collector. That doc keys on `member_id`, and says a sender with no linked member is dropped (l.197). Whether a guest has a
|
||||
`member_id`, and so whether their answers are collected at all, is not decided here. Default: not collected.
|
||||
- Erase for member and patron roles (ledger and vote records) is unmapped.
|
||||
|
||||
## Questions for the deciders
|
||||
|
||||
1. **Name and shape**: ruled (stored `Role`), see Rulings.
|
||||
2. **Bearer link or provider-bound invite?** Ruled (single-use code from a form), see Rulings.
|
||||
3. **Which Heimdall provider will sletz sign in with?** The lists are ruled (see Rulings); this is the issuer's to learn before the first invite.
|
||||
4. **`member_id` for the answer collector**: ruled (none for a guest), see Rulings.
|
||||
5. **Does promotion to member join the Members estate immediately**, or does the charter's notice and snapshot
|
||||
rule apply to the roll? The charter text is a proposal, not in force.
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Heimdall app secret from a file
|
||||
|
||||
`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the
|
||||
`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile`
|
||||
(`Heimdall__AppSecretFile`) names a file holding it instead.
|
||||
|
||||
- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`.
|
||||
- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names
|
||||
`Heimdall:AppSecretFile` and the path, never the secret.
|
||||
- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value).
|
||||
- Neither set: startup fails outside Development and Testing, naming `Heimdall:AppSecretFile` and `Heimdall:AppSecret`
|
||||
(`StartupConfigurationValidator`). Development and Testing still start, with Discord and Patreon sign-in off.
|
||||
|
||||
Under systemd, pass the file with `LoadCredential=heimdall-app-secret:<root-owned file>` and
|
||||
`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`.
|
||||
|
||||
Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at
|
||||
startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`.
|
||||
|
|
@ -105,8 +105,6 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
|
|||
|
||||
Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity.
|
||||
|
||||
A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). Strong proof for an unlink is a Bifrost-verified passkey (WebAuthn, RP ID the Bifrost host) that Bifrost asserts to Heimdall through its app-asserted step-up route; Bifrost owns the credential store and the ceremonies, Heimdall owns the unlink gate (`docs/passkeys.md`).
|
||||
|
||||
Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed.
|
||||
|
||||
### Patron Collection Crossing
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
# Linked accounts
|
||||
|
||||
`/Membership/LinkedAccounts` (`src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml[.cs]`) is where a signed-in member sees the provider logins attached to their Heimdall account, links another one, and unlinks one. Heimdall owns the logins and every rule about them; this page starts the flows and shows what Heimdall answered. A member reaches it from the membership status page. A session with no Heimdall account (a Bifrost-native GitHub sign-in) gets a note instead of controls.
|
||||
|
||||
## What it does
|
||||
|
||||
| Action | What Bifrost does | Heimdall call |
|
||||
|---|---|---|
|
||||
| Show my logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer |
|
||||
| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Needs a current list. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` |
|
||||
| Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer |
|
||||
| Unlink with a passkey (an account with a strong passkey) | A WebAuthn assertion verified at Bifrost, then a fresh ordinary sign-in as the account; inside its completion, Heimdall's app-asserted step-up, then the unlink with the token it returns | `POST /v1/apps/bifrost/step-up` `{method: "bifrost-passkey", nonce}`, then `POST /v1/me/identities/{id}/unlink` |
|
||||
| Add or revoke a passkey | Enrolment needs a fresh Heimdall sign-in first and cools off 48 hours; revoking needs no proof | none |
|
||||
|
||||
The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here.
|
||||
|
||||
## How the bearer is kept: it is not
|
||||
|
||||
Bifrost keeps no Heimdall token. Each action is an attempt: a Data Protection cookie (`__Host-bifrost_heimdall_<slot>`, HttpOnly, Secure, ten minutes) holding a purpose (`ListProof`, `UnlinkProof`, `LinkStart`, `LinkAwaitGoogle`, `LinkConfirm`, `LinkAwaitDirect`), the account the session must be, and a target (the provider to link, or the identity to unlink). The browser returns from Heimdall to `/auth/heimdall/complete`; Bifrost redeems the code, verifies the token, requires that the proven account is the attempt's account and that the browser's session is that same account, calls Heimdall with the bearer inside that one request, and drops it. The bearer is never stored, logged, put in a cookie or a redirect, or rendered. Heimdall's member routes go through an HTTP client that carries no app secret (`HeimdallMemberClient`), so a Bifrost fault cannot call them without a member present.
|
||||
|
||||
The list is kept for five minutes in this process only (`LinkedAccountsSnapshots`), keyed by the Heimdall account id, so the page can render after the completion's redirect: identity id, provider, display name (or username when there is none) and link date. Never the bearer, the email, the provider user id or the scopes. It is not given to the browser. A restart or the five minutes empties it and the page offers "Show my linked logins" again.
|
||||
|
||||
Every action is a POST with antiforgery; a GET starts nothing. The completion route's answers are `Cache-Control: no-store`. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's.
|
||||
|
||||
## Which provider proves the account
|
||||
|
||||
An ordinary proof sign-in through Discord or Patreon when the account no longer holds that login makes Heimdall mint a new, unrelated account that holds it (those providers are `mints_account`), and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. Google is `requires_link`: Heimdall refuses a Google sign-in for a login no account holds (`identity_not_linked`) and never mints. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now). This guarantee covers this page's proofs only: the older anonymous `GET /auth/heimdall/{provider}` still starts an ordinary sign-in through any named provider, and can overwrite an in-flight attempt slot; that route is unchanged here.
|
||||
|
||||
- With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google.
|
||||
- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Google (cannot create an account) / Discord / Patreon" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named Discord or Patreon the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`).
|
||||
- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. After an unlink whose result is not a definitive refusal (a 500, a lost or malformed answer, not found) the list is dropped, since Heimdall may have applied it; only `403 step_up_required`, `409 last_identity` and `401` leave it.
|
||||
|
||||
## The proof limit
|
||||
|
||||
Passkeys (`docs/passkeys.md`) are the strong proof: an account with a passkey past its cooling-off unlinks with it, and the Google step-up below is then closed (`passkey_required`). The rest of this section is the Google proof, which stays for an account with no strong passkey.
|
||||
|
||||
Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So:
|
||||
|
||||
- An account with no Google login linked and no strong passkey cannot unlink anything from here. The page says what it can do (add a passkey; the time a cooling-off one becomes strong) and shows no Unlink button; a posted unlink starts nothing.
|
||||
- Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie.
|
||||
- Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed."
|
||||
- A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`).
|
||||
|
||||
The unlink completion must also say a step-up was requested (`stepUpRequested`, set by Heimdall when the start asked for one); one that does not is refused without calling Heimdall. Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall.
|
||||
|
||||
A link refused because the login already belongs to another account (`409 identity_linked_elsewhere` from `links/confirm`; for Discord and Patreon the callback's `link_initiator_mismatch`, carried on by the complete page as a fixed result) is shown as "That login belongs to another account." The other account is never named.
|
||||
|
||||
## Not here
|
||||
|
||||
- Merging two accounts. A refused link ends there; nothing is moved or merged. Merging is the separate Merge accounts page, behind the `AccountMerge:Enabled` flag (`docs/account-merge.md`, Pages and flags).
|
||||
- Step-up through Discord, Patreon or Twitch. Heimdall does not offer it to the `bifrost` profile.
|
||||
- Linking Twitch or GitHub: not admitted by the `bifrost` profile, and Heimdall's GitHub runtime is not implemented.
|
||||
- A list of other apps' logins, grants or consent receipts (Heimdall's grants page).
|
||||
|
||||
## Where the code is
|
||||
|
||||
- `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers.
|
||||
- `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in.
|
||||
- `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping.
|
||||
- `Features/Passkeys/`, `wwwroot/js/passkeys.js`: the passkey ceremonies (`docs/passkeys.md`).
|
||||
- `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list.
|
||||
- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, `PasskeyTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`.
|
||||
|
||||
## Known leftover
|
||||
|
||||
Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_<slot>`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do.
|
||||
|
||||
A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again.
|
||||
|
||||
See [account-merge.md](account-merge.md) for what a merge does to Bifrost records, the states it moves through and how a folded account stands.
|
||||
|
|
@ -5,8 +5,7 @@ Authentication Service (MAS) can sign chat users in with their Bifrost account.
|
|||
Bifrost; it does not issue the identity chat sees.
|
||||
|
||||
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 (not suspended, and agreed to
|
||||
the training notice in force; see "Chat needs a receipt").
|
||||
decisions at the authorize endpoint: who the chat user is, and whether they may get a session.
|
||||
|
||||
## Endpoints
|
||||
|
||||
|
|
@ -81,76 +80,17 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A
|
|||
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.
|
||||
- Token endpoint and userinfo: suspension is read again; an account suspended or deleted since gets `invalid_grant` (token) or 403 (userinfo). The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership.
|
||||
|
||||
## Back-channel logout
|
||||
|
||||
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 who may no longer chat (the same
|
||||
reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, a subject that is not an account id, or
|
||||
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 and receipts are never written by this path: any way of being suspended, deleted or withdrawn (admin
|
||||
action, sync, a database edit, the consent page) is covered because the work is derived from stored state.
|
||||
reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, or a subject that is not an account id; an
|
||||
account with no membership or a pending one 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 being suspended or deleted (admin
|
||||
action, sync, a database edit) is covered because the work is derived from membership state.
|
||||
|
||||
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
|
||||
|
|
@ -163,10 +103,6 @@ to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configu
|
|||
and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
|
||||
else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
|
||||
|
||||
Without `BackchannelLogoutUri` none of this runs: suspension stops new chat tokens (the authorize, token and userinfo
|
||||
endpoints refuse a suspended account; there is no refresh token, and MAS does not call userinfo) but does not end a chat session that is already live, which runs to its natural expiry, and the issuer logs one warning at
|
||||
startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
|
||||
|
||||
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
|
||||
|
||||
MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an
|
||||
|
|
|
|||
133
docs/passkeys.md
133
docs/passkeys.md
|
|
@ -1,133 +0,0 @@
|
|||
# Passkeys: strong proof for unlinking a login
|
||||
|
||||
A Heimdall-backed member can enrol WebAuthn passkeys on `/Membership/LinkedAccounts` and use one as the strong proof that unlinking a provider login needs. Bifrost verifies the passkey; Heimdall accepts Bifrost's word through its app-asserted step-up route (`POST /v1/apps/bifrost/step-up`, Heimdall `docs/service-contract.md`, `docs/app-profiles/bifrost.md`). Scoping and the option comparison are in the handoff `strong-proof-scoping-20261113.md` (option 1a with the S1 sink).
|
||||
|
||||
## Scope
|
||||
|
||||
- Accounts that have a `HeimdallAccountId`: the accounts that can unlink at all. A Bifrost-native-only account (a native `BifrostIdentity` or GitHub sign-in) has no logins to unlink and gets no passkeys from this cut; the page shows it nothing.
|
||||
- Unlink only. A passkey is not a sign-in method here.
|
||||
|
||||
## The library: Fido2NetLib (`Fido2` 4.3.3)
|
||||
|
||||
Decision: use `Fido2` 4.3.3 (NuGet id `Fido2`, repository `passwordless-lib/fido2-net-lib`) for every byte of WebAuthn parsing and verification. Bifrost hand-rolls no CBOR, COSE or signature code.
|
||||
|
||||
What was checked on 2026-11-13, against the package and the repository, not from memory:
|
||||
|
||||
| Question | Finding |
|
||||
|---|---|
|
||||
| Licence | MIT, in the nuspec (`<license type="expression">MIT</license>`) and in the repository's `LICENSE.txt` (GitHub reports `MIT`). Its closure: `Fido2.Models` MIT; `NSec.Cryptography` 25.4.0 MIT; `libsodium` 1.0.20.1 ISC; `System.Formats.Cbor` 9.0.24, `Microsoft.IdentityModel.JsonWebTokens` 8.2.0, `Microsoft.Extensions.Http` 9.0.0, `Microsoft.Bcl.Memory` 9.0.14 all Microsoft MIT. All open source. |
|
||||
| Maintained | Not archived; repository pushed 2026-11-11; 4.3.3 released 2026-11-11 and 4.3.2 on 2026-10-29; about 1,460 stars. |
|
||||
| net8.0 | The 4.3.3 nuspec has a `net8.0` dependency group (and `net10.0`); it targets the framework this project targets. |
|
||||
| Packed attestation | `Src/Fido2/AttestationFormat/` holds `Packed`, `None`, `FidoU2f`, `Tpm`, `AndroidKey`, `AndroidSafetyNet`, `Apple`, `AppleAppAttest`. Bifrost asks for `none` attestation and does not use a metadata service, so it does not depend on any of them being trusted; a client that sends another format still has it parsed and its statement verified structurally by the library. |
|
||||
| What verification covers | Read in `AuthenticatorAssertionResponse.cs`: challenge, origin (against `Fido2Configuration.Origins`), `webauthn.get` type, RP ID hash, user-present and user-verified flags (when required), signature over `authData || SHA-256(clientDataJSON)` with the stored COSE key, user handle ownership, and the sign count (see "Sign count" below). Enrolment verifies the attestation, the credential id and the key. |
|
||||
| Transitive weight | Heavier than the repository's other packages: `Microsoft.Extensions.Http` 9.0.0 pulls `Microsoft.Extensions.*` 9.0.0, so existing 8.x entries move to 9.0.0 (seven in `src/Bifrost.Web/packages.lock.json`; twelve `Microsoft.Extensions.*` entries plus `System.Diagnostics.DiagnosticSource` in the test project's lockfile, which sees them too; app-local on top of the net8 shared framework, a supported combination), and the lockfiles gain `Fido2`, `Fido2.Models`, `Microsoft.Bcl.Memory`, `Microsoft.Extensions.Http`, `Microsoft.Extensions.Diagnostics[.Abstractions]`, `Microsoft.Extensions.Configuration[.Binder]`, `System.Diagnostics.DiagnosticSource`, `Microsoft.Extensions.Options.ConfigurationExtensions`, `NSec.Cryptography`, `System.Formats.Cbor` and the native `libsodium` package (the Linux x64 glibc and musl binaries ship in the package's `runtimes/`). `Microsoft.IdentityModel.JsonWebTokens` is requested at 8.2.0 and already resolved to 8.19.2 (through OpenIddict) before this change; it still does. `packages.lock.json` in both projects is regenerated and committed. |
|
||||
| Alternatives | Not needed: the candidate passed. |
|
||||
|
||||
Not verified: that the 9.0.0 `Microsoft.Extensions.*` assemblies behave on the live host's ASP.NET Core 8 runtime beyond the test host (the suite runs the real app under it); that `libsodium` loads on Yggdrasil (the EdDSA test loads it here, on Debian x64).
|
||||
|
||||
The library is used through four calls: `RequestNewCredential`, `MakeNewCredentialAsync`, `GetAssertionOptions`, `MakeAssertionAsync`. `PasskeyRelyingParty` builds it from `Host:PublicBaseUrl`.
|
||||
|
||||
## Sign count
|
||||
|
||||
The stored sign count is updated on every successful assertion, and the library refuses a counter that does not increase once either side is non-zero. Authenticators that always report zero (most synced passkeys) are accepted every time and get no clone detection from this: both counters stay zero. An authenticator that reported a non-zero count and then reports zero is refused. Clone detection is therefore real only for hardware keys and other counting authenticators.
|
||||
|
||||
## Relying party
|
||||
|
||||
RP ID is the host of `Host:PublicBaseUrl`, the one origin is its scheme, host and port. They come from configuration and from nothing the browser sends. Passkeys are unavailable, with a warning logged once at start, when `Host:PublicBaseUrl` is not https or its host is not `Host:ExpectedHost`: the page offers none and every ceremony endpoint refuses (`passkeys_unavailable`). `PasskeyRelyingParty` is resolved at startup, so a misconfiguration is logged once at boot (not at the first ceremony). `Host:ExpectedHost` must be a bare host name: one with a port (`host:443`) or an IP address (`[::1]`, `127.0.0.1`) makes passkeys unavailable and is reported, because an IP address is not a valid RP ID and a port is not part of it. No new configuration key exists.
|
||||
|
||||
If passkeys are unavailable while an account holds a strong passkey, the page shows no unlink form and says so: unlinking needs the passkey, the Google route stays closed (`passkey_required`), and the member can revoke their passkeys on the page (revoking does not need the relying party) to fall back to the Google proof, or wait for the operator to fix the server. This is a lockout until the operator or the member acts; it fails closed.
|
||||
|
||||
## State
|
||||
|
||||
- `PasskeyCredentials` (migration `20261113120000_AddPasskeyCredentials`, additive, applied at boot like the others): per `UserAccount` id, `CredentialId` (unique), `PublicKey`, `SignCount`, `Transports`, `AaGuid`, `Label`, `CreatedUtc`, `StrongFromUtc` (`CreatedUtc` + 48 h), `RevokedUtc`, `LastUsedUtc`. No secret exists to store: the private key never leaves the authenticator. Rows cascade with the account.
|
||||
- `PasskeyCeremonies` (in memory, this process only, like the Linked accounts list): the short-lived single-use records of the flows, each bound to a Heimdall account id and a kind (and to an identity id for unlink). A restart empties it and every flow is begun again; with more than one Bifrost instance a flow must finish on the instance that began it, otherwise it is refused and begun again. Both fail closed (a lost ticket, challenge or fact refuses; it never grants). It is chosen over a sealed cookie because a cookie cannot be spent: a copied cookie would replay.
|
||||
|
||||
| Kind | Lifetime | Spent |
|
||||
|---|---|---|
|
||||
| enrolment ticket | 5 min | by the registration post |
|
||||
| enrolment challenge | 2 min | by the registration post |
|
||||
| unlink challenge | 2 min | by the assertion post (success or not) |
|
||||
| unlink assertion fact | 5 min | by the completion that asks Heimdall for the step-up |
|
||||
|
||||
A challenge kind has one live record per account: a newer one replaces the older. The table holds at most 2048 records and refuses past that.
|
||||
|
||||
A caller's own preconditions are checked before anything is spent. A registration needs the live ticket cookie (a session thief has none), a decodable body and room under the cap; an assertion needs a decodable body naming one of the session account's own strong, un-revoked credentials. Only then are the verification permit, the challenge and (for enrolment) the ticket taken. An assertion also needs a live challenge for that account and identity before a permit is spent. So junk posted by someone who holds only the session, naming no credential of the account or arriving with no live challenge, spends nothing. Junk naming one of the account's own credentials (the id is in `allowCredentials`) spends one verification permit per challenge that was asked for, because the first such post takes the challenge; it cannot drain the 20 permits per 10 minutes in one go. It can still burn that challenge once, and a session holder who keeps asking for challenges (an options permit each) and posting junk can exhaust both budgets: a lockout the same session already has through revoking passkeys, not a bypass. A junk post naming no credential of the account costs two cheap database lookups and no permit; that is accepted rather than spending an options-style permit on it. Both budgets are per process: with more than one instance each has its own. Asking for options spends a separate options budget (20 per 10 minutes), which a session holder can exhaust: that blocks new options for a while and nothing else. A session holder can also replace the member's pending challenge by asking for new options, which makes the member's assertion answer `passkey_expired` and the member tries again; that session can already revoke passkeys, so it is not a new capability. The member whose challenge was burned retries with a new one.
|
||||
|
||||
## Enrolment
|
||||
|
||||
1. The member is on the page with a current list (Bifrost will not guess a proof provider) and presses "Sign in again to add a passkey". Bifrost starts a fresh ordinary Heimdall sign-in through a provider the account holds (an attempt with purpose `EnrolProof`, the same machinery as the list and link proofs, `ExpectedAccountId` the session's account).
|
||||
2. In that sign-in's completion, when the proven account is the session's own, Bifrost issues an enrolment ticket and sets its id in `__Host-bifrost_passkey_ticket` (HttpOnly, Secure, 5 minutes). The ticket exists only because a fresh sign-in completed in this browser: a stolen session cookie alone has no ticket and cannot get registration options.
|
||||
3. The page's script asks for creation options (antiforgery header), calls `navigator.credentials.create` (user verification required, resident key preferred, `none` attestation, existing credentials excluded) and posts the base64url result. Bifrost spends the ticket and the challenge, verifies, and stores the credential with `StrongFromUtc` = now + 48 hours.
|
||||
|
||||
A label is limited to 40 text elements (cut by rune and grapheme, never inside a surrogate pair) and loses control and format characters (bidi overrides, joiners, zero-width marks).
|
||||
|
||||
Cap: five un-revoked passkeys per account (`passkey_limit`; a sixth starts no sign-in). Every options request, enrolment, assertion and enrolment start spends a permit of one `FixedWindowBudget` per account (20 per 10 minutes, `rate_limited`).
|
||||
|
||||
## Cooling-off
|
||||
|
||||
A passkey is strong from `StrongFromUtc` (at that instant and after), until revoked. Until then it is listed on the page with its time and cannot unlink anything. So someone who controls both a member's session and a provider login long enough to add a key of their own still waits 48 hours with the key visible, and the member can revoke it from the page. Revoking needs no strong proof (it only removes capability) and is shown.
|
||||
|
||||
## Unlink with a passkey
|
||||
|
||||
When the account has a strong passkey, the page's Unlink button runs a WebAuthn assertion instead of the Google step-up, and the Google route (`Unlink` handler) answers `passkey_required` and starts nothing: a member who holds a strong passkey is not offered the weaker proof. With no strong passkey the Google step-up stays as it was, and when neither exists the page says what to do (add a passkey, with the time a cooling-off one becomes strong).
|
||||
|
||||
1. The script asks for request options for the chosen identity (`allowCredentials` is the account's strong passkeys; user verification required). One live challenge per account, bound to the identity.
|
||||
2. The browser's assertion is posted. Bifrost spends the challenge, requires the credential to be the session account's own, not revoked and past `StrongFromUtc`, and has the library verify origin, RP ID, challenge, user verification, signature and sign count. On success it records the new sign count and last use, and issues the single-use fact "passkey assertion verified" for this account and this identity (5 minutes; no credential, no bearer).
|
||||
3. Bifrost starts a fresh ordinary Heimdall sign-in for the account (attempt purpose `UnlinkPasskeyProof`; proof provider as on the page: one the current list shows, never a guess). The attempt carries the fact's id.
|
||||
4. In the completion, which is the only place the bearer exists: the fact is taken (missing, expired, spent, for another account or identity: no Heimdall step-up call and `passkey_expired`); the credential the fact was made with must still be the account's own, un-revoked and strong (a passkey revoked since the assertion: no step-up call, `passkey_failed`); the app secret, the bearer and `{method: "bifrost-passkey", nonce}` go to `POST /v1/apps/bifrost/step-up` (nonce: 32 fresh random bytes, base64url, 43 characters); the returned `accessToken` goes to the existing unlink call; everything is dropped. The bearer and the minted token are never stored, logged, put in a cookie or a redirect, or rendered.
|
||||
|
||||
Heimdall's refusals are shown as closed codes and nothing is retried in the flow: `401 sign_in_required` is `sign_in_required`; `403`/`400`/`401 app_auth_required` is `step_up_unavailable`; `409 step_up_nonce_replayed` is `step_up_replayed`; `429` is `step_up_rate_limited`; anything else, or Heimdall down, is `error`. A member whose attempt ends this way starts the unlink again (a new assertion).
|
||||
|
||||
## Abuse cases and what stops them
|
||||
|
||||
Each is a test in `tests/Bifrost.Web.Tests/PasskeyTests.cs` unless noted.
|
||||
|
||||
| Case | Defence |
|
||||
|---|---|
|
||||
| Stolen session cookie tries to enrol | No ticket without a fresh Heimdall sign-in completed in the victim's own browser. A ticket is per account, spent once, expires in 5 minutes, and is held by id in an HttpOnly cookie |
|
||||
| Stolen session tries to unlink | Needs an assertion by a strong passkey of the account; the Google route is closed while one exists; no step-up is asked of Heimdall without the fact |
|
||||
| Attacker who controls the provider session enrols their own key | Cooling-off: 48 hours before it counts, shown on the page, revocable by the member |
|
||||
| Replay of an assertion, double submit | The challenge is single-use; two concurrent posts start one sign-in |
|
||||
| Assertion for another account or identity | The credential must belong to the session's account; the challenge, the fact and the attempt are bound to the account and the chosen identity |
|
||||
| Phishing site | The library checks origin and RP ID hash against the configured values; a registration or assertion with another origin or RP ID is refused |
|
||||
| Cloned authenticator | A sign count that does not increase is refused; the stored count is updated on every use |
|
||||
| Cooling-off boundary | Strong at exactly `StrongFromUtc`, not one second before |
|
||||
| Revoked credential | Never strong; a revoke between challenge and assertion also refuses, and so does one between the assertion and the completion |
|
||||
| Junk from a session thief | Preconditions first, nothing spent before they hold (see State) |
|
||||
| No user verification | Required at enrolment and at assertion |
|
||||
| Cross-site POST | Antiforgery on every page POST including the JSON option requests |
|
||||
| Bearer anywhere | Only in the completion request's stack; tests look in logs (with HttpClient trace logging on), cookies, redirects, the page and the decrypted attempt |
|
||||
| Reused or replayed step-up | A fresh nonce each call; Heimdall's 409 is shown, not retried |
|
||||
| Brute force of ceremonies | One `FixedWindowBudget` per account |
|
||||
|
||||
## Account-scoped lookup and the allow list
|
||||
|
||||
An assertion is accepted only for a credential of the session's own account (strong, un-revoked), and the user handle, when the authenticator sends one, must be that account's id. The request options also carry the account's strong credentials as `allowCredentials`, which the library enforces, so a credential of another account is refused twice. The test for the account-scoped lookup (`The_credential_lookup_is_scoped_to_the_session_account_even_when_the_allow_list_is_empty`) takes the allow list out of the other account's challenge through the in-process ceremony store, which production code cannot do, so the lookup is exercised alone without weakening the server.
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
Bifrost sends no `Content-Security-Policy` header (nothing in the application sets one; this predates passkeys). The passkey script is first-party and external so that a policy of `script-src 'self'` would admit it, but nothing enforces that today.
|
||||
|
||||
## The script
|
||||
|
||||
`wwwroot/js/passkeys.js` is first-party, loaded by the page with an external `src` (nothing inline, no third-party asset, no `eval`, no `innerHTML`). It asks for options with the antiforgery header, calls `navigator.credentials`, and submits a same-origin form of base64url strings (`id`, `rawId`, `attestationObject` or `authenticatorData`, `clientDataJson`, `signature`, `userHandle`, `transports`). On any failure it navigates to this page with a result code from a closed set; nothing from a response is shown. `PasskeyScriptTests` runs it under Node against a simulated browser and pins what it posts.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- No passkey sign-in; passkeys prove an already signed-in session only.
|
||||
- No native-only accounts (no Heimdall account, nothing to unlink).
|
||||
- No recovery without mail: a member who loses every passkey revokes them and falls back to the Google step-up if a Google login is linked, or waits for the mail gate (`docs/registration-gates.md`) and the email-code factor that follows it. The email code is not built.
|
||||
- Named limit and next hardening: enrolling a second passkey should require an assertion by an existing strong passkey, and enrolment should use a Google step-up when a Google login is held. Today enrolment needs only the fresh ordinary sign-in and the cooling-off, so an attacker who controls a provider session can add a key that becomes strong after 48 hours unless the member sees and revokes it. Not built; a follow-up is filed.
|
||||
- A revoke between the completion's check and its unlink does not stop that unlink: the window is the few milliseconds of the two Heimdall calls and is inherent to checking before acting.
|
||||
- Revoking needs no strong proof, so a stolen session can revoke a member's passkeys and fall back to the Google proof: no weaker than the account was before it had a passkey.
|
||||
- A passkey synced through a cloud account the attacker also controls is a residual risk, as in the scoping note.
|
||||
- Heimdall cannot tell that a passkey was checked: `bifrost-passkey` is Bifrost's word, gated by the app secret and a live bearer (Heimdall `docs/app-profiles/bifrost.md`).
|
||||
- Not verified here: a real authenticator or browser. Tests use a software authenticator built from the WebAuthn specification and the script runs under Node against a simulated browser.
|
||||
|
||||
## Where the code is
|
||||
|
||||
- `Features/Passkeys/PasskeyService.cs`: enrol, assert, revoke, list; `PasskeyCeremonies.cs`: the single-use records and the rate limit; `PasskeyRelyingParty.cs`: RP ID, origin, library construction.
|
||||
- `Features/Heimdall/HeimdallAuthService.cs`: the `EnrolProof` and `UnlinkPasskeyProof` attempts, `StepUpAsync`.
|
||||
- `Pages/Membership/LinkedAccounts.cshtml[.cs]`, `wwwroot/js/passkeys.js`.
|
||||
- `Domain/BifrostModels.cs` (`PasskeyCredential`), `Data/Migrations/20261113120000_AddPasskeyCredentials.cs`.
|
||||
- Tests: `PasskeyTests.cs`, `PasskeyScriptTests.cs`, `Support/SoftwareAuthenticator.cs`, and the step-up route of the fake Heimdall in `Support/HeimdallTestKit.cs`.
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
# How a Ratatoskr voting module authenticates to Bifrost (design, no code)
|
||||
|
||||
Status: proposal, 2026-11-10, written by Heimdall (Self) against `origin/main` 276f611 (sections 1-13, citations as first read); section 14 and the corrections marked "e8e8fcc" in sections 1-13 are re-read against `origin/main` e8e8fcc. Decided 2026-11-11 by the steward of Bifrost for the read-only Desk feed (section 14); the cast scopes (`votes.cast`, `desk.answer`) stay gated by D1, D2 and D4. Docs only; nothing here is built or deployed.
|
||||
Labels: **FACT** = read at the cited `file:line` at the base named for that section or citation, 276f611 unless it says e8e8fcc (paths under `src/Bifrost.Web/` unless written otherwise). **INFERENCE** = my reading, not stated by code. **EXTERNAL** = OpenIddict/ASP.NET behaviour I know from its documentation and did not run here; each one needs a probe before the cut (section 11).
|
||||
Companion reads: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Eyes, at 416f11a; its Program.cs/OIDC lines still hold at 276f611, only `Features/Motions/MotionGovernanceService.cs` changed in between, in the close path), `heimdall-ratatoskr-sso-plan*.md`.
|
||||
|
||||
## 1. The problem in four facts
|
||||
|
||||
- FACT: a member's only Bifrost credential is the cookie `__Host-bifrost_session` (`Program.cs:189-200`; `Features/Shared/BifrostCookies.cs:10`). `__Host-` makes it host-only: chat.gamecult.org never holds it.
|
||||
- FACT: the member API carries no CORS: the single CORS policy is the anonymous tally GET (`Features/ConceptVotes/ConceptTallyExport.cs:8-27`), so a JSON POST from the chat origin is blocked at preflight.
|
||||
- FACT: Bifrost is an OIDC issuer with exactly one client, MAS, confidential, secret basic auth only, scopes `openid profile` (`Features/Oidc/OidcServiceCollectionExtensions.cs:39-46`; `Features/Oidc/OidcClientSeeder.cs:35-51` is the one MAS descriptor; startup deletes every other client, loop `:63-75`, all at e8e8fcc). No OpenIddict validation handler is registered (grep for `AddValidation` under `src` returns nothing), so no access token authenticates any `/api/*` route.
|
||||
- FACT: there is no JSON read or cast API for Standard motions (Razor form POST with 302 and no receipt: `Features/Motions/MotionGovernanceService.cs:47-86`, `Pages/App/Motions/Index.cshtml.cs`); sealed-ballot motions have JSON endpoints but no list (`Program.cs:718-763`).
|
||||
- INFERENCE: the access token chat holds today is Matrix's, minted by MAS after MAS authenticates the person at Bifrost (SSO plan section D). Bifrost's own access token goes to MAS, never to the browser. PR #24's "the OIDC access token Bifrost already issues to chat" is therefore not true as written: this design makes Bifrost issue a token to chat directly.
|
||||
- FACT (deployment): Bifrost has no `Bifrost__Oidc__*` variable live, MAS is disabled, Synapse is its own password authority (`heimdall-ratatoskr-sso-plan-live-read-20261110.md` section 1). Everything below is for after OIDC is enabled; `Bifrost:Oidc` absent means the provider surface is not mapped (`docs/oidc-issuer.md:37`).
|
||||
|
||||
## 2. Proposal in one paragraph
|
||||
|
||||
Ratatoskr's voting module is a second OIDC client of Bifrost: **public, PKCE (S256), no secret**, redirect URI exactly one URL on https://chat.gamecult.org. It runs authorization-code + PKCE in a top-level redirect to Bifrost (so the person's existing `__Host-` session cookie is used first-party; chat never sees it), receives an **access token for audience `bifrost-votes` and scopes `votes.read` / `votes.cast` (10 minutes for cast scopes; `desk.read`: see section 14)**, keeps it in memory only, and sends it as `Authorization: Bearer` to a **new `/api/votes` surface that accepts bearer tokens and refuses cookies**. CORS on exactly two route groups (`/connect/token`, `/api/votes`) allows exactly `https://chat.gamecult.org`, no credentials. Everything else in Bifrost keeps its current cookie-only behaviour. The Persona/Mind signed path is untouched.
|
||||
|
||||
## 3. Client registration
|
||||
|
||||
- A second client entry beside MAS, from configuration (`Bifrost:Oidc:Clients:Ratatoskr:{ClientId,RedirectUri}`), seeded by the same startup seeder. FACT the seeder today builds one descriptor and deletes all others (`OidcClientSeeder.cs:33-75` at e8e8fcc); it needs to build two and delete the rest.
|
||||
- `ClientType = Public`, no `ClientSecret`; permissions: authorization endpoint, token endpoint, authorization_code grant, response type `code`, scopes `profile`, `openid`, `votes.read`, `votes.cast`. PKCE is already required server-wide (`OidcServiceCollectionExtensions.cs:39`).
|
||||
- `RedirectUri` is one absolute URL, compared exactly (OpenIddict does exact match; EXTERNAL). No wildcard, no localhost entry in production configuration; a dev client is a separate config file never deployed.
|
||||
- FACT the token endpoint accepts only `client_secret_basic` (`OidcServiceCollectionExtensions.cs:45-46` clears and sets the list). EXTERNAL/needs probe: a public client authenticates by `client_id` alone and OpenIddict still accepts it with that list; if it does not, add `none` to the list for public clients only.
|
||||
- Not in scope: dynamic registration, implicit/hybrid flows, a client secret in the browser. The MAS client and its flow do not change.
|
||||
|
||||
## 4. Scopes, audience, what a token carries
|
||||
|
||||
| Scope | Grants | Never grants |
|
||||
|---|---|---|
|
||||
| `openid`, `profile` | `sub` = `UserAccount.Id`, `preferred_username`, `name` (FACT `OidcEndpoints.cs:79-84`, `docs/oidc-issuer.md:55-61`) | email, Heimdall id, membership detail |
|
||||
| `votes.read` | read motions the member may see, their own ballot status, results after close | other members' sealed ballots; weights of other members |
|
||||
| `votes.cast` | cast/change a Standard vote; sealed commit and reveal for the token's own `sub` | anything in the next list |
|
||||
|
||||
Audience: access tokens for these scopes carry audience `bifrost-votes` (INFERENCE: OpenIddict `SetResources`/audience per request; EXTERNAL detail). `/api/votes` requires the audience **and** a vote scope. This stops the MAS client's access token (same issuer, same signing key, scopes `openid profile`) from being accepted on `/api/votes`, and a Ratatoskr token from being accepted by anything else.
|
||||
|
||||
**A token may never:** grant or change a role (`RoleAssignments`); set, read-modify or choose a weight (weight is `Membership.EffectiveVotingWeight` computed server-side at cast, `Domain/BifrostModels.cs:360-364`; the API request has no weight field and the receipt does not echo it); open, close or edit a motion; register or revoke Mind keys (`Program.cs:754-763`, moderator, cookie only); reach `/App/*`, `/eve/*`, `/connect/userinfo` beyond the three profile claims; mint another token (no refresh token, `docs/oidc-issuer.md:26`; no token-exchange); act for a different account (the voter is always `sub`, never a body field). These are enforced by structure, not by a deny list: the vote endpoints take the account from the principal and expose no parameter for the rest.
|
||||
|
||||
## 5. The bearer surface
|
||||
|
||||
- Register OpenIddict validation (local server) as a **named, non-default** authentication scheme `BifrostBearer` (the desk's credential is built on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; keep that name, and read `BifrostBearer` as the role). FACT the default scheme is Cookie (`Program.cs:189-200`), so every existing endpoint stays cookie-only and a bearer token cannot reach `/App`, `/api/sealed-motions` or `/eve` (a test pins it).
|
||||
- New group `app.MapGroup("/api/votes")` with a policy `ActiveMember + scope + audience` whose `AuthenticationSchemes` is `BifrostBearer` only. A request that also carries the Bifrost session cookie is judged on the bearer alone, so the surface has no ambient credential: this is what removes CSRF from it (section 9).
|
||||
- **Actor resolution is a new job.** FACT `CurrentBifrostActorAccessor` builds the actor from cookie claims (`BifrostIdentity`, `HeimdallAccountId`, GitHub `NameIdentifier`) and returns Anonymous when none are present (`Features/Shared/CurrentBifrostActorAccessor.cs:33-46`). A bearer principal has only `sub`, so existing services would see an anonymous actor. FACT `OidcEndpoints.ActiveMemberIdAsync` already resolves by `sub` and re-reads membership (`OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`; e8e8fcc). Proposal: one accessor path "principal with `sub` and `BifrostBearer` → load `UserAccount` + `Membership` by id", reusing that reading, and the `/api/votes` handlers call the existing services (`MotionGovernanceService.CastVoteAsync`, `SealedBallotService.CommitAsync/RevealAsync`) with that actor. No second vote implementation.
|
||||
- Endpoints (names are a proposal, shapes belong to the cut spec): `GET /api/votes/motions`, `GET /api/votes/motions/{id}`, `PUT /api/votes/motions/{id}/ballot` `{choice, comment?}` (Standard), `POST …/commit` and `…/reveal` for sealed, `GET …/result`. FACT today's Standard cast returns silently when the motion is not open and throws on an unknown id (`MotionGovernanceService.cs:57-61`); the API wraps it with typed errors (404, 409 not open) rather than copying that.
|
||||
- Errors are JSON 401/403/404/409/429, never a 302 to a sign-in page (FACT the cookie challenge redirects, `Program.cs:197`; a `fetch` client must get a status it can act on, e.g. 401 with `WWW-Authenticate: Bearer error="invalid_token"`).
|
||||
- Persona/Mind path: FACT `POST /api/sealed-motions/{id}/mind` is anonymous, Ed25519-signed, rate-limited at 30/min/source and 4096 bytes (`Program.cs:767-809`). Unchanged. Minds never use this bearer flow; `/api/votes` has no Mind route, and a Persona's account is not a Ratatoskr login.
|
||||
|
||||
## 6. The flow (browser)
|
||||
|
||||
1. Ratatoskr voting module starts login: generates `state`, `nonce`, PKCE verifier/challenge (S256), stores them in `sessionStorage` for the one tab, navigates to `GET /connect/authorize` with `client_id`, exact `redirect_uri`, `scope=openid profile votes.read votes.cast`, `response_type=code`.
|
||||
2. FACT authorize without a session challenges to `/auth/sign-in` and resumes via a short `request_uri` (`OidcEndpoints.cs:44-51`); with a session it admits any account that is not Suspended and has an identity (chat tier; membership is neither required nor written), otherwise `access_denied` (`OidcEndpoints.cs:53-70`). So the sign-in leg does not prove Active: the `/api/votes` and `/api/desk` surfaces re-check Active on every request, which is what makes this safe. Heimdall sign-in and the Bifrost session happen on the Bifrost host exactly as for MAS.
|
||||
3. For a chat token without a live notice receipt (a desk or vote token needs none: the receipt gates the chat token only, Meta's ruling 2026-11-11), Bifrost first redirects to its training-notice consent page and resumes the parked request (`OidcEndpoints.cs:99-102`, e8e8fcc; not a grant to the client). Then, for cast scopes only, the per-client grant screen (section 7, D1); `desk.read` alone has none. Then 302 to `https://chat.gamecult.org/<redirect path>?code=…&state=…`.
|
||||
4. The module checks `state`, immediately removes `code` and `state` from the address bar (`history.replaceState`), POSTs `/connect/token` (form: `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `redirect_uri`) cross-origin. FACT the token endpoint re-reads the account and refuses with `invalid_grant` when `ChatRefusalAsync` says so: account missing, Suspended, or no live notice receipt (`OidcEndpoints.cs:107-120`, `ChatRefusalAsync` `:151`). It does not test Active; the per-request Active check is the new surfaces' own (section 14 D3).
|
||||
5. The access token lives in a JS variable. Not `localStorage`, not `sessionStorage`, not IndexedDB. A page reload logs the module out of Bifrost votes (not out of chat) and the next vote action restarts step 1; with an existing Bifrost session it is two redirects and no typing.
|
||||
|
||||
INFERENCE on re-auth without refresh tokens: FACT none exist (`docs/oidc-issuer.md:26`). EXTERNAL: `prompt=none` in a hidden iframe is dead (third-party cookie rules), so renewal is a top-level redirect. That is the cost of a no-refresh-token design; see D3.
|
||||
|
||||
## 7. Consent screen: needed, because one hop is cheap and a vote is not
|
||||
|
||||
- FACT (e8e8fcc) there is no per-client grant consent: authorize redirects to `ConsentEndpoints` when the member has no live receipt (`OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`), but that is the training-notice receipt, a once-per-notice-version agreement independent of any client, not a grant to Ratatoskr. "Consent" below means the per-client grant unless it says notice. MAS needs no grant screen (it is the chat's own login service).
|
||||
- Reasoning (INFERENCE): `votes.cast` lets whoever holds the token change the member's recorded vote. The member is already redirected to Bifrost, so a consent step costs one click and makes the grant visible and revocable in Bifrost, which is the only place it can be. Recommend **explicit consent for `votes.cast`, implicit for `votes.read`** (OpenIddict `ConsentType` per client or per authorization; EXTERNAL).
|
||||
- Page content: client name ("Ratatoskr, chat.gamecult.org"), the account being used, the two powers in plain words ("see motions and your own ballots", "cast and change your votes"), Allow / Deny, a note that the grant lasts until revoked and tokens last 10 minutes (cast scopes). Served with `frame-ancestors 'none'` and `X-Frame-Options: DENY` (clickjacking). The consent wording for the chat side is `ratatoskr-consent-wording-draft-20261109.md` and is separate from this one.
|
||||
- A stored authorization (OpenIddict permanent authorization) lets the next login skip the consent page; it is the object revoked in section 8.
|
||||
|
||||
## 8. Token lifetime, revocation, receipts, rate limits
|
||||
|
||||
**Lifetime.** FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 min, `OidcServiceCollectionExtensions.cs:47`); INFERENCE/EXTERNAL OpenIddict's defaults apply (about 1 hour for access tokens, 5 min for codes). Set explicitly: **access token 10 minutes for cast scopes (`desk.read`: 60 minutes, section 14 D3)**, authorization code 60 seconds, id_token short. No refresh token. Because every `/api/votes` call re-reads the account and membership (section 5), the lifetime bounds token theft, not demotion.
|
||||
|
||||
**Revocation.**
|
||||
- Demotion is immediate: each request re-checks Active (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, e8e8fcc). A token held by a suspended member returns 403.
|
||||
- Authorization cleanup: FACT `OidcLogoutReconciler` finds subjects who fail the chat-tier test (Suspended, account gone, or no live notice receipt) and hold authorizations of the MAS client and tells MAS (`docs/oidc-issuer.md:146-164`); it is MAS-specific (it needs `Clients:Mas:BackchannelLogoutUri`, `OidcLogoutReconciler.cs:180` at e8e8fcc) and nothing in it serves another client today. To be built: a derived-from-membership sweep that revokes the Ratatoskr/desk client's authorizations for non-Active subjects (no outbound POST needed; the client is ours). Membership is never written by it.
|
||||
- Member-initiated revoke: needs a place to list and revoke grants. Not built; D4.
|
||||
- Operator kill: with the seeder extended to more than one client, removing a client from configuration deletes it and its tokens at next start (the delete-others loop, `OidcClientSeeder.cs:63-75` at e8e8fcc, today spares only the MAS client); a runtime brake is D5.
|
||||
|
||||
**Receipts.** Every cast returns a JSON receipt, since Standard votes return none today (facts file section 5): `{motionId, choice, revision, castAtUtc, receiptId}`; sealed commit/reveal keep their existing receipt (`SealedBallotReceipt`, `SealedBallotService.cs`). Receipt contains no weight. The audit trail already records casts (`MotionGovernanceService.cs:86`); the audit row gains the client id and `jti` so a vote can be traced to the grant that cast it (INFERENCE: this is what "receipts" is for after an XSS: the member can see which client cast it).
|
||||
|
||||
**Rate limits.** FACT none on the member API (facts file section 5); the primitives exist: `FixedWindowBudget` keyed by account (`Features/ConceptVotes/ConceptVoteRateLimits.cs:14-24`) and a 429 + Retry-After pattern (`Program.cs:~773-774`, e8e8fcc). Proposal: per-account budgets — casts 30 per 10 min (the Mill's number), reads 120 per minute; token endpoint per source address; 429 with `Retry-After`. Numbers are D6.
|
||||
|
||||
## 9. Threat list
|
||||
|
||||
| Threat | Why it matters here | Answer |
|
||||
|---|---|---|
|
||||
| **Token theft by XSS in chat** | Ratatoskr renders other people's Matrix messages; a script in the chat origin can read an in-memory token or call the module's own functions | Token memory-only, 10 min (cast scopes), scopes limited to votes, audience-bound, no refresh. Honest limit: an XSS can **cast during the page's life** whatever the token allows; shortening and scoping bound it, they do not stop it. Mitigations to require: strict CSP on chat.gamecult.org, message sanitisation, and an in-module confirmation step for `votes.cast` that is rendered by the module itself (Ratatoskr's cut). D2 asks whether casting should demand a fresh re-authentication (`prompt=login`, `max_age`) for high-stakes motions. Sealed motions are better off: the salt is generated and held client-side, but a compromised page holds it too. |
|
||||
| **CSRF** | A third-party page makes the member's browser vote | `/api/votes` accepts no cookie (section 5), so a forged cross-site request carries no credential. CORS has no `AllowCredentials`, so a credentialed cross-origin read is unreadable. The cookie surfaces keep their current protection (FACT: Razor antiforgery, `Index.cshtml:127,139`; the sealed API relies on SameSite and JSON binding, `Program.cs:718-763`) and are untouched. |
|
||||
| **Authorization-code interception / injection** | Code arrives in a URL on the chat origin | PKCE S256 required (FACT `:39`), exact redirect match, codes single-use and 60 s, `state` checked, URL cleaned at once, `Referrer-Policy: no-referrer` on the callback page. |
|
||||
| **Login CSRF / mix-up** | Attacker finishes a flow with their own code | `state` bound to the tab, `nonce` checked in the id_token, one issuer only. |
|
||||
| **Replay** | A captured request resent | Token expiry; codes single-use; Standard cast is `PUT` and idempotent by `(motion, sub)` (FACT one row per member per motion, `MotionGovernanceService.cs:64-85`); sealed commit/reveal already carry `revision` and phase checks; the commitment binds the voter id (`docs/sealed-ballot-motion.md:22-26`). Replaying the same cast cannot double a vote. |
|
||||
| **Token substitution** | MAS-bound token used at `/api/votes`, or a Ratatoskr token elsewhere | Audience + scope + named scheme (sections 4-5). |
|
||||
| **Open redirect / redirect abuse** | | One exact redirect URI; Bifrost local return paths unchanged (FACT `Features/Shared/LocalReturnPath.cs:13-31`). |
|
||||
| **Overreach by a demoted or compromised account** | Membership changes after issue | Per-request Active check; sweep revokes authorizations. |
|
||||
| **CORS misread as security** | CORS stops browsers only | Not relied on for authorisation; it is an availability measure. Tokens authenticate; non-browser callers need a token. |
|
||||
| **Consent phishing** | Look-alike client asks for `votes.cast` | One registered client; consent page names it and shows the redirect host. |
|
||||
| **Scraping / vote spam** | | Rate limits (section 8); no list of other members' ballots exists. |
|
||||
| **Clickjacking of consent/sign-in** | | `frame-ancestors 'none'` on Bifrost auth pages. |
|
||||
| **Logout gap** | The Bifrost session ends but a token lives on: 10 min for cast scopes; up to 60 min for `desk.read` (section 14 D3) | Accepted; bounded by lifetime and the per-request Active check. For `desk.read` the Bifrost session can end while the token keeps feeding `/api/desk` for up to 60 minutes; accepted because the feed is read-only and every request re-checks Active. |
|
||||
|
||||
## 10. CORS, exactly
|
||||
|
||||
- A **second named policy**, separate from `ConceptTallies` (FACT that policy reads `ConceptVotes:AllowedOrigins`, `ConceptTallyExport.cs:13-27`, so reusing it would couple two features' origin lists): `WithOrigins("https://chat.gamecult.org")`, methods `GET, POST, PUT, OPTIONS`, headers `Authorization, Content-Type`, **no `AllowCredentials`**, no exposed headers except `Retry-After`, preflight cache bounded. Origin comes from configuration as one exact value; no wildcard, no subdomain pattern, no `null`.
|
||||
- Applied by route metadata to `/connect/token` and `/api/votes/*` only. `/connect/authorize` is a top-level navigation and needs none; `/.well-known/*` is not fetched by the module (the client config is static). Every response under those routes carries `Vary: Origin` (the pattern the tally export already follows).
|
||||
- Bifrost still sends no CORS headers for `/api/sealed-motions/*`, `/App`, `/eve`, `/connect/userinfo` (the facts file's blocker 1 stays closed there on purpose).
|
||||
|
||||
## 11. Probes owed before the cut spec (each is an EXTERNAL claim above)
|
||||
|
||||
1. A public client with no secret completes code exchange when `ClientAuthenticationMethods` holds only `client_secret_basic`.
|
||||
2. Per-client explicit grant consent (D1) works with the request-caching path (`request_uri`) used at `OidcEndpoints.cs:44-51`, alongside the existing notice-receipt redirect (`:99-102`, e8e8fcc), which parks and resumes the same request.
|
||||
3. Audience and scope reach the access token and the validation handler enforces them; the MAS client's token is refused at `/api/votes`.
|
||||
4. Default token lifetimes before the explicit settings.
|
||||
5. OpenIddict validation as a non-default scheme leaves cookie-default endpoints unchanged (test: bearer on `/App` and `/api/sealed-motions` is 302/401, as now).
|
||||
6. CORS preflight on `/connect/token` through OpenIddict's passthrough (the endpoint is mapped as a minimal API at `OidcEndpoints.cs:35`).
|
||||
7. (owed by D3) Per-audience lifetimes: `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` set at authorize give a `desk.read`-only request a 60-minute access token and a cast-scope request 10 minutes, and the token's `exp` shows it.
|
||||
|
||||
## 12. Questions as raised (decided in section 14)
|
||||
|
||||
Decided 2026-11-11 by the steward of Bifrost: D1-D7 below are answered in section 14; the questions are kept as asked and are not open.
|
||||
|
||||
For **Meta**:
|
||||
- D1 Is explicit consent for `votes.cast` wanted (recommended) or is a first-party client implicit?
|
||||
- D2 Should a vote on a high-stakes motion demand re-authentication (`prompt=login`) at cast time? Recommend yes for sealed commit and for any motion whose result moves money or roles; no for ordinary reads.
|
||||
- D3 Accept that a 10-minute cast token with no refresh means a redirect round-trip to keep voting, or allow a refresh token (it widens theft impact; I recommend against).
|
||||
|
||||
For **Eureka** (platform/ops, MAS and deploy order):
|
||||
- D4 Where does a member list and revoke grants (a Bifrost `/App` page)? Needed before `votes.cast` ships if consent is explicit.
|
||||
- D5 Is a runtime brake wanted on the Ratatoskr client (disable without redeploy), and who holds it?
|
||||
- D6 Rate-limit numbers, and whether they belong in configuration (recommended) or code.
|
||||
- D7 Order against the MAS rollout: this design needs Bifrost OIDC live (it is not, section 1), and the two clients can ship separately; do voting tokens wait for MAS, or does voting go first as the first OIDC consumer? Recommend independent; the collision-and-import hazards in the SSO live read concern MAS only.
|
||||
|
||||
For **Ratatoskr (Void)**: the module owns the CSP, the in-module confirmation step, the callback page behaviour (section 6) and memory-only storage; this doc cannot enforce them from Bifrost's side.
|
||||
|
||||
## 13. What this deliberately does not do
|
||||
|
||||
No new identity: `sub` stays `UserAccount.Id`. No change to Heimdall, MAS, or the Mind key path. No weights or roles in tokens. No second vote implementation: the bearer surface is a thin projection of the existing services. No cookie sharing between chat and Bifrost, and no proxy on the chat origin pretending to hold one.
|
||||
|
||||
## 14. Decision record (steward, 2026-11-11)
|
||||
|
||||
Decided by the steward of Bifrost (the Persona), recorded by Heimdall (Self). Scope: the first consumer is the read-only Desk feed (scope `desk.read`, audience `bifrost-desk`, `docs/desk-feed-design.md`, draft PR #67), not votes. Each decision is the least-power, most-inspectable option that keeps exit (the member or operator can end it) and receipts (the act can be traced afterwards). Numbering: D1-D7 here are this document's (D1 consent, D2 re-auth, D3 token lifetime, D4 grant page, D5 brake, D6 rate limits, D7 ordering vs MAS); the "D1: `options_hash` covers everything shown" of the fork-desk answer-binding handoff is a different ruling and is not decided here.
|
||||
|
||||
**D1 consent (per-client grant).**
|
||||
- Decision: explicit per-client grant consent stays REQUIRED for any scope that can cast or answer (`votes.cast`, `desk.answer`), as recommended in section 7. `desk.read` (read-only, the member's own account) needs no per-client grant screen: implicit. Two senses of "consent" exist and are kept apart: the training-notice receipt (every authorize already redirects to `ConsentEndpoints` without a live receipt, `OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`, e8e8fcc), which applies to all clients and is unchanged; and the per-client grant, which does not exist yet.
|
||||
- Reasons: FACT (e8e8fcc) there is no per-client grant consent today: the only consent step is the notice receipt; a `desk.read` grant changes nothing, so a click would buy no protection; a cast changes a recorded vote or answer, so the member must see the grant.
|
||||
- Keeps: exit is the revocable stored authorization (D4); receipts are the audit row carrying client id and `jti` (section 8).
|
||||
- Probe owed: 2 (section 11), the grant screen beside the notice redirect on the parked-request path.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D2 re-authentication.**
|
||||
- Decision: yes (`prompt=login` at cast time) for sealed commit, and for answering or casting on any motion or fork whose result moves money or roles. Not needed for `desk.read`.
|
||||
- Reasons: a stolen 10-minute token must not be enough to move money or roles; a read-only feed has nothing to protect that a fresh login would add.
|
||||
- Keeps: exit, because the member re-proves the account each time; receipts, because the cast receipt follows a fresh authentication.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D3 token lifetime and refresh.**
|
||||
- Decision: NO refresh token, anywhere. Access token 10 minutes for the cast and answer scopes; 60 minutes for the read-only audience `bifrost-desk`. Authorization code 60 seconds. One client may request both audiences; the lifetime follows the requested scope set: a request containing any cast or answer scope gets 10 minutes, a `desk.read`-only request gets 60.
|
||||
- Reasons: FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 minutes, `OidcServiceCollectionExtensions.cs:47`, e8e8fcc), so the explicit values replace OpenIddict defaults. Every `/api/desk` request re-checks Active membership (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`), so lifetime bounds only theft of a read-only credential, not demotion; a 10-minute token would force a redirect round trip into a polling feed. Not to be confused with `ChatRefusalAsync` (`OidcEndpoints.cs:151`), which the token and userinfo endpoints use for chat subjects and which refuses a Suspended account or one without a live notice receipt, not a non-Active one. A refresh token would outlive the redirect and widen theft impact.
|
||||
- Per-audience lifetime is not built or proven: it would be set with `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` at authorize. Probe 7 (section 11) is owed before the cut.
|
||||
- Keeps: exit, because demotion is immediate and the sweep (to be built, section 8) revokes grants of non-Active subjects; no long-lived secret exists to leak. Accepted gap: the Bifrost session can end while a `desk.read` token feeds `/api/desk` for up to 60 minutes; acceptable for a read-only feed because every request re-checks Active.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D4 grant list and revoke page.**
|
||||
- Decision: a member-facing page in `/App` that lists and revokes grants is a BLOCKING condition for shipping any cast or answer scope. It is not needed for `desk.read`, which is ended by the derived-from-membership sweep and by the operator brake (D5). Nothing further is decided here.
|
||||
- Reasons: explicit consent (D1) without a place to withdraw it is half a control; a read-only grant ends with the sweep and the brake. Neither exists for a second client today: the sweep is to be built (`OidcLogoutReconciler` is MAS-specific, section 8), and the brake covers only the three `/connect` endpoints (D5).
|
||||
- Keeps: exit (the page itself) before any scope that can act.
|
||||
- Holder: the steward of Bifrost (the page is Bifrost code).
|
||||
|
||||
**D5 runtime brake.**
|
||||
- Decision: yes. Reuse the existing marker-file brake of PR #50 (commit 5201508; `OidcGuardMiddleware`, `OidcGuard.cs:32`, `BrakeEngaged` at `OidcGuard.cs:71`, option `Bifrost:Oidc:BrakePath`, `Configuration/OidcIssuerOptions.cs:26`, `docs/oidc-issuer.md` "Limits, prune, brake") rather than a new mechanism. It is held by the Bifrost steward (the Persona).
|
||||
- The brake is NOT per-client. FACT (e8e8fcc) the guard matches `/connect/authorize`, `/token`, `/userinfo` for all clients (`EndpointOf`, `OidcGuard.cs:64-68`), so engaging it also 503s MAS sign-in; it is registered only when `Bifrost:Oidc` is configured (`Program.cs:294-297`) and `BrakePath` lives under `Bifrost:Oidc`. The code PR must extend the guard's matching to `/api/desk` and must settle a desk-only deployment (a host running the desk without `Bifrost:Oidc` configured has no guard and no brake path; either the desk requires `Bifrost:Oidc`, which it does for its tokens, or the brake option moves). Not decided here: that settlement is the code PR's to bring back.
|
||||
- Reasons: FACT the guard is checked per request (no restart) and treats an unreadable marker as engaged; no second brake, no second owner.
|
||||
- Keeps: exit for the operator (delete the file to release; discovery and keys stay up, as the issuer test asserts); receipts in the 503 `temporarily_unavailable` answers and the guard's log line.
|
||||
- Holder: the Bifrost steward holds the brake. FLAG, not decided: where the marker file lives on the host is Eureka's (ops) to name.
|
||||
|
||||
**D6 rate limits.**
|
||||
- Decision: the numbers live in configuration, defaults: `desk.read` 120 requests per minute per account; casts 30 per 10 minutes per account (the Mill's number) when they exist. The token endpoint keeps the existing per-address budget (`OidcRateLimit`, `OidcGuard.cs:102`): that is ONE budget shared by authorize, token and userinfo together (`docs/oidc-issuer.md:187-188`), not a token-endpoint budget of its own.
|
||||
- Reasons: FACT the member API has none today; the primitive `FixedWindowBudget` keyed by account exists (`ConceptVoteRateLimits.cs:14-24`) and the 429 plus `Retry-After` pattern too (`Program.cs:~773-774`, e8e8fcc; `RefuseAsync` in the guard). Config keeps the numbers inspectable and changeable without a build. Per-client budgets stay out, as in `docs/oidc-issuer.md` (the client id is public).
|
||||
- Keeps: exit via the brake (D5); receipts via the 429 answers. The desk cut map's open point (a 429 has no refusal code in the contract, Q3 there) is not decided here.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D7 ordering against MAS.**
|
||||
- Decision: independent. A second client is seeded only when `Bifrost:Oidc:Clients:Ratatoskr` is configured. This is a code change, not existing behaviour: FACT (e8e8fcc) the seeder builds one MAS descriptor and its delete-others loop (`OidcClientSeeder.cs:63-75`) removes everything else. Constraint on the code PR: under the MAS-less or default configuration it adds nothing observable (no new client, route, scope or discovery change), so window B (cut stays ab89556) is not widened. `desk.read` is the first consumer. It needs Bifrost OIDC live, as MAS does.
|
||||
- Reasons: nothing in this client touches the MAS client, the Heimdall path or the Mind key path; "seeded only when configured, removed when no longer configured" is the behaviour to build, and the loop must be changed to spare configured clients rather than only MAS.
|
||||
- Keeps: exit, because removing the configuration section deletes the client and its tokens at next start (once built); no receipts needed beyond the audit rows above.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**Decided with the feed.** The Desk feed never carries answerer account ids (the door withholds them from a non-decider, `eureka-substrate:ruling:desk-read-visibility-withheld-ids`; Bifrost masks nothing it cannot read, and this supersedes the Bifrost mask that closed X3 of `docs/desk-feed-design.md`).
|
||||
|
||||
**Flags to other stewards.**
|
||||
- Eureka: the Huginn door list and read wire and its audience policy are written in Huginn's `docs/desk-feed-door.md` (Huginn main 79d954c or later, with golden fixtures); the host path of the D5 marker is still open. The listing gate that implements the audience policy is NOT live on Huginn main (cut `cut-desk-feed-listing`): until it lands, the door returns every showable question to every reader, so no Mind that holds private questions goes behind a live receiver.
|
||||
- Libby (CultLib steward), ruled 2026-11-11: ForkCard gains `raised_at`, which stays OUT of `options_hash` (that hash keeps its five members: title, context, options, recommendation, depends, so digests are unchanged) and is a REQUIRED card key, an RFC3339 string or nil; the card goes from 16 to 17 keys and its hex changes. This closes X1 and X2 of `docs/desk-feed-design.md` (X2 withdrawn).
|
||||
|
||||
**Who sees which card: Huginn's door (Eureka), not Bifrost.** The policy is written in Huginn's `docs/desk-feed-door.md` ("Listing"), and this note holds no copy of it: a fork is shown to a reader who is not on the call's `deciders` only while a listing record for it is in force; a decider sees every showable fork; a hidden fork reads as `fork_unknown`, with the same bytes as a fork that does not exist. Bifrost applies no audience rule of its own: it relays exactly what the door returns. Reads carry `subject` and `deciders` (no `authority`; that is only on an answer), so anyone who must see unlisted forks is put on the Mind's decider list in Bifrost's admin. Bifrost adds three acts on the desk path: the per-request Active membership check, the `desk.read` bearer (audience `bifrost-desk`), and nilling notes for a non-decider; they sit beside the two already built (the note strip for non-deciders and the configured-Mind 404). Bifrost does NOT filter by audience, hold any per-card visibility rule, or keep a second copy of the policy (two copies would drift). The card is opaque to Bifrost (its `raised_at` key and order are being re-pinned in CultLib), so it is relayed as the door sent it.
|
||||
|
|
@ -11,7 +11,7 @@ This page scopes the three that did not exist, says where each lives, and record
|
|||
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
|
||||
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
|
||||
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
|
||||
| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. A Suspended row with no stored reason (suspended before the column existed) shows admins the fixed text "Suspended before reasons were recorded"; the old text is never read out of the receipt. An unsuspend still requires a reason, but nothing stores it. |
|
||||
| Suspension | `MembershipSuspensionService` | Merged. |
|
||||
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
|
||||
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
|
||||
| Email verification | nowhere | Scoped below, not built. |
|
||||
|
|
@ -80,11 +80,10 @@ raise the wait or lower the budget; do not add a visual or audio puzzle, which e
|
|||
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.
|
||||
- Consent is optional. Registration succeeds with or without it; a receipt exists only if the person agreed. 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).
|
||||
`answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 2 is in force.
|
||||
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
|
||||
|
|
@ -92,8 +91,7 @@ decision (`admission-is-bifrost-membership`).
|
|||
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
|
||||
that apply: 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
|
||||
|
|
|
|||
|
|
@ -137,10 +137,9 @@ to one member account and the account to that one persona (`MindPersonas`, uniqu
|
|||
cannot share the account and overwrite the first's ballot, and a registration race loses on the index). The trust is the moderator's: Bifrost cannot tell whether a registered public
|
||||
key really belongs to the Mind.
|
||||
|
||||
**What a Mind holds.** An Ed25519 private key (32-byte seed) at `/var/lib/mind-<persona>/sealed-motion.key`. The generator script writes it mode 0400 and the signer accepts 0400 or 0600.
|
||||
It is owned by `mind-<persona>`, the Persona user, which is also the signer; never in the mind store or a room. Eureka generates it on the host and prints only the public
|
||||
half. By rule, a moderator registers that public half only after the Persona shell, running as `mind-<persona>`, has signed a real `commit` envelope
|
||||
(production domain `gamecult.sealed_intake.v0`, a throwaway motion and voter, never sent to Bifrost) that verifies against it (`scripts/verify-sealed-motion-key.sh` in gamecult-ops, which must print `PASS`). **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot.
|
||||
**What a Mind holds.** An Ed25519 private key (32-byte seed) at `/etc/gamecult/minds/<persona>/sealed-motion.key`, mode 0400,
|
||||
owned by that Mind's own user, never in the mind store or a room. Eureka generates it on the host and prints only the public
|
||||
half; a moderator registers it. **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot.
|
||||
|
||||
**Signing.** `tools/sealed-motion-sign.mjs` (`pubkey`, `commit`, `reveal`) reads the key file, checks its type, mode and owner,
|
||||
and prints the envelope; it posts nothing. `commit` also prints the ballot (candidate, reason, salt), which the Mind keeps in
|
||||
|
|
|
|||
|
|
@ -1,32 +1,20 @@
|
|||
# Training notice, version 1
|
||||
# Training notice, version 2
|
||||
|
||||
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`.
|
||||
Eureka proposed a tighter notice and listed five gaps in the receipt flow (PR #55). The steward (Heimdall) ruled on
|
||||
2026-11-11. The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and
|
||||
what each gap became, and does not repeat the wording. Contract for the receipt and the export is in
|
||||
`registration-gates.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.
|
||||
## What version 2 changed from version 1
|
||||
|
||||
## 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 |
|
||||
| Version 1 | Version 2 |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
| "Chat messages and activity" | Names 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. |
|
||||
| "train and evaluate AI models" | "that GameCult builds and runs". Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice makes no claim either way beyond what it says. |
|
||||
| "No Discord material is used" | "Nothing that came from Discord is used, in any form." Kept: `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. |
|
||||
| Consent required to register | Optional. See below. |
|
||||
| "withdraw at any time" with no door | Names the door: the consent page, while signed in. |
|
||||
| Silent on deletion | Says plainly that withdrawing deletes nothing and what it does and does not undo. |
|
||||
|
||||
## Rulings on the five gaps
|
||||
|
||||
|
|
@ -41,27 +29,24 @@ asked again on their next chat sign-in.
|
|||
(not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The
|
||||
notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your
|
||||
messages are not used"), and the consent page shows the person that their agreement is to an older version and
|
||||
offers the new one. Since the chat gate, a bump also stops new chat tokens for everyone until they agree (their next chat sign-in lands on that page; a session already open runs to its natural expiry unless `BackchannelLogoutUri` is set, when the 30 s reconciler ends it). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no.
|
||||
offers the new one. 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.
|
||||
They can opt in on the consent page like anyone else, which is also the only way an existing member is asked.
|
||||
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.
|
||||
never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). No deploy has put
|
||||
version 2 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.
|
||||
Registration succeeds with or without agreement. A receipt exists only if the person agreed. The contract already
|
||||
excluded accounts without a receipt, so optional costs nothing in correctness; it costs training data, and it removes
|
||||
the objection that agreement obtained as a condition of an account is not a free choice. This is the steward's ruling
|
||||
on Eureka's recommendation; Meta can overrule it. One consequence: the registration form no longer enforces the notice.
|
||||
|
||||
## Still open for Meta
|
||||
|
||||
|
|
@ -69,10 +54,11 @@ registration form does not enforce the notice.
|
|||
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.
|
||||
- Whether existing members should be asked to opt in beyond the consent page existing.
|
||||
- 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.
|
||||
- Wording changes are a version bump with a new text; never edit version 2's text in place after it has been served.
|
||||
|
|
|
|||
|
|
@ -7,21 +7,17 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.12" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
<PackageReference Include="Fido2" Version="4.3.3" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
||||
<!-- Direct pins lift transitive packages past their advisories; the lockfile holds the result. -->
|
||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.Validation.ServerIntegration" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gates on the account-merge page (section <c>AccountMerge</c>). Dark by default: a merge Bifrost confirms against a Heimdall without
|
||||
/// the identity-move route stays Unknown forever and freezes the folded account, so set <c>AccountMerge__Enabled</c> only after
|
||||
/// Heimdall's identity-moves is deployed and probed.
|
||||
/// </summary>
|
||||
public sealed class AccountMergeOptions
|
||||
{
|
||||
public const string SectionName = "AccountMerge";
|
||||
|
||||
public bool Enabled { get; init; }
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
/// <summary>The chat host's pull of merge notices (MergeNoticeFeed). An empty token turns both routes off (404); startup does not require it.</summary>
|
||||
public sealed class ChatNoticesOptions
|
||||
{
|
||||
public const string SectionName = "ChatNotices";
|
||||
|
||||
/// <summary>The bearer the chat host presents (environment <c>ChatNotices__Token</c>). Its own secret: it grants nothing else.</summary>
|
||||
public string Token { get; init; } = string.Empty;
|
||||
}
|
||||
|
|
@ -14,11 +14,7 @@ public sealed class HeimdallOptions
|
|||
|
||||
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
|
||||
|
||||
/// <summary>Deprecated: rides in the process environment. Use <see cref="AppSecretFile"/>; the file wins when both are set.</summary>
|
||||
public string AppSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Path of a file holding the app secret (systemd <c>LoadCredential</c> style: <c>%d/heimdall-app-secret</c>).</summary>
|
||||
public string AppSecretFile { get; init; } = string.Empty;
|
||||
public string AppSecret { get; init; } = string.Empty;
|
||||
|
||||
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
|
||||
|
||||
|
|
@ -43,37 +39,4 @@ public sealed class HeimdallOptions
|
|||
IsConfigured &&
|
||||
EnablePatronSupportIntake &&
|
||||
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <see cref="AppSecret"/> once, when the options are built. A set <see cref="AppSecretFile"/> must be a
|
||||
/// readable, non-empty file, or startup fails; the message names the option and path, never a secret.
|
||||
/// </summary>
|
||||
public static void ResolveAppSecret(HeimdallOptions options, ILogger logger)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.AppSecret))
|
||||
{
|
||||
logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AppSecretFile))
|
||||
{
|
||||
string secret;
|
||||
try
|
||||
{
|
||||
secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n');
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name}).");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty.");
|
||||
}
|
||||
|
||||
options.AppSecret = secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
|
||||
public DbSet<MindKey> MindKeys => Set<MindKey>();
|
||||
public DbSet<PasskeyCredential> PasskeyCredentials => Set<PasskeyCredential>();
|
||||
|
||||
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
|
||||
|
||||
|
|
@ -81,12 +80,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<ConceptAppraisal> ConceptAppraisals => Set<ConceptAppraisal>();
|
||||
|
||||
public DbSet<AccountMerge> AccountMerges => Set<AccountMerge>();
|
||||
|
||||
public DbSet<AccountMergeEntry> AccountMergeEntries => Set<AccountMergeEntry>();
|
||||
|
||||
public DbSet<AccountMergeNoticeAck> AccountMergeNoticeAcks => Set<AccountMergeNoticeAck>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UserAccount>(entity =>
|
||||
|
|
@ -143,10 +136,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
entity.Property(x => x.Label).HasMaxLength(120);
|
||||
entity.Property(x => x.Weight).HasPrecision(18, 2);
|
||||
entity.HasIndex(x => new { x.MembershipId, x.Kind, x.IsCurrent });
|
||||
entity.HasIndex(x => x.MembershipId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_TierSnapshots_MembershipId_CurrentPatron")
|
||||
.HasFilter("\"Kind\" = 'Patron' AND \"IsCurrent\"");
|
||||
entity.HasOne(x => x.Membership)
|
||||
.WithMany(x => x.TierSnapshots)
|
||||
.HasForeignKey(x => x.MembershipId);
|
||||
|
|
@ -311,8 +300,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
modelBuilder.Entity<Vote>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.MotionId, x.UserAccountId }).IsUnique();
|
||||
// A merge voids a vote by stamping it; every tally reads through this filter, and only AccountMergeLedger ignores it.
|
||||
entity.HasQueryFilter(x => x.VoidedByMergeId == null);
|
||||
entity.Property(x => x.Choice).HasConversion<string>();
|
||||
entity.Property(x => x.Weight).HasPrecision(18, 2);
|
||||
entity.HasOne(x => x.Motion)
|
||||
|
|
@ -346,16 +333,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PasskeyCredential>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.CredentialId).IsUnique();
|
||||
entity.HasIndex(x => x.UserAccountId);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MindKey>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
|
||||
|
|
@ -619,7 +596,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
entity.Property(x => x.NetworkTag).HasMaxLength(64);
|
||||
entity.Property(x => x.Stance).HasConversion<string>().HasMaxLength(16);
|
||||
entity.Property(x => x.VoterClass).HasConversion<string>().HasMaxLength(16);
|
||||
entity.HasQueryFilter(x => x.VoidedByMergeId == null);
|
||||
entity.HasIndex(x => new { x.SubjectId, x.VoterKey }).IsUnique();
|
||||
entity.HasIndex(x => new { x.NetworkTag, x.CastAtUtc });
|
||||
entity.HasOne(x => x.Subject)
|
||||
|
|
@ -631,63 +607,5 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AccountMerge>(entity =>
|
||||
{
|
||||
// State is the token: a transition computed from a stale read (the sweeper's Final over a saga's UndoRequested) fails its save.
|
||||
entity.Property(x => x.State).HasConversion<string>().HasMaxLength(24).IsConcurrencyToken();
|
||||
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||
entity.Property(x => x.HeimdallMoveId).HasMaxLength(64);
|
||||
entity.Property(x => x.HeimdallReverseMoveId).HasMaxLength(64);
|
||||
entity.Property(x => x.PreviewDigest).HasMaxLength(64);
|
||||
entity.Property(x => x.SurvivorProofProvider).HasMaxLength(32);
|
||||
entity.Property(x => x.FoldedProofProviders).HasMaxLength(120);
|
||||
entity.Property(x => x.ProofFromTier).HasMaxLength(8);
|
||||
entity.Property(x => x.ProofToTier).HasMaxLength(8);
|
||||
entity.Property(x => x.LastOutcome).HasMaxLength(64);
|
||||
// Heimdall's idempotency keys: one merge, one forward key and one reverse key, and no key shared by two merges.
|
||||
entity.HasIndex(x => x.HeimdallMoveId).IsUnique().HasFilter("\"HeimdallMoveId\" IS NOT NULL");
|
||||
entity.HasIndex(x => x.HeimdallReverseMoveId).IsUnique().HasFilter("\"HeimdallReverseMoveId\" IS NOT NULL");
|
||||
entity.HasIndex(x => new { x.FoldedUserAccountId, x.State });
|
||||
// An account is folded once: the DB refuses a second live merge folding it. The cross-role case (a survivor that is also
|
||||
// folded) cannot be one unique index; the migration's trigger refuses it.
|
||||
entity.HasIndex(x => x.FoldedUserAccountId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AccountMerges_FoldedUserAccountId_Live")
|
||||
.HasFilter("\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')");
|
||||
entity.HasIndex(x => new { x.SurvivorUserAccountId, x.State });
|
||||
entity.HasIndex(x => new { x.State, x.GraceEndsUtc });
|
||||
entity.HasOne<UserAccount>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.SurvivorUserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<UserAccount>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.FoldedUserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AccountMergeEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind).HasConversion<string>().HasMaxLength(24);
|
||||
entity.Property(x => x.Action).HasConversion<string>().HasMaxLength(24);
|
||||
entity.Property(x => x.PriorText).HasMaxLength(120);
|
||||
entity.HasIndex(x => new { x.MergeId, x.Seq }).IsUnique();
|
||||
entity.HasOne(x => x.Merge)
|
||||
.WithMany(x => x.Entries)
|
||||
.HasForeignKey(x => x.MergeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AccountMergeNoticeAck>(entity =>
|
||||
{
|
||||
entity.HasKey(x => new { x.MergeId, x.Kind, x.Target });
|
||||
entity.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
|
||||
entity.Property(x => x.Target).HasConversion<string>().HasMaxLength(16);
|
||||
entity.HasOne<AccountMerge>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.MergeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
using System;
|
||||
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. Additive only.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261113120000_AddPasskeyCredentials")]
|
||||
public partial class AddPasskeyCredentials : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PasskeyCredentials",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CredentialId = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
PublicKey = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
SignCount = table.Column<long>(type: "bigint", nullable: false),
|
||||
Transports = table.Column<string>(type: "text", nullable: false),
|
||||
AaGuid = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Label = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
StrongFromUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RevokedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastUsedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PasskeyCredentials", x => x.Id);
|
||||
table.ForeignKey("FK_PasskeyCredentials_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_CredentialId", table: "PasskeyCredentials", column: "CredentialId", unique: true);
|
||||
migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_UserAccountId", table: "PasskeyCredentials", column: "UserAccountId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "PasskeyCredentials");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Scaffolded by IMigrationsScaffolder against the previous snapshot (no dotnet-ef on the authoring host), the designer file dropped and
|
||||
// the snapshot edited by hand to match, like AddPasskeyCredentials. Additive only; Down drops what Up adds.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261114120000_AddAccountMerges")]
|
||||
public partial class AddAccountMerges : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "VoidedByMergeId",
|
||||
table: "Votes",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "VoidedUtc",
|
||||
table: "Votes",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "VoidedByMergeId",
|
||||
table: "ConceptAppraisals",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "VoidedUtc",
|
||||
table: "ConceptAppraisals",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountMerges",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
State = table.Column<string>(type: "character varying(24)", maxLength: 24, nullable: false),
|
||||
SurvivorUserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FoldedUserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CommittedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
GraceEndsUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
FinalizedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
UndoneUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
FailedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
FailureCode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountMerges", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMerges_UserAccounts_FoldedUserAccountId",
|
||||
column: x => x.FoldedUserAccountId,
|
||||
principalTable: "UserAccounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMerges_UserAccounts_SurvivorUserAccountId",
|
||||
column: x => x.SurvivorUserAccountId,
|
||||
principalTable: "UserAccounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountMergeEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MergeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Seq = table.Column<int>(type: "integer", nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(24)", maxLength: 24, nullable: false),
|
||||
RecordId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Action = table.Column<string>(type: "character varying(24)", maxLength: 24, nullable: false),
|
||||
PriorUserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
PriorText = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
PriorAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
AtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountMergeEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMergeEntries_AccountMerges_MergeId",
|
||||
column: x => x.MergeId,
|
||||
principalTable: "AccountMerges",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMergeEntries_MergeId_Seq",
|
||||
table: "AccountMergeEntries",
|
||||
columns: new[] { "MergeId", "Seq" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_AccountMerges_FoldedUserAccountId_Live",
|
||||
table: "AccountMerges",
|
||||
column: "FoldedUserAccountId",
|
||||
unique: true,
|
||||
filter: "\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMerges_FoldedUserAccountId_State",
|
||||
table: "AccountMerges",
|
||||
columns: new[] { "FoldedUserAccountId", "State" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMerges_State_GraceEndsUtc",
|
||||
table: "AccountMerges",
|
||||
columns: new[] { "State", "GraceEndsUtc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMerges_SurvivorUserAccountId_State",
|
||||
table: "AccountMerges",
|
||||
columns: new[] { "SurvivorUserAccountId", "State" });
|
||||
|
||||
// One current Patron snapshot per membership: two restages racing (two merges into one survivor, a merge and an intake) would
|
||||
// each demote the old row and add their own, and EffectiveVotingWeight sums the current ones. A pre-existing duplicate keeps
|
||||
// only its newest row current so the index can be built.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""TierSnapshots"" t SET ""IsCurrent"" = false
|
||||
WHERE t.""Kind"" = 'Patron' AND t.""IsCurrent"" AND EXISTS (
|
||||
SELECT 1 FROM ""TierSnapshots"" o
|
||||
WHERE o.""MembershipId"" = t.""MembershipId"" AND o.""Kind"" = 'Patron' AND o.""IsCurrent""
|
||||
AND (o.""CapturedAtUtc"" > t.""CapturedAtUtc"" OR (o.""CapturedAtUtc"" = t.""CapturedAtUtc"" AND o.""Id"" > t.""Id"")));");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_TierSnapshots_MembershipId_CurrentPatron",
|
||||
table: "TierSnapshots",
|
||||
column: "MembershipId",
|
||||
unique: true,
|
||||
filter: "\"Kind\" = 'Patron' AND \"IsCurrent\"");
|
||||
|
||||
// The cross-role guard no single unique index can express: a live merge's survivor is never another live merge's folded
|
||||
// account (no chains), whichever order they arrive in. Advisory locks on both accounts, taken in a fixed order, serialise two
|
||||
// transactions racing over the same account, so the second one's check sees the first's committed row.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE FUNCTION ""AccountMerges_refuse_chain""() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.""State"" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed') THEN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(LEAST(NEW.""SurvivorUserAccountId""::text, NEW.""FoldedUserAccountId""::text), 0));
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(GREATEST(NEW.""SurvivorUserAccountId""::text, NEW.""FoldedUserAccountId""::text), 0));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM ""AccountMerges"" m
|
||||
WHERE m.""Id"" <> NEW.""Id""
|
||||
AND m.""State"" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')
|
||||
AND (m.""SurvivorUserAccountId"" = NEW.""FoldedUserAccountId"" OR m.""FoldedUserAccountId"" = NEW.""SurvivorUserAccountId""))
|
||||
THEN
|
||||
RAISE EXCEPTION 'account merge chain refused' USING ERRCODE = '23P01';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
CREATE TRIGGER ""AccountMerges_refuse_chain"" BEFORE INSERT OR UPDATE OF ""State"", ""SurvivorUserAccountId"", ""FoldedUserAccountId"" ON ""AccountMerges""
|
||||
FOR EACH ROW EXECUTE FUNCTION ""AccountMerges_refuse_chain""();");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Roll-forward only (heimdall-body:ruling:merge-roll-forward-only): once any merge exists the Merged membership status
|
||||
// cannot be read by an older build, and voided votes and appraisals would be counted again. Refuse while any merge row
|
||||
// exists; see docs/account-merge.md (Rollback).
|
||||
migrationBuilder.Sql(@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM ""AccountMerges"") THEN
|
||||
RAISE EXCEPTION 'AddAccountMerges cannot be reverted once an account merge exists: Bifrost is roll-forward only';
|
||||
END IF;
|
||||
END
|
||||
$$;");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "UX_TierSnapshots_MembershipId_CurrentPatron",
|
||||
table: "TierSnapshots");
|
||||
|
||||
migrationBuilder.Sql(@"DROP TRIGGER ""AccountMerges_refuse_chain"" ON ""AccountMerges""; DROP FUNCTION ""AccountMerges_refuse_chain""();");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountMergeEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountMerges");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VoidedByMergeId",
|
||||
table: "Votes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VoidedUtc",
|
||||
table: "Votes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VoidedByMergeId",
|
||||
table: "ConceptAppraisals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VoidedUtc",
|
||||
table: "ConceptAppraisals");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddAccountMerges, the snapshot edited to match. Additive: the saga's columns on AccountMerges. Down is
|
||||
// roll-forward only, like AddAccountMerges: it drops the Heimdall idempotency keys and the consent record, so it refuses while
|
||||
// any merge row exists (heimdall-body:ruling:merge-roll-forward-only).
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261114130000_AddAccountMergeSaga")]
|
||||
public partial class AddAccountMergeSaga : Migration
|
||||
{
|
||||
private static readonly string[] Columns =
|
||||
[
|
||||
"HeimdallMoveId", "HeimdallReverseMoveId", "SealedProofs", "PreviewDigest", "ConfirmedByUserAccountId", "ConfirmedUtc",
|
||||
"SurvivorProofProvider", "FoldedProofProviders", "ProofFromTier", "ProofToTier", "MovedIdentityIds", "Attempts",
|
||||
"NextAttemptUtc", "LastOutcome", "UndoRequestedUtc", "ReversedUtc"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(name: "HeimdallMoveId", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "HeimdallReverseMoveId", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "SealedProofs", table: "AccountMerges", type: "text", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "PreviewDigest", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<Guid>(name: "ConfirmedByUserAccountId", table: "AccountMerges", type: "uuid", nullable: true);
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(name: "ConfirmedUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "SurvivorProofProvider", table: "AccountMerges", type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<string>(name: "FoldedProofProviders", table: "AccountMerges", type: "character varying(120)", maxLength: 120, nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<string>(name: "ProofFromTier", table: "AccountMerges", type: "character varying(8)", maxLength: 8, nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "ProofToTier", table: "AccountMerges", type: "character varying(8)", maxLength: 8, nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "MovedIdentityIds", table: "AccountMerges", type: "text", nullable: true);
|
||||
migrationBuilder.AddColumn<int>(name: "Attempts", table: "AccountMerges", type: "integer", nullable: false, defaultValue: 0);
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(name: "NextAttemptUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "LastOutcome", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(name: "UndoRequestedUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true);
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(name: "ReversedUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMerges_HeimdallMoveId",
|
||||
table: "AccountMerges",
|
||||
column: "HeimdallMoveId",
|
||||
unique: true,
|
||||
filter: "\"HeimdallMoveId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMerges_HeimdallReverseMoveId",
|
||||
table: "AccountMerges",
|
||||
column: "HeimdallReverseMoveId",
|
||||
unique: true,
|
||||
filter: "\"HeimdallReverseMoveId\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM ""AccountMerges"") THEN
|
||||
RAISE EXCEPTION 'AddAccountMergeSaga cannot be reverted once an account merge exists: Bifrost is roll-forward only';
|
||||
END IF;
|
||||
END
|
||||
$$;");
|
||||
|
||||
migrationBuilder.DropIndex(name: "IX_AccountMerges_HeimdallMoveId", table: "AccountMerges");
|
||||
migrationBuilder.DropIndex(name: "IX_AccountMerges_HeimdallReverseMoveId", table: "AccountMerges");
|
||||
foreach (var column in Columns)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: column, table: "AccountMerges");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddAccountMergeSaga (no dotnet-ef on the authoring host), the snapshot edited to match. Additive: one table of
|
||||
// acks, the only fact a notice reader adds; the notices themselves are derived from AccountMerges. Down may drop it: it holds only acks.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261114140000_AddAccountMergeNoticeAcks")]
|
||||
public partial class AddAccountMergeNoticeAcks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountMergeNoticeAcks",
|
||||
columns: table => new
|
||||
{
|
||||
MergeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Target = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
AckedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountMergeNoticeAcks", x => new { x.MergeId, x.Kind, x.Target });
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMergeNoticeAcks_AccountMerges_MergeId",
|
||||
column: x => x.MergeId,
|
||||
principalTable: "AccountMerges",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "AccountMergeNoticeAcks");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,198 +22,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMerge", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("CommittedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ConfirmedByUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConfirmedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("FailedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FailureCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("FinalizedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FoldedProofProviders")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid>("FoldedUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("GraceEndsUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("HeimdallMoveId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("HeimdallReverseMoveId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("LastOutcome")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("MovedIdentityIds")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("NextAttemptUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreviewDigest")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ProofFromTier")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("ProofToTier")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReversedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SealedProofs")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("character varying(24)");
|
||||
|
||||
b.Property<string>("SurvivorProofProvider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("SurvivorUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("UndoneUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("UndoRequestedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FoldedUserAccountId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AccountMerges_FoldedUserAccountId_Live")
|
||||
.HasFilter("\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')");
|
||||
|
||||
b.HasIndex("FoldedUserAccountId", "State");
|
||||
|
||||
b.HasIndex("HeimdallMoveId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"HeimdallMoveId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("HeimdallReverseMoveId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"HeimdallReverseMoveId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("State", "GraceEndsUtc");
|
||||
|
||||
b.HasIndex("SurvivorUserAccountId", "State");
|
||||
|
||||
b.ToTable("AccountMerges");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("character varying(24)");
|
||||
|
||||
b.Property<DateTimeOffset>("AtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("character varying(24)");
|
||||
|
||||
b.Property<Guid>("MergeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("PriorAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PriorText")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid?>("PriorUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RecordId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Seq")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MergeId", "Seq")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMergeEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeNoticeAck", b =>
|
||||
{
|
||||
b.Property<Guid>("MergeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("AckedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("MergeId", "Kind", "Target");
|
||||
|
||||
b.ToTable("AccountMergeNoticeAcks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -629,12 +437,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<Guid?>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("VoidedByMergeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("VoidedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VoterClass")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
|
|
@ -1019,9 +821,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SuspensionReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
@ -1534,11 +1333,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
b.HasIndex("MembershipId", "Kind", "IsCurrent");
|
||||
|
||||
b.HasIndex("MembershipId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_TierSnapshots_MembershipId_CurrentPatron")
|
||||
.HasFilter("\"Kind\" = 'Patron' AND \"IsCurrent\"");
|
||||
|
||||
b.ToTable("TierSnapshots");
|
||||
});
|
||||
|
||||
|
|
@ -1673,59 +1467,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("ConsentReceipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AaGuid")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("CredentialId")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastUsedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("PublicKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("SignCount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("StrongFromUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Transports")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CredentialId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserAccountId");
|
||||
|
||||
b.ToTable("PasskeyCredentials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -1879,12 +1620,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("VoidedByMergeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("VoidedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("Weight")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
|
@ -2272,41 +2007,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("OpenIddictTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMerge", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FoldedUserAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SurvivorUserAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeEntry", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.AccountMerge", "Merge")
|
||||
.WithMany("Entries")
|
||||
.HasForeignKey("MergeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Merge");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeNoticeAck", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.AccountMerge", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MergeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount")
|
||||
|
|
@ -2480,17 +2180,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", 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 =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
|
|
@ -2846,11 +2535,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("Authorization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AccountMerge", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Membership", b =>
|
||||
{
|
||||
b.Navigation("RoleAssignments");
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
namespace Bifrost.Web.Domain;
|
||||
|
||||
/// <summary>The whole lifecycle of an account merge, declared once; later cuts add columns, not states. Stored as a string.</summary>
|
||||
public enum AccountMergeState
|
||||
{
|
||||
Proposed,
|
||||
Confirmed,
|
||||
HeimdallMoved,
|
||||
Committed,
|
||||
Final,
|
||||
UndoRequested,
|
||||
HeimdallReversed,
|
||||
Undone,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum AccountMergeEntryKind
|
||||
{
|
||||
Vote,
|
||||
ConceptAppraisal,
|
||||
Membership,
|
||||
Standing,
|
||||
PatronSupportEvent
|
||||
}
|
||||
|
||||
public enum AccountMergeEntryAction
|
||||
{
|
||||
Voided,
|
||||
Moved,
|
||||
Closed,
|
||||
Suspended,
|
||||
Created,
|
||||
Restored,
|
||||
KeptAfterUndo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The merge receipt: two Heimdall-backed accounts, one survivor. Whether an account is folded or frozen is derived from this row's
|
||||
/// State on every request (AccountMergeLedger.StandingAsync); no column on the account says so.
|
||||
/// </summary>
|
||||
public sealed class AccountMerge
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public AccountMergeState State { get; set; } = AccountMergeState.Proposed;
|
||||
|
||||
public Guid SurvivorUserAccountId { get; set; }
|
||||
|
||||
public Guid FoldedUserAccountId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? CommittedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? GraceEndsUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? FinalizedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? UndoneUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? FailedUtc { get; set; }
|
||||
|
||||
public string FailureCode { get; set; } = string.Empty;
|
||||
|
||||
// ---- the saga's columns (AccountMergeSaga is their one writer; docs/account-merge.md, Saga) ----
|
||||
|
||||
/// <summary>Heimdall's idempotency key of the forward move: random, minted in the save that enters Confirmed, never derived from <see cref="Id"/>, never re-minted.</summary>
|
||||
public string? HeimdallMoveId { get; set; }
|
||||
|
||||
/// <summary>The same for the reversal, minted in the save that enters UndoRequested.</summary>
|
||||
public string? HeimdallReverseMoveId { get; set; }
|
||||
|
||||
/// <summary>The Heimdall bearers the move or reversal will replay, protected; non-null only from Confirmed/UndoRequested until Heimdall's final answer.</summary>
|
||||
public string? SealedProofs { get; set; }
|
||||
|
||||
/// <summary>Digest of the plan the member saw when confirming (MergePlan.Digest); with the next four columns it is the consent record.</summary>
|
||||
public string PreviewDigest { get; set; } = string.Empty;
|
||||
|
||||
public Guid? ConfirmedByUserAccountId { get; set; }
|
||||
|
||||
public DateTimeOffset? ConfirmedUtc { get; set; }
|
||||
|
||||
public string SurvivorProofProvider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The providers of the folded side's proofs, sorted and comma-joined.</summary>
|
||||
public string FoldedProofProviders { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The proof tiers Heimdall's receipt named (weak or strong); ProofToTier is null for a receipt without one.</summary>
|
||||
public string? ProofFromTier { get; set; }
|
||||
|
||||
public string? ProofToTier { get; set; }
|
||||
|
||||
/// <summary>The logins the forward move carried, a JSON array of Heimdall identity ids, from the receipt.</summary>
|
||||
public string? MovedIdentityIds { get; set; }
|
||||
|
||||
public int Attempts { get; set; }
|
||||
|
||||
public DateTimeOffset? NextAttemptUtc { get; set; }
|
||||
|
||||
/// <summary>The last Heimdall answer's class or refusal code, or the saga's own stall code; never an input value.</summary>
|
||||
public string LastOutcome { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? UndoRequestedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? ReversedUtc { get; set; }
|
||||
|
||||
public ICollection<AccountMergeEntry> Entries { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Append-only journal of what a merge did to one record; undo and the later preview read it, tallies and gates never do.</summary>
|
||||
public sealed class AccountMergeEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid MergeId { get; set; }
|
||||
|
||||
public int Seq { get; set; }
|
||||
|
||||
public AccountMergeEntryKind Kind { get; set; }
|
||||
|
||||
public Guid RecordId { get; set; }
|
||||
|
||||
public AccountMergeEntryAction Action { get; set; }
|
||||
|
||||
public Guid? PriorUserAccountId { get; set; }
|
||||
|
||||
/// <summary>What the record held before: the prior status; for a moved vote its weight; for a moved appraisal its voter class and key (class|key).</summary>
|
||||
public string? PriorText { get; set; }
|
||||
|
||||
/// <summary>The cast time of a moved vote or appraisal, so undo can tell whether the survivor re-cast it.</summary>
|
||||
public DateTimeOffset? PriorAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset AtUtc { get; set; }
|
||||
|
||||
public AccountMerge Merge { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>What a merge notice tells its reader: the merge committed, was undone, or can no longer be undone. Stored as a string.</summary>
|
||||
public enum AccountMergeNoticeKind
|
||||
{
|
||||
Merged,
|
||||
Undone,
|
||||
Final
|
||||
}
|
||||
|
||||
/// <summary>Who reads the notice feed. A closed set: a new reader is a new constant (and its own token), never a string from a request. Stored as a string.</summary>
|
||||
public enum AccountMergeNoticeTarget
|
||||
{
|
||||
Chat
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one fact a notice reader adds: it has seen this notice. The notice itself is derived from the merge row on every read
|
||||
/// (MergeNoticeFeed) and has no stored status; nothing but the feed writes this row.
|
||||
/// </summary>
|
||||
public sealed class AccountMergeNoticeAck
|
||||
{
|
||||
public Guid MergeId { get; set; }
|
||||
|
||||
public AccountMergeNoticeKind Kind { get; set; }
|
||||
|
||||
public AccountMergeNoticeTarget Target { get; set; }
|
||||
|
||||
public DateTimeOffset AckedUtc { get; set; }
|
||||
}
|
||||
|
|
@ -7,9 +7,7 @@ public enum MembershipStatus
|
|||
Authenticated,
|
||||
PendingApproval,
|
||||
Active,
|
||||
Suspended,
|
||||
/// <summary>Terminal status of a folded membership; written only by the account merge ledger, never read to decide standing.</summary>
|
||||
Merged
|
||||
Suspended
|
||||
}
|
||||
|
||||
public enum MemberRole
|
||||
|
|
@ -336,9 +334,6 @@ public sealed class Membership
|
|||
|
||||
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 CanManageProjects { get; set; }
|
||||
|
|
@ -359,10 +354,6 @@ public sealed class Membership
|
|||
|
||||
public ICollection<TierSnapshot> TierSnapshots { get; set; } = [];
|
||||
|
||||
/// <summary>The folded membership of a landed merge. Terminal: only AccountMergeLedger writes it or leaves it, so every other status writer (sign-in sync, bootstrap, claim, approval, suspension) refuses a merged row.</summary>
|
||||
[NotMapped]
|
||||
public bool IsMerged => Status == MembershipStatus.Merged;
|
||||
|
||||
[NotMapped]
|
||||
public decimal EffectiveVotingWeight =>
|
||||
Status != MembershipStatus.Active
|
||||
|
|
@ -782,11 +773,6 @@ public sealed class Vote
|
|||
|
||||
public DateTimeOffset CastAtUtc { get; set; }
|
||||
|
||||
/// <summary>Set by the account merge ledger when a merge removes this vote from every tally; the model's query filter then hides the row.</summary>
|
||||
public Guid? VoidedByMergeId { get; set; }
|
||||
|
||||
public DateTimeOffset? VoidedUtc { get; set; }
|
||||
|
||||
public Motion Motion { get; set; } = null!;
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
|
|
@ -1188,41 +1174,6 @@ public sealed class ConsentReceipt
|
|||
public UserAccount? UserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A WebAuthn passkey a Heimdall-backed member enrolled (docs/passkeys.md). Holds only what verifying an assertion needs:
|
||||
/// the credential id, the public key and the sign count. No secret exists to hold. It counts as strong proof only from
|
||||
/// <see cref="StrongFromUtc"/> (the cooling-off after enrolment) and only while not revoked.
|
||||
/// </summary>
|
||||
public sealed class PasskeyCredential
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public byte[] CredentialId { get; set; } = [];
|
||||
|
||||
public byte[] PublicKey { get; set; } = [];
|
||||
|
||||
public long SignCount { get; set; }
|
||||
|
||||
// Comma-separated WebAuthn transport hints the authenticator reported at enrolment; a hint for the browser, never trusted.
|
||||
public string Transports { get; set; } = string.Empty;
|
||||
|
||||
public Guid? AaGuid { get; set; }
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset StrongFromUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? RevokedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? LastUsedUtc { get; set; }
|
||||
|
||||
public UserAccount? UserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
|
||||
public sealed class MindKey
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,10 +46,5 @@ public sealed class ConceptAppraisal
|
|||
|
||||
public DateTimeOffset CastAtUtc { get; set; }
|
||||
|
||||
/// <summary>Set by the account merge ledger when a merge removes this appraisal from every tally; the model's query filter then hides the row.</summary>
|
||||
public Guid? VoidedByMergeId { get; set; }
|
||||
|
||||
public DateTimeOffset? VoidedUtc { get; set; }
|
||||
|
||||
public ConceptSubject? Subject { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ using System.Net;
|
|||
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;
|
||||
|
|
@ -81,42 +80,13 @@ public sealed class ConceptAppraisalService(
|
|||
? string.Empty
|
||||
: NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
|
||||
|
||||
// A voter resolved before a merge froze the account must not write an appraisal after it: the write gate reads standing at the write.
|
||||
Task<CastResult> Write() => voter.UserAccountId is { } voterAccountId
|
||||
? AccountMergeLedger.WriteAsAsync(
|
||||
dbContext,
|
||||
voterAccountId,
|
||||
standing => standing is null ? WriteAppraisalAsync(subject, voter, stance, tag, cancellationToken) : Task.FromResult(CastResult.NoVoter),
|
||||
cancellationToken)
|
||||
: WriteAppraisalAsync(subject, voter, stance, tag, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
return await Write();
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation })
|
||||
{
|
||||
// A concurrent cast by the same voter won the insert (and the failed save aborted the gate's transaction): go through the
|
||||
// gate again, which now finds their row and applies this stance to it, once.
|
||||
dbContext.ChangeTracker.Clear();
|
||||
if (await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken) is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
return await Write();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CastResult> WriteAppraisalAsync(ConceptSubject subject, VoterIdentity voter, ConceptStance stance, string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return await ApplyStanceAsync(existing, stance, tag, cancellationToken);
|
||||
}
|
||||
|
||||
dbContext.ConceptAppraisals.Add(new ConceptAppraisal
|
||||
var row = new ConceptAppraisal
|
||||
{
|
||||
SubjectId = subject.Id,
|
||||
VoterKey = voter.Key,
|
||||
|
|
@ -125,9 +95,26 @@ public sealed class ConceptAppraisalService(
|
|||
UserAccountId = voter.UserAccountId,
|
||||
NetworkTag = tag,
|
||||
CastAtUtc = timeProvider.GetUtcNow()
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CastResult.Recorded;
|
||||
};
|
||||
dbContext.ConceptAppraisals.Add(row);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CastResult.Recorded;
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation })
|
||||
{
|
||||
// A concurrent cast by the same voter won the insert: apply this stance to their row, once.
|
||||
dbContext.Entry(row).State = EntityState.Detached;
|
||||
var winner = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
|
||||
if (winner is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
return await ApplyStanceAsync(winner, stance, tag, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RetractAsync(
|
||||
|
|
|
|||
|
|
@ -4,13 +4,9 @@ using System.Text.Json.Serialization;
|
|||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
|
||||
// (commit d3025d3c) spells them: snake_case keys, every key written (nil where absent), declared in the
|
||||
// (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;
|
||||
// DeskAnswerDocument, ForkList and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
|
||||
// The card (gamecult.fork.card.v0) is deliberately NOT typed: the Huginn door owns what a reader's card holds (the
|
||||
// decider view carries ids, the non-decider view withholds them and keeps decider_count), and Bifrost neither reads
|
||||
// nor edits it, so it is an opaque JsonElement that keeps every key, key order and integer. The door refuses a float,
|
||||
// a binary or an extension value anywhere in a reply (the contract has none), so no number is ever rewritten.
|
||||
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
|
||||
|
||||
public static class DeskSchemas
|
||||
{
|
||||
|
|
@ -57,6 +53,32 @@ public sealed record DeskUnavailable(
|
|||
[property: JsonPropertyName("error")] string Error,
|
||||
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
|
||||
|
||||
public sealed record ForkOption(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("text")] string Text);
|
||||
|
||||
public sealed record ForkRef(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("id")] string Id);
|
||||
|
||||
public sealed record ForkCard(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("context")] string Context,
|
||||
[property: JsonPropertyName("options")] ForkOption[] Options,
|
||||
[property: JsonPropertyName("recommendation")] string Recommendation,
|
||||
[property: JsonPropertyName("depends")] string[] Depends,
|
||||
[property: JsonPropertyName("leans")] string? Leans,
|
||||
[property: JsonPropertyName("flag")] string? Flag,
|
||||
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
|
||||
[property: JsonPropertyName("deciders")] string[] Deciders,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("ruling")] string? Ruling,
|
||||
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
|
||||
[property: JsonPropertyName("order")] long Order,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash);
|
||||
|
||||
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
|
||||
public sealed record DeskAnswerDocument(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
|
|
@ -72,9 +94,7 @@ public sealed record DeskAnswerDocument(
|
|||
[property: JsonPropertyName("answered_at")] string AnsweredAt,
|
||||
[property: JsonPropertyName("received_at")] string ReceivedAt,
|
||||
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
|
||||
// Nil in a non-decider's view (Huginn withholds the ids). The key is required, and the door's wire walker (Closed in
|
||||
// UnixSocketDeskDoor) is the one owner of that: a missing key is a door fault, never a nil Bifrost invented.
|
||||
[property: JsonPropertyName("answerer")] string? Answerer,
|
||||
[property: JsonPropertyName("answerer")] string Answerer,
|
||||
[property: JsonPropertyName("authority")] string Authority,
|
||||
[property: JsonPropertyName("request_id")] string RequestId);
|
||||
|
||||
|
|
@ -83,12 +103,11 @@ public sealed record ForkRecord(
|
|||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
|
||||
[property: JsonPropertyName("answerer_count")] int AnswererCount,
|
||||
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
|
||||
|
||||
public sealed record ForkEntry(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("card")] JsonElement Card,
|
||||
[property: JsonPropertyName("card")] ForkCard Card,
|
||||
[property: JsonPropertyName("record")] ForkRecord Record);
|
||||
|
||||
public sealed record ForkList(
|
||||
|
|
@ -103,7 +122,7 @@ public sealed record ForkList(
|
|||
public sealed record AnswerResult(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
|
||||
[property: JsonPropertyName("card")] JsonElement? Card,
|
||||
[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).
|
||||
|
|
@ -164,3 +183,14 @@ public static class DeskUnavailables
|
|||
"unavailable: the outcome is not confirmed, try again with the same request_id",
|
||||
retryAfterSeconds);
|
||||
}
|
||||
|
||||
// Notes are returned only to deciders. For everyone else Bifrost nils every note at the door: the key stays written
|
||||
// (every key written, nil where absent), so the stripped body is still the contract's shape. Cards, outcomes, codes
|
||||
// and counts are untouched; Huginn returns the full record.
|
||||
public static class DeskNotes
|
||||
{
|
||||
public static ForkEntry Strip(ForkEntry entry) =>
|
||||
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
|
||||
|
||||
public static ForkList Strip(ForkList list) => list with { Forks = [.. list.Forks.Select(Strip)] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ using Bifrost.Web.Data;
|
|||
using Bifrost.Web.Features.Oidc;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
|
|
@ -23,30 +22,8 @@ public static class DeskEndpoints
|
|||
public const string AnswerScope = "desk.answer";
|
||||
public const string CorsPolicy = "DeskCors";
|
||||
|
||||
public static bool IsDeskScope(string scope) => scope is ReadScope or AnswerScope;
|
||||
|
||||
// The desk's credential is OpenIddict validation against the local server, so it exists only where Bifrost:Oidc is
|
||||
// configured. Without it nothing is registered here and startup fails in the validator, with its own message.
|
||||
public static IServiceCollection AddBifrostDesk(this IServiceCollection services, bool oidcConfigured)
|
||||
public static IServiceCollection AddBifrostDesk(this IServiceCollection services)
|
||||
{
|
||||
if (oidcConfigured)
|
||||
{
|
||||
services.AddOpenIddict()
|
||||
.AddServer(server => server.RegisterScopes(ReadScope, AnswerScope))
|
||||
.AddValidation(validation =>
|
||||
{
|
||||
validation.UseLocalServer();
|
||||
// The bearer travels in the Authorization header only: a token in a URL lands in access logs.
|
||||
validation.UseAspNetCore().Configure(options =>
|
||||
{
|
||||
options.DisableAccessTokenExtractionFromQueryString = true;
|
||||
options.DisableAccessTokenExtractionFromBodyForm = true;
|
||||
});
|
||||
});
|
||||
services.TryAddSingleton<IDeskCredential, OidcDeskCredential>();
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IDeskDoor>(_ => new UnixSocketDeskDoor());
|
||||
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
|
||||
services.AddCors();
|
||||
// Exactly the configured origin, no credentials, and only on the routes that require this policy.
|
||||
|
|
@ -190,7 +167,7 @@ public static class DeskEndpoints
|
|||
var listed = await door.ListAsync(mind.Socket, context, listRequest! with { Mind = mind.Id }, cancellationToken);
|
||||
if (listed is DeskListOutcome.Listed(var list))
|
||||
{
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskRedaction.ForNonDecider(list));
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskNotes.Strip(list));
|
||||
}
|
||||
else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder))
|
||||
{
|
||||
|
|
@ -210,7 +187,7 @@ public static class DeskEndpoints
|
|||
var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken);
|
||||
if (found is DeskReadOutcome.Found(var entry))
|
||||
{
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskRedaction.ForNonDecider(entry));
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskNotes.Strip(entry));
|
||||
}
|
||||
else if (found is DeskReadOutcome.ForkTooLarge(var largeFork))
|
||||
{
|
||||
|
|
@ -276,8 +253,7 @@ public static class DeskEndpoints
|
|||
}
|
||||
}
|
||||
|
||||
// A note is a decider's own words: only a caller on the admin-set list reads one. Bifrost strips notes itself; answerer
|
||||
// and decider ids are withheld by Huginn's door (desk-read-visibility-withheld-ids), not here.
|
||||
// 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ namespace Bifrost.Web.Features.Desk;
|
|||
|
||||
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
|
||||
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
|
||||
// peer-checked door. The door's production implementation is UnixSocketDeskDoor (registered by AddBifrostDesk unless
|
||||
// a test or host registered its own); the credential has none registered here, so with the Desk section configured
|
||||
// and a port missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else: every note is
|
||||
// nil, with every key still written, so the body is still the contract's shape. Account ids are not Bifrost's to hide:
|
||||
// Huginn's door withholds the decider ids and answerer ids from a non-decider and returns counts
|
||||
// (eureka-substrate:ruling:desk-read-visibility-withheld-ids), and Bifrost cannot mask what it cannot read (the card is
|
||||
// opaque to it). Cards, outcomes, codes, counts and the number of answers are untouched.
|
||||
public static class DeskRedaction
|
||||
{
|
||||
public static ForkEntry ForNonDecider(ForkEntry entry) =>
|
||||
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
|
||||
|
||||
public static ForkList ForNonDecider(ForkList list) => list with { Forks = [.. list.Forks.Select(ForNonDecider)] };
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The production IDeskCredential: OpenIddict's validation handler against the local server, asked for by its own
|
||||
// scheme name and no other. It authenticates only. The default scheme stays the cookie, so a bearer token reaches no
|
||||
// cookie surface, and this credential never reads the cookie, so the session cookie authenticates nothing on the
|
||||
// desk. Audience, scope and Active membership are the desk's judgments (DeskEndpoints), not this port's.
|
||||
public sealed class OidcDeskCredential : IDeskCredential
|
||||
{
|
||||
public async ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
|
||||
if (!result.Succeeded || result.Principal is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// A token may name several audiences; the desk's own is reported when present so the desk judges it, else the
|
||||
// first (or none).
|
||||
var audiences = result.Principal.GetAudiences();
|
||||
var audience = audiences.Contains(DeskEndpoints.Audience) ? DeskEndpoints.Audience : audiences.FirstOrDefault();
|
||||
return new DeskToken(result.Principal.GetClaim(Claims.Subject), audience, [.. result.Principal.GetScopes()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,624 +0,0 @@
|
|||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using MessagePack;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The door could not confirm an outcome: no connection, no whole reply in time, or a reply that is not the contract's
|
||||
// (an error, a failure, a Mind refusal, an unknown variant). The desk edge maps any exception from the door to 503
|
||||
// `unavailable`; this is the one type the client throws for every one of those, so a failure is never an empty page.
|
||||
public sealed class DeskDoorException(string message, Exception? inner = null) : Exception(message, inner);
|
||||
|
||||
// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at 232764bf). One new
|
||||
// connection per call, one request frame, one reply frame, closed after, all inside one deadline. The client holds no
|
||||
// connection state, so a dead socket fails only the calls addressed to that Mind.
|
||||
//
|
||||
// frame = u32 big-endian length || CultNet message (MessagePack map), at most 65536 bytes each way
|
||||
// payload = standard base64 of the named MessagePack of Huginn's externally tagged DeskRequest / DeskResponse
|
||||
//
|
||||
// The reply is decoded by key, never by position, and strictly: every contract document is a closed map whose keys are
|
||||
// all present with the right type (the variants' own maps too: as_of is an unsigned integer, as Huginn's DeskResponse
|
||||
// declares), a key written twice anywhere is a failure, and so is a float, a binary or an extension value (the
|
||||
// contract has none; integers keep every digit). The card inside an entry is not read beyond
|
||||
// being a map: it stays an opaque JsonElement, so whatever keys and integers Huginn wrote leave unchanged.
|
||||
public sealed partial class UnixSocketDeskDoor : IDeskDoor
|
||||
{
|
||||
public const int MaxFrameBytes = 65536;
|
||||
public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(1);
|
||||
|
||||
private const string ServiceId = "huginn.desk";
|
||||
private const string RequestSchema = "huginn.desk_request.v1";
|
||||
private const string ResponseSchema = "huginn.desk_response.v1";
|
||||
private const string Encoding64 = "messagepack-base64";
|
||||
private const int MaxMessageIdBytes = 256;
|
||||
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
|
||||
private readonly Func<string> messageIds;
|
||||
|
||||
public UnixSocketDeskDoor()
|
||||
: this(() => Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
public UnixSocketDeskDoor(Func<string> messageIds) => this.messageIds = messageIds;
|
||||
|
||||
public async Task<DeskAnswerOutcome> AnswerAsync(
|
||||
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] request;
|
||||
try
|
||||
{
|
||||
// Every key, null and key order of the client's body, as MessagePack.
|
||||
request = MessagePackSerializer.ConvertFromJson(StrictUtf8.GetString(body.Span));
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
throw new DeskDoorException("the answer body does not convert to the request map", e);
|
||||
}
|
||||
|
||||
var payload = Payload("Answer", 5, (ref MessagePackWriter w) =>
|
||||
{
|
||||
w.Write("mind");
|
||||
w.Write(context.Mind);
|
||||
w.Write("subject");
|
||||
w.Write(context.Subject);
|
||||
w.Write("authority");
|
||||
w.Write(context.Authority);
|
||||
w.Write("deciders");
|
||||
Strings(ref w, context.Deciders);
|
||||
w.Write("request");
|
||||
w.WriteRaw(request);
|
||||
});
|
||||
|
||||
var (variant, value) = await ExchangeAsync(socket, "answer", payload, cancellationToken);
|
||||
switch (variant)
|
||||
{
|
||||
case "Answered":
|
||||
Closed(value, "Answered", string.Empty, ("answer", Kind.Map), ("counted_answer_id", Kind.TextOrNull), ("as_of", Kind.Unsigned));
|
||||
var answer = AnswerDocument(value, "answer");
|
||||
|
||||
// Huginn's reply carries no card: a stale_fork answer gets the current card from the edge's read.
|
||||
return new DeskAnswerOutcome.Answered(new AnswerResult(
|
||||
DeskSchemas.AnswerResult, answer, null, OptionalString(value, "counted_answer_id")));
|
||||
case "NotStored":
|
||||
var code = RefusalCode(value);
|
||||
return code is DeskRefusals.ForkTooLarge
|
||||
? throw new DeskDoorException("fork_too_large is not an answer refusal")
|
||||
: new DeskAnswerOutcome.NotStored(code);
|
||||
default:
|
||||
throw Unexpected(variant);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeskReadOutcome> ReadAsync(
|
||||
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = Payload("Fork", 4, (ref MessagePackWriter w) =>
|
||||
{
|
||||
w.Write("mind");
|
||||
w.Write(context.Mind);
|
||||
w.Write("subject");
|
||||
w.Write(context.Subject);
|
||||
w.Write("deciders");
|
||||
Strings(ref w, context.Deciders);
|
||||
w.Write("fork_id");
|
||||
w.Write(forkId);
|
||||
});
|
||||
|
||||
var (variant, value) = await ExchangeAsync(socket, "fork", payload, cancellationToken);
|
||||
switch (variant)
|
||||
{
|
||||
case "Fork":
|
||||
Closed(value, "Fork", string.Empty, ("entry", Kind.Map), ("as_of", Kind.Unsigned));
|
||||
var entry = Entry(Required(value, "entry"), "entry");
|
||||
// The route asked for one fork. An entry whose card names another is not the answer to it. (That the record
|
||||
// agrees with the card is a rule of the entry, CultLib's validators' and Huginn's, not the door's.)
|
||||
if (!entry.Card.TryGetProperty("fork_id", out var cardFork) || cardFork.ValueKind != JsonValueKind.String
|
||||
|| cardFork.GetString() != forkId)
|
||||
{
|
||||
throw new DeskDoorException("the entry is not for the fork asked");
|
||||
}
|
||||
|
||||
return new DeskReadOutcome.Found(entry);
|
||||
case "NotStored":
|
||||
var code = RefusalCode(value);
|
||||
// The route's fork id, never the refusal's informational text.
|
||||
return code == DeskRefusals.ForkTooLarge
|
||||
? new DeskReadOutcome.ForkTooLarge(forkId)
|
||||
: new DeskReadOutcome.NotFound(code);
|
||||
default:
|
||||
throw Unexpected(variant);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeskListOutcome> ListAsync(
|
||||
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = Payload("Forks", 6, (ref MessagePackWriter w) =>
|
||||
{
|
||||
w.Write("mind");
|
||||
w.Write(context.Mind);
|
||||
w.Write("subject");
|
||||
w.Write(context.Subject);
|
||||
w.Write("deciders");
|
||||
Strings(ref w, context.Deciders);
|
||||
w.Write("status");
|
||||
w.Write(request.Status);
|
||||
w.Write("cursor");
|
||||
w.Write(request.Cursor);
|
||||
w.Write("limit");
|
||||
w.Write(request.Limit);
|
||||
});
|
||||
|
||||
var (variant, value) = await ExchangeAsync(socket, "forks", payload, cancellationToken);
|
||||
switch (variant)
|
||||
{
|
||||
case "Forks":
|
||||
Closed(value, "Forks", string.Empty, ("list", Kind.Map), ("as_of", Kind.Unsigned));
|
||||
var list = List(Required(value, "list"));
|
||||
return list.Mind == context.Mind
|
||||
? new DeskListOutcome.Listed(list)
|
||||
: throw new DeskDoorException("the list is for another Mind");
|
||||
case "NotStored":
|
||||
var code = RefusalCode(value);
|
||||
if (code != DeskRefusals.ForkTooLarge)
|
||||
{
|
||||
return new DeskListOutcome.NotListed(code);
|
||||
}
|
||||
|
||||
// The fork and its order exist only in the informational text; anything but the exact form is a fault.
|
||||
var text = RequiredString(value, "error");
|
||||
var match = ListTooLarge().Match(text);
|
||||
if (!match.Success || !int.TryParse(match.Groups["order"].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var order))
|
||||
{
|
||||
throw new DeskDoorException("fork_too_large does not name its fork and order in the list form");
|
||||
}
|
||||
|
||||
return new DeskListOutcome.ForkTooLarge(match.Groups["id"].Value, order);
|
||||
default:
|
||||
throw Unexpected(variant);
|
||||
}
|
||||
}
|
||||
|
||||
// fork_too_large: fork <id> at order <N> is past the page budget by itself; ...
|
||||
[GeneratedRegex(@"\Afork_too_large: fork (?<id>\S+) at order (?<order>0|[1-9][0-9]*) is past the page budget(?:[ ;][^\r\n]*)?\z", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ListTooLarge();
|
||||
|
||||
// ---- strict documents: a closed map, every key present with its type, then bound to the typed record ----
|
||||
|
||||
private enum Kind
|
||||
{
|
||||
Text,
|
||||
TextOrNull,
|
||||
Flag,
|
||||
Count,
|
||||
Unsigned,
|
||||
Map,
|
||||
Items,
|
||||
}
|
||||
|
||||
private static readonly (string, Kind)[] AnswerKeys =
|
||||
[
|
||||
("schemaVersion", Kind.Text), ("answer_id", Kind.Text), ("fork_id", Kind.Text), ("outcome", Kind.Text),
|
||||
("of", Kind.TextOrNull), ("retracts", Kind.TextOrNull), ("code", Kind.TextOrNull), ("options_hash", Kind.Text),
|
||||
("choice", Kind.TextOrNull), ("note", Kind.TextOrNull), ("answered_at", Kind.Text), ("received_at", Kind.Text),
|
||||
("claim_in_future", Kind.Flag), ("answerer", Kind.TextOrNull), ("authority", Kind.Text), ("request_id", Kind.Text),
|
||||
];
|
||||
|
||||
private static void Closed(JsonElement map, string what, string schema, params (string Key, Kind Kind)[] keys)
|
||||
{
|
||||
if (map.ValueKind != JsonValueKind.Object || map.EnumerateObject().Count() != keys.Length)
|
||||
{
|
||||
throw new DeskDoorException($"{what} is not a closed map of {keys.Length} keys");
|
||||
}
|
||||
|
||||
foreach (var (key, kind) in keys)
|
||||
{
|
||||
var ok = Property(map, key) is { } v && kind switch
|
||||
{
|
||||
Kind.Text => v.ValueKind == JsonValueKind.String,
|
||||
Kind.TextOrNull => v.ValueKind is JsonValueKind.String or JsonValueKind.Null,
|
||||
Kind.Flag => v.ValueKind is JsonValueKind.True or JsonValueKind.False,
|
||||
Kind.Count => v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) && n >= 0,
|
||||
Kind.Unsigned => v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out _),
|
||||
Kind.Map => v.ValueKind == JsonValueKind.Object,
|
||||
_ => v.ValueKind == JsonValueKind.Array,
|
||||
};
|
||||
if (!ok)
|
||||
{
|
||||
throw new DeskDoorException($"{what}.{key} is missing or not the contract's type");
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.Length > 0 && Property(map, "schemaVersion")!.Value.GetString() != schema)
|
||||
{
|
||||
throw new DeskDoorException($"{what} is not {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
private static DeskAnswerDocument AnswerDocument(JsonElement value, string what)
|
||||
{
|
||||
var map = Required(value, what);
|
||||
Closed(map, what, DeskSchemas.DeskAnswer, AnswerKeys);
|
||||
return Bind<DeskAnswerDocument>(map, what);
|
||||
}
|
||||
|
||||
private static ForkEntry Entry(JsonElement map, string what)
|
||||
{
|
||||
Closed(map, what, "gamecult.fork.entry.v0", ("schemaVersion", Kind.Text), ("card", Kind.Map), ("record", Kind.Map));
|
||||
var record = Property(map, "record")!.Value;
|
||||
Closed(record, what + ".record", "gamecult.fork.record.v0",
|
||||
("schemaVersion", Kind.Text), ("fork_id", Kind.Text), ("options_hash", Kind.Text), ("counted_answer_id", Kind.TextOrNull),
|
||||
("answerer_count", Kind.Count), ("answers", Kind.Items));
|
||||
foreach (var answer in Property(record, "answers")!.Value.EnumerateArray())
|
||||
{
|
||||
Closed(answer, what + ".record.answers[]", DeskSchemas.DeskAnswer, AnswerKeys);
|
||||
}
|
||||
|
||||
return Bind<ForkEntry>(map, what);
|
||||
}
|
||||
|
||||
private static ForkList List(JsonElement map)
|
||||
{
|
||||
Closed(map, "list", "gamecult.fork.list.v0",
|
||||
("schemaVersion", Kind.Text), ("mind", Kind.Text), ("stamp", Kind.Text), ("open_count", Kind.Count),
|
||||
("recorded_count", Kind.Count), ("forks", Kind.Items), ("next_cursor", Kind.TextOrNull));
|
||||
foreach (var entry in Property(map, "forks")!.Value.EnumerateArray())
|
||||
{
|
||||
Entry(entry, "list.forks[]");
|
||||
}
|
||||
|
||||
return Bind<ForkList>(map, "list");
|
||||
}
|
||||
|
||||
private static T Bind<T>(JsonElement map, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
return map.Deserialize<T>(DeskJson.Options) ?? throw new DeskDoorException($"{what} does not decode");
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
throw new DeskDoorException($"{what} does not decode", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement Required(JsonElement map, string name) =>
|
||||
Property(map, name) ?? throw new DeskDoorException($"{name} is missing");
|
||||
|
||||
private static DeskDoorException Unexpected(string variant) =>
|
||||
new($"the door answered with {(variant == "Refused" ? "a Mind refusal" : "a variant this call does not take")}");
|
||||
|
||||
private static string RefusalCode(JsonElement refusal)
|
||||
{
|
||||
Closed(refusal, "the refusal", DeskSchemas.Refusal, ("schemaVersion", Kind.Text), ("code", Kind.Text), ("error", Kind.Text));
|
||||
var code = RequiredString(refusal, "code");
|
||||
return code is DeskRefusals.ForkUnknown or DeskRefusals.BodyInvalid or DeskRefusals.RequestIdConflict or DeskRefusals.ForkTooLarge
|
||||
? code
|
||||
: throw new DeskDoorException("the refusal code is not one the contract names");
|
||||
}
|
||||
|
||||
// ---- request ----
|
||||
|
||||
private delegate void FieldWriter(ref MessagePackWriter writer);
|
||||
|
||||
private static byte[] Payload(string variant, int fields, FieldWriter write)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var w = new MessagePackWriter(buffer);
|
||||
w.WriteMapHeader(1);
|
||||
w.Write(variant);
|
||||
w.WriteMapHeader(fields);
|
||||
write(ref w);
|
||||
w.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void Strings(ref MessagePackWriter w, IReadOnlyList<string> values)
|
||||
{
|
||||
w.WriteArrayHeader(values.Count);
|
||||
foreach (var value in values)
|
||||
{
|
||||
w.Write(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Operation messages write their keys sorted, absent optionals omitted.
|
||||
private static byte[] RequestMessage(string messageId, string operation, byte[] payload)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var w = new MessagePackWriter(buffer);
|
||||
w.WriteMapHeader(7);
|
||||
w.Write("messageId");
|
||||
w.Write(messageId);
|
||||
w.Write("operation");
|
||||
w.Write(operation);
|
||||
w.Write("payload");
|
||||
w.Write(Convert.ToBase64String(payload));
|
||||
w.Write("payloadEncoding");
|
||||
w.Write(Encoding64);
|
||||
w.Write("payloadSchema");
|
||||
w.Write(RequestSchema);
|
||||
w.Write("schemaVersion");
|
||||
w.Write("cultnet.operation_request.v0");
|
||||
w.Write("serviceId");
|
||||
w.Write(ServiceId);
|
||||
w.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
// ---- exchange ----
|
||||
|
||||
private async Task<(string Variant, JsonElement Value)> ExchangeAsync(
|
||||
string socketPath, string operation, byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var messageId = messageIds();
|
||||
if (messageId.Length == 0 || StrictUtf8.GetByteCount(messageId) > MaxMessageIdBytes)
|
||||
{
|
||||
throw new DeskDoorException("the message id is empty or over 256 bytes");
|
||||
}
|
||||
|
||||
var message = RequestMessage(messageId, operation, payload);
|
||||
if (message.Length > MaxFrameBytes)
|
||||
{
|
||||
throw new DeskDoorException("the request is past the frame bound");
|
||||
}
|
||||
|
||||
var frame = new byte[4 + message.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
|
||||
message.CopyTo(frame, 4);
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(Deadline);
|
||||
byte[] reply;
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), deadline.Token);
|
||||
for (var sent = 0; sent < frame.Length;)
|
||||
{
|
||||
sent += await socket.SendAsync(frame.AsMemory(sent), SocketFlags.None, deadline.Token);
|
||||
}
|
||||
|
||||
var header = new byte[4];
|
||||
await ReadWholeAsync(socket, header, deadline.Token);
|
||||
var length = BinaryPrimitives.ReadUInt32BigEndian(header);
|
||||
if (length == 0 || length > MaxFrameBytes)
|
||||
{
|
||||
// Refused on the prefix alone: the body is never read.
|
||||
throw new DeskDoorException("the reply's length prefix is outside the frame bound");
|
||||
}
|
||||
|
||||
reply = new byte[length];
|
||||
await ReadWholeAsync(socket, reply, deadline.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new DeskDoorException("no whole reply within the deadline");
|
||||
}
|
||||
catch (Exception e) when (e is SocketException or IOException or ObjectDisposedException or ArgumentException)
|
||||
{
|
||||
throw new DeskDoorException("the desk socket failed", e);
|
||||
}
|
||||
|
||||
return Decode(reply, messageId, operation);
|
||||
}
|
||||
|
||||
// Loops until the buffer is whole: a stream socket may hand it over in pieces. A peer closing early is a failure.
|
||||
private static async Task ReadWholeAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var read = 0; read < buffer.Length;)
|
||||
{
|
||||
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, cancellationToken);
|
||||
if (count == 0)
|
||||
{
|
||||
throw new DeskDoorException("the peer closed before a whole reply frame");
|
||||
}
|
||||
|
||||
read += count;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reply ----
|
||||
|
||||
// The envelope judged by key; the payload's one variant returned with its value. Everything else throws.
|
||||
private static (string Variant, JsonElement Value) Decode(byte[] frame, string messageId, string operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var envelope = ToJson(frame);
|
||||
var root = envelope.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new DeskDoorException("the reply is not a map");
|
||||
}
|
||||
|
||||
// A CultNet Error message is not an operation reply (and its keys are not in operation order).
|
||||
if (RequiredString(root, "schemaVersion") != "cultnet.operation_response.v0")
|
||||
{
|
||||
throw new DeskDoorException("the reply is not an operation response");
|
||||
}
|
||||
|
||||
if (RequiredString(root, "serviceId") != ServiceId
|
||||
|| RequiredString(root, "operation") != operation
|
||||
|| RequiredString(root, "messageId") != messageId)
|
||||
{
|
||||
throw new DeskDoorException("the reply is not for this request");
|
||||
}
|
||||
|
||||
var status = RequiredString(root, "status");
|
||||
if (RequiredString(root, "payloadSchema") != ResponseSchema || RequiredString(root, "payloadEncoding") != Encoding64)
|
||||
{
|
||||
// Including gamecult.cultnet.operation_failure.v1: the envelope or payload did not decode.
|
||||
throw new DeskDoorException("the reply carries no desk response");
|
||||
}
|
||||
|
||||
// Strict standard base64: Convert would skip whitespace, so the text must be the canonical encoding of what it decodes to.
|
||||
var text = RequiredString(root, "payload");
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(text);
|
||||
}
|
||||
catch (FormatException e)
|
||||
{
|
||||
throw new DeskDoorException("the payload is not base64", e);
|
||||
}
|
||||
|
||||
if (Convert.ToBase64String(payload) != text)
|
||||
{
|
||||
throw new DeskDoorException("the payload is not strict standard base64");
|
||||
}
|
||||
|
||||
using var body = ToJson(payload);
|
||||
if (body.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new DeskDoorException("the payload is not a tagged variant");
|
||||
}
|
||||
|
||||
var variants = body.RootElement.EnumerateObject().ToList();
|
||||
if (variants.Count != 1)
|
||||
{
|
||||
throw new DeskDoorException("the payload is not a one-key variant");
|
||||
}
|
||||
|
||||
var (variant, expectedStatus) = (variants[0].Name, variants[0].Name switch
|
||||
{
|
||||
"Answered" or "Forks" or "Fork" => "accepted",
|
||||
"NotStored" or "Refused" => "rejected",
|
||||
_ => null,
|
||||
});
|
||||
var takesVariant = operation switch
|
||||
{
|
||||
"answer" => variant == "Answered",
|
||||
"forks" => variant == "Forks",
|
||||
"fork" => variant == "Fork",
|
||||
_ => false,
|
||||
} || variant is "NotStored" or "Refused";
|
||||
if (expectedStatus is null || !takesVariant || status != expectedStatus)
|
||||
{
|
||||
throw new DeskDoorException("the reply's variant or status does not fit the operation");
|
||||
}
|
||||
|
||||
return (variant, variants[0].Value.Clone());
|
||||
}
|
||||
catch (Exception e) when (e is not (DeskDoorException or OperationCanceledException))
|
||||
{
|
||||
throw new DeskDoorException("the reply does not decode", e);
|
||||
}
|
||||
}
|
||||
|
||||
// MessagePack to JSON text by walking the reader: the JSON writer escapes every control character, keys keep their
|
||||
// order, integers keep every digit. A map key that is not text, a key written twice, a float, a binary, an
|
||||
// extension, invalid UTF-8 or nesting past 64 is a failure; none is in the contract.
|
||||
private static JsonDocument ToJson(ReadOnlyMemory<byte> messagePack)
|
||||
{
|
||||
var reader = new MessagePackReader(messagePack);
|
||||
var json = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(json, new JsonWriterOptions { MaxDepth = 64 }))
|
||||
{
|
||||
Walk(ref reader, writer, 0);
|
||||
}
|
||||
|
||||
if (!reader.End)
|
||||
{
|
||||
throw new DeskDoorException("bytes follow the message");
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(json.WrittenMemory, new JsonDocumentOptions { MaxDepth = 64 });
|
||||
}
|
||||
|
||||
private static void Walk(ref MessagePackReader reader, Utf8JsonWriter writer, int depth)
|
||||
{
|
||||
if (depth > 64)
|
||||
{
|
||||
throw new DeskDoorException("the message nests past 64");
|
||||
}
|
||||
|
||||
switch (reader.NextMessagePackType)
|
||||
{
|
||||
case MessagePackType.Nil:
|
||||
reader.ReadNil();
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
case MessagePackType.Boolean:
|
||||
writer.WriteBooleanValue(reader.ReadBoolean());
|
||||
break;
|
||||
case MessagePackType.Integer:
|
||||
if (reader.NextCode == MessagePackCode.UInt64)
|
||||
{
|
||||
writer.WriteNumberValue(reader.ReadUInt64());
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteNumberValue(reader.ReadInt64());
|
||||
}
|
||||
|
||||
break;
|
||||
case MessagePackType.String:
|
||||
writer.WriteStringValue(Text(ref reader));
|
||||
break;
|
||||
case MessagePackType.Array:
|
||||
var items = reader.ReadArrayHeader();
|
||||
writer.WriteStartArray();
|
||||
for (var i = 0; i < items; i++)
|
||||
{
|
||||
Walk(ref reader, writer, depth + 1);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
case MessagePackType.Map:
|
||||
var pairs = reader.ReadMapHeader();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
writer.WriteStartObject();
|
||||
for (var i = 0; i < pairs; i++)
|
||||
{
|
||||
if (reader.NextMessagePackType != MessagePackType.String)
|
||||
{
|
||||
throw new DeskDoorException("a map key is not text");
|
||||
}
|
||||
|
||||
var key = Text(ref reader);
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
throw new DeskDoorException($"{key} is written twice");
|
||||
}
|
||||
|
||||
writer.WritePropertyName(key);
|
||||
Walk(ref reader, writer, depth + 1);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
default:
|
||||
throw new DeskDoorException("the message holds a float, binary or extension value, which the contract does not use");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Text(ref MessagePackReader reader) =>
|
||||
StrictUtf8.GetString(reader.ReadStringSequence() ?? throw new DeskDoorException("text is nil"));
|
||||
|
||||
// A key's value by name; a key written twice never gets here (the walk refuses it).
|
||||
private static JsonElement? Property(JsonElement map, string name) =>
|
||||
map.ValueKind == JsonValueKind.Object && map.TryGetProperty(name, out var value) ? value : null;
|
||||
|
||||
private static string RequiredString(JsonElement map, string name) =>
|
||||
Property(map, name) is { ValueKind: JsonValueKind.String } value
|
||||
? value.GetString()!
|
||||
: throw new DeskDoorException($"{name} is missing or not text");
|
||||
|
||||
private static string? OptionalString(JsonElement map, string name) =>
|
||||
Property(map, name) switch
|
||||
{
|
||||
{ ValueKind: JsonValueKind.String } value => value.GetString(),
|
||||
{ ValueKind: JsonValueKind.Null } => null,
|
||||
_ => throw new DeskDoorException($"{name} is missing or not text"),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,7 @@ namespace Bifrost.Web.Features.Heimdall;
|
|||
/// <summary>
|
||||
/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which
|
||||
/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with
|
||||
/// the attempt to the same path. A Google link returns a pending link id the same way instead of a code. The page
|
||||
/// is static: it never contains a code or an id.
|
||||
/// the attempt to the same path. The page is static: it never contains a code.
|
||||
/// </summary>
|
||||
public static class HeimdallCompletePage
|
||||
{
|
||||
|
|
@ -23,30 +22,21 @@ public static class HeimdallCompletePage
|
|||
<form id="complete" method="post" action="/auth/heimdall/complete">
|
||||
<input type="hidden" name="attempt" />
|
||||
<input type="hidden" name="code" />
|
||||
<input type="hidden" name="pendingLinkId" />
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
var fragment = new URLSearchParams(location.hash.slice(1));
|
||||
var attempt = new URLSearchParams(location.search).get("attempt");
|
||||
var code = fragment.get("heimdall_completion_code");
|
||||
var pendingLinkId = fragment.get("heimdall_pending_link_id");
|
||||
var failed = fragment.get("heimdall_status") === "error";
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
// A provider other than Google attaches in Heimdall's callback, which refuses an identity another account
|
||||
// holds. Only this one refusal is carried on, as a fixed result the page words itself; nothing from the fragment is shown.
|
||||
if (failed && attempt && fragment.get("heimdall_error") === "link_initiator_mismatch") {
|
||||
location.replace("/Membership/LinkedAccounts?result=identity_linked_elsewhere");
|
||||
return;
|
||||
}
|
||||
if (failed || !(code || pendingLinkId) || !attempt) {
|
||||
if (failed || !code || !attempt) {
|
||||
location.replace("/?auth=heimdall-error");
|
||||
return;
|
||||
}
|
||||
var form = document.getElementById("complete");
|
||||
form.elements.attempt.value = attempt;
|
||||
form.elements.code.value = code || "";
|
||||
form.elements.pendingLinkId.value = pendingLinkId || "";
|
||||
form.elements.code.value = code;
|
||||
form.submit();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,351 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Heimdall;
|
||||
|
||||
/// <summary>
|
||||
/// What Bifrost asks Heimdall to move (docs/service-contract.md, POST /v1/apps/{app}/identity-moves). Exactly one of
|
||||
/// <see cref="ToProof"/> (a forward move, optionally of <see cref="IdentityIds"/>) and <see cref="ReversesMoveId"/> (the undo). The body
|
||||
/// never names an account: the proofs, live Heimdall access tokens, say which accounts are meant.
|
||||
/// </summary>
|
||||
public sealed record HeimdallMoveRequest(
|
||||
string MoveId, IReadOnlyList<string> FromProofs, string? ToProof = null, IReadOnlyList<string>? IdentityIds = null, string? ReversesMoveId = null)
|
||||
{
|
||||
// The proofs are live bearers: the generated ToString would print them into any log or exception that holds the request.
|
||||
private bool PrintMembers(StringBuilder builder)
|
||||
{
|
||||
builder.Append($"MoveId = {MoveId}, FromProofs = {FromProofs?.Count}, ToProof = {(ToProof is null ? "none" : "present")}, ReversesMoveId = {ReversesMoveId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public enum HeimdallProofTier
|
||||
{
|
||||
Strong,
|
||||
Weak
|
||||
}
|
||||
|
||||
/// <summary>The receipt of a move: the destination account, the logins that moved and the tier of the proofs that carried it.</summary>
|
||||
public sealed record HeimdallMoveReceipt(
|
||||
string MoveId, string AccountId, IReadOnlyList<string> MovedIdentityIds, HeimdallProofTier ProofFrom, HeimdallProofTier? ProofTo, string? ReversesMoveId);
|
||||
|
||||
/// <summary>Heimdall's fixed refusal codes for a move, plus invalid_request, Heimdall's own schema 400.</summary>
|
||||
public enum HeimdallMoveRefusal
|
||||
{
|
||||
AppAuthRequired,
|
||||
NotAllowedForApp,
|
||||
NotReversible,
|
||||
ProofInvalid,
|
||||
SameAccount,
|
||||
ProofStale,
|
||||
IdentityNotProved,
|
||||
AlreadyMoved,
|
||||
ProviderConflict,
|
||||
IdentityNotFound,
|
||||
RequestInvalid
|
||||
}
|
||||
|
||||
public enum HeimdallProofSide
|
||||
{
|
||||
From,
|
||||
To
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The closed answer to a move. Moved: the receipt. Refused: Heimdall answered a 4xx before applying anything, so nothing changed.
|
||||
/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one, and so may a 409 move_id_conflict: Heimdall
|
||||
/// answers it to a replay of a committed move whose proofs no longer verify): the caller keeps its proofs and replays the same request.
|
||||
/// </summary>
|
||||
public abstract record HeimdallMoveOutcome
|
||||
{
|
||||
private HeimdallMoveOutcome()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Moved(HeimdallMoveReceipt Receipt) : HeimdallMoveOutcome;
|
||||
|
||||
public sealed record Refused(HeimdallMoveRefusal Code, HeimdallProofSide? Side = null, string? Provider = null) : HeimdallMoveOutcome;
|
||||
|
||||
public sealed record Unknown(string Reason) : HeimdallMoveOutcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Heimdall's move route under Bifrost's app secret (HeimdallAuthService.ConfigureClient adds the header). Each call sends one request and
|
||||
/// retries nothing: replay with the same <see cref="HeimdallMoveRequest.MoveId"/> is the caller's, and Heimdall answers it with the stored
|
||||
/// receipt. Logs name the outcome class and refusal code only.
|
||||
/// </summary>
|
||||
public sealed partial class HeimdallIdentityMoveClient(
|
||||
HttpClient httpClient, IOptions<HeimdallOptions> heimdallOptions, ILogger<HeimdallIdentityMoveClient> logger)
|
||||
{
|
||||
/// <summary>The deadline of one call, headers and body together, set on the HttpClient by <see cref="HeimdallIdentityMoveClientServices"/>.</summary>
|
||||
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private const int MaxMovedIdentities = 256;
|
||||
private const int MaxIdentityIdLength = 128;
|
||||
private const int MaxReceiptBytes = 64 * 1024;
|
||||
public const int MaxProofLength = 8192;
|
||||
|
||||
// \z, not $: $ also matches before a final line feed, which Heimdall's schema pattern refuses with a 400.
|
||||
[GeneratedRegex("^[A-Za-z0-9_-]{16,64}\\z")]
|
||||
private static partial Regex MoveIdShape();
|
||||
|
||||
public async Task<HeimdallMoveOutcome> MoveAsync(HeimdallMoveRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = Serialize(request);
|
||||
var outcome = await SendAsync(request, body, cancellationToken);
|
||||
logger.LogInformation("Heimdall identity move: {Outcome} {Code}", outcome.GetType().Name, outcome is HeimdallMoveOutcome.Refused refused ? refused.Code : null);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task<HeimdallMoveOutcome> SendAsync(HeimdallMoveRequest request, string body, CancellationToken cancellationToken)
|
||||
{
|
||||
// One deadline over the whole call: HttpClient.Timeout ends at the headers, and a stalled body must not outlive it.
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(httpClient.Timeout);
|
||||
var token = deadline.Token;
|
||||
try
|
||||
{
|
||||
using var message = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/identity-moves")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
var status = (int)response.StatusCode;
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return await ReadReceiptAsync(response, request, token) is { } receipt
|
||||
? new HeimdallMoveOutcome.Moved(receipt)
|
||||
: new HeimdallMoveOutcome.Unknown("malformed or mismatched receipt");
|
||||
}
|
||||
|
||||
if (status is < 400 or >= 500 || status == 429)
|
||||
{
|
||||
return new HeimdallMoveOutcome.Unknown($"HTTP {status}");
|
||||
}
|
||||
|
||||
return Refused(await HeimdallRefusal.ReadAsync(response, token), status);
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Whatever the pipeline throws after the request may have gone out is not a refusal; only the caller's own cancellation propagates.
|
||||
return new HeimdallMoveOutcome.Unknown("transport");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute, and its error handler's invalid_request 400). A 4xx without one of those codes
|
||||
/// (a gateway's HTML 404, a 408, a code a later Heimdall adds) proves nothing was applied, so it is Unknown. move_id_conflict is
|
||||
/// Unknown too: a committed move answers it when its proofs no longer verify.
|
||||
/// </summary>
|
||||
private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal, int status)
|
||||
{
|
||||
if (refusal?.Code == "move_id_conflict")
|
||||
{
|
||||
return new HeimdallMoveOutcome.Unknown("move_id_conflict");
|
||||
}
|
||||
|
||||
var code = refusal?.Code switch
|
||||
{
|
||||
"app_auth_required" => HeimdallMoveRefusal.AppAuthRequired,
|
||||
"not_allowed_for_app" => HeimdallMoveRefusal.NotAllowedForApp,
|
||||
"not_reversible" => HeimdallMoveRefusal.NotReversible,
|
||||
"proof_invalid" => HeimdallMoveRefusal.ProofInvalid,
|
||||
"same_account" => HeimdallMoveRefusal.SameAccount,
|
||||
"proof_stale" => HeimdallMoveRefusal.ProofStale,
|
||||
"identity_not_proved" => HeimdallMoveRefusal.IdentityNotProved,
|
||||
"already_moved" => HeimdallMoveRefusal.AlreadyMoved,
|
||||
"provider_conflict" => HeimdallMoveRefusal.ProviderConflict,
|
||||
"identity_not_found" => HeimdallMoveRefusal.IdentityNotFound,
|
||||
// Heimdall's error handler gives its own 400 this code; an nginx or other edge 400 has none, so it is not Heimdall's refusal.
|
||||
"invalid_request" when status == 400 => HeimdallMoveRefusal.RequestInvalid,
|
||||
_ => (HeimdallMoveRefusal?)null
|
||||
};
|
||||
if (code is not { } known)
|
||||
{
|
||||
return new HeimdallMoveOutcome.Unknown($"HTTP {status} without a Heimdall refusal code");
|
||||
}
|
||||
|
||||
return known switch
|
||||
{
|
||||
HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(known, Side: refusal?.Proof switch
|
||||
{
|
||||
"from" => HeimdallProofSide.From,
|
||||
"to" => HeimdallProofSide.To,
|
||||
_ => null
|
||||
}),
|
||||
HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(known, Provider: refusal?.Provider),
|
||||
_ => new HeimdallMoveOutcome.Refused(known)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 200 body is a receipt only when it is whole and answers this request: its moveId (and reversesMoveId) equal the request's,
|
||||
/// a forward move names a destination proof tier and a reversal does not. Anything else is not trusted as a move.
|
||||
/// </summary>
|
||||
private static async Task<HeimdallMoveReceipt?> ReadReceiptAsync(HttpResponseMessage response, HeimdallMoveRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[4096];
|
||||
while (await stream.ReadAsync(chunk, cancellationToken) is > 0 and var read)
|
||||
{
|
||||
if (buffer.Length + read > MaxReceiptBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(buffer.ToArray());
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| Text(root, "moveId") != request.MoveId
|
||||
|| Text(root, "reversesMoveId") != request.ReversesMoveId
|
||||
|| Text(root, "accountId") is not { Length: > 0 } account
|
||||
|| !root.TryGetProperty("movedIdentityIds", out var moved)
|
||||
|| moved.ValueKind != JsonValueKind.Array
|
||||
|| moved.GetArrayLength() is 0 or > MaxMovedIdentities
|
||||
|| !root.TryGetProperty("proof", out var proof)
|
||||
|| proof.ValueKind != JsonValueKind.Object
|
||||
|| Tier(proof, "from") is not { } from)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ids = new List<string>();
|
||||
foreach (var item in moved.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.String || item.GetString() is not { Length: > 0 and <= MaxIdentityIdLength })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ids.Add(item.GetString()!);
|
||||
}
|
||||
|
||||
HeimdallProofTier? to = null;
|
||||
if (request.ReversesMoveId is null)
|
||||
{
|
||||
to = Tier(proof, "to");
|
||||
if (to is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (!proof.TryGetProperty("to", out var none) || none.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new HeimdallMoveReceipt(request.MoveId, account, ids, from, to, request.ReversesMoveId);
|
||||
}
|
||||
|
||||
private static HeimdallProofTier? Tier(JsonElement proof, string name) => Text(proof, name) switch
|
||||
{
|
||||
"strong" => HeimdallProofTier.Strong,
|
||||
"weak" => HeimdallProofTier.Weak,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? Text(JsonElement item, string name) =>
|
||||
item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
private static bool IsBadProof(string? proof) => string.IsNullOrEmpty(proof) || proof.Length > MaxProofLength;
|
||||
|
||||
/// <summary>The contract's closed body, or ArgumentException before any I/O when the request breaks it.</summary>
|
||||
private static string Serialize(HeimdallMoveRequest request)
|
||||
{
|
||||
if (request.MoveId is null || !MoveIdShape().IsMatch(request.MoveId))
|
||||
{
|
||||
throw new ArgumentException("moveId must match ^[A-Za-z0-9_-]{16,64}$", nameof(request));
|
||||
}
|
||||
|
||||
if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(IsBadProof))
|
||||
{
|
||||
throw new ArgumentException("fromProofs must hold 1 to 8 proofs", nameof(request));
|
||||
}
|
||||
|
||||
if ((request.ToProof is null) == (request.ReversesMoveId is null))
|
||||
{
|
||||
throw new ArgumentException("exactly one of toProof and reversesMoveId is required", nameof(request));
|
||||
}
|
||||
|
||||
if (request.ToProof is not null && IsBadProof(request.ToProof))
|
||||
{
|
||||
throw new ArgumentException("toProof is empty", nameof(request));
|
||||
}
|
||||
|
||||
if (request.ReversesMoveId is not null && !MoveIdShape().IsMatch(request.ReversesMoveId))
|
||||
{
|
||||
throw new ArgumentException("reversesMoveId must match the moveId shape", nameof(request));
|
||||
}
|
||||
|
||||
if (request.IdentityIds is not null
|
||||
&& (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrEmpty)
|
||||
|| request.IdentityIds.Distinct().Count() != request.IdentityIds.Count))
|
||||
{
|
||||
throw new ArgumentException("identityIds are 1 to 32 unique ids and only with toProof", nameof(request));
|
||||
}
|
||||
|
||||
var body = new Dictionary<string, object?> { ["moveId"] = request.MoveId, ["fromProofs"] = request.FromProofs };
|
||||
if (request.ToProof is not null)
|
||||
{
|
||||
body["toProof"] = request.ToProof;
|
||||
}
|
||||
|
||||
if (request.IdentityIds is not null)
|
||||
{
|
||||
body["identityIds"] = request.IdentityIds;
|
||||
}
|
||||
|
||||
if (request.ReversesMoveId is not null)
|
||||
{
|
||||
body["reversesMoveId"] = request.ReversesMoveId;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(body);
|
||||
}
|
||||
}
|
||||
|
||||
public static class HeimdallClientServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Every Heimdall client Bifrost owns. The auth client carries the app secret, the member client only the member's bearer and
|
||||
/// the mover the app secret and its call deadline; all share the header redaction.
|
||||
/// </summary>
|
||||
public static void AddHeimdallClients(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
||||
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
||||
.WithoutRedirects()
|
||||
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
|
||||
services.AddHttpClient<HeimdallMemberClient>((serviceProvider, client) =>
|
||||
HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
||||
.WithoutRedirects()
|
||||
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
|
||||
services.AddHeimdallIdentityMoveClient();
|
||||
}
|
||||
|
||||
/// <summary>A 3xx from Heimdall is its answer: following it would carry the app secret or a member's bearer to another origin.</summary>
|
||||
public static IHttpClientBuilder WithoutRedirects(this IHttpClientBuilder builder) =>
|
||||
builder.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
|
||||
|
||||
/// <summary>
|
||||
/// The mover's HttpClient: Heimdall's base address and app secret from HeimdallAuthService.ConfigureClient (the one secret path), the
|
||||
/// call deadline, no redirects (a 3xx must never carry the secret or the proofs to another origin), and the shared header redaction.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddHeimdallIdentityMoveClient(this IServiceCollection services) =>
|
||||
services.AddHttpClient<HeimdallIdentityMoveClient>((serviceProvider, client) =>
|
||||
{
|
||||
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value);
|
||||
client.Timeout = HeimdallIdentityMoveClient.Timeout;
|
||||
})
|
||||
.WithoutRedirects()
|
||||
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
|
||||
namespace Bifrost.Web.Features.Heimdall;
|
||||
|
||||
/// <summary>One login a Heimdall account holds, as the Linked accounts page shows it. No provider user id, scope or email.</summary>
|
||||
public sealed record LinkedIdentitySummary(string Id, string Provider, string? DisplayName, DateTimeOffset? LinkedAt);
|
||||
|
||||
public enum HeimdallMemberCall
|
||||
{
|
||||
Ok,
|
||||
SignInRequired,
|
||||
StepUpRequired,
|
||||
IdentityNotFound,
|
||||
LastIdentity,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record HeimdallMemberResult(HeimdallMemberCall Call, IReadOnlyList<LinkedIdentitySummary>? Identities);
|
||||
|
||||
/// <summary>
|
||||
/// Heimdall's member routes (docs/service-contract.md: GET /v1/me/identities, POST /v1/me/identities/{id}/unlink). Each
|
||||
/// takes the member's bearer as an argument, uses it for that one request and keeps nothing. Its HttpClient carries no
|
||||
/// app secret: these routes act only for a member who is present, so a Bifrost fault cannot reach them without one.
|
||||
/// </summary>
|
||||
public sealed class HeimdallMemberClient(HttpClient httpClient)
|
||||
{
|
||||
private const int MaxIdentities = 32;
|
||||
private const int MaxDisplayName = 80;
|
||||
|
||||
public static void ConfigureClient(HttpClient client, HeimdallOptions options) =>
|
||||
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
|
||||
|
||||
public Task<HeimdallMemberResult> ListAsync(string bearer, CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Get, "/v1/me/identities", bearer, cancellationToken);
|
||||
|
||||
public Task<HeimdallMemberResult> UnlinkAsync(string bearer, string identityId, CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Post, $"/v1/me/identities/{Uri.EscapeDataString(identityId)}/unlink", bearer, cancellationToken);
|
||||
|
||||
private async Task<HeimdallMemberResult> SendAsync(HttpMethod method, string path, string bearer, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var message = new HttpRequestMessage(method, path);
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return new HeimdallMemberResult(HeimdallMemberCall.Ok, await ReadIdentitiesAsync(response, cancellationToken));
|
||||
}
|
||||
|
||||
var code = await HeimdallRefusal.CodeAsync(response, cancellationToken);
|
||||
var call = (response.StatusCode, code) switch
|
||||
{
|
||||
(HttpStatusCode.Unauthorized, _) => HeimdallMemberCall.SignInRequired,
|
||||
(HttpStatusCode.Forbidden, "step_up_required") => HeimdallMemberCall.StepUpRequired,
|
||||
(HttpStatusCode.NotFound, "identity_not_found") => HeimdallMemberCall.IdentityNotFound,
|
||||
(HttpStatusCode.Conflict, "last_identity") => HeimdallMemberCall.LastIdentity,
|
||||
_ => HeimdallMemberCall.Error
|
||||
};
|
||||
return new HeimdallMemberResult(call, null);
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException
|
||||
&& !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new HeimdallMemberResult(HeimdallMemberCall.Error, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The identities array of a 200 answer, keeping only the fields the page shows; a malformed entry is dropped.</summary>
|
||||
private static async Task<IReadOnlyList<LinkedIdentitySummary>> ReadIdentitiesAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
var list = new List<LinkedIdentitySummary>();
|
||||
if (body.RootElement.ValueKind != JsonValueKind.Object
|
||||
|| !body.RootElement.TryGetProperty("identities", out var identities)
|
||||
|| identities.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
foreach (var item in identities.EnumerateArray().Take(MaxIdentities))
|
||||
{
|
||||
var id = Text(item, "id");
|
||||
var provider = Text(item, "provider");
|
||||
if (!Guid.TryParse(id, out _)
|
||||
|| provider is not { Length: > 0 and <= 32 }
|
||||
|| !provider.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = Text(item, "displayName") ?? Text(item, "username");
|
||||
if (name is { Length: > MaxDisplayName })
|
||||
{
|
||||
name = name[..MaxDisplayName];
|
||||
}
|
||||
|
||||
DateTimeOffset? linkedAt = DateTimeOffset.TryParse(Text(item, "linkedAt"), out var parsed) ? parsed : null;
|
||||
list.Add(new LinkedIdentitySummary(id!, provider, name, linkedAt));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string? Text(JsonElement item, string name) =>
|
||||
item.ValueKind == JsonValueKind.Object && item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
|
@ -64,22 +64,23 @@ public sealed class HeimdallPatronSupportIntakeService(
|
|||
x.ProviderEventId == request.ProviderEventId.Trim(),
|
||||
cancellationToken);
|
||||
|
||||
if (existingEvent is not null)
|
||||
{
|
||||
return HeimdallPatronSupportIntakeResult.Processed(
|
||||
$"Already processed {request.Provider} patron support event {request.ProviderEventId}.",
|
||||
existingEvent.Id);
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return existingEvent is not null
|
||||
? HeimdallPatronSupportIntakeResult.Processed(
|
||||
$"Already processed {request.Provider} patron support event {request.ProviderEventId}.",
|
||||
existingEvent.Id)
|
||||
: HeimdallPatronSupportIntakeResult.BadRequest(
|
||||
$"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}.");
|
||||
return HeimdallPatronSupportIntakeResult.BadRequest(
|
||||
$"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}.");
|
||||
}
|
||||
|
||||
// The write gate behind RecordSupportEventAsync routes a folded account's event to its survivor and journals it, and on a
|
||||
// provider retry returns the stored event, journalling it if it sits on the survivor and was left unjournalled.
|
||||
var supportEvent = await patronageService.RecordSupportEventAsync(
|
||||
actorUserAccountId: null,
|
||||
userAccountId: user.Id,
|
||||
|
|
@ -97,9 +98,7 @@ public sealed class HeimdallPatronSupportIntakeService(
|
|||
cancellationToken);
|
||||
|
||||
return HeimdallPatronSupportIntakeResult.Processed(
|
||||
existingEvent is not null
|
||||
? $"Already processed {request.Provider} patron support event {request.ProviderEventId}."
|
||||
: $"Processed {request.Provider} patron support event {request.ProviderEventId}.",
|
||||
$"Processed {request.Provider} patron support event {request.ProviderEventId}.",
|
||||
supportEvent.Id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Bifrost.Web.Features.Heimdall;
|
||||
|
||||
/// <summary>Reads the one thing Bifrost takes from a Heimdall refusal body: its error code, and only if it is a safe token.</summary>
|
||||
public static class HeimdallRefusal
|
||||
{
|
||||
private const int BodyLimit = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The error code in the first 4 KiB of a body when it is a short snake_case token, else null. A body that cannot be
|
||||
/// read or parsed yields null: a refusal never becomes a 500, and free text or tokens never reach a log or a page.
|
||||
/// </summary>
|
||||
public static async Task<string?> CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) =>
|
||||
(await ReadAsync(response, cancellationToken))?.Code;
|
||||
|
||||
/// <summary>
|
||||
/// The error code plus the two qualifiers a move refusal carries (the contract's `proof`, only "from" or "to", and `provider`, a
|
||||
/// short snake_case token), each kept only when it is that safe shape. Nothing else of the body is read. Null when no code reads.
|
||||
/// </summary>
|
||||
public static async Task<HeimdallRefusalBody?> ReadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var head = new byte[BodyLimit];
|
||||
var length = 0;
|
||||
while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read)
|
||||
{
|
||||
length += read;
|
||||
}
|
||||
|
||||
using var body = JsonDocument.Parse(head.AsMemory(0, length));
|
||||
if (body.RootElement.ValueKind == JsonValueKind.Object && Token(body.RootElement, "error", 64) is { } code)
|
||||
{
|
||||
var proof = body.RootElement.TryGetProperty("proof", out var side) && side.ValueKind == JsonValueKind.String ? side.GetString() : null;
|
||||
return new HeimdallRefusalBody(code, proof is "from" or "to" ? proof : null, Token(body.RootElement, "provider", 32));
|
||||
}
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Token(JsonElement root, string name, int maxLength) =>
|
||||
root.TryGetProperty(name, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
&& value.GetString() is { Length: > 0 } text
|
||||
&& text.Length <= maxLength
|
||||
&& text.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')
|
||||
? text
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>A Heimdall refusal's safe fields: the code, the proof side ("from"/"to") and the provider, when present and well formed.</summary>
|
||||
public sealed record HeimdallRefusalBody(string Code, string? Proof, string? Provider);
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Bifrost.Web.Features.Heimdall;
|
||||
|
||||
/// <summary>
|
||||
/// What the Linked accounts page last read from Heimdall for an account: id, provider, display name and link date of
|
||||
/// each login, nothing else, for <see cref="Lifetime"/>. It lives in this process only, keyed by the Heimdall account id,
|
||||
/// and is never given to the browser; it holds no bearer. The page renders from it because the list is fetched inside a
|
||||
/// completion request, which redirects. After a restart it is empty and the page offers to read the list again.
|
||||
/// </summary>
|
||||
public sealed class LinkedAccountsSnapshots(TimeProvider timeProvider)
|
||||
{
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ConcurrentDictionary<string, (IReadOnlyList<LinkedIdentitySummary> Identities, DateTimeOffset ExpiresAt)> _held = new(StringComparer.Ordinal);
|
||||
|
||||
public void Put(string accountId, IReadOnlyList<LinkedIdentitySummary> identities)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
foreach (var (key, value) in _held)
|
||||
{
|
||||
if (value.ExpiresAt <= now)
|
||||
{
|
||||
_held.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
_held[accountId] = (identities, now + Lifetime);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LinkedIdentitySummary>? Get(string accountId) =>
|
||||
_held.TryGetValue(accountId, out var value) && value.ExpiresAt > timeProvider.GetUtcNow() ? value.Identities : null;
|
||||
|
||||
public void Remove(string accountId) => _held.TryRemove(accountId, out _);
|
||||
}
|
||||
|
|
@ -1,768 +0,0 @@
|
|||
using System.Globalization;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Patronage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
/// <summary>How an account stands under a merge: the folded account of an unfinished merge is Frozen, of a landed one Folded.</summary>
|
||||
public abstract record MergeStanding(Guid MergeId)
|
||||
{
|
||||
public sealed record Frozen(Guid MergeId) : MergeStanding(MergeId);
|
||||
|
||||
public sealed record Folded(Guid MergeId, Guid SurvivorId) : MergeStanding(MergeId);
|
||||
}
|
||||
|
||||
public sealed record MergePlanEntry(
|
||||
AccountMergeEntryKind Kind,
|
||||
AccountMergeEntryAction Action,
|
||||
Guid RecordId,
|
||||
Guid? PriorUserAccountId = null,
|
||||
string? PriorText = null,
|
||||
DateTimeOffset? PriorAtUtc = null);
|
||||
|
||||
/// <summary>A dry run: the block codes (never account names), the journal entries a commit would write, and the record counts left untouched.</summary>
|
||||
public sealed record MergePlan(
|
||||
IReadOnlyList<string> Blocks,
|
||||
IReadOnlyList<MergePlanEntry> Entries,
|
||||
IReadOnlyDictionary<string, int> Left)
|
||||
{
|
||||
/// <summary>
|
||||
/// Lowercase hex SHA-256 over a canonical text of the plan, in plan order (Left by key): what the member saw, comparable at confirm.
|
||||
/// Every field is length-prefixed, so no value can imitate a separator. The plan holds no account ids of its own, so the caller
|
||||
/// names the pair the plan was made for.
|
||||
/// </summary>
|
||||
public string Digest(Guid survivorId, Guid foldedId)
|
||||
{
|
||||
var text = new System.Text.StringBuilder();
|
||||
void Field(string? value) => text.Append(value is null ? "-" : $"{value.Length}:{value}").Append('|');
|
||||
Field(survivorId.ToString("N"));
|
||||
Field(foldedId.ToString("N"));
|
||||
foreach (var entry in Entries)
|
||||
{
|
||||
Field(entry.Kind.ToString());
|
||||
Field(entry.Action.ToString());
|
||||
// A Created row does not exist yet: its id is minted anew by every plan, so it cannot be part of what the member saw.
|
||||
// Left unpinned on purpose: the mutant that keeps the id in (digest-includes-created-id in tools/merge-saga-mutants.py) is behaviourally equivalent bar one benign case.
|
||||
Field(entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N"));
|
||||
Field(entry.PriorUserAccountId?.ToString("N"));
|
||||
Field(entry.PriorText);
|
||||
Field(entry.PriorAtUtc?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
text.Append('\n');
|
||||
}
|
||||
|
||||
text.Append("left\n");
|
||||
foreach (var (key, count) in Left.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Field(key);
|
||||
Field(count.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
text.Append("\nblocks\n");
|
||||
foreach (var block in Blocks)
|
||||
{
|
||||
Field(block);
|
||||
}
|
||||
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one owner of what an account merge does to Bifrost records and of how an account stands under one: the plan, the Committed
|
||||
/// save, the undo save, the Final transition and StandingAsync. A merge only reduces: nothing is deleted, votes and
|
||||
/// appraisals are stamped void (the model's query filter then removes them from every tally) or re-pointed, and every change is
|
||||
/// journalled in AccountMergeEntry. Design: /opt/commons/handoff/heimdall-account-merge-design-20261113.md sections 3, 5, 7.
|
||||
/// </summary>
|
||||
public sealed class AccountMergeLedger(
|
||||
BifrostDbContext dbContext,
|
||||
PatronageService patronageService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public static readonly TimeSpan Grace = TimeSpan.FromDays(7);
|
||||
|
||||
private static readonly AccountMergeState[] FrozenStates = [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved];
|
||||
|
||||
private static readonly AccountMergeState[] FoldedStates =
|
||||
[AccountMergeState.Committed, AccountMergeState.Final, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed];
|
||||
|
||||
private static readonly AccountMergeState[] InFlightStates =
|
||||
[AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed];
|
||||
|
||||
public static string SuspensionReason(Guid mergeId) => $"merged with a suspended account (merge {mergeId})";
|
||||
|
||||
public Task<MergeStanding?> StandingAsync(Guid userAccountId, CancellationToken cancellationToken) =>
|
||||
StandingAsync(dbContext, userAccountId, cancellationToken);
|
||||
|
||||
/// <summary>The single reading of standing, shared by the actor accessor and the OIDC refusal, which holds only a context. Derived from the merge state on every call.</summary>
|
||||
public static async Task<MergeStanding?> StandingAsync(BifrostDbContext db, Guid userAccountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var merge = await db.AccountMerges.AsNoTracking()
|
||||
.Where(x => x.FoldedUserAccountId == userAccountId &&
|
||||
(FrozenStates.Contains(x.State) || FoldedStates.Contains(x.State)))
|
||||
.OrderByDescending(x => x.CreatedUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return merge is null
|
||||
? null
|
||||
: FrozenStates.Contains(merge.State)
|
||||
? new MergeStanding.Frozen(merge.Id)
|
||||
: new MergeStanding.Folded(merge.Id, merge.SurvivorUserAccountId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one gate every writer that acts for an account passes through (votes, appraisals, sealed ballots, patron events, the Heimdall
|
||||
/// sign-in's membership). On Postgres it holds a shared advisory lock on the account, the key the chain trigger locks exclusively
|
||||
/// when a merge row is inserted or changes state and that <see cref="CommitAsync"/> takes exclusively before it reduces, across the
|
||||
/// standing read and everything <paramref name="write"/> saves, then commits. So a merge cannot freeze or fold the account between a
|
||||
/// writer's standing read and its save: it waits for the writer, and a writer that starts after it reads the new standing. The
|
||||
/// lock covers writes made through this gate and nothing else; it is held until the transaction ends, so a writer must not
|
||||
/// catch a database error inside <paramref name="write"/> and carry on (the transaction is already aborted). On the in-memory
|
||||
/// provider there is no lock and no transaction: the standing is read, then the writer saves.
|
||||
/// </summary>
|
||||
public static async Task<T> WriteAsAsync<T>(BifrostDbContext db, Guid userAccountId, Func<MergeStanding?, Task<T>> write, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var transaction = await BeginAsync(db, cancellationToken);
|
||||
await LockAsync(db, userAccountId, exclusive: false, cancellationToken);
|
||||
var result = await write(await StandingAsync(db, userAccountId, cancellationToken));
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>A transaction for the caller to commit, or null where there is none to open: not Postgres, or one already open (its owner commits).</summary>
|
||||
private static async Task<Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction?> BeginAsync(BifrostDbContext db, CancellationToken cancellationToken) =>
|
||||
db.Database.IsNpgsql() && db.Database.CurrentTransaction is null ? await db.Database.BeginTransactionAsync(cancellationToken) : null;
|
||||
|
||||
/// <summary>The account's lock, on the key the chain trigger uses (hashtextextended of the id's text). A no-op off Postgres.</summary>
|
||||
private static async Task LockAsync(BifrostDbContext db, Guid userAccountId, bool exclusive, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!db.Database.IsNpgsql())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = userAccountId.ToString();
|
||||
await (exclusive
|
||||
? db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({key}, 0))", cancellationToken)
|
||||
: db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock_shared(hashtextextended({key}, 0))", cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>Writes nothing. <paramref name="exceptMergeId"/> lets the saga re-plan a merge it already holds without it blocking itself.</summary>
|
||||
public async Task<MergePlan> PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null)
|
||||
{
|
||||
var blocks = await BlocksAsync(survivorId, foldedId, exceptMergeId, cancellationToken);
|
||||
var entries = new List<MergePlanEntry>();
|
||||
var left = new Dictionary<string, int>();
|
||||
|
||||
if (!blocks.Contains("account_missing"))
|
||||
{
|
||||
var reduction = await ReduceAsync(survivorId, foldedId, cancellationToken);
|
||||
entries.AddRange(reduction.Entries);
|
||||
left["ConsentReceipts"] = await dbContext.ConsentReceipts.CountAsync(x => x.UserAccountId == foldedId, cancellationToken);
|
||||
left["PasskeyCredentials"] = await dbContext.PasskeyCredentials.CountAsync(x => x.UserAccountId == foldedId, cancellationToken);
|
||||
left["LedgerEntries"] = await dbContext.LedgerEntries.CountAsync(x => x.UserAccountId == foldedId, cancellationToken);
|
||||
}
|
||||
|
||||
return new MergePlan(blocks, entries, left);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> BlocksAsync(Guid survivorId, Guid foldedId, Guid? exceptMergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var blocks = new List<string>();
|
||||
if (survivorId == foldedId)
|
||||
{
|
||||
blocks.Add("same_account");
|
||||
}
|
||||
|
||||
var accounts = await dbContext.UserAccounts.AsNoTracking()
|
||||
.Where(x => x.Id == survivorId || x.Id == foldedId)
|
||||
.Select(x => new { x.Id, x.HeimdallAccountId })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (accounts.Count < (survivorId == foldedId ? 1 : 2))
|
||||
{
|
||||
blocks.Add("account_missing");
|
||||
return blocks;
|
||||
}
|
||||
|
||||
if (await dbContext.MindPersonas.AnyAsync(x => x.UserAccountId == survivorId || x.UserAccountId == foldedId, cancellationToken))
|
||||
{
|
||||
blocks.Add("mind_account");
|
||||
}
|
||||
|
||||
if (accounts.Any(x => string.IsNullOrWhiteSpace(x.HeimdallAccountId)))
|
||||
{
|
||||
blocks.Add("native_account");
|
||||
}
|
||||
|
||||
if (await dbContext.AccountMerges.AnyAsync(
|
||||
x => x.Id != exceptMergeId &&
|
||||
InFlightStates.Contains(x.State) &&
|
||||
(x.SurvivorUserAccountId == survivorId || x.FoldedUserAccountId == survivorId ||
|
||||
x.SurvivorUserAccountId == foldedId || x.FoldedUserAccountId == foldedId),
|
||||
cancellationToken))
|
||||
{
|
||||
blocks.Add("merge_in_flight");
|
||||
}
|
||||
|
||||
if (await FoldedLinkAsync(survivorId, exceptMergeId, cancellationToken))
|
||||
{
|
||||
blocks.Add("survivor_is_folded");
|
||||
}
|
||||
|
||||
if (await FoldedLinkAsync(foldedId, exceptMergeId, cancellationToken))
|
||||
{
|
||||
blocks.Add("already_folded");
|
||||
}
|
||||
|
||||
// One hop only: a landed merge's survivor never folds into a third account, so an alias is never a chain.
|
||||
if (await dbContext.AccountMerges.AnyAsync(
|
||||
x => x.Id != exceptMergeId && FoldedStates.Contains(x.State) && x.SurvivorUserAccountId == foldedId,
|
||||
cancellationToken))
|
||||
{
|
||||
blocks.Add("folded_is_survivor");
|
||||
}
|
||||
|
||||
if (await dbContext.SealedBallots.AnyAsync(
|
||||
x => x.UserAccountId == foldedId && x.Motion.Status == MotionStatus.Open,
|
||||
cancellationToken))
|
||||
{
|
||||
blocks.Add("sealed_ballot_pending");
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private Task<bool> FoldedLinkAsync(Guid accountId, Guid? exceptMergeId, CancellationToken cancellationToken) =>
|
||||
dbContext.AccountMerges.AnyAsync(
|
||||
x => x.Id != exceptMergeId && x.FoldedUserAccountId == accountId && FoldedStates.Contains(x.State),
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the merge to Bifrost records in one save, together with State=Committed. Total: blocks were checked before the move
|
||||
/// and a refusal now would strand moved logins, so it recomputes the reductions from current rows and applies them. Committed
|
||||
/// and Final are no-ops returning the receipt; any other state but HeimdallMoved is an error.
|
||||
/// </summary>
|
||||
public async Task<AccountMerge> CommitAsync(Guid mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken);
|
||||
|
||||
// The exclusive lock first, so no writer that read the folded account's standing is still to save when the reduction reads its
|
||||
// rows, then the state again: a commit that waited here found the merge landed and returns its receipt.
|
||||
await using var transaction = await BeginAsync(dbContext, cancellationToken);
|
||||
await LockAsync(dbContext, merge.FoldedUserAccountId, exclusive: true, cancellationToken);
|
||||
await dbContext.Entry(merge).ReloadAsync(cancellationToken);
|
||||
if (merge.State is AccountMergeState.Committed or AccountMergeState.Final)
|
||||
{
|
||||
return merge;
|
||||
}
|
||||
|
||||
if (merge.State != AccountMergeState.HeimdallMoved)
|
||||
{
|
||||
throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallMoved merge can be committed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reduction = await ReduceAsync(merge.SurvivorUserAccountId, merge.FoldedUserAccountId, cancellationToken, mergeId);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var seq = 0;
|
||||
foreach (var entry in reduction.Entries)
|
||||
{
|
||||
dbContext.AccountMergeEntries.Add(Journal(merge.Id, ++seq, entry, now));
|
||||
}
|
||||
|
||||
reduction.Apply(now);
|
||||
if (reduction.MovedPatronEvents)
|
||||
{
|
||||
await RestageTiersAsync(reduction.Survivor, reduction.Folded, now, cancellationToken);
|
||||
}
|
||||
|
||||
reduction.ReweighMovedVotes();
|
||||
merge.State = AccountMergeState.Committed;
|
||||
merge.CommittedUtc = now;
|
||||
merge.GraceEndsUtc = now + Grace;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return merge;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing was saved; drop what was staged so a retry on this context starts from the stored rows.
|
||||
dbContext.ChangeTracker.Clear();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Replays the journal backward in one save with State=Undone. Total, like commit. Undone is a no-op; any other state but HeimdallReversed is an error.</summary>
|
||||
public async Task<AccountMerge> UndoLocalAsync(Guid mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken);
|
||||
if (merge.State == AccountMergeState.Undone)
|
||||
{
|
||||
return merge;
|
||||
}
|
||||
|
||||
if (merge.State != AccountMergeState.HeimdallReversed)
|
||||
{
|
||||
throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallReversed merge can be undone locally.");
|
||||
}
|
||||
|
||||
if (merge.FinalizedUtc is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"Merge {mergeId} is Final; a merge is never undone past Final.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var journal = await dbContext.AccountMergeEntries.Where(x => x.MergeId == mergeId).OrderByDescending(x => x.Seq).ToListAsync(cancellationToken);
|
||||
var seq = journal.Count == 0 ? 0 : journal.Max(x => x.Seq);
|
||||
var survivor = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == merge.SurvivorUserAccountId, cancellationToken);
|
||||
var folded = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == merge.FoldedUserAccountId, cancellationToken);
|
||||
var survivorKey = AppraisalKey(survivor.Id);
|
||||
var restagePatron = false;
|
||||
|
||||
void Record(AccountMergeEntry original, bool restored) =>
|
||||
dbContext.AccountMergeEntries.Add(new AccountMergeEntry
|
||||
{
|
||||
MergeId = mergeId,
|
||||
Seq = ++seq,
|
||||
Kind = original.Kind,
|
||||
RecordId = original.RecordId,
|
||||
Action = restored ? AccountMergeEntryAction.Restored : AccountMergeEntryAction.KeptAfterUndo,
|
||||
PriorUserAccountId = original.PriorUserAccountId,
|
||||
AtUtc = now
|
||||
});
|
||||
|
||||
foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended or AccountMergeEntryAction.Created))
|
||||
{
|
||||
switch (entry.Kind, entry.Action)
|
||||
{
|
||||
case (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided):
|
||||
{
|
||||
var vote = await dbContext.Votes.IgnoreQueryFilters().Include(x => x.Motion).SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = vote?.VoidedByMergeId == mergeId && vote.Motion.Status == MotionStatus.Open;
|
||||
if (restore)
|
||||
{
|
||||
vote!.VoidedByMergeId = null;
|
||||
vote.VoidedUtc = null;
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved):
|
||||
{
|
||||
var vote = await dbContext.Votes.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = vote is not null && vote.UserAccountId == survivor.Id && vote.CastAtUtc == entry.PriorAtUtc;
|
||||
if (restore)
|
||||
{
|
||||
vote!.UserAccountId = entry.PriorUserAccountId!.Value;
|
||||
if (decimal.TryParse(entry.PriorText, NumberStyles.Number, CultureInfo.InvariantCulture, out var priorWeight))
|
||||
{
|
||||
vote.Weight = priorWeight;
|
||||
}
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided):
|
||||
{
|
||||
var row = await dbContext.ConceptAppraisals.IgnoreQueryFilters().SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = row?.VoidedByMergeId == mergeId;
|
||||
if (restore)
|
||||
{
|
||||
row!.VoidedByMergeId = null;
|
||||
row.VoidedUtc = null;
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved):
|
||||
{
|
||||
var row = await dbContext.ConceptAppraisals.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = row is not null && row.UserAccountId == survivor.Id && row.VoterKey == survivorKey && row.CastAtUtc == entry.PriorAtUtc;
|
||||
if (restore)
|
||||
{
|
||||
row!.UserAccountId = entry.PriorUserAccountId!.Value;
|
||||
var (priorClass, priorKey) = ParseAppraisalPrior(entry.PriorText);
|
||||
row.VoterKey = priorKey ?? row.VoterKey;
|
||||
row.VoterClass = priorClass ?? row.VoterClass;
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed):
|
||||
{
|
||||
var restore = folded.Membership is { Status: MembershipStatus.Merged } && Enum.TryParse<MembershipStatus>(entry.PriorText, out _);
|
||||
if (restore)
|
||||
{
|
||||
folded.Membership!.Status = Enum.Parse<MembershipStatus>(entry.PriorText!);
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended):
|
||||
{
|
||||
// Only while the survivor still carries exactly this merge's suspension; an admin who changed it since owns the standing now.
|
||||
var restore = survivor.Membership is { Status: MembershipStatus.Suspended } member &&
|
||||
member.SuspensionReason == SuspensionReason(mergeId) &&
|
||||
Enum.TryParse<MembershipStatus>(entry.PriorText, out _);
|
||||
if (restore)
|
||||
{
|
||||
survivor.Membership!.Status = Enum.Parse<MembershipStatus>(entry.PriorText!);
|
||||
survivor.Membership.SuspensionReason = null;
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created):
|
||||
{
|
||||
// The membership this merge made to carry a suspension goes with the merge, unless an admin has taken it over since.
|
||||
var created = await dbContext.Memberships.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = created is { Status: MembershipStatus.Suspended } && created.SuspensionReason == SuspensionReason(mergeId);
|
||||
if (restore)
|
||||
{
|
||||
dbContext.Memberships.Remove(created!);
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved):
|
||||
{
|
||||
var row = await dbContext.PatronSupportEvents.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = row is not null && row.UserAccountId == survivor.Id;
|
||||
if (restore)
|
||||
{
|
||||
row!.UserAccountId = entry.PriorUserAccountId!.Value;
|
||||
restagePatron = true;
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (restagePatron)
|
||||
{
|
||||
await RestageTiersAsync(survivor, folded, now, cancellationToken);
|
||||
}
|
||||
|
||||
merge.State = AccountMergeState.Undone;
|
||||
merge.UndoneUtc = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return merge;
|
||||
}
|
||||
catch
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Committed merges whose grace has ended become Final; returns how many this call finalized. Undo is never allowed past Final.
|
||||
/// State is the merge's concurrency token, so a merge another writer moved first (UndoRequested) fails its save and is left to that
|
||||
/// transition: a stale Committed read can never overwrite it.
|
||||
/// </summary>
|
||||
public async Task<int> FinalizeDueAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var dueIds = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => x.State == AccountMergeState.Committed && x.GraceEndsUtc <= now)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var finalized = 0;
|
||||
foreach (var id in dueIds)
|
||||
{
|
||||
var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == id, cancellationToken);
|
||||
if (merge.State != AccountMergeState.Committed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merge.State = AccountMergeState.Final;
|
||||
merge.FinalizedUtc = now;
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
finalized++;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
return finalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stages the journal entry for a patron event that is on a folded account's survivor, so undo moves it back with the rest; saved
|
||||
/// with the event by <see cref="PatronageService.RecordSupportEventAsync"/>, the only caller. Idempotent: a provider retry stages nothing.
|
||||
/// </summary>
|
||||
public static async Task StageIntakeJournalAsync(BifrostDbContext db, Guid mergeId, Guid foldedUserAccountId, Guid supportEventId, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await db.AccountMergeEntries.AnyAsync(
|
||||
x => x.MergeId == mergeId && x.Kind == AccountMergeEntryKind.PatronSupportEvent && x.RecordId == supportEventId,
|
||||
cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seq = await db.AccountMergeEntries.Where(x => x.MergeId == mergeId).MaxAsync(x => (int?)x.Seq, cancellationToken) ?? 0;
|
||||
db.AccountMergeEntries.Add(Journal(
|
||||
mergeId,
|
||||
seq + 1,
|
||||
new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEventId, foldedUserAccountId),
|
||||
now));
|
||||
}
|
||||
|
||||
private sealed class Reduction(
|
||||
UserAccount survivor,
|
||||
UserAccount folded,
|
||||
List<MergePlanEntry> entries,
|
||||
List<Action<Guid, DateTimeOffset>> effects,
|
||||
List<Vote> movedVotes,
|
||||
bool movedPatronEvents)
|
||||
{
|
||||
public UserAccount Survivor { get; } = survivor;
|
||||
|
||||
public UserAccount Folded { get; } = folded;
|
||||
|
||||
public IReadOnlyList<MergePlanEntry> Entries => entries;
|
||||
|
||||
public bool MovedPatronEvents { get; } = movedPatronEvents;
|
||||
|
||||
public Guid MergeId { get; set; }
|
||||
|
||||
/// <summary>A moved vote carries the survivor's weight, derived once from the survivor's tiers as they stand after the restage, never the folded account's.</summary>
|
||||
public void ReweighMovedVotes()
|
||||
{
|
||||
foreach (var vote in movedVotes)
|
||||
{
|
||||
vote.Weight = Survivor.Membership!.EffectiveVotingWeight;
|
||||
}
|
||||
}
|
||||
|
||||
public void Apply(DateTimeOffset now)
|
||||
{
|
||||
foreach (var effect in effects)
|
||||
{
|
||||
effect(MergeId, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a merge does to records, computed from current rows without writing: the journal entries and the effects that apply them.
|
||||
/// Rules (design 5): suspension wins; the folded membership is closed and its roles never copied; open standard motions and
|
||||
/// concept appraisals keep one voice per person (the survivor's), a lone folded one moving only to an Active survivor; patron
|
||||
/// events follow the person.
|
||||
/// </summary>
|
||||
private async Task<Reduction> ReduceAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? mergeId = null)
|
||||
{
|
||||
var survivor = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == survivorId, cancellationToken);
|
||||
var folded = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == foldedId, cancellationToken);
|
||||
|
||||
var entries = new List<MergePlanEntry>();
|
||||
var effects = new List<Action<Guid, DateTimeOffset>>();
|
||||
var movedVotes = new List<Vote>();
|
||||
|
||||
var foldedSuspended = folded.Membership?.Status == MembershipStatus.Suspended;
|
||||
var survivorSuspendedNow = survivor.Membership?.Status == MembershipStatus.Suspended;
|
||||
var survivorStanding = survivor.Membership?.Status;
|
||||
if (foldedSuspended && !survivorSuspendedNow)
|
||||
{
|
||||
if (survivor.Membership is { } survivorMembership)
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString()));
|
||||
effects.Add((id, _) =>
|
||||
{
|
||||
survivorMembership.Status = MembershipStatus.Suspended;
|
||||
survivorMembership.SuspensionReason = SuspensionReason(id);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// The chat tier holds no membership, and an absent one chats: without a row the suspension would be gone. The row is journalled so undo removes it.
|
||||
var created = new Bifrost.Web.Domain.Membership { UserAccountId = survivor.Id, Status = MembershipStatus.Suspended };
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created, created.Id));
|
||||
effects.Add((id, now) =>
|
||||
{
|
||||
created.SuspensionReason = SuspensionReason(id);
|
||||
created.CreatedAtUtc = now;
|
||||
created.Notes = "Created by an account merge to carry the folded account's suspension.";
|
||||
dbContext.Memberships.Add(created);
|
||||
});
|
||||
}
|
||||
|
||||
survivorStanding = MembershipStatus.Suspended;
|
||||
}
|
||||
|
||||
var survivorActive = survivorStanding == MembershipStatus.Active;
|
||||
|
||||
if (folded.Membership is { } foldedMembership)
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed, foldedMembership.Id, null, foldedMembership.Status.ToString()));
|
||||
effects.Add((_, _) => foldedMembership.Status = MembershipStatus.Merged);
|
||||
}
|
||||
|
||||
var foldedVotes = await dbContext.Votes
|
||||
.Where(x => x.UserAccountId == foldedId && x.Motion.Status == MotionStatus.Open && x.Motion.Kind == MotionKind.Standard)
|
||||
.ToListAsync(cancellationToken);
|
||||
var survivorVotedMotions = (await dbContext.Votes
|
||||
.Where(x => x.UserAccountId == survivorId && x.Motion.Status == MotionStatus.Open && x.Motion.Kind == MotionKind.Standard)
|
||||
.Select(x => x.MotionId)
|
||||
.ToListAsync(cancellationToken)).ToHashSet();
|
||||
foreach (var vote in foldedVotes)
|
||||
{
|
||||
if (survivorVotedMotions.Contains(vote.MotionId) || !survivorActive)
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided, vote.Id, vote.UserAccountId));
|
||||
effects.Add((id, now) =>
|
||||
{
|
||||
vote.VoidedByMergeId = id;
|
||||
vote.VoidedUtc = now;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, vote.Weight.ToString(CultureInfo.InvariantCulture), vote.CastAtUtc));
|
||||
movedVotes.Add(vote);
|
||||
effects.Add((_, _) => vote.UserAccountId = survivorId);
|
||||
}
|
||||
}
|
||||
|
||||
var survivorKey = AppraisalKey(survivorId);
|
||||
var foldedAppraisals = await dbContext.ConceptAppraisals.Where(x => x.UserAccountId == foldedId).ToListAsync(cancellationToken);
|
||||
var survivorSubjects = (await dbContext.ConceptAppraisals
|
||||
.Where(x => x.UserAccountId == survivorId || x.VoterKey == survivorKey)
|
||||
.Select(x => x.SubjectId)
|
||||
.ToListAsync(cancellationToken)).ToHashSet();
|
||||
foreach (var appraisal in foldedAppraisals)
|
||||
{
|
||||
if (survivorSubjects.Contains(appraisal.SubjectId) || !survivorActive)
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided, appraisal.Id, appraisal.UserAccountId));
|
||||
effects.Add((id, now) =>
|
||||
{
|
||||
appraisal.VoidedByMergeId = id;
|
||||
appraisal.VoidedUtc = now;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, $"{appraisal.VoterClass}|{appraisal.VoterKey}", appraisal.CastAtUtc));
|
||||
effects.Add((_, _) =>
|
||||
{
|
||||
appraisal.UserAccountId = survivorId;
|
||||
appraisal.VoterKey = survivorKey;
|
||||
appraisal.VoterClass = VoterClass.Member; // the survivor is Active here, which is what makes an account a Member voter
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var foldedEvents = folded.PatronSupportEvents.ToList();
|
||||
foreach (var supportEvent in foldedEvents)
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEvent.Id, supportEvent.UserAccountId));
|
||||
effects.Add((_, _) => supportEvent.UserAccountId = survivorId);
|
||||
}
|
||||
|
||||
var reduction = new Reduction(survivor, folded, entries, effects, movedVotes, foldedEvents.Count > 0);
|
||||
if (mergeId is { } m)
|
||||
{
|
||||
reduction.MergeId = m;
|
||||
}
|
||||
|
||||
return reduction;
|
||||
}
|
||||
|
||||
// Tiers derive once from all the events now on each account, never tier A plus tier B. Ownership is read from the
|
||||
// tracked rows, so it holds before the save as well as after it.
|
||||
private async Task RestageTiersAsync(UserAccount survivor, UserAccount folded, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var events = survivor.PatronSupportEvents.Concat(folded.PatronSupportEvents).DistinctBy(x => x.Id).ToList();
|
||||
await StageIfMembershipAsync(survivor.Membership, events.Where(x => x.UserAccountId == survivor.Id).ToList(), now, cancellationToken);
|
||||
await StageIfMembershipAsync(folded.Membership, events.Where(x => x.UserAccountId == folded.Id).ToList(), now, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task StageIfMembershipAsync(Bifrost.Web.Domain.Membership? membership, IEnumerable<PatronSupportEvent> events, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (membership is not null && dbContext.Entry(membership).State != EntityState.Deleted)
|
||||
{
|
||||
await patronageService.StageTierSnapshotAsync(membership, events, now, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static AccountMergeEntry Journal(Guid mergeId, int seq, MergePlanEntry entry, DateTimeOffset now) => new()
|
||||
{
|
||||
MergeId = mergeId,
|
||||
Seq = seq,
|
||||
Kind = entry.Kind,
|
||||
RecordId = entry.RecordId,
|
||||
Action = entry.Action,
|
||||
PriorUserAccountId = entry.PriorUserAccountId,
|
||||
PriorText = entry.PriorText,
|
||||
PriorAtUtc = entry.PriorAtUtc,
|
||||
AtUtc = now
|
||||
};
|
||||
|
||||
private static string AppraisalKey(Guid accountId) => "account:" + accountId.ToString("N");
|
||||
|
||||
private static (VoterClass? Class, string? Key) ParseAppraisalPrior(string? prior)
|
||||
{
|
||||
var split = prior?.IndexOf('|') ?? -1;
|
||||
return split > 0 && Enum.TryParse<VoterClass>(prior![..split], out var voterClass) ? (voterClass, prior[(split + 1)..]) : (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one merge loop, every 30 seconds: the proof holder drops expired bearers, the saga advances every due in-flight merge (confirm,
|
||||
/// replay, undo), then the ledger finalizes merges past their grace. A new scope per tick, a warning on failure, the SealedBallotCloser shape. No second merge hosted service exists.
|
||||
/// </summary>
|
||||
public sealed class AccountMergeSweeper(IServiceScopeFactory scopes, TimeProvider clock, ILogger<AccountMergeSweeper> logger) : BackgroundService
|
||||
{
|
||||
public static readonly TimeSpan Tick = TimeSpan.FromSeconds(30);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(Tick);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
var now = clock.GetUtcNow();
|
||||
scope.ServiceProvider.GetRequiredService<MergeProofs>().PruneExpired();
|
||||
await scope.ServiceProvider.GetRequiredService<AccountMergeSaga>().AdvanceDueAsync(now, stoppingToken);
|
||||
await scope.ServiceProvider.GetRequiredService<AccountMergeLedger>().FinalizeDueAsync(now, stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(error, "Account merge sweep failed; retrying next tick.");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,576 +0,0 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// A live Heimdall bearer a member redeemed from a fresh sign-in, with the Heimdall account and provider it came through. The token is a
|
||||
/// secret: <see cref="PrintMembers"/> never prints it (nor the account id), so no log or exception that holds a proof can leak it.
|
||||
/// </summary>
|
||||
public sealed record MergeProof(string AccessToken, string HeimdallAccountId, string Provider)
|
||||
{
|
||||
private bool PrintMembers(StringBuilder builder)
|
||||
{
|
||||
builder.Append($"Provider = {Provider}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the member confirmed: the pair, the plan digest they saw, and a proof for every login of each side that must speak.</summary>
|
||||
public sealed record ConfirmMergeRequest(
|
||||
Guid ActorUserAccountId,
|
||||
Guid SurvivorUserAccountId,
|
||||
Guid FoldedUserAccountId,
|
||||
string PreviewDigest,
|
||||
MergeProof SurvivorProof,
|
||||
IReadOnlyList<MergeProof> FoldedProofs);
|
||||
|
||||
/// <summary>The closed answer to a confirm. Only Confirmed wrote a row.</summary>
|
||||
public abstract record ConfirmOutcome
|
||||
{
|
||||
private ConfirmOutcome()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Confirmed(Guid MergeId) : ConfirmOutcome;
|
||||
|
||||
/// <summary>The plan's block codes (never account names), or merge_in_flight when the database refused an overlapping merge.</summary>
|
||||
public sealed record Blocked(IReadOnlyList<string> Codes) : ConfirmOutcome;
|
||||
|
||||
public sealed record PreviewStale : ConfirmOutcome;
|
||||
|
||||
public sealed record NotParty : ConfirmOutcome;
|
||||
|
||||
public sealed record ProofAccountMismatch : ConfirmOutcome;
|
||||
}
|
||||
|
||||
/// <summary>The closed answer to an undo request. Only Requested wrote.</summary>
|
||||
public abstract record UndoOutcome
|
||||
{
|
||||
private UndoOutcome()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Requested : UndoOutcome;
|
||||
|
||||
public sealed record NotCommitted : UndoOutcome;
|
||||
|
||||
public sealed record GraceOver : UndoOutcome;
|
||||
|
||||
public sealed record NotSurvivor : UndoOutcome;
|
||||
|
||||
public sealed record ProofAccountMismatch : UndoOutcome;
|
||||
}
|
||||
|
||||
/// <summary>The two ways an operator may end a stuck leg: the forward move is failed, or the undo is given up and the merge stands.</summary>
|
||||
public enum SettleChoice
|
||||
{
|
||||
FailForward,
|
||||
AbandonUndo
|
||||
}
|
||||
|
||||
/// <summary>The closed answer to an operator's settle. Only Settled wrote.</summary>
|
||||
public abstract record SettleOutcome
|
||||
{
|
||||
private SettleOutcome()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Settled : SettleOutcome;
|
||||
|
||||
/// <summary>The merge is missing, in another state than the choice ends, or not stuck on one of the settleable outcomes.</summary>
|
||||
public sealed record NotStuck : SettleOutcome;
|
||||
|
||||
public sealed record WrongMoveId : SettleOutcome;
|
||||
|
||||
/// <summary>The State token went to another writer first; the other transition stands and nothing was changed.</summary>
|
||||
public sealed record Lost : SettleOutcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one owner of an account merge's lifecycle outside the ledger's Committed, Final and Undone, and the only caller of
|
||||
/// <see cref="HeimdallIdentityMoveClient"/>. It inserts Confirmed (the consent record, with the Heimdall bearers sealed and the move
|
||||
/// id minted), runs the one idempotent step function <see cref="AdvanceAsync"/> (move, commit, reverse, undo), and writes
|
||||
/// HeimdallMoved, Failed, UndoRequested and HeimdallReversed. It never writes votes, memberships or journal rows (the ledger's), never
|
||||
/// judges a proof's tier or freshness (Heimdall's), and reads no standing of its own: the plan's blocks and the database's live-merge
|
||||
/// index answer overlap, and the writers that act for an account go through <see cref="AccountMergeLedger.WriteAsAsync{T}"/>.
|
||||
/// Every transition is one save guarded by State, the merge's concurrency token; the loser of a race reloads and continues from the
|
||||
/// stored state. Heimdall's closed outcome is consumed as is: Unknown (a lost answer, an outage) is replayed with the same stored
|
||||
/// move id and never fails a merge. Logs name the merge id, state, outcome class and refusal code only.
|
||||
/// </summary>
|
||||
public sealed partial class AccountMergeSaga(
|
||||
BifrostDbContext dbContext,
|
||||
AccountMergeLedger ledger,
|
||||
HeimdallIdentityMoveClient mover,
|
||||
IDataProtectionProvider dataProtection,
|
||||
AuditTrailService audit,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AccountMergeSaga> logger)
|
||||
{
|
||||
public const string MergeInFlight = "merge_in_flight";
|
||||
public const string ReceiptMismatch = "receipt_mismatch";
|
||||
public const string ProofsUnreadable = "proofs_unreadable";
|
||||
public const string MoveIdConflict = "move_id_conflict";
|
||||
public const string OperatorFailed = "operator_failed";
|
||||
public const string UndoOperatorAbandoned = "undo_operator_abandoned";
|
||||
|
||||
private const string LiveMergeIndex = "UX_AccountMerges_FoldedUserAccountId_Live";
|
||||
private const int MaxProofs = 8;
|
||||
|
||||
private static readonly TimeSpan[] Backoff = [TimeSpan.FromSeconds(15), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15)];
|
||||
private static readonly TimeSpan UnreadableRetry = TimeSpan.FromHours(1);
|
||||
|
||||
private static readonly AccountMergeState[] DueStates =
|
||||
[AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed];
|
||||
|
||||
private readonly IDataProtector _seal = dataProtection.CreateProtector("Bifrost.AccountMerge.Proofs.v1");
|
||||
|
||||
private sealed record ProofSeal(List<string> From, string? To)
|
||||
{
|
||||
// The seal holds live bearers: the generated ToString would print them into any log or exception that holds it.
|
||||
private bool PrintMembers(StringBuilder builder)
|
||||
{
|
||||
builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- confirm ----
|
||||
|
||||
/// <summary>
|
||||
/// The member's consent, as one insert. The actor must be one of the pair; the plan must have no blocks and be the one the member
|
||||
/// saw (digest); every proof must be of its side's Heimdall account. Calls no Heimdall: the caller runs <see cref="AdvanceAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<ConfirmOutcome> ConfirmAsync(ConfirmMergeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireProofs(request.SurvivorProof, request.FoldedProofs);
|
||||
if (request.ActorUserAccountId != request.SurvivorUserAccountId && request.ActorUserAccountId != request.FoldedUserAccountId)
|
||||
{
|
||||
return new ConfirmOutcome.NotParty();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await AccountMergeLedger.WriteAsAsync(dbContext, request.ActorUserAccountId, _ => InsertConfirmedAsync(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException error) when (IsLiveMergeViolation(error))
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
return new ConfirmOutcome.Blocked([MergeInFlight]);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ConfirmOutcome> InsertConfirmedAsync(ConfirmMergeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await ledger.PlanAsync(request.SurvivorUserAccountId, request.FoldedUserAccountId, cancellationToken);
|
||||
if (plan.Blocks.Count > 0)
|
||||
{
|
||||
return new ConfirmOutcome.Blocked(plan.Blocks);
|
||||
}
|
||||
|
||||
if (!string.Equals(plan.Digest(request.SurvivorUserAccountId, request.FoldedUserAccountId), request.PreviewDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return new ConfirmOutcome.PreviewStale();
|
||||
}
|
||||
|
||||
var survivorHeimdall = await HeimdallAccountAsync(request.SurvivorUserAccountId, cancellationToken);
|
||||
var foldedHeimdall = await HeimdallAccountAsync(request.FoldedUserAccountId, cancellationToken);
|
||||
if (request.SurvivorProof.HeimdallAccountId != survivorHeimdall || request.FoldedProofs.Any(x => x.HeimdallAccountId != foldedHeimdall))
|
||||
{
|
||||
return new ConfirmOutcome.ProofAccountMismatch();
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var merge = new AccountMerge
|
||||
{
|
||||
State = AccountMergeState.Confirmed,
|
||||
SurvivorUserAccountId = request.SurvivorUserAccountId,
|
||||
FoldedUserAccountId = request.FoldedUserAccountId,
|
||||
CreatedUtc = now,
|
||||
ConfirmedByUserAccountId = request.ActorUserAccountId,
|
||||
ConfirmedUtc = now,
|
||||
PreviewDigest = request.PreviewDigest,
|
||||
SurvivorProofProvider = request.SurvivorProof.Provider,
|
||||
FoldedProofProviders = string.Join(',', request.FoldedProofs.Select(x => x.Provider).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)),
|
||||
// Heimdall's idempotency key: random, minted once here, read back from the row on every call, never derived from the merge id.
|
||||
HeimdallMoveId = Guid.NewGuid().ToString("N"),
|
||||
SealedProofs = Seal(request.FoldedProofs.Select(x => x.AccessToken), request.SurvivorProof.AccessToken),
|
||||
LastOutcome = "confirmed"
|
||||
};
|
||||
dbContext.AccountMerges.Add(merge);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Account merge {MergeId} {State}", merge.Id, merge.State);
|
||||
return new ConfirmOutcome.Confirmed(merge.Id);
|
||||
}
|
||||
|
||||
// ---- undo request ----
|
||||
|
||||
/// <summary>
|
||||
/// The survivor's request to give the moved logins back, inside the grace and with fresh survivor proofs. One save: UndoRequested, a
|
||||
/// freshly minted reverse move id, the sealed proofs. Losing the race to the Final sweep is the State token's, answered GraceOver.
|
||||
/// </summary>
|
||||
public async Task<UndoOutcome> RequestUndoAsync(
|
||||
Guid mergeId, Guid actorUserAccountId, IReadOnlyList<MergeProof> survivorProofs, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireProofs(null, survivorProofs);
|
||||
try
|
||||
{
|
||||
return await AccountMergeLedger.WriteAsAsync(dbContext, actorUserAccountId, _ => WriteUndoRequestedAsync(mergeId, actorUserAccountId, survivorProofs, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
return new UndoOutcome.GraceOver();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UndoOutcome> WriteUndoRequestedAsync(
|
||||
Guid mergeId, Guid actorUserAccountId, IReadOnlyList<MergeProof> survivorProofs, CancellationToken cancellationToken)
|
||||
{
|
||||
var merge = await LoadAsync(mergeId, cancellationToken);
|
||||
if (merge is null)
|
||||
{
|
||||
return new UndoOutcome.NotCommitted();
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (actorUserAccountId != merge.SurvivorUserAccountId)
|
||||
{
|
||||
return new UndoOutcome.NotSurvivor();
|
||||
}
|
||||
|
||||
if (merge.State == AccountMergeState.Final || merge.FinalizedUtc is not null)
|
||||
{
|
||||
return new UndoOutcome.GraceOver();
|
||||
}
|
||||
|
||||
if (merge.State != AccountMergeState.Committed)
|
||||
{
|
||||
return new UndoOutcome.NotCommitted();
|
||||
}
|
||||
|
||||
if (merge.GraceEndsUtc is not { } graceEnds || now >= graceEnds)
|
||||
{
|
||||
return new UndoOutcome.GraceOver();
|
||||
}
|
||||
|
||||
var survivorHeimdall = await HeimdallAccountAsync(merge.SurvivorUserAccountId, cancellationToken);
|
||||
if (survivorProofs.Any(x => x.HeimdallAccountId != survivorHeimdall))
|
||||
{
|
||||
return new UndoOutcome.ProofAccountMismatch();
|
||||
}
|
||||
|
||||
merge.State = AccountMergeState.UndoRequested;
|
||||
merge.UndoRequestedUtc = now;
|
||||
merge.HeimdallReverseMoveId = Guid.NewGuid().ToString("N");
|
||||
merge.SealedProofs = Seal(survivorProofs.Select(x => x.AccessToken), null);
|
||||
merge.Attempts = 0;
|
||||
merge.NextAttemptUtc = null;
|
||||
merge.LastOutcome = "undo_requested";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Account merge {MergeId} {State}", merge.Id, merge.State);
|
||||
return new UndoOutcome.Requested();
|
||||
}
|
||||
|
||||
// ---- the step function ----
|
||||
|
||||
/// <summary>
|
||||
/// The one step function: confirm's inline advance, the sweeper's resume after a crash and an undo request all run it. From the
|
||||
/// stored state it makes the Heimdall move (or reversal) under the stored id, records the answer, and calls the ledger's commit (or
|
||||
/// local undo). Idempotent: every state has one next step and every step is safe to repeat. Other states are a no-op.
|
||||
/// </summary>
|
||||
public async Task<AccountMerge> AdvanceAsync(Guid mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var lost = 0; ; lost++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await AdvanceOnceAsync(mergeId, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) when (lost < 2)
|
||||
{
|
||||
// Another advancer saved first: forget what this context staged and continue from the stored state.
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AccountMerge> AdvanceOnceAsync(Guid mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var merge = await LoadAsync(mergeId, cancellationToken) ?? throw new InvalidOperationException($"Merge {mergeId} does not exist.");
|
||||
if (merge.State is AccountMergeState.Confirmed or AccountMergeState.UndoRequested)
|
||||
{
|
||||
merge = await MoveAsync(merge, cancellationToken);
|
||||
}
|
||||
|
||||
return merge.State switch
|
||||
{
|
||||
AccountMergeState.HeimdallMoved => await ledger.CommitAsync(merge.Id, cancellationToken),
|
||||
AccountMergeState.HeimdallReversed => await ledger.UndoLocalAsync(merge.Id, cancellationToken),
|
||||
_ => merge
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AccountMerge> MoveAsync(AccountMerge merge, CancellationToken cancellationToken)
|
||||
{
|
||||
if (merge.LastOutcome == ReceiptMismatch)
|
||||
{
|
||||
return merge; // Heimdall answered, wrongly, and its proofs are wiped: replay can only repeat the same answer.
|
||||
}
|
||||
|
||||
var forward = merge.State == AccountMergeState.Confirmed;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (Open(merge.SealedProofs) is not { } proofs)
|
||||
{
|
||||
// The key ring is lost or rotated out. Heimdall may already have moved, so nothing changes but the retry time.
|
||||
merge.LastOutcome = ProofsUnreadable;
|
||||
merge.NextAttemptUtc = now + UnreadableRetry;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogError("Account merge {MergeId} {State}: sealed proofs unreadable", merge.Id, merge.State);
|
||||
return merge;
|
||||
}
|
||||
|
||||
var request = forward
|
||||
? new HeimdallMoveRequest(merge.HeimdallMoveId!, proofs.From, proofs.To)
|
||||
: new HeimdallMoveRequest(merge.HeimdallReverseMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId);
|
||||
var outcome = await mover.MoveAsync(request, cancellationToken);
|
||||
var code = outcome is HeimdallMoveOutcome.Refused refused ? RefusalCode(refused.Code) : null;
|
||||
switch (outcome)
|
||||
{
|
||||
case HeimdallMoveOutcome.Moved moved:
|
||||
// The leg's destination is the survivor going forward and the folded account coming back.
|
||||
var destination = await HeimdallAccountAsync(forward ? merge.SurvivorUserAccountId : merge.FoldedUserAccountId, cancellationToken);
|
||||
merge.SealedProofs = null;
|
||||
if (moved.Receipt.AccountId != destination)
|
||||
{
|
||||
merge.LastOutcome = ReceiptMismatch;
|
||||
break;
|
||||
}
|
||||
|
||||
merge.Attempts = 0;
|
||||
merge.NextAttemptUtc = null;
|
||||
merge.LastOutcome = "moved";
|
||||
if (forward)
|
||||
{
|
||||
merge.State = AccountMergeState.HeimdallMoved;
|
||||
merge.ProofFromTier = Tier(moved.Receipt.ProofFrom);
|
||||
merge.ProofToTier = moved.Receipt.ProofTo is { } to ? Tier(to) : null;
|
||||
merge.MovedIdentityIds = JsonSerializer.Serialize(moved.Receipt.MovedIdentityIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
merge.State = AccountMergeState.HeimdallReversed;
|
||||
merge.ReversedUtc = now;
|
||||
}
|
||||
|
||||
break;
|
||||
case HeimdallMoveOutcome.Refused:
|
||||
// Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was.
|
||||
merge.SealedProofs = null;
|
||||
merge.NextAttemptUtc = null;
|
||||
if (forward)
|
||||
{
|
||||
merge.State = AccountMergeState.Failed;
|
||||
merge.FailedUtc = now;
|
||||
merge.FailureCode = code!;
|
||||
merge.LastOutcome = "refused";
|
||||
}
|
||||
else
|
||||
{
|
||||
merge.State = AccountMergeState.Committed;
|
||||
merge.LastOutcome = "undo_" + code;
|
||||
}
|
||||
|
||||
break;
|
||||
case HeimdallMoveOutcome.Unknown unknown:
|
||||
merge.Attempts++;
|
||||
merge.NextAttemptUtc = now + Backoff[Math.Min(merge.Attempts, Backoff.Length) - 1];
|
||||
merge.LastOutcome = unknown.Reason.Length > 64 ? unknown.Reason[..64] : unknown.Reason;
|
||||
break;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code}", merge.Id, merge.State, outcome.GetType().Name, code);
|
||||
return merge;
|
||||
}
|
||||
|
||||
/// <summary>Advances every in-flight merge that is due, oldest first. One that throws is logged and skipped; returns how many were advanced.</summary>
|
||||
public async Task<int> AdvanceDueAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var due = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => DueStates.Contains(x.State) && (x.NextAttemptUtc == null || x.NextAttemptUtc <= now))
|
||||
.OrderBy(x => x.CreatedUtc)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var advanced = 0;
|
||||
foreach (var id in due)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AdvanceAsync(id, cancellationToken);
|
||||
advanced++;
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
logger.LogWarning("Account merge {MergeId} advance failed: {Error}", id, error.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
return advanced;
|
||||
}
|
||||
|
||||
// ---- operator settle ----
|
||||
|
||||
/// <summary>The Heimdall answers no replay can change: the one the saga recorded and stopped on, the key ring's loss, and a move Heimdall holds but will not confirm.</summary>
|
||||
public static readonly IReadOnlyList<string> SettleableOutcomes = [ReceiptMismatch, ProofsUnreadable, MoveIdConflict];
|
||||
|
||||
/// <summary>
|
||||
/// Whether an operator may end this merge's leg: in flight (Confirmed or UndoRequested) and stopped on one of the closed
|
||||
/// outcomes. Derived from the row on every read; there is no stuck flag.
|
||||
/// </summary>
|
||||
public static bool IsSettleable(AccountMerge merge) =>
|
||||
merge.State is AccountMergeState.Confirmed or AccountMergeState.UndoRequested && SettleableOutcomes.Contains(merge.LastOutcome);
|
||||
|
||||
/// <summary>
|
||||
/// A platform admin's end of a stuck leg (ruling merge-stuck-end-only-typed-id): FailForward turns a stuck Confirmed merge into
|
||||
/// Failed, AbandonUndo returns a stuck UndoRequested merge to Committed with its grace. Only with the leg's stored move id typed back.
|
||||
/// Never adopts or invents a Heimdall receipt; a merge Heimdall moved (HeimdallMoved, Committed) is never settleable. One save
|
||||
/// guarded by State carries the transition and its audit row (choice only): the loser of a race with <see cref="AdvanceAsync"/>
|
||||
/// answers Lost and changes nothing. The caller has checked the operator's authority.
|
||||
/// </summary>
|
||||
public async Task<SettleOutcome> SettleStuckAsync(
|
||||
Guid mergeId, SettleChoice choice, string typedMoveId, Guid operatorUserAccountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var forward = choice == SettleChoice.FailForward;
|
||||
var merge = await LoadAsync(mergeId, cancellationToken);
|
||||
if (merge is null || merge.State != (forward ? AccountMergeState.Confirmed : AccountMergeState.UndoRequested) || !IsSettleable(merge))
|
||||
{
|
||||
return new SettleOutcome.NotStuck();
|
||||
}
|
||||
|
||||
if (!string.Equals(typedMoveId, forward ? merge.HeimdallMoveId : merge.HeimdallReverseMoveId, StringComparison.Ordinal))
|
||||
{
|
||||
return new SettleOutcome.WrongMoveId();
|
||||
}
|
||||
|
||||
merge.SealedProofs = null;
|
||||
merge.NextAttemptUtc = null;
|
||||
if (forward)
|
||||
{
|
||||
merge.State = AccountMergeState.Failed;
|
||||
merge.FailedUtc = timeProvider.GetUtcNow();
|
||||
merge.FailureCode = OperatorFailed;
|
||||
merge.LastOutcome = OperatorFailed;
|
||||
}
|
||||
else
|
||||
{
|
||||
merge.State = AccountMergeState.Committed;
|
||||
merge.LastOutcome = UndoOperatorAbandoned;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await audit.RecordAsync(operatorUserAccountId, nameof(AccountMerge), merge.Id, "merge.settled", choice.ToString(), cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
return new SettleOutcome.Lost();
|
||||
}
|
||||
|
||||
logger.LogInformation("Account merge {MergeId} {State}: settled by an operator ({Choice})", merge.Id, merge.State, choice);
|
||||
return new SettleOutcome.Settled();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>The merge as stored now: a row this context already tracks is reloaded, never trusted.</summary>
|
||||
private async Task<AccountMerge?> LoadAsync(Guid mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tracked = dbContext.AccountMerges.Local.FirstOrDefault(x => x.Id == mergeId);
|
||||
if (tracked is not null)
|
||||
{
|
||||
await dbContext.Entry(tracked).ReloadAsync(cancellationToken);
|
||||
return dbContext.Entry(tracked).State == EntityState.Detached ? null : tracked;
|
||||
}
|
||||
|
||||
return await dbContext.AccountMerges.SingleOrDefaultAsync(x => x.Id == mergeId, cancellationToken);
|
||||
}
|
||||
|
||||
private Task<string> HeimdallAccountAsync(Guid userAccountId, CancellationToken cancellationToken) =>
|
||||
dbContext.UserAccounts.AsNoTracking().Where(x => x.Id == userAccountId).Select(x => x.HeimdallAccountId).SingleAsync(cancellationToken);
|
||||
|
||||
/// <summary>Shapes Heimdall would 400 are refused before a row exists, so a confirm can never store a merge that cannot call. Messages name no value.</summary>
|
||||
private static void RequireProofs(MergeProof? one, IReadOnlyList<MergeProof> many)
|
||||
{
|
||||
if (many is not { Count: >= 1 and <= MaxProofs })
|
||||
{
|
||||
throw new ArgumentException("1 to 8 proofs are required", nameof(many));
|
||||
}
|
||||
|
||||
foreach (var proof in one is null ? many : [one, .. many])
|
||||
{
|
||||
if (string.IsNullOrEmpty(proof.AccessToken) || proof.AccessToken.Length > HeimdallIdentityMoveClient.MaxProofLength
|
||||
|| string.IsNullOrEmpty(proof.HeimdallAccountId) || string.IsNullOrEmpty(proof.Provider) || proof.Provider.Length > 32)
|
||||
{
|
||||
throw new ArgumentException("a proof is malformed", nameof(many));
|
||||
}
|
||||
}
|
||||
|
||||
if (many.Select(x => x.Provider).Distinct(StringComparer.Ordinal).Sum(x => x.Length + 1) > 121)
|
||||
{
|
||||
throw new ArgumentException("the proofs' providers do not fit the record", nameof(many));
|
||||
}
|
||||
}
|
||||
|
||||
private string Seal(IEnumerable<string> from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to)));
|
||||
|
||||
private ProofSeal? Open(string? sealedProofs)
|
||||
{
|
||||
if (sealedProofs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ProofSeal>(_seal.Unprotect(sealedProofs)) is { From.Count: > 0 } proofs ? proofs : null;
|
||||
}
|
||||
catch (Exception error) when (error is CryptographicException or JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Tier(HeimdallProofTier tier) => tier == HeimdallProofTier.Strong ? "strong" : "weak";
|
||||
|
||||
private static string RefusalCode(HeimdallMoveRefusal code) =>
|
||||
code == HeimdallMoveRefusal.RequestInvalid ? "invalid_request" : WordBoundary().Replace(code.ToString(), "_$1").ToLowerInvariant();
|
||||
|
||||
[GeneratedRegex("(?<!^)([A-Z])")]
|
||||
private static partial Regex WordBoundary();
|
||||
|
||||
/// <summary>The database's refusal of an overlapping merge: the live-merge unique index or the chain trigger's exclusion violation.</summary>
|
||||
private static bool IsLiveMergeViolation(DbUpdateException error)
|
||||
{
|
||||
for (var current = (Exception?)error; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is PostgresException { SqlState: "23P01" } or PostgresException { SqlState: "23505", ConstraintName: LiveMergeIndex })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ public enum SuspensionOutcome
|
|||
|
||||
/// <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
|
||||
/// receipt naming the admin, the account and the reason, 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>
|
||||
|
|
@ -49,12 +49,6 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi
|
|||
return SuspensionOutcome.NoSuchAccount;
|
||||
}
|
||||
|
||||
// A merged account is terminal: only the merge ledger leaves that status, so neither suspending nor lifting applies.
|
||||
if (membership.IsMerged)
|
||||
{
|
||||
return SuspensionOutcome.NotApplicable;
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
|
@ -71,16 +65,14 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi
|
|||
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).
|
||||
// One save: the status change and its 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}).",
|
||||
$"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}): {clipped}",
|
||||
cancellationToken);
|
||||
return SuspensionOutcome.Done;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ public sealed class MembershipSynchronizationService(
|
|||
BifrostDbContext dbContext,
|
||||
BifrostIdentityService bifrostIdentityService,
|
||||
IOptions<BootstrapOptions> bootstrapOptions,
|
||||
TimeProvider timeProvider,
|
||||
AuditTrailService auditTrailService,
|
||||
ILogger<MembershipSynchronizationService> logger)
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -111,14 +109,9 @@ public sealed class MembershipSynchronizationService(
|
|||
invitation.AcceptedAtUtc = now;
|
||||
}
|
||||
|
||||
MembershipStatus? promotedFrom = null;
|
||||
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
|
||||
{
|
||||
promotedFrom = userAccount.Membership!.Status;
|
||||
if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger))
|
||||
{
|
||||
promotedFrom = null;
|
||||
}
|
||||
PromoteToBootstrapAdmin(userAccount.Membership!, now);
|
||||
}
|
||||
else if (userAccount.Membership!.Status == MembershipStatus.Active)
|
||||
{
|
||||
|
|
@ -132,16 +125,22 @@ public sealed class MembershipSynchronizationService(
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
ClaimsPrincipal principal,
|
||||
out long gitHubUserId,
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Domain;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// The chat host's pull of merge notices: GET the unacked feed, POST an ack. JSON only because the reader is the chat host's script (a xenos
|
||||
/// boundary). The bearer is the chat host's own secret (ChatNotices:Token); an empty token turns both routes off. No cookie, no CORS, never stored.
|
||||
/// </summary>
|
||||
public static class MergeNoticeEndpoints
|
||||
{
|
||||
private const int MaxAckBodyBytes = 1024;
|
||||
|
||||
public static void Map(WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/merge-notices/chat", async (HttpContext http, IOptions<ChatNoticesOptions> options, MergeNoticeFeed feed, CancellationToken ct) =>
|
||||
{
|
||||
if (Gate(http, options.Value) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
DateTimeOffset? after = null;
|
||||
if (http.Request.Query.TryGetValue("after", out var raw) && raw.Count > 0)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(raw[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
after = parsed;
|
||||
}
|
||||
|
||||
int? limit = http.Request.Query.TryGetValue("limit", out var rawLimit) && int.TryParse(rawLimit[0], CultureInfo.InvariantCulture, out var n) ? n : null;
|
||||
var notices = await feed.ReadAsync(AccountMergeNoticeTarget.Chat, after, limit, ct);
|
||||
return Results.Text(JsonSerializer.Serialize(notices.Select(Wire)), "application/json");
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/api/merge-notices/chat/ack", async (HttpContext http, IOptions<ChatNoticesOptions> options, MergeNoticeFeed feed, CancellationToken ct) =>
|
||||
{
|
||||
if (Gate(http, options.Value) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
if (await ReadAckAsync(http.Request, ct) is not var (mergeId, kind))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
return await feed.AckAsync(AccountMergeNoticeTarget.Chat, mergeId, kind, ct)
|
||||
? Results.StatusCode(StatusCodes.Status204NoContent)
|
||||
: Results.StatusCode(StatusCodes.Status404NotFound);
|
||||
}).AllowAnonymous();
|
||||
}
|
||||
|
||||
private static IResult? Gate(HttpContext http, ChatNoticesOptions options)
|
||||
{
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
if (string.IsNullOrEmpty(options.Token))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var header = http.Request.Headers.Authorization.ToString();
|
||||
const string scheme = "Bearer ";
|
||||
var presented = header.StartsWith(scheme, StringComparison.Ordinal) ? header[scheme.Length..] : string.Empty;
|
||||
// Both sides are hashed first, so the comparison is over equal-length digests and says nothing about the token's length.
|
||||
var matches = CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(presented)), SHA256.HashData(Encoding.UTF8.GetBytes(options.Token)));
|
||||
return matches && presented.Length > 0 ? null : Results.StatusCode(StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
private static async Task<(Guid MergeId, AccountMergeNoticeKind Kind)?> ReadAckAsync(HttpRequest request, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[MaxAckBodyBytes + 1];
|
||||
var read = 0;
|
||||
int n;
|
||||
while (read < buffer.Length && (n = await request.Body.ReadAsync(buffer.AsMemory(read), ct)) > 0)
|
||||
{
|
||||
read += n;
|
||||
}
|
||||
|
||||
if (read == 0 || read > MaxAckBodyBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(buffer.AsMemory(0, read));
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!root.TryGetProperty("merge_id", out var id) || id.ValueKind != JsonValueKind.String || !Guid.TryParse(id.GetString(), out var mergeId) ||
|
||||
!root.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AccountMergeNoticeKind? kind = type.GetString() switch
|
||||
{
|
||||
"merged" => AccountMergeNoticeKind.Merged,
|
||||
"undone" => AccountMergeNoticeKind.Undone,
|
||||
"final" => AccountMergeNoticeKind.Final,
|
||||
_ => null
|
||||
};
|
||||
return kind is { } k ? (mergeId, k) : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The wire shape, exactly these keys: ids in format D, times ISO 8601 UTC, no email, display name, Heimdall id or Matrix id.</summary>
|
||||
private static Dictionary<string, object?> Wire(MergeNotice notice) => new()
|
||||
{
|
||||
["type"] = notice.Kind.ToString().ToLowerInvariant(),
|
||||
["merge_id"] = notice.MergeId.ToString("D"),
|
||||
["occurred_at"] = notice.OccurredAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
["grace_ends_at"] = notice.GraceEndsAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
["old_subject"] = notice.OldSubject.ToString("D"),
|
||||
["new_subject"] = notice.NewSubject.ToString("D"),
|
||||
["old_preferred_username"] = notice.OldPreferredUsername,
|
||||
["new_preferred_username"] = notice.NewPreferredUsername
|
||||
};
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
/// <summary>What the chat host is told about one merge. Derived from the merge row on every read; never stored.</summary>
|
||||
public sealed record MergeNotice(
|
||||
AccountMergeNoticeKind Kind,
|
||||
Guid MergeId,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? GraceEndsAt,
|
||||
Guid OldSubject,
|
||||
Guid NewSubject,
|
||||
string OldPreferredUsername,
|
||||
string NewPreferredUsername);
|
||||
|
||||
/// <summary>
|
||||
/// The one owner of what a notice reader is told about merges: merged (CommittedUtc), undone (UndoneUtc) and final (FinalizedUtc) are read off
|
||||
/// the AccountMerges row on every call, so a notice cannot disagree with the merge, and the reader's ack row is the only thing stored.
|
||||
/// Reads only AccountMerges and UserAccounts; writes only AccountMergeNoticeAck.
|
||||
/// </summary>
|
||||
public sealed class MergeNoticeFeed(BifrostDbContext dbContext, TimeProvider timeProvider)
|
||||
{
|
||||
public const int DefaultLimit = 50;
|
||||
public const int MaxLimit = 200;
|
||||
|
||||
private static readonly AccountMergeNoticeKind[] Kinds = [AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Undone, AccountMergeNoticeKind.Final];
|
||||
|
||||
private static DateTimeOffset? OccurredAt(AccountMerge merge, AccountMergeNoticeKind kind) => kind switch
|
||||
{
|
||||
AccountMergeNoticeKind.Merged => merge.CommittedUtc,
|
||||
AccountMergeNoticeKind.Undone => merge.UndoneUtc,
|
||||
_ => merge.FinalizedUtc
|
||||
};
|
||||
|
||||
/// <summary>Unacked notices for <paramref name="target"/>, oldest first by (occurred_at, merge_id, kind), strictly later than <paramref name="after"/> when given. Acks are the cursor; <c>after</c> only narrows the scan.</summary>
|
||||
public async Task<IReadOnlyList<MergeNotice>> ReadAsync(AccountMergeNoticeTarget target, DateTimeOffset? after, int? limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var take = limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit;
|
||||
var picked = new List<(AccountMerge Merge, AccountMergeNoticeKind Kind, DateTimeOffset At)>();
|
||||
// Each kind's own oldest `take` rows contain every row of the overall oldest `take`, so merging three bounded lists is exact.
|
||||
foreach (var kind in Kinds)
|
||||
{
|
||||
var rows = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => (kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) != null)
|
||||
.Where(x => after == null || (kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) > after)
|
||||
.Where(x => !dbContext.AccountMergeNoticeAcks.Any(a => a.MergeId == x.Id && a.Kind == kind && a.Target == target))
|
||||
.OrderBy(x => kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
picked.AddRange(rows.Select(x => (x, kind, OccurredAt(x, kind)!.Value)));
|
||||
}
|
||||
|
||||
var page = picked.OrderBy(x => x.At).ThenBy(x => x.Merge.Id).ThenBy(x => x.Kind).Take(take).ToList();
|
||||
var ids = page.SelectMany(x => new[] { x.Merge.FoldedUserAccountId, x.Merge.SurvivorUserAccountId }).Distinct().ToList();
|
||||
var names = await dbContext.UserAccounts.AsNoTracking().Where(x => ids.Contains(x.Id))
|
||||
.Select(x => new { x.Id, x.NormalizedBifrostIdentity }).ToDictionaryAsync(x => x.Id, x => x.NormalizedBifrostIdentity, cancellationToken);
|
||||
return [.. page.Select(x => new MergeNotice(
|
||||
x.Kind, x.Merge.Id, x.At, x.Merge.GraceEndsUtc, x.Merge.FoldedUserAccountId, x.Merge.SurvivorUserAccountId,
|
||||
names.GetValueOrDefault(x.Merge.FoldedUserAccountId, string.Empty), names.GetValueOrDefault(x.Merge.SurvivorUserAccountId, string.Empty)))];
|
||||
}
|
||||
|
||||
/// <summary>Records that the reader has seen (merge, kind). False when the feed would not emit that notice now (no such merge, or its timestamp unset); true otherwise, a repeat included.</summary>
|
||||
public async Task<bool> AckAsync(AccountMergeNoticeTarget target, Guid mergeId, AccountMergeNoticeKind kind, CancellationToken cancellationToken)
|
||||
{
|
||||
var merge = await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == mergeId, cancellationToken);
|
||||
if (merge is null || OccurredAt(merge, kind) is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await dbContext.AccountMergeNoticeAcks.AnyAsync(x => x.MergeId == mergeId && x.Kind == kind && x.Target == target, cancellationToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
dbContext.AccountMergeNoticeAcks.Add(new AccountMergeNoticeAck { MergeId = mergeId, Kind = kind, Target = target, AckedUtc = timeProvider.GetUtcNow() });
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent ack of the same key won the unique index; the notice is acked, which is what the caller asked for.
|
||||
dbContext.ChangeTracker.Clear();
|
||||
if (!await dbContext.AccountMergeNoticeAcks.AnyAsync(x => x.MergeId == mergeId && x.Kind == kind && x.Target == target, cancellationToken))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
using Bifrost.Web.Domain;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// The only wording of what a merge would do, in closed vocabulary: counts by entry kind and action from <see cref="MergePlan"/>, the
|
||||
/// sentence for each block code, and the statements every merge carries. It decides nothing, and names no account: the plan holds none.
|
||||
/// </summary>
|
||||
public static class MergePreview
|
||||
{
|
||||
public static IReadOnlyList<string> Lines(MergePlan plan)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var group in plan.Entries.GroupBy(x => (x.Kind, x.Action)))
|
||||
{
|
||||
var n = group.Count();
|
||||
lines.Add(group.Key switch
|
||||
{
|
||||
(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved) =>
|
||||
$"{Count(n, "open vote")} of the other account on motions you did not vote on move to the kept account.",
|
||||
(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided) =>
|
||||
$"{Count(n, "open vote")} of the other account are set aside: the kept account already voted on that motion or cannot vote.",
|
||||
(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved) =>
|
||||
$"{Count(n, "appraisal")} of the other account move to the kept account.",
|
||||
(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided) =>
|
||||
$"{Count(n, "appraisal")} of the other account are set aside: the kept account already appraised the same thing or cannot.",
|
||||
(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed) =>
|
||||
"The other account's membership closes; the kept account's membership stays.",
|
||||
(AccountMergeEntryKind.Standing, _) =>
|
||||
"The kept account is suspended too, because the other account is.",
|
||||
(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved) =>
|
||||
$"{Count(n, "patron event")} of the other account count for the kept account.",
|
||||
_ => $"{Count(n, "other record")} change."
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var (key, n) in plan.Left.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (n > 0 && Remains(key) is { } noun)
|
||||
{
|
||||
lines.Add($"{Count(n, noun)} stay{(n == 1 ? "s" : string.Empty)} where {(n == 1 ? "it is" : "they are")}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
lines.Add("No votes, appraisals or patron events change.");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>Why a pair cannot be merged, by the plan's block code. No account is named; a code this class does not know gets a generic line, never an echo.</summary>
|
||||
public static string Block(string code) => code switch
|
||||
{
|
||||
"same_account" => "These are the same account.",
|
||||
"account_missing" => "One of the accounts no longer exists.",
|
||||
"mind_account" => "An account that belongs to a Mind cannot be merged.",
|
||||
"native_account" => "An account without a Heimdall sign-in cannot be merged.",
|
||||
"merge_in_flight" => "One of the accounts is already part of a merge that has not finished.",
|
||||
"survivor_is_folded" => "The account you would keep was already merged into another account.",
|
||||
"already_folded" => "The other account was already merged into another account.",
|
||||
"folded_is_survivor" => "Another account was merged into the other account, so it cannot be folded in turn.",
|
||||
"sealed_ballot_pending" => "The other account has a sealed ballot on an open motion; wait until it is revealed.",
|
||||
_ => "These accounts cannot be merged now."
|
||||
};
|
||||
|
||||
/// <summary>The block for two accounts that both hold a login of one provider, which Heimdall will not combine. Providers only: no account is named.</summary>
|
||||
public static string ProviderConflict(IEnumerable<string> providerNames) =>
|
||||
$"Both accounts hold a {string.Join(" and ", providerNames)} login, and Heimdall will not combine two logins of one provider. Unlink one of them first, then prove that account again: the sign-in you gave still carries the old list of its logins.";
|
||||
|
||||
/// <summary>What every merge says, whatever the plan: what it cannot carry over, what it asks again, and how long it can be undone.</summary>
|
||||
public static IReadOnlyList<string> Statements { get; } =
|
||||
[
|
||||
"Chat: your chat history, rooms and keys belong to one chat id and cannot be moved. Keep the account you use in chat. Whichever you keep, the other chat id stays behind: rooms you were in on it must be joined again with the kept one, and messages encrypted for it cannot be read on the kept one.",
|
||||
"Your connections (Twitch, Discord and others) will ask for consent again.",
|
||||
"If you stream or moderate with StreamPixels and one of these connections is the one it uses for your channel, that connection stops until you reconnect it.",
|
||||
"Roles and admin rights of the other account end; a steward must grant them again.",
|
||||
"You will have one membership and one vote per motion: where both accounts voted on an open motion, the kept account's vote counts and the other is set aside. Nothing is added together.",
|
||||
"You can undo this for 7 days, from Merge status. After that it is final.",
|
||||
"The merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end."
|
||||
];
|
||||
|
||||
private static string Count(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";
|
||||
|
||||
private static string? Remains(string key) => key switch
|
||||
{
|
||||
"ConsentReceipts" => "consent record",
|
||||
"PasskeyCredentials" => "passkey",
|
||||
"LedgerEntries" => "ledger entry",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
using Bifrost.Web.Features.Heimdall;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
public enum MergeSide
|
||||
{
|
||||
/// <summary>The account signed in here.</summary>
|
||||
Self,
|
||||
|
||||
/// <summary>The second account, proved by its own fresh sign-in.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
public enum PutOutcome
|
||||
{
|
||||
Stored,
|
||||
AmbiguousLogins,
|
||||
TooManyProofs
|
||||
}
|
||||
|
||||
/// <summary>What a page may know of one side: no bearer, no Heimdall account id, no login names.</summary>
|
||||
public sealed record MergeSideView(
|
||||
Guid UserAccountId, IReadOnlyList<string> ProvedProviders, IReadOnlyList<string> LoginProviders, TimeSpan OldestProofAge, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record MergeProofsView(MergeSideView? Self, MergeSideView? Other);
|
||||
|
||||
public abstract record PeekResult
|
||||
{
|
||||
private PeekResult()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Ready(MergeProof Survivor, IReadOnlyList<MergeProof> Folded) : PeekResult;
|
||||
|
||||
/// <summary>A side holds no live proof.</summary>
|
||||
public sealed record Expired : PeekResult;
|
||||
|
||||
/// <summary>Logins whose provider has no live proof, by provider name only.</summary>
|
||||
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : PeekResult;
|
||||
|
||||
/// <summary>Both accounts hold a login of these providers, a pair Heimdall always refuses; by provider name only.</summary>
|
||||
public sealed record ProviderConflict(IReadOnlyList<string> Providers) : PeekResult;
|
||||
}
|
||||
|
||||
public abstract record UndoPeekResult
|
||||
{
|
||||
private UndoPeekResult()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Ready(IReadOnlyList<MergeProof> Proofs) : UndoPeekResult;
|
||||
|
||||
public sealed record Expired : UndoPeekResult;
|
||||
|
||||
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : UndoPeekResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one holder of live Heimdall bearers between a merge-proof sign-in's completion and the page's call into the saga, keyed by the
|
||||
/// acting Bifrost account. It lives in this process's memory only and is never persisted, logged or sent to a browser; after a restart
|
||||
/// the member proves again. It decides nothing about the merge: the saga owns consent, the move and the lifecycle, and Heimdall judges
|
||||
/// each proof's tier and freshness. <see cref="ProofLifetime"/> is only a margin under Heimdall's 300 s weak window, so a stale proof
|
||||
/// is not sent to fail a merge. Provider is the only proof bookkeeping Bifrost has (the redeem names the provider, not the identity),
|
||||
/// so a login list holding two logins of one provider is refused, and two sides holding a login of one provider are a conflict Peek names before consent. Every access, and the merge sweeper's tick, drops expired bearers. Peek never consumes: the caller <see cref="Clear"/>s once the saga
|
||||
/// has taken the proofs, so a refusal that wrote nothing does not cost a re-proof.
|
||||
/// </summary>
|
||||
public sealed class MergeProofs(TimeProvider timeProvider)
|
||||
{
|
||||
public static readonly TimeSpan ProofLifetime = TimeSpan.FromSeconds(270);
|
||||
|
||||
/// <summary>The saga's own bound on the proofs of one move.</summary>
|
||||
public const int MaxProofsPerSide = 8;
|
||||
|
||||
private sealed record Held(MergeProof Proof, DateTimeOffset RedeemedAt);
|
||||
|
||||
private sealed class Side(Guid userAccountId, string heimdallAccountId)
|
||||
{
|
||||
public Guid UserAccountId { get; } = userAccountId;
|
||||
|
||||
public string HeimdallAccountId { get; } = heimdallAccountId;
|
||||
|
||||
public IReadOnlyList<LinkedIdentitySummary> Logins { get; set; } = [];
|
||||
|
||||
public Dictionary<string, Held> Proofs { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private readonly Dictionary<Guid, Dictionary<MergeSide, Side>> _held = [];
|
||||
private readonly object _gate = new();
|
||||
|
||||
public PutOutcome Put(Guid actorId, MergeSide side, MergeProof proof, Guid userAccountId, IReadOnlyList<LinkedIdentitySummary> logins)
|
||||
{
|
||||
if (logins.GroupBy(x => x.Provider, StringComparer.Ordinal).Any(x => x.Count() > 1))
|
||||
{
|
||||
return PutOutcome.AmbiguousLogins;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
Prune(now);
|
||||
var sides = _held.TryGetValue(actorId, out var existing) ? existing : _held[actorId] = [];
|
||||
if (!sides.TryGetValue(side, out var held) || held.UserAccountId != userAccountId || held.HeimdallAccountId != proof.HeimdallAccountId)
|
||||
{
|
||||
held = new Side(userAccountId, proof.HeimdallAccountId);
|
||||
}
|
||||
|
||||
if (!held.Proofs.ContainsKey(proof.Provider) && held.Proofs.Count >= MaxProofsPerSide)
|
||||
{
|
||||
return PutOutcome.TooManyProofs;
|
||||
}
|
||||
|
||||
held.Logins = logins;
|
||||
held.Proofs[proof.Provider] = new Held(proof, now);
|
||||
sides[side] = held;
|
||||
return PutOutcome.Stored;
|
||||
}
|
||||
}
|
||||
|
||||
public MergeProofsView Snapshot(Guid actorId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
Prune(now);
|
||||
var sides = _held.GetValueOrDefault(actorId);
|
||||
return new MergeProofsView(View(sides, MergeSide.Self, now), View(sides, MergeSide.Other, now));
|
||||
}
|
||||
}
|
||||
|
||||
public PeekResult Peek(Guid actorId, MergeSide survivorSide)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(timeProvider.GetUtcNow());
|
||||
var sides = _held.GetValueOrDefault(actorId);
|
||||
if (sides is null || !sides.TryGetValue(survivorSide, out var survivor)
|
||||
|| !sides.TryGetValue(survivorSide == MergeSide.Self ? MergeSide.Other : MergeSide.Self, out var folded))
|
||||
{
|
||||
return new PeekResult.Expired();
|
||||
}
|
||||
|
||||
var shared = survivor.Logins.Select(x => x.Provider).Intersect(folded.Logins.Select(x => x.Provider), StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
if (shared.Length > 0)
|
||||
{
|
||||
return new PeekResult.ProviderConflict(shared);
|
||||
}
|
||||
|
||||
var unproved = UnprovedProviders(folded, folded.Logins.Select(x => x.Provider));
|
||||
return unproved.Count > 0
|
||||
? new PeekResult.LoginsUnproved(unproved)
|
||||
: new PeekResult.Ready(survivor.Proofs.Values.MaxBy(x => x.RedeemedAt)!.Proof, [.. folded.Proofs.Values.Select(x => x.Proof)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Self side's proofs for an undo, once every login whose id is in <paramref name="movedIdentityIds"/> is proved.</summary>
|
||||
public UndoPeekResult PeekUndo(Guid actorId, IReadOnlyCollection<string> movedIdentityIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(timeProvider.GetUtcNow());
|
||||
if (!_held.TryGetValue(actorId, out var sides) || !sides.TryGetValue(MergeSide.Self, out var self))
|
||||
{
|
||||
return new UndoPeekResult.Expired();
|
||||
}
|
||||
|
||||
var needed = self.Logins.Where(x => movedIdentityIds.Contains(x.Id)).Select(x => x.Provider).ToArray();
|
||||
var unproved = UnprovedProviders(self, needed);
|
||||
if (needed.Length < movedIdentityIds.Count)
|
||||
{
|
||||
// A moved login the list does not hold cannot be proved by it.
|
||||
return new UndoPeekResult.LoginsUnproved(unproved);
|
||||
}
|
||||
|
||||
return unproved.Count > 0
|
||||
? new UndoPeekResult.LoginsUnproved(unproved)
|
||||
: new UndoPeekResult.Ready([.. needed.Distinct(StringComparer.Ordinal).Select(x => self.Proofs[x].Proof)]);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear(Guid actorId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(timeProvider.GetUtcNow());
|
||||
_held.Remove(actorId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops every expired bearer without any member touching the page; the merge sweeper's tick calls it, so none outlives <see cref="ProofLifetime"/> plus one tick.</summary>
|
||||
public void PruneExpired()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> UnprovedProviders(Side side, IEnumerable<string> providers) =>
|
||||
[.. providers.Distinct(StringComparer.Ordinal).Where(x => !side.Proofs.ContainsKey(x))];
|
||||
|
||||
private MergeSideView? View(Dictionary<MergeSide, Side>? sides, MergeSide which, DateTimeOffset now) =>
|
||||
sides is not null && sides.TryGetValue(which, out var side)
|
||||
? new MergeSideView(
|
||||
side.UserAccountId,
|
||||
[.. side.Proofs.Keys],
|
||||
[.. side.Logins.Select(x => x.Provider)],
|
||||
now - side.Proofs.Values.Min(x => x.RedeemedAt),
|
||||
side.Proofs.Values.Min(x => x.RedeemedAt) + ProofLifetime)
|
||||
: null;
|
||||
|
||||
/// <summary>Drops every proof at or past its lifetime, then every side without one, then every actor without a side.</summary>
|
||||
private void Prune(DateTimeOffset now)
|
||||
{
|
||||
foreach (var (actor, sides) in _held.ToArray())
|
||||
{
|
||||
foreach (var (which, side) in sides.ToArray())
|
||||
{
|
||||
foreach (var (provider, held) in side.Proofs.ToArray())
|
||||
{
|
||||
if (now - held.RedeemedAt >= ProofLifetime)
|
||||
{
|
||||
side.Proofs.Remove(provider);
|
||||
}
|
||||
}
|
||||
|
||||
if (side.Proofs.Count == 0)
|
||||
{
|
||||
sides.Remove(which);
|
||||
}
|
||||
}
|
||||
|
||||
if (sides.Count == 0)
|
||||
{
|
||||
_held.Remove(actor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
using System.Globalization;
|
||||
using Bifrost.Web.Domain;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// The one closed set of words the merge pages say, so no page ever prints what the store holds: a state, a Heimdall refusal code, a
|
||||
/// saga outcome and an undo answer each map to a sentence here, and anything this class does not know gets a generic line, never an
|
||||
/// echo. Dates are UTC.
|
||||
/// </summary>
|
||||
public static class MergeWording
|
||||
{
|
||||
public static string State(AccountMergeState state) => state switch
|
||||
{
|
||||
AccountMergeState.Proposed => "Proposed, not yet confirmed.",
|
||||
AccountMergeState.Confirmed => "Confirmed; Heimdall has not yet moved the logins.",
|
||||
AccountMergeState.HeimdallMoved => "The logins have moved; Bifrost is finishing the merge.",
|
||||
AccountMergeState.Committed => "Merged. You can still undo it.",
|
||||
AccountMergeState.Final => "Merged for good; the undo window has ended.",
|
||||
AccountMergeState.UndoRequested => "Undo requested; the logins are being given back.",
|
||||
AccountMergeState.HeimdallReversed => "The logins are back; Bifrost is restoring the accounts.",
|
||||
AccountMergeState.Undone => "Undone; the accounts are separate again.",
|
||||
AccountMergeState.Failed => "The merge did not happen and nothing was merged.",
|
||||
_ => "In an unknown state."
|
||||
};
|
||||
|
||||
/// <summary>Why Heimdall refused a merge, by its refusal code; null for a code this class does not know.</summary>
|
||||
public static string? Failure(string code) => code switch
|
||||
{
|
||||
"app_auth_required" or "not_allowed_for_app" => "Heimdall would not take Bifrost's request.",
|
||||
"not_reversible" => "Heimdall says those logins cannot be moved back.",
|
||||
"proof_invalid" => "Heimdall did not accept a sign-in as proof.",
|
||||
"same_account" => "Both sides were the same Heimdall account.",
|
||||
"proof_stale" => "A sign-in was too old by the time Heimdall saw it.",
|
||||
"identity_not_proved" => "A login was not proved by a fresh sign-in.",
|
||||
"already_moved" => "Heimdall had already moved one of those logins.",
|
||||
"provider_conflict" => "Both accounts hold a login of the same provider, which Heimdall will not combine.",
|
||||
"identity_not_found" => "A login was no longer on its account.",
|
||||
"invalid_request" => "Heimdall could not read the request.",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>The saga's last note on a merge, in words. Always a sentence: an outcome this class does not know is a retry line, never its own text.</summary>
|
||||
public static string Outcome(string lastOutcome) => lastOutcome switch
|
||||
{
|
||||
"confirmed" => "Waiting for Heimdall to move the logins.",
|
||||
"moved" => "Heimdall moved the logins.",
|
||||
"refused" => "Heimdall refused the merge.",
|
||||
"undo_requested" => "Waiting for Heimdall to give the logins back.",
|
||||
"operator_failed" => "A steward ended this merge. Nothing was merged.",
|
||||
"undo_operator_abandoned" => "A steward ended the undo, so the merge stands.",
|
||||
"receipt_mismatch" => "Heimdall's answer did not match the merge. An operator must look at it.",
|
||||
"proofs_unreadable" => "The stored proofs cannot be read. An operator must look at it.",
|
||||
"transport" or "move_id_conflict" or "malformed or mismatched receipt" => "Heimdall could not be reached or gave an answer Bifrost could not use. It will try again.",
|
||||
_ when lastOutcome.StartsWith("undo_", StringComparison.Ordinal) && Failure(lastOutcome["undo_".Length..]) is { } reason =>
|
||||
"Heimdall refused the undo, so the merge stands. " + reason,
|
||||
_ => "Waiting to try again."
|
||||
};
|
||||
|
||||
/// <summary>The words for a closed undo code (<see cref="UndoCode"/> or <c>logins_unproved</c>); empty for anything else.</summary>
|
||||
public static string Undo(string code) => code switch
|
||||
{
|
||||
"requested" => "Undo requested. The logins are being given back.",
|
||||
"not_committed" => "This merge is not in a state that can be undone.",
|
||||
"grace_over" => "The undo window has ended.",
|
||||
"not_survivor" => "Only the account the merge kept can undo it.",
|
||||
"proof_account_mismatch" => "That sign-in was not for this account. Nothing was changed.",
|
||||
"logins_unproved" => "Every login that moved needs a fresh sign-in first. Nothing was changed.",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
/// <summary>The words for a closed code the merge page answers a confirm or a start with; empty for anything else.</summary>
|
||||
public static string Confirm(string code) => code switch
|
||||
{
|
||||
"blocked" => "These accounts cannot be merged now. Nothing was changed.",
|
||||
"preview_stale" => "Something changed since you looked; review the merge again. Nothing was changed and your sign-ins are kept.",
|
||||
"not_party" => "Only one of the two accounts can confirm this merge. Nothing was changed.",
|
||||
"proof_account_mismatch" => "A sign-in was not for the account it was meant for. Nothing was changed.",
|
||||
"proofs_needed" => "Both accounts need a fresh sign-in, one for every login of the account that would be folded. Nothing was changed.",
|
||||
"consent_needed" => "Tick both boxes to confirm. Nothing was changed.",
|
||||
"bad_choice" => "Choose which account to keep. Nothing was changed.",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
public static string UndoCode(UndoOutcome outcome) => outcome switch
|
||||
{
|
||||
UndoOutcome.Requested => "requested",
|
||||
UndoOutcome.NotCommitted => "not_committed",
|
||||
UndoOutcome.GraceOver => "grace_over",
|
||||
UndoOutcome.NotSurvivor => "not_survivor",
|
||||
UndoOutcome.ProofAccountMismatch => "proof_account_mismatch",
|
||||
_ => "not_committed"
|
||||
};
|
||||
|
||||
public static string TierNote(string? fromTier, DateTimeOffset graceEnds) => fromTier == "strong"
|
||||
? $"Approved with a passkey; you have until {Date(graceEnds)} to undo."
|
||||
: $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo.";
|
||||
|
||||
/// <summary>Whole days from <paramref name="now"/> to <paramref name="end"/>, rounded up, at least one while the window is open.</summary>
|
||||
public static int DaysUntil(DateTimeOffset end, DateTimeOffset now) => Math.Max(1, (int)Math.Ceiling((end - now).TotalDays));
|
||||
|
||||
public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left";
|
||||
|
||||
public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";
|
||||
|
||||
public static string UndoRequestedNotice() => "A merge into your account is being undone: Heimdall is giving the logins back. Review it on Merge status.";
|
||||
|
||||
public static string FrozenNotice() => "A merge is in progress; you cannot act until it settles.";
|
||||
|
||||
public static string FoldedNotice(DateTimeOffset mergedOn) =>
|
||||
$"This account was merged on {Date(mergedOn)}; sign in with a login you had on it to reach the account that holds them.";
|
||||
|
||||
public static string Date(DateTimeOffset when) => when.UtcDateTime.ToString("d MMM yyyy", CultureInfo.InvariantCulture) + " UTC";
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -53,7 +52,6 @@ public sealed class MotionGovernanceService(
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required.");
|
||||
|
||||
var motion = await dbContext.Motions
|
||||
.Include(x => x.Votes)
|
||||
.SingleAsync(x => x.Id == motionId, cancellationToken);
|
||||
|
|
@ -63,44 +61,28 @@ public sealed class MotionGovernanceService(
|
|||
return;
|
||||
}
|
||||
|
||||
// An actor resolved before a merge froze this account must not write a vote after it: the write gate reads standing at the write.
|
||||
var wrote = await AccountMergeLedger.WriteAsAsync(dbContext, actorUser.Id, async standing =>
|
||||
var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id);
|
||||
if (vote is null)
|
||||
{
|
||||
if (standing is not null)
|
||||
dbContext.Votes.Add(new Vote
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id);
|
||||
if (vote is null)
|
||||
{
|
||||
dbContext.Votes.Add(new Vote
|
||||
{
|
||||
MotionId = motionId,
|
||||
UserAccountId = actorUser.Id,
|
||||
Choice = choice,
|
||||
Weight = actor.EffectiveVotingWeight,
|
||||
Comment = comment?.Trim() ?? string.Empty,
|
||||
CastAtUtc = timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Choice = choice;
|
||||
vote.Weight = actor.EffectiveVotingWeight;
|
||||
vote.Comment = comment?.Trim() ?? string.Empty;
|
||||
vote.CastAtUtc = timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
if (!wrote)
|
||||
MotionId = motionId,
|
||||
UserAccountId = actorUser.Id,
|
||||
Choice = choice,
|
||||
Weight = actor.EffectiveVotingWeight,
|
||||
Comment = comment?.Trim() ?? string.Empty,
|
||||
CastAtUtc = timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
vote.Choice = choice;
|
||||
vote.Weight = actor.EffectiveVotingWeight;
|
||||
vote.Comment = comment?.Trim() ?? string.Empty;
|
||||
vote.CastAtUtc = timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUser.Id,
|
||||
nameof(Motion),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ using System.Text;
|
|||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -100,27 +99,21 @@ public sealed class SealedBallotService(
|
|||
throw new SealedBallotException(SealedBallotFailure.Invalid, "A commitment is 64 lowercase hex characters (SHA-256).");
|
||||
}
|
||||
|
||||
var ballot = await AccountMergeLedger.WriteAsAsync(dbContext, user.Id, async standing =>
|
||||
var ballot = await dbContext.SealedBallots
|
||||
.SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken);
|
||||
if (ballot is null)
|
||||
{
|
||||
RequireStanding(standing);
|
||||
var written = await dbContext.SealedBallots
|
||||
.SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken);
|
||||
if (written is null)
|
||||
{
|
||||
written = new SealedBallot { MotionId = motionId, UserAccountId = user.Id };
|
||||
dbContext.SealedBallots.Add(written);
|
||||
}
|
||||
else
|
||||
{
|
||||
written.Revision++;
|
||||
}
|
||||
|
||||
written.Commitment = commitment;
|
||||
written.CommittedAtUtc = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return written;
|
||||
}, cancellationToken);
|
||||
ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id };
|
||||
dbContext.SealedBallots.Add(ballot);
|
||||
}
|
||||
else
|
||||
{
|
||||
ballot.Revision++;
|
||||
}
|
||||
|
||||
ballot.Commitment = commitment;
|
||||
ballot.CommittedAtUtc = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
user.Id, nameof(Motion), motionId, "sealed.committed",
|
||||
$"{actor.DisplayName} committed a sealed ballot (revision {ballot.Revision}), commitment {commitment}.",
|
||||
|
|
@ -166,43 +159,36 @@ public sealed class SealedBallotService(
|
|||
|
||||
// RevealSeq and Status are concurrency tokens. A conflict means either the close froze the set (refuse, truthfully) or
|
||||
// another voter's reveal landed first while the window is still open (re-read the row and write again).
|
||||
// Standing is read once, under the gate's lock, which holds across every attempt: a merge cannot freeze the account between them.
|
||||
await AccountMergeLedger.WriteAsAsync(dbContext, user.Id, async standing =>
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
RequireStanding(standing);
|
||||
for (var attempt = 0; ; attempt++)
|
||||
ballot.RevealedCandidate = candidate;
|
||||
ballot.RevealedReason = reason;
|
||||
ballot.Salt = document.Salt;
|
||||
ballot.RevealedAtUtc = now;
|
||||
motion.RevealSeq++;
|
||||
try
|
||||
{
|
||||
ballot.RevealedCandidate = candidate;
|
||||
ballot.RevealedReason = reason;
|
||||
ballot.Salt = document.Salt;
|
||||
ballot.RevealedAtUtc = now;
|
||||
motion.RevealSeq++;
|
||||
try
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException error)
|
||||
{
|
||||
foreach (var entry in error.Entries)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
break;
|
||||
await entry.ReloadAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException error)
|
||||
|
||||
if (PhaseOf(motion, now) != SealedPhase.Reveal)
|
||||
{
|
||||
foreach (var entry in error.Entries)
|
||||
{
|
||||
await entry.ReloadAsync(cancellationToken);
|
||||
}
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over.");
|
||||
}
|
||||
|
||||
if (PhaseOf(motion, now) != SealedPhase.Reveal)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over.");
|
||||
}
|
||||
|
||||
if (attempt >= 4)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again.");
|
||||
}
|
||||
if (attempt >= 4)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
user.Id, nameof(Motion), motionId, "sealed.revealed",
|
||||
|
|
@ -373,15 +359,6 @@ public sealed class SealedBallotService(
|
|||
|
||||
private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName;
|
||||
|
||||
// A member resolved before a merge froze the account must not write a ballot after it: the write gate reads standing at the write.
|
||||
private static void RequireStanding(MergeStanding? standing)
|
||||
{
|
||||
if (standing is not null)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static UserAccount RequireMember(CurrentBifrostActor actor) =>
|
||||
actor is { IsActiveMember: true, UserAccount: { } user }
|
||||
? user
|
||||
|
|
|
|||
|
|
@ -14,17 +14,10 @@ public sealed class OidcClientSeeder(
|
|||
IServiceScopeFactory scopeFactory,
|
||||
OidcIssuerOptions options,
|
||||
OidcKeyMaterial material,
|
||||
IOptions<OpenIddictServerOptions> serverOptions,
|
||||
ILogger<OidcClientSeeder> logger) : IHostedService
|
||||
IOptions<OpenIddictServerOptions> serverOptions) : IHostedService
|
||||
{
|
||||
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.
|
||||
_ = serverOptions.Value;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ using System.Security.Claims;
|
|||
using Microsoft.AspNetCore;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
|
@ -23,10 +21,8 @@ namespace Bifrost.Web.Features.Oidc;
|
|||
public static class OidcEndpoints
|
||||
{
|
||||
public const string SuspendedDescription = "The account is suspended.";
|
||||
public const string MergedDescription = "The account is part of an account merge and can no longer sign in.";
|
||||
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 IdentityAuthenticationType = "Bifrost.Oidc";
|
||||
|
|
@ -38,7 +34,7 @@ public static class OidcEndpoints
|
|||
app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db)
|
||||
private static async Task<IResult> AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors)
|
||||
{
|
||||
var request = http.GetOpenIddictServerRequest()
|
||||
?? throw new InvalidOperationException("The OpenIddict request could not be retrieved.");
|
||||
|
|
@ -57,7 +53,7 @@ public static class OidcEndpoints
|
|||
// 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, actor.Merge is not null ? MergedDescription : NoIdentityDescription);
|
||||
return Deny(Errors.AccessDenied, NoIdentityDescription);
|
||||
}
|
||||
|
||||
if (actor.Membership?.Status == MembershipStatus.Suspended)
|
||||
|
|
@ -87,13 +83,6 @@ public static class OidcEndpoints
|
|||
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
principal.SetScopes(request.GetScopes());
|
||||
// A desk scope is what makes a token a desk token: its audience is the desk's. The client's permissions already
|
||||
// decided whether it may ask for the scope (the MAS client may not), so this branch is reached only by one that may.
|
||||
if (request.GetScopes().Any(DeskEndpoints.IsDeskScope))
|
||||
{
|
||||
principal.SetResources(DeskEndpoints.Audience);
|
||||
}
|
||||
|
||||
principal.SetDestinations(claim => claim.Type switch
|
||||
{
|
||||
Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken],
|
||||
|
|
@ -102,32 +91,19 @@ public static class OidcEndpoints
|
|||
_ => [Destinations.AccessToken]
|
||||
});
|
||||
|
||||
// The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11).
|
||||
// Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree.
|
||||
// Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request
|
||||
// (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it.
|
||||
if (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
|
||||
{
|
||||
return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString));
|
||||
}
|
||||
|
||||
return Results.SignIn(principal, properties: null, OpenIddictScheme);
|
||||
}
|
||||
|
||||
/// <summary>A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt.</summary>
|
||||
// Only a token whose every scope is a desk scope skips the consent gate: a client that may hold chat scopes too cannot ride a desk scope past the receipt.
|
||||
private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes() is { Length: > 0 } scopes && scopes.All(DeskEndpoints.IsDeskScope);
|
||||
|
||||
private static async Task<IResult> ExchangeAsync(HttpContext http, BifrostDbContext db)
|
||||
{
|
||||
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
|
||||
// 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 (an account suspended or withdrawn since gets no tokens).
|
||||
// The code's own principal carries the claims chosen at authorize time; suspension is read again here,
|
||||
// because the code outlives the authorize decision (an account suspended since gets no tokens).
|
||||
var result = await http.AuthenticateAsync(OpenIddictScheme);
|
||||
var subject = result.Principal?.GetClaim(Claims.Subject);
|
||||
if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
|
||||
if (!await IsStillChatEligibleAsync(db, subject, http.RequestAborted))
|
||||
{
|
||||
return Deny(Errors.InvalidGrant, refusal);
|
||||
return Deny(Errors.InvalidGrant, SuspendedDescription);
|
||||
}
|
||||
|
||||
return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme);
|
||||
|
|
@ -141,10 +117,10 @@ public static class OidcEndpoints
|
|||
return Results.Challenge(authenticationSchemes: [OpenIddictScheme]);
|
||||
}
|
||||
|
||||
// An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered.
|
||||
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
|
||||
// An access token outlives the authorize decision too: an account suspended since is no longer answered.
|
||||
if (!await IsStillChatEligibleAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted))
|
||||
{
|
||||
return Deny(Errors.InsufficientAccess, refusal);
|
||||
return Deny(Errors.InsufficientAccess, SuspendedDescription);
|
||||
}
|
||||
|
||||
var claims = new Dictionary<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) };
|
||||
|
|
@ -159,31 +135,19 @@ public static class OidcEndpoints
|
|||
|
||||
/// <summary>
|
||||
/// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler:
|
||||
/// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending
|
||||
/// or Active all chat), and, for a chat token (not a desk token), it holds a live receipt for the notice version in force. Re-read on every call.
|
||||
/// the account exists and its membership is not Suspended. Membership may be absent, pending or Active. Re-read on every call.
|
||||
/// </summary>
|
||||
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken, bool deskToken = false)
|
||||
internal static async Task<bool> IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(subject, out var accountId))
|
||||
{
|
||||
return SuspendedDescription;
|
||||
return false;
|
||||
}
|
||||
|
||||
var account = await db.UserAccounts.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
|
||||
if (account is null || account.Membership?.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
return SuspendedDescription;
|
||||
}
|
||||
|
||||
// A folded or frozen subject's chat sessions end: token, userinfo and the logout reconciler all read this one refusal.
|
||||
if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null)
|
||||
{
|
||||
return MergedDescription;
|
||||
}
|
||||
|
||||
return deskToken || await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
|
||||
return account is not null && account.Membership?.Status != MembershipStatus.Suspended;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
|
@ -194,12 +158,6 @@ public static class OidcEndpoints
|
|||
return null;
|
||||
}
|
||||
|
||||
// A folded or frozen subject's live token acts as nobody on the desk too: the same standing the cookie gate and ChatRefusalAsync read.
|
||||
if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var account = await db.UserAccounts.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ using static OpenIddict.Abstractions.OpenIddictConstants;
|
|||
namespace Bifrost.Web.Features.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid
|
||||
/// 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
|
||||
/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it.
|
||||
|
|
@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler(
|
|||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null)
|
||||
if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken))
|
||||
{
|
||||
_backoff.Remove(subject);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
using Bifrost.Web.Features.Shared;
|
||||
|
||||
namespace Bifrost.Web.Features.Passkeys;
|
||||
|
||||
public enum CeremonyKind
|
||||
{
|
||||
/// <summary>Permission to enrol one passkey, earned by a fresh Heimdall sign-in. Held by id in a cookie of the browser that signed in.</summary>
|
||||
EnrolTicket,
|
||||
/// <summary>The WebAuthn creation options in flight for an account.</summary>
|
||||
EnrolChallenge,
|
||||
/// <summary>The WebAuthn request options in flight for an account's unlink of one identity.</summary>
|
||||
UnlinkChallenge,
|
||||
/// <summary>The fact "this account's passkey assertion was verified for unlinking this identity". Carries no bearer and no credential.</summary>
|
||||
UnlinkProof
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The short-lived single-use server-side state of the passkey flows, in this process only (like the Linked accounts list):
|
||||
/// a restart empties it and every flow is simply begun again. Every entry is bound to an account and a kind, and the unlink
|
||||
/// kinds to the identity they are for; <c>Take</c> removes the entry whether or not the caller then succeeds, so a
|
||||
/// challenge, a ticket or an assertion fact can be spent once. A challenge kind has one live entry per account: a newer one
|
||||
/// replaces the older. A full table refuses new entries rather than grow.
|
||||
/// </summary>
|
||||
public sealed class PasskeyCeremonies(TimeProvider clock)
|
||||
{
|
||||
public static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(5);
|
||||
public static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan ProofLifetime = TimeSpan.FromMinutes(5);
|
||||
public const int MaxLive = 2048;
|
||||
|
||||
public sealed record Entry(string Id, CeremonyKind Kind, string Account, string? Subject, string? Data, DateTimeOffset ExpiresAt);
|
||||
|
||||
private readonly Dictionary<string, Entry> _held = new(StringComparer.Ordinal);
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>Adds an entry and returns its id, or null when the table is full.</summary>
|
||||
public string? Issue(CeremonyKind kind, string account, string? subject, string? data, TimeSpan lifetime)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Purge();
|
||||
if (kind != CeremonyKind.UnlinkProof)
|
||||
{
|
||||
foreach (var old in _held.Values.Where(x => x.Kind == kind && x.Account == account).ToList())
|
||||
{
|
||||
_held.Remove(old.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (_held.Count >= MaxLive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
_held[id] = new Entry(id, kind, account, subject, data, clock.GetUtcNow() + lifetime);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The live entry, without spending it. <paramref name="id"/> null finds the account's one current entry of that kind.</summary>
|
||||
public Entry? Peek(CeremonyKind kind, string account, string? subject, string? id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Purge();
|
||||
return Find(kind, account, subject, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The live entry, removed: it can be taken once.</summary>
|
||||
public Entry? Take(CeremonyKind kind, string account, string? subject, string? id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Purge();
|
||||
var entry = Find(kind, account, subject, id);
|
||||
if (entry is not null)
|
||||
{
|
||||
_held.Remove(entry.Id);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public void Discard(string? id)
|
||||
{
|
||||
if (id is not null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_held.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Entry? Find(CeremonyKind kind, string account, string? subject, string? id)
|
||||
{
|
||||
var entry = id is null
|
||||
? _held.Values.FirstOrDefault(x => x.Kind == kind && x.Account == account)
|
||||
: _held.GetValueOrDefault(id);
|
||||
return entry is not null && entry.Kind == kind
|
||||
&& string.Equals(entry.Account, account, StringComparison.Ordinal)
|
||||
&& string.Equals(entry.Subject, subject, StringComparison.Ordinal)
|
||||
? entry
|
||||
: null;
|
||||
}
|
||||
|
||||
private void Purge()
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
foreach (var dead in _held.Values.Where(x => x.ExpiresAt <= now).ToList())
|
||||
{
|
||||
_held.Remove(dead.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The budgets on the passkey ceremonies, per Heimdall account, on the shared <see cref="FixedWindowBudget"/>. Two separate
|
||||
/// ones: asking for options or starting an enrolment sign-in (anyone holding the session can do that), and verifying a
|
||||
/// registration or an assertion, which is spent only after the caller holds what the verification needs (a live ticket, or one of
|
||||
/// the account's own strong credentials and a live challenge). Junk from a session thief that names no credential of the account, or
|
||||
/// arrives with no live challenge, spends nothing; junk naming one of the account's credentials spends one permit per challenge that
|
||||
/// was asked for, because the first such post takes the challenge. A session holder can still exhaust both budgets by asking for
|
||||
/// challenges over and over: a lockout the same session already has through revoking passkeys, not a bypass. Budgets are per process.
|
||||
/// </summary>
|
||||
public sealed class PasskeyRateLimit(TimeProvider clock)
|
||||
{
|
||||
public const int Permits = 20;
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly FixedWindowBudget _options = new(Permits, Window, 10_000, clock);
|
||||
private readonly FixedWindowBudget _verifications = new(Permits, Window, 10_000, clock);
|
||||
|
||||
public bool TryAcquire(string account, bool verification = false) => (verification ? _verifications : _options).Spend(account) is null;
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Fido2NetLib;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Passkeys;
|
||||
|
||||
/// <summary>
|
||||
/// The relying party every passkey ceremony runs as. RP ID and origin come from <see cref="BifrostHostOptions"/> and from
|
||||
/// nothing the browser sends: the public base URL must be https and its host must be the expected host, otherwise passkeys
|
||||
/// are unavailable (the page offers none and every ceremony endpoint refuses). A passkey is bound to the RP ID by the
|
||||
/// authenticator, so a wrong or http origin would enrol keys that cannot be used or can be phished.
|
||||
/// </summary>
|
||||
public sealed class PasskeyRelyingParty
|
||||
{
|
||||
public const string Name = "Bifrost";
|
||||
|
||||
public PasskeyRelyingParty(IOptions<BifrostHostOptions> hostOptions, ILogger<PasskeyRelyingParty> logger)
|
||||
{
|
||||
var host = hostOptions.Value;
|
||||
if (host.ExpectedHost.Contains(':') || System.Net.IPAddress.TryParse(host.ExpectedHost.Trim('[', ']'), out _))
|
||||
{
|
||||
logger.LogWarning("Passkeys are unavailable: Host:ExpectedHost must be a bare DNS host name, with no port and not an IP address (an IP address is not a valid RP ID).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var url)
|
||||
|| url.Scheme != Uri.UriSchemeHttps
|
||||
|| string.IsNullOrWhiteSpace(host.ExpectedHost)
|
||||
|| !string.Equals(url.Host, host.ExpectedHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("Passkeys are unavailable: Host:PublicBaseUrl must be https and its host must equal Host:ExpectedHost.");
|
||||
return;
|
||||
}
|
||||
|
||||
Origin = url.GetLeftPart(UriPartial.Authority);
|
||||
RpId = url.Host.ToLowerInvariant();
|
||||
Fido2 = new Fido2(new Fido2Configuration
|
||||
{
|
||||
RPID = RpId,
|
||||
RPName = Name,
|
||||
Origins = new HashSet<string>([Origin], StringComparer.Ordinal),
|
||||
ChallengeSize = 32,
|
||||
Timeout = 120_000
|
||||
});
|
||||
}
|
||||
|
||||
public bool IsAvailable => Fido2 is not null;
|
||||
|
||||
public string? Origin { get; }
|
||||
|
||||
public string? RpId { get; }
|
||||
|
||||
public IFido2? Fido2 { get; }
|
||||
}
|
||||
|
|
@ -1,470 +0,0 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Passkeys;
|
||||
|
||||
/// <summary>One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key.</summary>
|
||||
public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong);
|
||||
|
||||
/// <summary>The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment).</summary>
|
||||
public sealed record PasskeyFields(
|
||||
string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle,
|
||||
string? AttestationObject, string? Transports);
|
||||
|
||||
/// <summary>
|
||||
/// Passkeys for accounts that have a Heimdall account id (docs/passkeys.md). Enrolment needs a ticket earned by a fresh
|
||||
/// Heimdall sign-in and starts a credential in a 48 hour cooling-off; an assertion for an unlink needs a credential that is
|
||||
/// the session's own, not revoked and past its cooling-off, and records a single-use fact for that account and identity.
|
||||
/// The WebAuthn parsing and verification (CBOR, COSE, signatures, origin, RP ID, challenge, user verification, sign count)
|
||||
/// is Fido2NetLib's; this class decides who may begin, what is bound to what, and what is kept. Outcomes are the closed set
|
||||
/// of <see cref="HeimdallLinkOutcome"/> the page words itself.
|
||||
/// </summary>
|
||||
public sealed class PasskeyService(
|
||||
BifrostDbContext db,
|
||||
PasskeyRelyingParty relyingParty,
|
||||
PasskeyCeremonies ceremonies,
|
||||
PasskeyRateLimit rateLimit,
|
||||
TimeProvider clock,
|
||||
ILogger<PasskeyService> logger)
|
||||
{
|
||||
public const int MaxActive = 5;
|
||||
public const int MaxLabel = 40;
|
||||
public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48);
|
||||
|
||||
private static readonly HashSet<string> TransportNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
"usb", "nfc", "ble", "internal", "hybrid", "smart-card"
|
||||
};
|
||||
|
||||
public bool IsAvailable => relyingParty.IsAvailable;
|
||||
|
||||
private Task<UserAccount?> AccountAsync(string heimdallAccountId, CancellationToken ct) =>
|
||||
string.IsNullOrEmpty(heimdallAccountId)
|
||||
? Task.FromResult<UserAccount?>(null)
|
||||
: db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct);
|
||||
|
||||
private IQueryable<PasskeyCredential> Active(Guid userId) =>
|
||||
db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null);
|
||||
|
||||
public async Task<IReadOnlyList<PasskeyView>> ListAsync(string heimdallAccountId, CancellationToken ct)
|
||||
{
|
||||
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var rows = await Active(user.Id).ToListAsync(ct);
|
||||
return rows.OrderBy(x => x.CreatedUtc)
|
||||
.Select(x => new PasskeyView(x.Id, x.Label, x.CreatedUtc, x.StrongFromUtc, x.LastUsedUtc, x.StrongFromUtc <= now))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Whether a fresh enrolment ticket is held by this browser for this account.</summary>
|
||||
public bool HasTicket(string heimdallAccountId, string? ticketId) =>
|
||||
ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null;
|
||||
|
||||
/// <summary>Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit.</summary>
|
||||
public async Task<HeimdallLinkOutcome?> PreflightEnrolStartAsync(string heimdallAccountId, CancellationToken ct)
|
||||
{
|
||||
if (!relyingParty.IsAvailable)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeysUnavailable;
|
||||
}
|
||||
|
||||
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return HeimdallLinkOutcome.Error;
|
||||
}
|
||||
|
||||
if (await Active(user.Id).CountAsync(ct) >= MaxActive)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeyLimit;
|
||||
}
|
||||
|
||||
return rateLimit.TryAcquire(heimdallAccountId) ? null : HeimdallLinkOutcome.RateLimited;
|
||||
}
|
||||
|
||||
/// <summary>The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned.</summary>
|
||||
public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginEnrolAsync(string heimdallAccountId, string? ticketId, CancellationToken ct)
|
||||
{
|
||||
if (!relyingParty.IsAvailable)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
|
||||
}
|
||||
|
||||
if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return (HeimdallLinkOutcome.EnrolNeedsSignIn, null);
|
||||
}
|
||||
|
||||
if (!rateLimit.TryAcquire(heimdallAccountId))
|
||||
{
|
||||
return (HeimdallLinkOutcome.RateLimited, null);
|
||||
}
|
||||
|
||||
var existing = await Active(user.Id).Select(x => x.CredentialId).ToListAsync(ct);
|
||||
if (existing.Count >= MaxActive)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyLimit, null);
|
||||
}
|
||||
|
||||
var options = relyingParty.Fido2!.RequestNewCredential(new RequestNewCredentialParams
|
||||
{
|
||||
User = new Fido2User { Id = user.Id.ToByteArray(), Name = Label(user.DisplayName, "Bifrost member"), DisplayName = Label(user.DisplayName, "Bifrost member") },
|
||||
ExcludeCredentials = existing.Select(x => new PublicKeyCredentialDescriptor(x)).ToList(),
|
||||
AuthenticatorSelection = new AuthenticatorSelection
|
||||
{
|
||||
ResidentKey = ResidentKeyRequirement.Preferred,
|
||||
UserVerification = UserVerificationRequirement.Required
|
||||
},
|
||||
AttestationPreference = AttestationConveyancePreference.None
|
||||
});
|
||||
var json = options.ToJson();
|
||||
return ceremonies.Issue(CeremonyKind.EnrolChallenge, heimdallAccountId, null, json, PasskeyCeremonies.ChallengeLifetime) is null
|
||||
? (HeimdallLinkOutcome.Error, null)
|
||||
: (null, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a registration and stores the credential, cooling off for <see cref="CoolingOff"/>. The ticket and the
|
||||
/// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge.
|
||||
/// </summary>
|
||||
public async Task<HeimdallLinkOutcome> EnrolAsync(
|
||||
string heimdallAccountId, string? ticketId, PasskeyFields fields, string? label, CancellationToken ct)
|
||||
{
|
||||
if (!relyingParty.IsAvailable)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeysUnavailable;
|
||||
}
|
||||
|
||||
// The caller's own preconditions first, spending nothing: someone with only the session has no ticket, so junk from them
|
||||
// can neither burn the member's ticket and challenge nor spend the verification permits.
|
||||
if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return HeimdallLinkOutcome.EnrolNeedsSignIn;
|
||||
}
|
||||
|
||||
if (await Active(user.Id).CountAsync(ct) >= MaxActive)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeyLimit;
|
||||
}
|
||||
|
||||
if (!TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AttestationObject, out var attestation)
|
||||
|| !TryDecode(fields.ClientDataJson, out var clientData) || fields.Id is null)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeyFailed;
|
||||
}
|
||||
|
||||
if (!rateLimit.TryAcquire(heimdallAccountId, verification: true))
|
||||
{
|
||||
return HeimdallLinkOutcome.RateLimited;
|
||||
}
|
||||
|
||||
// Now both are spent, whatever the outcome.
|
||||
var ticket = ceremonies.Take(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId);
|
||||
var challenge = ceremonies.Take(CeremonyKind.EnrolChallenge, heimdallAccountId, null, null);
|
||||
if (ticket is null)
|
||||
{
|
||||
return HeimdallLinkOutcome.EnrolNeedsSignIn;
|
||||
}
|
||||
|
||||
if (challenge?.Data is null)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeyExpired;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var registered = await relyingParty.Fido2!.MakeNewCredentialAsync(new MakeNewCredentialParams
|
||||
{
|
||||
AttestationResponse = new AuthenticatorAttestationRawResponse
|
||||
{
|
||||
Id = fields.Id,
|
||||
RawId = rawId,
|
||||
Type = PublicKeyCredentialType.PublicKey,
|
||||
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
|
||||
Response = new AuthenticatorAttestationRawResponse.AttestationResponse
|
||||
{
|
||||
AttestationObject = attestation,
|
||||
ClientDataJson = clientData,
|
||||
Transports = Transports(fields.Transports)
|
||||
}
|
||||
},
|
||||
OriginalOptions = CredentialCreateOptions.FromJson(challenge.Data),
|
||||
IsCredentialIdUniqueToUserCallback = async (parameters, token) =>
|
||||
!await db.PasskeyCredentials.AnyAsync(x => x.CredentialId == parameters.CredentialId, token)
|
||||
}, ct);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
db.PasskeyCredentials.Add(new PasskeyCredential
|
||||
{
|
||||
UserAccountId = user.Id,
|
||||
CredentialId = registered.Id,
|
||||
PublicKey = registered.PublicKey,
|
||||
SignCount = registered.SignCount,
|
||||
Transports = string.Join(',', Transports(fields.Transports).Select(x => TransportName(x))),
|
||||
AaGuid = registered.AaGuid == Guid.Empty ? null : registered.AaGuid,
|
||||
Label = Label(label, "Passkey"),
|
||||
CreatedUtc = now,
|
||||
StrongFromUtc = now + CoolingOff
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return HeimdallLinkOutcome.PasskeyAdded;
|
||||
}
|
||||
catch (Exception error) when (error is Fido2VerificationException or DbUpdateException or FormatException or System.Text.Json.JsonException)
|
||||
{
|
||||
logger.LogWarning("Passkey enrolment refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name);
|
||||
return HeimdallLinkOutcome.PasskeyFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation.</summary>
|
||||
public async Task<HeimdallLinkOutcome> RevokeAsync(string heimdallAccountId, Guid credentialId, CancellationToken ct)
|
||||
{
|
||||
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return HeimdallLinkOutcome.Error;
|
||||
}
|
||||
|
||||
var credential = await Active(user.Id).SingleOrDefaultAsync(x => x.Id == credentialId, ct);
|
||||
if (credential is null)
|
||||
{
|
||||
return HeimdallLinkOutcome.PasskeyFailed;
|
||||
}
|
||||
|
||||
credential.RevokedUtc = clock.GetUtcNow();
|
||||
await db.SaveChangesAsync(ct);
|
||||
return HeimdallLinkOutcome.PasskeyRevoked;
|
||||
}
|
||||
|
||||
private IQueryable<PasskeyCredential> Strong(Guid userId)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
return Active(userId).Where(x => x.StrongFromUtc <= now);
|
||||
}
|
||||
|
||||
public async Task<bool> HasStrongAsync(string heimdallAccountId, CancellationToken ct) =>
|
||||
await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct);
|
||||
|
||||
/// <summary>When the first cooling-off credential becomes strong, or null when none is cooling.</summary>
|
||||
public async Task<DateTimeOffset?> NextStrongAtAsync(string heimdallAccountId, CancellationToken ct)
|
||||
{
|
||||
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var cooling = await Active(user.Id).Where(x => x.StrongFromUtc > now).Select(x => x.StrongFromUtc).ToListAsync(ct);
|
||||
return cooling.Count == 0 ? null : cooling.Min();
|
||||
}
|
||||
|
||||
/// <summary>The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification.</summary>
|
||||
public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginAssertionAsync(string heimdallAccountId, string identityId, CancellationToken ct)
|
||||
{
|
||||
if (!relyingParty.IsAvailable)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
|
||||
}
|
||||
|
||||
if (!rateLimit.TryAcquire(heimdallAccountId))
|
||||
{
|
||||
return (HeimdallLinkOutcome.RateLimited, null);
|
||||
}
|
||||
|
||||
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||
{
|
||||
return (HeimdallLinkOutcome.Error, null);
|
||||
}
|
||||
|
||||
var strong = await Strong(user.Id).ToListAsync(ct);
|
||||
if (strong.Count == 0)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyRequired, null);
|
||||
}
|
||||
|
||||
var options = relyingParty.Fido2!.GetAssertionOptions(new GetAssertionOptionsParams
|
||||
{
|
||||
AllowedCredentials = strong.Select(x => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, x.CredentialId, Transports(x.Transports))).ToList(),
|
||||
UserVerification = UserVerificationRequirement.Required
|
||||
});
|
||||
var json = options.ToJson();
|
||||
return ceremonies.Issue(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, json, PasskeyCeremonies.ChallengeLifetime) is null
|
||||
? (HeimdallLinkOutcome.Error, null)
|
||||
: (null, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an assertion made for unlinking <paramref name="identityId"/>. The challenge is spent first. On success the
|
||||
/// credential's sign count and last use are recorded and a single-use fact for this account and identity is issued; its id
|
||||
/// (not a credential, not a bearer) is what the fresh Heimdall sign-in's attempt carries.
|
||||
/// </summary>
|
||||
public async Task<(HeimdallLinkOutcome? Refusal, string? FactId)> VerifyAssertionAsync(
|
||||
string heimdallAccountId, string identityId, PasskeyFields fields, CancellationToken ct)
|
||||
{
|
||||
if (!relyingParty.IsAvailable)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
|
||||
}
|
||||
|
||||
// The caller's own preconditions first, spending nothing: a decodable assertion naming one of this account's strong,
|
||||
// un-revoked credentials. Junk from someone holding only the session fails here and burns neither the member's challenge
|
||||
// nor the verification permits.
|
||||
if (await AccountAsync(heimdallAccountId, ct) is not { } user
|
||||
|| !TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AuthenticatorData, out var authenticatorData)
|
||||
|| !TryDecode(fields.ClientDataJson, out var clientData) || !TryDecode(fields.Signature, out var signature)
|
||||
|| fields.Id is null)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||
}
|
||||
|
||||
byte[]? userHandle = null;
|
||||
if (!string.IsNullOrEmpty(fields.UserHandle) && !TryDecode(fields.UserHandle, out userHandle))
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||
}
|
||||
|
||||
// The credential must be this session's own, not revoked and past its cooling-off. Anything else is not a strong proof.
|
||||
var credential = await Strong(user.Id).SingleOrDefaultAsync(x => x.CredentialId == rawId, ct);
|
||||
if (credential is null)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||
}
|
||||
|
||||
// Without a live challenge for this account and identity nothing can verify, so no permit is spent either: junk naming the
|
||||
// account's own credential spends at most one permit per challenge that was asked for (the first junk then takes it).
|
||||
if (ceremonies.Peek(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null) is null)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyExpired, null);
|
||||
}
|
||||
|
||||
if (!rateLimit.TryAcquire(heimdallAccountId, verification: true))
|
||||
{
|
||||
return (HeimdallLinkOutcome.RateLimited, null);
|
||||
}
|
||||
|
||||
// Spent from here on, whatever the outcome.
|
||||
var challenge = ceremonies.Take(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null);
|
||||
if (challenge?.Data is null)
|
||||
{
|
||||
return (HeimdallLinkOutcome.PasskeyExpired, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await relyingParty.Fido2!.MakeAssertionAsync(new MakeAssertionParams
|
||||
{
|
||||
AssertionResponse = new AuthenticatorAssertionRawResponse
|
||||
{
|
||||
Id = fields.Id,
|
||||
RawId = rawId,
|
||||
Type = PublicKeyCredentialType.PublicKey,
|
||||
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
|
||||
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
|
||||
{
|
||||
AuthenticatorData = authenticatorData,
|
||||
ClientDataJson = clientData,
|
||||
Signature = signature,
|
||||
UserHandle = userHandle
|
||||
}
|
||||
},
|
||||
OriginalOptions = AssertionOptions.FromJson(challenge.Data),
|
||||
StoredPublicKey = credential.PublicKey,
|
||||
StoredSignatureCounter = (uint)credential.SignCount,
|
||||
IsUserHandleOwnerOfCredentialIdCallback = (parameters, _) =>
|
||||
Task.FromResult(parameters.UserHandle.AsSpan().SequenceEqual(user.Id.ToByteArray()) && parameters.CredentialId.AsSpan().SequenceEqual(credential.CredentialId))
|
||||
}, ct);
|
||||
|
||||
credential.SignCount = result.SignCount;
|
||||
credential.LastUsedUtc = clock.GetUtcNow();
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception error) when (error is Fido2VerificationException or FormatException or System.Text.Json.JsonException)
|
||||
{
|
||||
logger.LogWarning("Passkey assertion refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name);
|
||||
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||
}
|
||||
|
||||
var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, heimdallAccountId, identityId, credential.Id.ToString(), PasskeyCeremonies.ProofLifetime);
|
||||
return fact is null ? (HeimdallLinkOutcome.Error, null) : (null, fact);
|
||||
}
|
||||
|
||||
public void DiscardFact(string? factId) => ceremonies.Discard(factId);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the credential an assertion fact was made with is still this account's own, un-revoked and strong. Asked in the
|
||||
/// completion, before Heimdall is: a passkey revoked between the assertion and the completion no longer proves anything.
|
||||
/// </summary>
|
||||
public async Task<bool> StillStrongAsync(string heimdallAccountId, string? credentialId, CancellationToken ct) =>
|
||||
Guid.TryParse(credentialId, out var id) && await AccountAsync(heimdallAccountId, ct) is { } user
|
||||
&& await Strong(user.Id).AnyAsync(x => x.Id == id, ct);
|
||||
|
||||
/// <summary>A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left.</summary>
|
||||
private static string Label(string? value, string fallback)
|
||||
{
|
||||
// By rune and by text element, never by UTF-16 unit: a cut inside a surrogate pair is not valid text and Postgres refuses it.
|
||||
// Control and format characters (bidi overrides, joiners, zero-width marks) are dropped; a lone surrogate becomes U+FFFD.
|
||||
var runes = (value ?? string.Empty).EnumerateRunes()
|
||||
.Where(r => Rune.GetUnicodeCategory(r) is not (UnicodeCategory.Control or UnicodeCategory.Format))
|
||||
.Take(MaxLabel * 4);
|
||||
var cleaned = string.Concat(runes.Select(r => r.ToString())).Trim();
|
||||
var elements = new StringInfo(cleaned);
|
||||
if (elements.LengthInTextElements > MaxLabel)
|
||||
{
|
||||
cleaned = elements.SubstringByTextElements(0, MaxLabel);
|
||||
}
|
||||
|
||||
return cleaned.Length == 0 ? fallback : cleaned;
|
||||
}
|
||||
|
||||
private static bool TryDecode(string? value, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
if (string.IsNullOrEmpty(value) || value.Length > 16_384)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bytes = WebEncoders.Base64UrlDecode(value);
|
||||
return bytes.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthenticatorTransport[] Transports(string? csv)
|
||||
{
|
||||
var parsed = new List<AuthenticatorTransport>();
|
||||
foreach (var name in (csv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only names of the closed set reach the converter; one it does not know is dropped.
|
||||
if (TransportNames.Contains(name))
|
||||
{
|
||||
parsed.Add(System.Text.Json.JsonSerializer.Deserialize<AuthenticatorTransport>($"\"{name}\""));
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return parsed.ToArray();
|
||||
}
|
||||
|
||||
private static string TransportName(AuthenticatorTransport transport) =>
|
||||
System.Text.Json.JsonSerializer.Serialize(transport).Trim('"');
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -11,13 +10,6 @@ public sealed class PatronageService(
|
|||
AuditTrailService auditTrailService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
private const string CurrentPatronIndex = "UX_TierSnapshots_MembershipId_CurrentPatron";
|
||||
|
||||
/// <summary>
|
||||
/// Records a patron event for an account, through the merge ledger's write gate: an event for a folded account lands on its survivor
|
||||
/// and is journalled in the same save (an undo moves it back); an event for a frozen account lands on it, where the commit's
|
||||
/// reduction finds it. A provider retry returns the stored event and, if it sits on a folded account's survivor, journals it once.
|
||||
/// </summary>
|
||||
public async Task<PatronSupportEvent> RecordSupportEventAsync(
|
||||
Guid? actorUserAccountId,
|
||||
Guid userAccountId,
|
||||
|
|
@ -35,128 +27,105 @@ public sealed class PatronageService(
|
|||
CancellationToken cancellationToken,
|
||||
Guid? projectId = null)
|
||||
{
|
||||
var normalizedProviderEventId = providerEventId.Trim();
|
||||
var (supportEvent, recordedOn, isNew, recordedFor) = await AccountMergeLedger.WriteAsAsync(dbContext, userAccountId, async standing =>
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
var folded = standing as MergeStanding.Folded;
|
||||
var recordOnId = folded?.SurvivorId ?? userAccountId;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId)
|
||||
? null
|
||||
: await dbContext.PatronSupportEvents
|
||||
.SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken);
|
||||
|
||||
if (existingProviderEvent is not null)
|
||||
{
|
||||
if (folded is not null && existingProviderEvent.UserAccountId == folded.SurvivorId)
|
||||
{
|
||||
await AccountMergeLedger.StageIntakeJournalAsync(dbContext, folded.MergeId, userAccountId, existingProviderEvent.Id, now, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return (existingProviderEvent, recordOnId, false, string.Empty);
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.SingleAsync(x => x.Id == recordOnId, cancellationToken);
|
||||
|
||||
if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
throw new PatronSupportRefusedException();
|
||||
}
|
||||
|
||||
if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support amount must be greater than zero.");
|
||||
}
|
||||
|
||||
if (amount == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support amount must not be zero.");
|
||||
}
|
||||
|
||||
if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support adjustments must reduce support.");
|
||||
}
|
||||
|
||||
if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId))
|
||||
{
|
||||
throw new InvalidOperationException("External patron provider events require a provider event id.");
|
||||
}
|
||||
|
||||
var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode)
|
||||
? "USD"
|
||||
: currencyCode.Trim().ToUpperInvariant();
|
||||
|
||||
if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport)
|
||||
{
|
||||
var existingCurrentEvents = await dbContext.PatronSupportEvents
|
||||
.Where(x => x.UserAccountId == recordOnId &&
|
||||
x.Kind == PatronSupportEventKind.RecurringSupportSnapshot &&
|
||||
x.IsCurrentRecurringSupport)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var existing in existingCurrentEvents)
|
||||
{
|
||||
existing.IsCurrentRecurringSupport = false;
|
||||
}
|
||||
}
|
||||
|
||||
var created = new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = recordOnId,
|
||||
ProjectId = projectId,
|
||||
Kind = kind,
|
||||
ExternalSupportId = externalSupportId.Trim(),
|
||||
Provider = provider,
|
||||
ProviderEventId = normalizedProviderEventId,
|
||||
ProviderPayerId = providerPayerId.Trim(),
|
||||
ProviderSubscriptionId = providerSubscriptionId.Trim(),
|
||||
Amount = amount,
|
||||
CurrencyCode = normalizedCurrency,
|
||||
IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport,
|
||||
SupportedAtUtc = supportedAtUtc.ToUniversalTime(),
|
||||
RecordedAtUtc = now,
|
||||
Notes = notes.Trim()
|
||||
};
|
||||
|
||||
dbContext.PatronSupportEvents.Add(created);
|
||||
dbContext.PointTransactions.Add(new PointTransaction
|
||||
{
|
||||
UserAccountId = recordOnId,
|
||||
ProjectId = projectId,
|
||||
Type = PointTransactionType.PatronSupport,
|
||||
Amount = amount,
|
||||
IsDecaying = kind == PatronSupportEventKind.OneTimeDonation,
|
||||
CreatedAtUtc = now,
|
||||
Note = $"Recorded {provider} {kind} patron support event {created.ExternalSupportId}."
|
||||
});
|
||||
|
||||
if (folded is not null)
|
||||
{
|
||||
await AccountMergeLedger.StageIntakeJournalAsync(dbContext, folded.MergeId, userAccountId, created.Id, now, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return (created, recordOnId, true, user.DisplayName);
|
||||
}, cancellationToken);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUserAccountId,
|
||||
nameof(PatronSupportEvent),
|
||||
supportEvent.Id,
|
||||
"patron-support.recorded",
|
||||
$"Recorded {amount:0.##} {supportEvent.CurrencyCode} {provider} {kind} patron support for {recordedFor}.",
|
||||
cancellationToken);
|
||||
|
||||
await RefreshPatronTierSnapshotAsync(actorUserAccountId, recordedOn, cancellationToken);
|
||||
throw new InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account.");
|
||||
}
|
||||
|
||||
if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support amount must be greater than zero.");
|
||||
}
|
||||
|
||||
if (amount == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support amount must not be zero.");
|
||||
}
|
||||
|
||||
if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support adjustments must reduce support.");
|
||||
}
|
||||
|
||||
var normalizedProviderEventId = providerEventId.Trim();
|
||||
if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId))
|
||||
{
|
||||
throw new InvalidOperationException("External patron provider events require a provider event id.");
|
||||
}
|
||||
|
||||
var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId)
|
||||
? null
|
||||
: await dbContext.PatronSupportEvents
|
||||
.SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken);
|
||||
|
||||
if (existingProviderEvent is not null)
|
||||
{
|
||||
return existingProviderEvent;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode)
|
||||
? "USD"
|
||||
: currencyCode.Trim().ToUpperInvariant();
|
||||
|
||||
if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport)
|
||||
{
|
||||
var existingCurrentEvents = await dbContext.PatronSupportEvents
|
||||
.Where(x => x.UserAccountId == userAccountId &&
|
||||
x.Kind == PatronSupportEventKind.RecurringSupportSnapshot &&
|
||||
x.IsCurrentRecurringSupport)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var existing in existingCurrentEvents)
|
||||
{
|
||||
existing.IsCurrentRecurringSupport = false;
|
||||
}
|
||||
}
|
||||
|
||||
var supportEvent = new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
ProjectId = projectId,
|
||||
Kind = kind,
|
||||
ExternalSupportId = externalSupportId.Trim(),
|
||||
Provider = provider,
|
||||
ProviderEventId = normalizedProviderEventId,
|
||||
ProviderPayerId = providerPayerId.Trim(),
|
||||
ProviderSubscriptionId = providerSubscriptionId.Trim(),
|
||||
Amount = amount,
|
||||
CurrencyCode = normalizedCurrency,
|
||||
IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport,
|
||||
SupportedAtUtc = supportedAtUtc.ToUniversalTime(),
|
||||
RecordedAtUtc = now,
|
||||
Notes = notes.Trim()
|
||||
};
|
||||
|
||||
dbContext.PatronSupportEvents.Add(supportEvent);
|
||||
dbContext.PointTransactions.Add(new PointTransaction
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
ProjectId = projectId,
|
||||
Type = PointTransactionType.PatronSupport,
|
||||
Amount = amount,
|
||||
IsDecaying = kind == PatronSupportEventKind.OneTimeDonation,
|
||||
CreatedAtUtc = now,
|
||||
Note = $"Recorded {provider} {kind} patron support event {supportEvent.ExternalSupportId}."
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUserAccountId,
|
||||
nameof(PatronSupportEvent),
|
||||
supportEvent.Id,
|
||||
"patron-support.recorded",
|
||||
$"Recorded {amount:0.##} {normalizedCurrency} {provider} {kind} patron support for {user.DisplayName}.",
|
||||
cancellationToken);
|
||||
|
||||
await RefreshPatronTierSnapshotAsync(actorUserAccountId, userAccountId, cancellationToken);
|
||||
return supportEvent;
|
||||
}
|
||||
|
||||
|
|
@ -165,67 +134,24 @@ public sealed class PatronageService(
|
|||
Guid userAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// One current Patron snapshot per membership is a database invariant (UX_TierSnapshots_MembershipId_CurrentPatron). A restage that
|
||||
// lost a race to another restage of the same membership reads the winner's snapshot and derives again from the events it now sees.
|
||||
UserAccount user;
|
||||
PatronPointSummary summary;
|
||||
PatronTierDefinition? tier;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot refresh patron tier for a user without membership.");
|
||||
}
|
||||
|
||||
(summary, tier) = await StageTierSnapshotAsync(user.Membership, user.PatronSupportEvents, timeProvider.GetUtcNow(), cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (DbUpdateException error) when (attempt < 3 &&
|
||||
error.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation, ConstraintName: CurrentPatronIndex })
|
||||
{
|
||||
}
|
||||
throw new InvalidOperationException("Cannot refresh patron tier for a user without membership.");
|
||||
}
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUserAccountId,
|
||||
nameof(TierSnapshot),
|
||||
user.Membership.Id,
|
||||
"patron-tier.derived",
|
||||
tier is null
|
||||
? $"Cleared patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points."
|
||||
: $"Derived {tier.Label} patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points.",
|
||||
cancellationToken);
|
||||
|
||||
return summary with { TierLabel = tier?.Label ?? string.Empty, VotingWeight = tier?.VotingWeight ?? 0m };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the membership's patron tier from the events once and stages it on the tracker: demotes the current Patron snapshots
|
||||
/// and adds the new one. It never saves and never clears the tracker, so a caller can stage it inside a larger single save
|
||||
/// (the account merge ledger does).
|
||||
/// </summary>
|
||||
public async Task<(PatronPointSummary Summary, PatronTierDefinition? Tier)> StageTierSnapshotAsync(
|
||||
Bifrost.Web.Domain.Membership membership,
|
||||
IEnumerable<PatronSupportEvent> events,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var summary = CalculatePatronPoints(events, now);
|
||||
var summary = CalculatePatronPoints(user.PatronSupportEvents, timeProvider.GetUtcNow());
|
||||
var tier = PatronTierPolicy.ResolveTier(summary.EffectivePoints);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var currentPatronSnapshots = await dbContext.TierSnapshots
|
||||
.Where(x => x.MembershipId == membership.Id &&
|
||||
.Where(x => x.MembershipId == user.Membership.Id &&
|
||||
x.Kind == TierSnapshotKind.Patron &&
|
||||
x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -239,7 +165,7 @@ public sealed class PatronageService(
|
|||
{
|
||||
dbContext.TierSnapshots.Add(new TierSnapshot
|
||||
{
|
||||
MembershipId = membership.Id,
|
||||
MembershipId = user.Membership.Id,
|
||||
Kind = TierSnapshotKind.Patron,
|
||||
Label = PatronTierPolicy.SnapshotLabel(tier),
|
||||
Weight = tier.VotingWeight,
|
||||
|
|
@ -250,7 +176,18 @@ public sealed class PatronageService(
|
|||
});
|
||||
}
|
||||
|
||||
return (summary, tier);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUserAccountId,
|
||||
nameof(TierSnapshot),
|
||||
user.Membership.Id,
|
||||
"patron-tier.derived",
|
||||
tier is null
|
||||
? $"Cleared patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points."
|
||||
: $"Derived {tier.Label} patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points.",
|
||||
cancellationToken);
|
||||
|
||||
return summary with { TierLabel = tier?.Label ?? string.Empty, VotingWeight = tier?.VotingWeight ?? 0m };
|
||||
}
|
||||
|
||||
public static PatronPointSummary CalculatePatronPoints(
|
||||
|
|
@ -278,9 +215,6 @@ public sealed class PatronageService(
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>The account patron support would record on has no membership or is suspended.</summary>
|
||||
public sealed class PatronSupportRefusedException() : InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account.");
|
||||
|
||||
public sealed record PatronPointSummary(
|
||||
decimal RecurringPoints,
|
||||
decimal HistoricalPoints,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ public sealed class StripeWebhookService(
|
|||
|
||||
var patronageAccount = await ResolvePatronageAccountAsync(session, stripeOptions, cancellationToken);
|
||||
|
||||
if (patronageAccount is null)
|
||||
if (patronageAccount?.Membership is null || patronageAccount.Membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
return StripeWebhookResult.NotConfigured(
|
||||
"Stripe checkout session is not linked to an unsuspended Bifrost patron account.");
|
||||
|
|
@ -83,32 +83,22 @@ public sealed class StripeWebhookService(
|
|||
? "USD"
|
||||
: session.Currency.ToUpperInvariant();
|
||||
|
||||
// A checkout opened by an account that has since folded pays the survivor: the write gate behind RecordSupportEventAsync routes it.
|
||||
PatronSupportEvent supportEvent;
|
||||
try
|
||||
{
|
||||
supportEvent = await patronageService.RecordSupportEventAsync(
|
||||
actorUserAccountId: null,
|
||||
patronageAccount.Id,
|
||||
PatronSupportEventKind.OneTimeDonation,
|
||||
amount,
|
||||
currencyCode,
|
||||
session.Id,
|
||||
ExternalPatronProvider.Stripe,
|
||||
stripeEvent.Id,
|
||||
session.Customer,
|
||||
string.Empty,
|
||||
DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created),
|
||||
isCurrentRecurringSupport: false,
|
||||
BuildNotes(session),
|
||||
cancellationToken,
|
||||
projectId.ProjectId);
|
||||
}
|
||||
catch (PatronSupportRefusedException)
|
||||
{
|
||||
return StripeWebhookResult.NotConfigured(
|
||||
"Stripe checkout session is not linked to an unsuspended Bifrost patron account.");
|
||||
}
|
||||
await patronageService.RecordSupportEventAsync(
|
||||
actorUserAccountId: null,
|
||||
patronageAccount.Id,
|
||||
PatronSupportEventKind.OneTimeDonation,
|
||||
amount,
|
||||
currencyCode,
|
||||
session.Id,
|
||||
ExternalPatronProvider.Stripe,
|
||||
stripeEvent.Id,
|
||||
session.Customer,
|
||||
string.Empty,
|
||||
DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created),
|
||||
isCurrentRecurringSupport: false,
|
||||
BuildNotes(session),
|
||||
cancellationToken,
|
||||
projectId.ProjectId);
|
||||
|
||||
return StripeWebhookResult.Processed("Stripe patronage support event recorded.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public static class ConsentEndpoints
|
|||
|
||||
public static void MapConsent(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet(Path, async (HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) =>
|
||||
app.MapGet(Path, async (ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var actor = await actors.GetAsync(ct);
|
||||
if (!actor.IsAuthenticated || actor.UserAccount is null)
|
||||
|
|
@ -28,7 +28,7 @@ public static class ConsentEndpoints
|
|||
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"]));
|
||||
return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct);
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost(Path + "/accept", async (
|
||||
|
|
@ -53,7 +53,7 @@ public static class ConsentEndpoints
|
|||
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));
|
||||
return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct);
|
||||
}
|
||||
|
||||
var accountId = actor.UserAccount.Id;
|
||||
|
|
@ -77,7 +77,7 @@ public static class ConsentEndpoints
|
|||
}
|
||||
}
|
||||
|
||||
return new SeeOther(ReturnPathOf(form["returnUrl"]) ?? Path);
|
||||
return Back();
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost(Path + "/withdraw", async (
|
||||
|
|
@ -133,17 +133,9 @@ public static class ConsentEndpoints
|
|||
|
||||
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) =>
|
||||
private 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)
|
||||
|
|
@ -180,10 +172,10 @@ public static class ConsentEndpoints
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct, string? returnPath = null)
|
||||
private static async Task<IResult> PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct)
|
||||
{
|
||||
var receipts = await db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccountId == accountId).ToListAsync(ct);
|
||||
return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error, returnPath), status);
|
||||
return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error), status);
|
||||
}
|
||||
|
||||
private sealed class HtmlPage(string html, int status) : IResult
|
||||
|
|
|
|||
|
|
@ -42,26 +42,25 @@ public static class ConsentPage
|
|||
|
||||
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)
|
||||
public static string Render(ConsentState state, string? error)
|
||||
{
|
||||
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)
|
||||
ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. 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 and your messages are not used until you agree to it.", true),
|
||||
ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. Your messages are not used.", false),
|
||||
_ => ("You have not agreed. 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>""";
|
||||
: $"""<form method="post" action="/auth/bifrost/consent/accept"><input type="hidden" name="version" value="{version}" /><button type="submit">Agree to version {version}</button></form>""";
|
||||
var button = agreeButton + withdrawButton;
|
||||
|
||||
return $$"""
|
||||
|
|
@ -92,11 +91,11 @@ public static class ConsentPage
|
|||
<h1>Training consent</h1>
|
||||
{{summary}}
|
||||
<p id="state" aria-live="polite">{{E(message)}}</p>
|
||||
{{button}}
|
||||
<fieldset>
|
||||
<legend>Training notice, version {{version}}</legend>
|
||||
{{TrainingNotice.Html("notice")}}
|
||||
</fieldset>
|
||||
{{button}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ public static class RegistrationPage
|
|||
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>""",
|
||||
RegistrationField.Consent => """<ul><li><a href="#consent">I agree to this notice (optional)</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>""");
|
||||
|
|
@ -154,8 +154,8 @@ public static class RegistrationPage
|
|||
<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>
|
||||
<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 (optional)</label>
|
||||
<p class="hint">Optional. You can create an account without agreeing; then none of your messages are used. You can agree later on your consent page.</p>
|
||||
<input type="hidden" name="acceptedNoticeVersion" value="{{notice}}" />
|
||||
</fieldset>
|
||||
<input type="hidden" name="challenge" value="{{E(model.Challenge)}}" />
|
||||
|
|
|
|||
|
|
@ -5,22 +5,21 @@ 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.
|
||||
/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for an
|
||||
/// older version no longer counts and the person is asked again on the consent page.
|
||||
/// </summary>
|
||||
public static class TrainingNotice
|
||||
{
|
||||
public const int Version = 1;
|
||||
public const int Version = 2;
|
||||
|
||||
public const string Text =
|
||||
"GameCult chat training notice, version 1\n\n" +
|
||||
"GameCult chat training notice, version 2\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" +
|
||||
"It is your choice. You can use GameCult chat without agreeing. If you do not agree, 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.";
|
||||
"If you withdraw, we leave all your messages out of every training set made after that moment. 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, 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)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ public sealed class ApplicationBootstrapper(
|
|||
BifrostDbContext dbContext,
|
||||
IOptions<BootstrapOptions> bootstrapOptions,
|
||||
TimeProvider timeProvider,
|
||||
AuditTrailService auditTrailService,
|
||||
ILogger<ApplicationBootstrapper> logger)
|
||||
{
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
|
|
@ -41,11 +40,16 @@ public sealed class ApplicationBootstrapper(
|
|||
|
||||
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
|
||||
{
|
||||
var before = adminUser.Membership!.Status;
|
||||
if (TryPromoteBootstrapAdmin(adminUser, now, logger) && before != adminUser.Membership.Status)
|
||||
{
|
||||
await RecordPromotionAsync(auditTrailService, adminUser, before, cancellationToken);
|
||||
}
|
||||
var membership = adminUser.Membership!;
|
||||
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");
|
||||
}
|
||||
|
||||
if (dbContext.ChangeTracker.HasChanges())
|
||||
|
|
@ -54,47 +58,6 @@ public sealed class ApplicationBootstrapper(
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one bootstrap-admin promotion, used at startup and at sign-in. A Merged or 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.IsMerged)
|
||||
{
|
||||
logger.LogWarning("Bootstrap admin promotion skipped for merged account {UserAccountId}.", account.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
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(
|
||||
Bifrost.Web.Domain.Membership membership,
|
||||
MemberRole role,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ public static class BifrostCookies
|
|||
public const string Antiforgery = HostPrefix + "bifrost_antiforgery";
|
||||
public const string Voter = HostPrefix + "bifrost_voter";
|
||||
public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_";
|
||||
public const string PasskeyTicket = HostPrefix + "bifrost_passkey_ticket";
|
||||
|
||||
public const string Path = "/";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
public sealed record CurrentBifrostActor(bool IsAuthenticated, UserAccount? UserAccount)
|
||||
{
|
||||
/// <summary>Set when the account behind this sign-in is frozen or folded by an account merge; the actor then has no account.</summary>
|
||||
public MergeStanding? Merge { get; init; }
|
||||
|
||||
public static readonly CurrentBifrostActor Anonymous = new(false, null);
|
||||
|
||||
public Bifrost.Web.Domain.Membership? Membership => UserAccount?.Membership;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ public interface ICurrentBifrostActorAccessor
|
|||
|
||||
public sealed class CurrentBifrostActorAccessor(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
BifrostDbContext dbContext,
|
||||
AccountMergeLedger ledger) : ICurrentBifrostActorAccessor
|
||||
BifrostDbContext dbContext) : ICurrentBifrostActorAccessor
|
||||
{
|
||||
private const string CacheKey = "Bifrost.CurrentActor";
|
||||
|
||||
|
|
@ -46,41 +45,18 @@ public sealed class CurrentBifrostActorAccessor(
|
|||
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
||||
}
|
||||
|
||||
// One credential names one row: identity first, then Heimdall id, then GitHub id; the first hit wins. Two rows matching
|
||||
// one principal (a merge can leave a login on either side) must never throw or pick by luck.
|
||||
var accounts = dbContext.UserAccounts
|
||||
var userAccount = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.Include(x => x.MemberProfile)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.TierSnapshots);
|
||||
|
||||
Bifrost.Web.Domain.UserAccount? userAccount = null;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity))
|
||||
{
|
||||
userAccount = await accounts.SingleOrDefaultAsync(x => x.NormalizedBifrostIdentity == normalizedBifrostIdentity, cancellationToken);
|
||||
}
|
||||
|
||||
if (userAccount is null && !string.IsNullOrWhiteSpace(heimdallAccountId))
|
||||
{
|
||||
userAccount = await accounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, cancellationToken);
|
||||
}
|
||||
|
||||
if (userAccount is null && hasGitHubUserId)
|
||||
{
|
||||
userAccount = await accounts.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
|
||||
}
|
||||
|
||||
// A folded or frozen account acts as nobody on every route: this is the one gate (merge ledger standing, derived per request).
|
||||
if (userAccount is not null)
|
||||
{
|
||||
var standing = await ledger.StandingAsync(userAccount.Id, cancellationToken);
|
||||
if (standing is not null)
|
||||
{
|
||||
return Cache(httpContext, new CurrentBifrostActor(true, null) { Merge = standing });
|
||||
}
|
||||
}
|
||||
.ThenInclude(x => x.TierSnapshots)
|
||||
.SingleOrDefaultAsync(
|
||||
x => (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity) && x.NormalizedBifrostIdentity == normalizedBifrostIdentity) ||
|
||||
(!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) ||
|
||||
(hasGitHubUserId && x.GitHubUserId == gitHubUserId),
|
||||
cancellationToken);
|
||||
|
||||
return Cache(httpContext, new CurrentBifrostActor(true, userAccount));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
var recentActivity = await dbContext.AuditEvents
|
||||
.AsNoTracking()
|
||||
.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
|
||||
.Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended")) // a reason is for admins, not the member dashboard
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.Take(8)
|
||||
.Select(x => new DashboardActivity(
|
||||
|
|
|
|||
|
|
@ -15,16 +15,12 @@ public sealed class StartupConfigurationValidator(
|
|||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<ConceptVoteOptions> conceptVoteOptions,
|
||||
IOptions<DeskOptions> deskOptions,
|
||||
IOptions<RegistrationOptions> registrationOptions,
|
||||
OidcIssuerOptions? oidcOptions = null)
|
||||
IOptions<RegistrationOptions> registrationOptions)
|
||||
{
|
||||
public void Validate()
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
// Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment.
|
||||
_ = heimdallOptions.Value;
|
||||
|
||||
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
|
||||
var conceptVotes = conceptVoteOptions.Value;
|
||||
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
|
||||
|
|
@ -105,13 +101,6 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
|
||||
}
|
||||
|
||||
// The options are already resolved, so a secret from Heimdall:AppSecretFile or Heimdall:AppSecret lands in AppSecret.
|
||||
// Blank would silently switch Discord and Patreon member sign-in off.
|
||||
if (string.IsNullOrWhiteSpace(heimdallOptions.Value.AppSecret))
|
||||
{
|
||||
failures.Add("Heimdall:AppSecretFile (or Heimdall:AppSecret) must supply the Heimdall app secret outside development.");
|
||||
}
|
||||
|
||||
if (stripeOptions.Value.EnableCheckout)
|
||||
{
|
||||
if (!stripeOptions.Value.IsCheckoutConfigured)
|
||||
|
|
@ -160,12 +149,6 @@ public sealed class StartupConfigurationValidator(
|
|||
// mis-attribute answers. Account existence is a request-time question, not a startup one.
|
||||
private void ValidateDesk(DeskOptions desk, List<string> failures)
|
||||
{
|
||||
// The desk's bearer is validated against Bifrost's own issuer: without Bifrost:Oidc there is no credential to serve with.
|
||||
if (oidcOptions is null && configuration.GetSection(DeskOptions.SectionName).Exists())
|
||||
{
|
||||
failures.Add("Bifrost:Oidc must be configured when the Desk section exists: the desk validates its bearer tokens against Bifrost's own issuer.");
|
||||
}
|
||||
|
||||
if (!desk.IsConfigured)
|
||||
{
|
||||
if (configuration.GetSection(DeskOptions.SectionName).Exists())
|
||||
|
|
|
|||
|
|
@ -14,11 +14,6 @@
|
|||
because trust and contributor accountability are product concerns, not just auth concerns.
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Notice))
|
||||
{
|
||||
<p class="validation-summary-errors" role="alert">@Model.Notice</p>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanManageMembership)
|
||||
{
|
||||
<form method="post" asp-page-handler="Invite">
|
||||
|
|
@ -191,10 +186,6 @@
|
|||
|
||||
@if (Model.Actor.CanManageMembership)
|
||||
{
|
||||
@if (user.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
<div class="muted" style="margin-top: 0.8rem;">@(string.IsNullOrWhiteSpace(user.SuspensionReason) ? Bifrost.Web.Pages.App.Members.IndexModel.LegacySuspensionText : "Suspended: " + user.SuspensionReason)</div>
|
||||
}
|
||||
<form method="post" asp-page-handler="@(user.Status == MembershipStatus.Suspended ? "Unsuspend" : "Suspend")" style="margin-top: 0.8rem;">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
<input type="text" name="reason" maxlength="500" required placeholder="Reason" />
|
||||
|
|
|
|||
|
|
@ -22,27 +22,6 @@ public sealed class IndexModel(
|
|||
[BindProperty]
|
||||
public UpdateProfileInput Profile { get; set; } = new();
|
||||
|
||||
/// <summary>Why the last membership action did nothing; one of <see cref="RefusalNotices"/>, carried across the redirect.</summary>
|
||||
[TempData]
|
||||
public string? Notice { get; set; }
|
||||
|
||||
private static readonly IReadOnlyDictionary<SuspensionOutcome, string> RefusalNotices = new Dictionary<SuspensionOutcome, string>
|
||||
{
|
||||
[SuspensionOutcome.NoReason] = "Nothing changed: a reason is required to suspend or unsuspend an account.",
|
||||
[SuspensionOutcome.ProtectedAccount] = "Nothing changed: you cannot suspend yourself or another platform admin.",
|
||||
[SuspensionOutcome.NoSuchAccount] = "Nothing changed: that account or its membership does not exist.",
|
||||
[SuspensionOutcome.NotApplicable] = "Nothing changed: the account is already in that state."
|
||||
};
|
||||
|
||||
internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend.";
|
||||
/// <summary>Admin-only text for a Suspended row whose reason predates the SuspensionReason column; the old text is never read back from the audit trail.</summary>
|
||||
internal const string LegacySuspensionText = "Suspended before reasons were recorded";
|
||||
internal const string ApproveMergedNotice = "Nothing changed: that account was merged into another and cannot be approved.";
|
||||
|
||||
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 IReadOnlyList<MemberDirectoryItem> Users { get; private set; } = [];
|
||||
|
|
@ -123,25 +102,11 @@ public sealed class IndexModel(
|
|||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
// The chat tier holds no membership by design (docs/oidc-issuer.md); approval refuses rather than inventing one.
|
||||
if (user is null || user.Membership is null)
|
||||
if (user.Membership!.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
Notice = user is null ? RefusalNotices[SuspensionOutcome.NoSuchAccount] : NoMembershipNotice;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
if (user.Membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
if (user.Membership.IsMerged)
|
||||
{
|
||||
Notice = ApproveMergedNotice; // only the merge ledger leaves Merged
|
||||
return RedirectToPage();
|
||||
return RedirectToPage(); // only Unsuspend lifts a suspension
|
||||
}
|
||||
|
||||
user.Membership.Status = MembershipStatus.Active;
|
||||
|
|
@ -178,13 +143,7 @@ public sealed class IndexModel(
|
|||
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();
|
||||
return outcome == SuspensionOutcome.NotAdmin ? Forbid() : RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken)
|
||||
|
|
@ -198,29 +157,10 @@ public sealed class IndexModel(
|
|||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount];
|
||||
return RedirectToPage();
|
||||
}
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -235,10 +175,8 @@ public sealed class IndexModel(
|
|||
Notes = "Assigned from the member admin page."
|
||||
});
|
||||
}
|
||||
else
|
||||
else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin))
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
@ -282,13 +220,7 @@ public sealed class IndexModel(
|
|||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.TierSnapshots)
|
||||
.SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount];
|
||||
return RedirectToPage();
|
||||
}
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null)
|
||||
{
|
||||
|
|
@ -407,7 +339,6 @@ public sealed class IndexModel(
|
|||
user.MemberProfile?.Skills ?? string.Empty,
|
||||
user.MemberProfile?.Availability ?? string.Empty,
|
||||
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() ?? [],
|
||||
patronTier?.Label ?? string.Empty,
|
||||
patronTier?.Weight ?? 0m,
|
||||
|
|
@ -495,7 +426,6 @@ public sealed class IndexModel(
|
|||
string Skills,
|
||||
string Availability,
|
||||
MembershipStatus Status,
|
||||
string SuspensionReason,
|
||||
IReadOnlyList<MemberRole> Roles,
|
||||
string PatronTierLabel,
|
||||
decimal PatronTierWeight,
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
@page
|
||||
@using Bifrost.Web.Domain
|
||||
@model Bifrost.Web.Pages.App.Merges.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Stuck merges";
|
||||
}
|
||||
|
||||
<section class="panel-grid">
|
||||
<article class="panel">
|
||||
<span class="eyebrow">Merges</span>
|
||||
<h1>Stuck account merges</h1>
|
||||
<p class="panel-intro">
|
||||
A merge listed here has stopped advancing on its own. Before ending one, read Heimdall's identity move by the move id shown
|
||||
(docs/account-merge.md, Stuck merges). Ending a merge cannot be undone, and Bifrost never adopts a move Heimdall made.
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Notice))
|
||||
{
|
||||
<p class="validation-summary-errors" role="alert">@Model.Notice</p>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="table-panel" style="margin-top: 1.5rem;">
|
||||
@if (Model.Merges.Count == 0)
|
||||
{
|
||||
<div class="empty-state" style="margin: 1.25rem;">No merge is stuck.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Merge</th>
|
||||
<th>State</th>
|
||||
<th>Last outcome</th>
|
||||
<th>Attempts</th>
|
||||
<th>Move id of this leg</th>
|
||||
<th>End it</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var merge in Model.Merges)
|
||||
{
|
||||
<tr>
|
||||
<td>@merge.Id</td>
|
||||
<td>@merge.State</td>
|
||||
<td>@merge.LastOutcome</td>
|
||||
<td>@merge.Attempts</td>
|
||||
<td><code>@merge.MoveId</code></td>
|
||||
<td>
|
||||
@if (!merge.CanSettle)
|
||||
{
|
||||
<span>Still being retried; nothing to end.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" asp-page-handler="@(merge.State == AccountMergeState.UndoRequested ? "AbandonUndo" : "FailForward")">
|
||||
<input type="hidden" name="mergeId" value="@merge.Id" />
|
||||
<input type="text" name="moveId" autocomplete="off" spellcheck="false" aria-label="Type the move id to confirm" />
|
||||
<button class="button" type="submit">@(merge.State == AccountMergeState.UndoRequested ? "Give up the undo" : "Fail the merge")</button>
|
||||
</form>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Pages.App.Merges;
|
||||
|
||||
/// <summary>One in-flight merge the sweeper has stopped advancing, as an operator may see it: no account, Heimdall id or login of either side.</summary>
|
||||
public sealed record StuckMerge(Guid Id, AccountMergeState State, string LastOutcome, int Attempts, string MoveId, bool CanSettle);
|
||||
|
||||
/// <summary>
|
||||
/// The operator's door for a stuck merge (ruling merge-stuck-end-only-typed-id), platform admins only. It lists in-flight merges that
|
||||
/// stopped on a settleable outcome or have been retried eight times, with the stored move id of the leg in flight, and passes an
|
||||
/// admin's choice and the typed move id to <see cref="AccountMergeSaga.SettleStuckAsync"/>, the one writer. It calls no Heimdall.
|
||||
/// </summary>
|
||||
public sealed class IndexModel(BifrostDbContext dbContext, ICurrentBifrostActorAccessor actorAccessor, AccountMergeSaga saga) : PageModel
|
||||
{
|
||||
private const int RetriesBeforeListed = 8;
|
||||
|
||||
public const string Url = "/App/Merges";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> ResultWords = new Dictionary<string, string>
|
||||
{
|
||||
["settled"] = "The merge was ended and the sweeper will not touch it again.",
|
||||
["not_stuck"] = "Nothing changed: that merge is not stuck on a state this choice can end.",
|
||||
["wrong_move_id"] = "Nothing changed: the move id typed is not the stored one of that leg.",
|
||||
["lost"] = "Nothing changed: the merge moved on by itself while you were settling it."
|
||||
};
|
||||
|
||||
public IReadOnlyList<StuckMerge> Merges { get; private set; } = [];
|
||||
|
||||
/// <summary>The words for the closed <c>?result=</c> code of the last settle; null for no code or one this page does not know.</summary>
|
||||
public string? Notice { get; private set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string? result, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!(await actorAccessor.GetAsync(cancellationToken)).IsPlatformAdmin)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
Notice = result is not null && ResultWords.TryGetValue(result, out var words) ? words : null;
|
||||
var rows = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => (x.State == AccountMergeState.Confirmed || x.State == AccountMergeState.UndoRequested)
|
||||
&& (AccountMergeSaga.SettleableOutcomes.Contains(x.LastOutcome) || x.Attempts >= RetriesBeforeListed))
|
||||
.OrderBy(x => x.CreatedUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
Merges = [.. rows.Select(x => new StuckMerge(
|
||||
x.Id, x.State, x.LastOutcome, x.Attempts,
|
||||
(x.State == AccountMergeState.UndoRequested ? x.HeimdallReverseMoveId : x.HeimdallMoveId) ?? string.Empty,
|
||||
AccountMergeSaga.IsSettleable(x)))];
|
||||
return Page();
|
||||
}
|
||||
|
||||
public Task<IActionResult> OnPostFailForwardAsync(Guid mergeId, string? moveId, CancellationToken cancellationToken) =>
|
||||
SettleAsync(mergeId, SettleChoice.FailForward, moveId, cancellationToken);
|
||||
|
||||
public Task<IActionResult> OnPostAbandonUndoAsync(Guid mergeId, string? moveId, CancellationToken cancellationToken) =>
|
||||
SettleAsync(mergeId, SettleChoice.AbandonUndo, moveId, cancellationToken);
|
||||
|
||||
private async Task<IActionResult> SettleAsync(Guid mergeId, SettleChoice choice, string? moveId, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!actor.CanManageMembership || actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var outcome = await saga.SettleStuckAsync(mergeId, choice, moveId ?? string.Empty, actor.UserAccount.Id, cancellationToken);
|
||||
var code = outcome switch
|
||||
{
|
||||
SettleOutcome.Settled => "settled",
|
||||
SettleOutcome.WrongMoveId => "wrong_move_id",
|
||||
SettleOutcome.Lost => "lost",
|
||||
_ => "not_stuck"
|
||||
};
|
||||
return Redirect($"{Url}?result={code}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
@page
|
||||
@model Bifrost.Web.Pages.Membership.LinkedAccountsModel
|
||||
@{
|
||||
ViewData["Title"] = "Linked accounts";
|
||||
}
|
||||
|
||||
<section class="panel">
|
||||
<span class="eyebrow">Heimdall</span>
|
||||
<h1>Linked accounts</h1>
|
||||
<p class="panel-intro">
|
||||
The provider logins that sign in to this account. Heimdall holds them and decides what may change; Bifrost keeps none of
|
||||
your Heimdall sign-in, so each action sends you through Heimdall once more.
|
||||
</p>
|
||||
|
||||
@if (Model.ResultMessage is not null)
|
||||
{
|
||||
<div class="callout" role="status">@Model.ResultMessage</div>
|
||||
}
|
||||
|
||||
@if (!Model.HasHeimdallAccount)
|
||||
{
|
||||
<div class="callout warning">
|
||||
This session is not a Heimdall account, so there are no provider logins to manage here. Sign in with Discord or Patreon
|
||||
through Heimdall to see them.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2>Your logins</h2>
|
||||
@if (Model.Identities is null)
|
||||
{
|
||||
<p class="muted">
|
||||
Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for
|
||||
five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you.
|
||||
Pick a login you know it holds. Signing in through Discord or Patreon when the account no longer holds that login
|
||||
would create a separate Heimdall account holding it. Google cannot do that: Heimdall refuses a Google sign-in for a
|
||||
login no account holds.
|
||||
</p>
|
||||
<div class="panel-actions">
|
||||
@foreach (var provider in Model.LinkableProviders.OrderBy(x => x == "google" ? 0 : 1))
|
||||
{
|
||||
<form method="post" asp-page-handler="Refresh" class="inline-actions">
|
||||
<input type="hidden" name="via" value="@provider" />
|
||||
<button class="button secondary" type="submit">Show my logins through @LinkedAccountsModel.ProviderName(provider)@(provider == "google" ? " (cannot create an account)" : "")</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul>
|
||||
@foreach (var identity in Model.Identities)
|
||||
{
|
||||
<li>
|
||||
<strong>@LinkedAccountsModel.ProviderName(identity.Provider)</strong>
|
||||
@if (identity.DisplayName is not null)
|
||||
{
|
||||
<span>@identity.DisplayName</span>
|
||||
}
|
||||
@if (identity.LinkedAt is { } linkedAt)
|
||||
{
|
||||
<span class="muted">linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
|
||||
}
|
||||
@if (Model.HasStrongPasskey)
|
||||
{
|
||||
@if (Model.PasskeysAvailable)
|
||||
{
|
||||
<form method="post" asp-page-handler="UnlinkPasskey" class="inline-actions" data-passkey-unlink>
|
||||
<input type="hidden" name="identityId" value="@identity.Id" />
|
||||
<input type="hidden" name="id" />
|
||||
<input type="hidden" name="rawId" />
|
||||
<input type="hidden" name="authenticatorData" />
|
||||
<input type="hidden" name="clientDataJson" />
|
||||
<input type="hidden" name="signature" />
|
||||
<input type="hidden" name="userHandle" />
|
||||
<button class="button secondary" type="submit">Unlink with passkey</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
else if (Model.HasGoogle)
|
||||
{
|
||||
<form method="post" asp-page-handler="Unlink" class="inline-actions">
|
||||
<input type="hidden" name="identityId" value="@identity.Id" />
|
||||
<button class="button secondary" type="submit">Unlink</button>
|
||||
</form>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (Model.Identities.Count == 1)
|
||||
{
|
||||
<p class="muted">This is your only login. Heimdall will not remove the last one.</p>
|
||||
}
|
||||
|
||||
@if (Model.HasStrongPasskey && !Model.PasskeysAvailable)
|
||||
{
|
||||
<p class="callout warning">
|
||||
This account has a passkey, so unlinking needs it, but passkeys are not available on this server right now (a
|
||||
configuration fault the operator must fix), so nothing can be unlinked from here at the moment. Nothing is lost:
|
||||
try again later. If you cannot wait, revoke your passkeys below; unlinking then falls back to a fresh Google sign-in,
|
||||
if a Google login is linked to this account.
|
||||
</p>
|
||||
}
|
||||
else if (Model.HasStrongPasskey)
|
||||
{
|
||||
<p class="muted">Unlinking asks for one of your passkeys, then signs you in to Heimdall once more and removes only the login you picked.</p>
|
||||
}
|
||||
else if (Model.HasGoogle)
|
||||
{
|
||||
<p class="muted">Unlinking asks you to sign in with Google again as proof, then removes only the login you picked. A passkey, once it has cooled off, is stronger proof and takes over from Google.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">
|
||||
Unlinking needs strong proof, and none is available. Add a passkey below@(Model.NextStrongAt is { } waiting
|
||||
? $" (your newest one counts from {waiting.UtcDateTime:dd MMM yyyy HH:mm} UTC)"
|
||||
: " (it counts after a 48 hour cooling-off)"),
|
||||
or link a Google login once Heimdall has Google set up. Discord and Patreon cannot give that proof.
|
||||
</p>
|
||||
}
|
||||
|
||||
<form method="post" asp-page-handler="Refresh" class="panel-actions">
|
||||
<button class="button secondary" type="submit">Read the list again</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<h2>Passkeys</h2>
|
||||
@if (!Model.PasskeysAvailable)
|
||||
{
|
||||
<p class="muted">Passkeys are not available on this server right now, so none can be added or used. You can still revoke the ones listed.</p>
|
||||
}
|
||||
@if (Model.PasskeysAvailable)
|
||||
{
|
||||
<p class="muted">
|
||||
A passkey is the strong proof for unlinking a login: it stays on your device, and a site that is not Bifrost cannot use it.
|
||||
A new passkey is added only right after you sign in to Heimdall again, and it counts as strong proof 48 hours later, so
|
||||
someone who borrows your open browser cannot add one and use it at once. You can revoke a passkey here at any time.
|
||||
</p>
|
||||
}
|
||||
@if (Model.Passkeys.Count > 0)
|
||||
{
|
||||
<ul>
|
||||
@foreach (var key in Model.Passkeys)
|
||||
{
|
||||
<li>
|
||||
<strong>@key.Label</strong>
|
||||
@if (key.IsStrong)
|
||||
{
|
||||
<span class="muted">active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="muted">cooling off until @key.StrongFromUtc.UtcDateTime.ToString("dd MMM yyyy HH:mm") UTC; cannot unlink yet</span>
|
||||
}
|
||||
<span class="muted">added @key.CreatedUtc.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
|
||||
@if (key.LastUsedUtc is { } used)
|
||||
{
|
||||
<span class="muted">last used @used.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
|
||||
}
|
||||
<form method="post" asp-page-handler="PasskeyRevoke" class="inline-actions">
|
||||
<input type="hidden" name="credentialId" value="@key.Id" />
|
||||
<button class="button secondary" type="submit">Revoke</button>
|
||||
</form>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (Model.PasskeysAvailable)
|
||||
{
|
||||
if (Model.PasskeyLimitReached)
|
||||
{
|
||||
<p class="muted">This is the most passkeys an account can hold. Revoke one to add another.</p>
|
||||
}
|
||||
else if (Model.CanEnrol)
|
||||
{
|
||||
<form method="post" asp-page-handler="PasskeyEnrol" id="passkey-enrol" data-passkey-enrol>
|
||||
<input type="hidden" name="id" />
|
||||
<input type="hidden" name="rawId" />
|
||||
<input type="hidden" name="attestationObject" />
|
||||
<input type="hidden" name="clientDataJson" />
|
||||
<input type="hidden" name="transports" />
|
||||
<label>Name for this passkey <input type="text" name="label" maxlength="40" value="Passkey" /></label>
|
||||
<button class="button" type="submit">Add a passkey</button>
|
||||
</form>
|
||||
<noscript><p class="muted">Adding a passkey needs JavaScript.</p></noscript>
|
||||
}
|
||||
else if (Model.Identities is not null)
|
||||
{
|
||||
<form method="post" asp-page-handler="PasskeyEnrolStart" class="panel-actions">
|
||||
<button class="button secondary" type="submit">Sign in again to add a passkey</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">Read your linked logins first; adding a passkey then asks you to sign in to Heimdall once more.</p>
|
||||
}
|
||||
}
|
||||
<p id="passkey-status" class="muted" role="status"></p>
|
||||
|
||||
@if (Model.Identities is not null)
|
||||
{
|
||||
<h2>Link another login</h2>
|
||||
<p class="muted">
|
||||
You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account,
|
||||
nothing is linked and nothing is merged.
|
||||
</p>
|
||||
<div class="panel-actions">
|
||||
@foreach (var provider in Model.LinkableProviders)
|
||||
{
|
||||
<form method="post" asp-page-handler="Link" class="inline-actions">
|
||||
<input type="hidden" name="provider" value="@provider" />
|
||||
<button class="button secondary" type="submit">Link @LinkedAccountsModel.ProviderName(provider)</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.MergeEnabled)
|
||||
{
|
||||
<p class="muted">
|
||||
Hold two accounts? <a asp-page="/Membership/MergeAccounts">Merge two accounts</a>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="panel-actions">
|
||||
<a class="button secondary" asp-page="/Membership/Status">Back to membership status</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
@if (Model.HasHeimdallAccount && Model.PasskeysAvailable)
|
||||
{
|
||||
<script src="~/js/passkeys.js" asp-append-version="true"></script>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
using System.Security.Claims;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Passkeys;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Pages.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// The member's provider logins (docs/linked-accounts.md). Bifrost keeps no Heimdall token, so every action here begins a
|
||||
/// fresh Heimdall sign-in for the account already signed in and finishes inside that sign-in's completion
|
||||
/// (<c>POST /auth/heimdall/complete</c>); this page only starts them and shows what the last read found. Every handler is a
|
||||
/// POST with antiforgery. Nothing from the query or the form is ever shown: the outcome is a code from a closed set.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class LinkedAccountsModel(
|
||||
HeimdallAuthService heimdallAuthService,
|
||||
LinkedAccountsSnapshots snapshots,
|
||||
PasskeyService passkeys,
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<AccountMergeOptions> mergeOptions,
|
||||
ILogger<LinkedAccountsModel> logger) : PageModel
|
||||
{
|
||||
private static readonly Dictionary<string, string> ProviderNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
["discord"] = "Discord",
|
||||
["patreon"] = "Patreon",
|
||||
["google"] = "Google"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A folded or frozen account manages no logins and enrols no passkey: the page's handlers all begin a Heimdall flow on the
|
||||
/// account's raw claim, so the one gate is here, on the actor accessor's standing, before any handler runs.
|
||||
/// </summary>
|
||||
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
|
||||
{
|
||||
if ((await actors.GetAsync(context.HttpContext.RequestAborted)).Merge is not null)
|
||||
{
|
||||
context.Result = Forbid();
|
||||
return;
|
||||
}
|
||||
|
||||
await base.OnPageHandlerExecutionAsync(context, next);
|
||||
}
|
||||
|
||||
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
|
||||
|
||||
/// <summary>Whether the merge page exists on this server (a flag, dark until Heimdall can move logins).</summary>
|
||||
public bool MergeEnabled => mergeOptions.Value.Enabled;
|
||||
|
||||
/// <summary>The wording of the outcome the page was sent back with: a closed set, null for anything else.</summary>
|
||||
public string? ResultMessage { get; private set; }
|
||||
|
||||
/// <summary>Whether this session is a Heimdall account. A Bifrost-native GitHub session has none, so there is nothing to list.</summary>
|
||||
public bool HasHeimdallAccount => AccountId is not null;
|
||||
|
||||
/// <summary>The logins the last read found, or null when there is none or it has expired.</summary>
|
||||
public IReadOnlyList<LinkedIdentitySummary>? Identities { get; private set; }
|
||||
|
||||
/// <summary>Whether a fresh Google sign-in can be had for this account: without a strong passkey, unlinking needs one, and only Google can give it.</summary>
|
||||
public bool HasGoogle => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true;
|
||||
|
||||
/// <summary>The account's passkeys that are not revoked, with their cooling-off state.</summary>
|
||||
public IReadOnlyList<PasskeyView> Passkeys { get; private set; } = [];
|
||||
|
||||
/// <summary>Whether a passkey past its cooling-off exists: unlinking then needs it and no longer accepts Google as the proof.</summary>
|
||||
public bool HasStrongPasskey => Passkeys.Any(x => x.IsStrong);
|
||||
|
||||
/// <summary>When the first passkey still cooling off becomes strong.</summary>
|
||||
public DateTimeOffset? NextStrongAt => Passkeys.Where(x => !x.IsStrong).Select(x => (DateTimeOffset?)x.StrongFromUtc).Min();
|
||||
|
||||
/// <summary>Whether this browser holds a live ticket from a fresh sign-in, so a passkey can be added now.</summary>
|
||||
public bool CanEnrol { get; private set; }
|
||||
|
||||
/// <summary>Whether this server can run passkey ceremonies at all (https and the host it expects).</summary>
|
||||
public bool PasskeysAvailable => passkeys.IsAvailable;
|
||||
|
||||
public bool PasskeyLimitReached => Passkeys.Count >= PasskeyService.MaxActive;
|
||||
|
||||
public bool CanUnlink => HasStrongPasskey || HasGoogle;
|
||||
|
||||
public IReadOnlyList<string> LinkableProviders => HeimdallAuthService.LinkableProviders;
|
||||
|
||||
public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider);
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
|
||||
Identities = AccountId is { } id ? snapshots.Get(id) : null;
|
||||
if (AccountId is { } account)
|
||||
{
|
||||
Passkeys = await passkeys.ListAsync(account, cancellationToken);
|
||||
CanEnrol = passkeys.HasTicket(account, Request.Cookies[BifrostCookies.PasskeyTicket]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion.</summary>
|
||||
public Task<IActionResult> OnPostRefreshAsync(string? via, CancellationToken cancellationToken)
|
||||
{
|
||||
// With no current list Bifrost cannot tell which provider the account still holds, so the member names one.
|
||||
if (AccountId is not { } id || (snapshots.Get(id) is null && (via is null || !HeimdallAuthService.LinkableProviders.Contains(via))))
|
||||
{
|
||||
return Task.FromResult(Done(HeimdallLinkOutcome.ProviderNeeded));
|
||||
}
|
||||
|
||||
return BeginAsync(
|
||||
(account, ct) => heimdallAuthService.StartListAsync(account, via, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||
HeimdallLinkOutcome.Error,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing, and so does a missing list.</summary>
|
||||
public Task<IActionResult> OnPostLinkAsync(string? provider, CancellationToken cancellationToken)
|
||||
{
|
||||
if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider))
|
||||
{
|
||||
return Task.FromResult(Done(HeimdallLinkOutcome.Error));
|
||||
}
|
||||
|
||||
if (AccountId is not { } id || snapshots.Get(id) is null)
|
||||
{
|
||||
return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired));
|
||||
}
|
||||
|
||||
return BeginAsync(
|
||||
(account, ct) => heimdallAuthService.StartLinkAsync(account, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||
HeimdallLinkOutcome.Error,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must
|
||||
/// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken)
|
||||
{
|
||||
var shown = AccountId is { } id ? snapshots.Get(id) : null;
|
||||
if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId))
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.ListExpired);
|
||||
}
|
||||
|
||||
// A strong passkey replaces Google as the proof: a member who has one is never asked for the weaker one.
|
||||
if (await passkeys.HasStrongAsync(AccountId!, cancellationToken))
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.PasskeyRequired);
|
||||
}
|
||||
|
||||
if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider))
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.ProofUnavailable);
|
||||
}
|
||||
|
||||
return await BeginAsync(
|
||||
(account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||
HeimdallLinkOutcome.ProofUnavailable,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Begins the fresh sign-in whose completion issues the enrolment ticket. Needs a current list: the proof provider is never guessed.</summary>
|
||||
public async Task<IActionResult> OnPostPasskeyEnrolStartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (AccountId is not { } id)
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
|
||||
if (snapshots.Get(id) is null)
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.ListExpired);
|
||||
}
|
||||
|
||||
if (await passkeys.PreflightEnrolStartAsync(id, cancellationToken) is { } refused)
|
||||
{
|
||||
return Done(refused);
|
||||
}
|
||||
|
||||
return await BeginAsync(
|
||||
(account, ct) => heimdallAuthService.StartEnrolProofAsync(account, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||
HeimdallLinkOutcome.Error,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The WebAuthn creation options for the ticket this browser holds, as JSON for the page's script.</summary>
|
||||
public async Task<IActionResult> OnPostPasskeyEnrolOptionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (AccountId is not { } id)
|
||||
{
|
||||
return Options(HeimdallLinkOutcome.Error, null);
|
||||
}
|
||||
|
||||
var (refusal, json) = await passkeys.BeginEnrolAsync(id, Request.Cookies[BifrostCookies.PasskeyTicket], cancellationToken);
|
||||
return Options(refusal, json);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the registration the script posted. The ticket cookie is spent whatever the outcome.</summary>
|
||||
public async Task<IActionResult> OnPostPasskeyEnrolAsync(
|
||||
string? id, string? rawId, string? attestationObject, string? clientDataJson, string? transports, string? label, CancellationToken cancellationToken)
|
||||
{
|
||||
if (AccountId is not { } account)
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
|
||||
var outcome = await passkeys.EnrolAsync(
|
||||
account, Request.Cookies[BifrostCookies.PasskeyTicket],
|
||||
new PasskeyFields(id, rawId, null, clientDataJson, null, null, attestationObject, transports), label, cancellationToken);
|
||||
Response.Cookies.Delete(BifrostCookies.PasskeyTicket, HeimdallAttemptCookie.DeleteOptions());
|
||||
return Done(outcome);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostPasskeyRevokeAsync(Guid credentialId, CancellationToken cancellationToken) =>
|
||||
AccountId is { } id ? Done(await passkeys.RevokeAsync(id, credentialId, cancellationToken)) : Done(HeimdallLinkOutcome.Error);
|
||||
|
||||
/// <summary>The WebAuthn request options for unlinking one identity, as JSON for the page's script.</summary>
|
||||
public async Task<IActionResult> OnPostPasskeyChallengeAsync(string? identityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (AccountId is not { } id)
|
||||
{
|
||||
return Options(HeimdallLinkOutcome.Error, null);
|
||||
}
|
||||
|
||||
if (snapshots.Get(id) is not { } shown || identityId is null || !shown.Any(x => x.Id == identityId))
|
||||
{
|
||||
return Options(HeimdallLinkOutcome.ListExpired, null);
|
||||
}
|
||||
|
||||
var (refusal, json) = await passkeys.BeginAssertionAsync(id, identityId, cancellationToken);
|
||||
return Options(refusal, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlinks one login on a passkey assertion. The assertion is verified here; only then does Bifrost start the fresh Heimdall sign-in
|
||||
/// whose completion asks Heimdall for the step-up and unlinks. A refused or unverifiable assertion starts no sign-in.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> OnPostUnlinkPasskeyAsync(
|
||||
string? identityId, string? id, string? rawId, string? authenticatorData, string? clientDataJson, string? signature, string? userHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var shown = AccountId is { } account ? snapshots.Get(account) : null;
|
||||
if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId))
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.ListExpired);
|
||||
}
|
||||
|
||||
var (refusal, factId) = await passkeys.VerifyAssertionAsync(
|
||||
AccountId!, identityId, new PasskeyFields(id, rawId, authenticatorData, clientDataJson, signature, userHandle, null, null), cancellationToken);
|
||||
if (refusal is { } failed)
|
||||
{
|
||||
return Done(failed);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = await heimdallAuthService.StartUnlinkPasskeyAsync(
|
||||
AccountId!, identityId, factId!, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
|
||||
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||
return Redirect(started.AuthorizationUrl.ToString());
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
logger.LogWarning("Linked accounts start refused: {Reason}", error.Message);
|
||||
passkeys.DiscardFact(factId);
|
||||
return Done(HeimdallLinkOutcome.ProofUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>JSON for the page's script: the options, or only a closed outcome code with the status that fits.</summary>
|
||||
private IActionResult Options(HeimdallLinkOutcome? refusal, string? json)
|
||||
{
|
||||
if (refusal is null && json is not null)
|
||||
{
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
|
||||
var status = refusal == HeimdallLinkOutcome.RateLimited ? StatusCodes.Status429TooManyRequests : StatusCodes.Status400BadRequest;
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = status,
|
||||
ContentType = "application/json",
|
||||
Content = "{\"error\":\"" + HeimdallLinkOutcomes.Code(refusal ?? HeimdallLinkOutcome.Error) + "\"}"
|
||||
};
|
||||
}
|
||||
|
||||
private IActionResult Done(HeimdallLinkOutcome outcome) =>
|
||||
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
|
||||
|
||||
private async Task<IActionResult> BeginAsync(
|
||||
Func<string, CancellationToken, Task<HeimdallAuthStartResult>> start, HeimdallLinkOutcome onRefusal, CancellationToken cancellationToken)
|
||||
{
|
||||
if (AccountId is not { } accountId)
|
||||
{
|
||||
return Done(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = await start(accountId, cancellationToken);
|
||||
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||
return Redirect(started.AuthorizationUrl.ToString());
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
logger.LogWarning("Linked accounts start refused: {Reason}", error.Message);
|
||||
return Done(onRefusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
@page
|
||||
@model Bifrost.Web.Pages.Membership.MergeAccountsModel
|
||||
@{
|
||||
ViewData["Title"] = "Merge two accounts";
|
||||
}
|
||||
|
||||
<section class="panel">
|
||||
<span class="eyebrow">Account merge</span>
|
||||
<h1>Merge two accounts</h1>
|
||||
<p class="panel-intro">
|
||||
If one person holds two accounts, they can be merged into one. You prove both accounts with a fresh sign-in each, choose
|
||||
which one stays, see what changes and confirm; nothing is named until both proofs exist, and the other account is only
|
||||
ever the one Heimdall identified from your second sign-in.
|
||||
</p>
|
||||
|
||||
@if (Model.ResultMessage is not null)
|
||||
{
|
||||
<div class="callout" role="status">@Model.ResultMessage</div>
|
||||
}
|
||||
|
||||
@if (Model.ConfirmMessage is not null)
|
||||
{
|
||||
<div class="callout warning" role="status">@Model.ConfirmMessage</div>
|
||||
}
|
||||
|
||||
@if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.Prove)
|
||||
{
|
||||
<h2>Prove both accounts</h2>
|
||||
<p class="muted">
|
||||
Sign-ins are held in memory for a few minutes and then dropped. For each account, name a provider that account holds:
|
||||
signing in through a provider it does not hold would make an unrelated account.
|
||||
</p>
|
||||
|
||||
<h3>This account</h3>
|
||||
@if (Model.SelfProved)
|
||||
{
|
||||
<p>This account is proved.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="panel-actions">
|
||||
@foreach (var provider in Model.LinkableProviders)
|
||||
{
|
||||
<form method="post" asp-page-handler="ProveSelf" class="inline-actions">
|
||||
<input type="hidden" name="via" value="@provider" />
|
||||
<button class="button secondary" type="submit">Prove this account with @Bifrost.Web.Pages.Membership.MergeAccountsModel.ProviderName(provider)</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<h3>The other account</h3>
|
||||
@if (Model.OtherProved)
|
||||
{
|
||||
<p>The other account is proved.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="panel-actions">
|
||||
@foreach (var provider in Model.LinkableProviders)
|
||||
{
|
||||
<form method="post" asp-page-handler="ProveOther" class="inline-actions">
|
||||
<input type="hidden" name="via" value="@provider" />
|
||||
<button class="button secondary" type="submit">Prove the other account with @Bifrost.Web.Pages.Membership.MergeAccountsModel.ProviderName(provider)</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2>The two accounts</h2>
|
||||
<ul>
|
||||
<li>This account: <strong>@Model.SelfName</strong></li>
|
||||
<li>The other account: <strong>@Model.OtherName</strong></li>
|
||||
</ul>
|
||||
|
||||
@if (Model.Keep is null)
|
||||
{
|
||||
<p>Choose the account that stays. The other one is folded into it.</p>
|
||||
<div class="panel-actions">
|
||||
<a class="button" asp-page="/Membership/MergeAccounts" asp-route-keep="self">Keep this account</a>
|
||||
<a class="button" asp-page="/Membership/MergeAccounts" asp-route-keep="other">Keep the other account</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2>Keep @Model.KeptName</h2>
|
||||
<p>@Model.FoldedName is folded into it.</p>
|
||||
<ul>
|
||||
@foreach (var line in Model.PreviewLines)
|
||||
{
|
||||
<li>@line</li>
|
||||
}
|
||||
</ul>
|
||||
@foreach (var statement in Model.Statements)
|
||||
{
|
||||
<p class="muted">@statement</p>
|
||||
}
|
||||
|
||||
<p>
|
||||
<a asp-page="/Membership/MergeAccounts" asp-route-keep="@(Model.Keep == "self" ? "other" : "self")">Keep @Model.FoldedName instead</a>
|
||||
</p>
|
||||
|
||||
@if (Model.BlockWords.Count > 0)
|
||||
{
|
||||
<div class="callout warning">
|
||||
<ul>
|
||||
@foreach (var words in Model.BlockWords)
|
||||
{
|
||||
<li>@words</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
else if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.ProveRemaining)
|
||||
{
|
||||
<p>
|
||||
@Model.FoldedName holds more logins. Sign in once more through each one, so Heimdall knows the whole account agrees.
|
||||
</p>
|
||||
<div class="panel-actions">
|
||||
@foreach (var provider in Model.ProvidersToProve)
|
||||
{
|
||||
<form method="post" asp-page-handler="ProveRemaining" class="inline-actions">
|
||||
<input type="hidden" name="via" value="@provider" />
|
||||
<input type="hidden" name="keep" value="@Model.Keep" />
|
||||
<button class="button secondary" type="submit">Sign in with @Bifrost.Web.Pages.Membership.MergeAccountsModel.ProviderName(provider)</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.Confirm)
|
||||
{
|
||||
<form method="post" asp-page-handler="Confirm">
|
||||
<input type="hidden" name="keep" value="@Model.Keep" />
|
||||
<input type="hidden" name="digest" value="@Model.Digest" />
|
||||
<p>
|
||||
<label><input type="checkbox" name="consentSelf" value="true" /> I agree, for the account @Model.SelfName.</label>
|
||||
</p>
|
||||
<p>
|
||||
<label><input type="checkbox" name="consentOther" value="true" /> I agree, for the account @Model.OtherName.</label>
|
||||
</p>
|
||||
<button class="button" type="submit">Merge into @Model.KeptName</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
using System.Security.Claims;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Pages.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// Where a member merges two accounts: proves both by fresh sign-ins, chooses which one stays against a preview, ticks one box for each
|
||||
/// account and confirms. The page owns only that choice and that consent. Its stage is derived from <see cref="MergeProofs"/> and the
|
||||
/// query on every request, nothing about progress is kept in a cookie or a hidden field except the chosen direction and the preview's
|
||||
/// digest, and both are re-checked by the saga. The other account is named (by display name only) once both proofs are held, and
|
||||
/// found by no email, name or login string: it is the account Heimdall identified from the second proof. The page writes no merge,
|
||||
/// vote, membership or journal row: <see cref="AccountMergeSaga.ConfirmAsync"/> inserts the one Confirmed row that is the consent.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class MergeAccountsModel(
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
BifrostDbContext dbContext,
|
||||
AccountMergeLedger ledger,
|
||||
AccountMergeSaga saga,
|
||||
MergeProofs proofs,
|
||||
HeimdallAuthService heimdallAuthService,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<AccountMergeOptions> mergeOptions,
|
||||
ILogger<MergeAccountsModel> logger) : PageModel
|
||||
{
|
||||
private const string KeepParameter = "keep";
|
||||
private const string MergeParameter = "merge";
|
||||
private const string Self = "self";
|
||||
private const string Other = "other";
|
||||
|
||||
public enum MergeStage
|
||||
{
|
||||
/// <summary>One side or both lack a proof: nothing is named.</summary>
|
||||
Prove,
|
||||
|
||||
/// <summary>Both sides are proved: the member chooses which stays.</summary>
|
||||
Choose,
|
||||
|
||||
/// <summary>A direction is chosen and the folded account's remaining logins are asked one sign-in each.</summary>
|
||||
ProveRemaining,
|
||||
|
||||
/// <summary>Everything the saga needs is held: the confirm form.</summary>
|
||||
Confirm
|
||||
}
|
||||
|
||||
public MergeStage Stage { get; private set; } = MergeStage.Prove;
|
||||
|
||||
public string? ResultMessage { get; private set; }
|
||||
|
||||
public string? ConfirmMessage { get; private set; }
|
||||
|
||||
public bool SelfProved { get; private set; }
|
||||
|
||||
public bool OtherProved { get; private set; }
|
||||
|
||||
/// <summary>Display names, set only once both sides are proved.</summary>
|
||||
public string? SelfName { get; private set; }
|
||||
|
||||
public string? OtherName { get; private set; }
|
||||
|
||||
/// <summary>The chosen direction, "self" or "other", or null.</summary>
|
||||
public string? Keep { get; private set; }
|
||||
|
||||
public string? KeptName => Keep == Self ? SelfName : Keep == Other ? OtherName : null;
|
||||
|
||||
public string? FoldedName => Keep == Self ? OtherName : Keep == Other ? SelfName : null;
|
||||
|
||||
public IReadOnlyList<string> PreviewLines { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockWords { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<string> Statements => MergePreview.Statements;
|
||||
|
||||
public IReadOnlyList<string> ProvidersToProve { get; private set; } = [];
|
||||
|
||||
public string Digest { get; private set; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> LinkableProviders => HeimdallAuthService.LinkableProviders;
|
||||
|
||||
public static string ProviderName(string provider) => MergeStatusModel.ProviderName(provider);
|
||||
|
||||
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
|
||||
|
||||
/// <summary>Off, the page does not exist; a folded or frozen account is forbidden it, as it is Linked accounts.</summary>
|
||||
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
|
||||
{
|
||||
if (!mergeOptions.Value.Enabled)
|
||||
{
|
||||
context.Result = NotFound();
|
||||
return;
|
||||
}
|
||||
|
||||
var actor = await actors.GetAsync(context.HttpContext.RequestAborted);
|
||||
if (actor.Merge is not null || actor.UserAccount is null || AccountId is null)
|
||||
{
|
||||
context.Result = Forbid();
|
||||
return;
|
||||
}
|
||||
|
||||
await base.OnPageHandlerExecutionAsync(context, next);
|
||||
}
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
|
||||
ConfirmMessage = MergeWording.Confirm(Request.Query[MergeParameter].ToString()) is { Length: > 0 } words ? words : null;
|
||||
|
||||
var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id;
|
||||
var held = proofs.Snapshot(me);
|
||||
SelfProved = held.Self is not null;
|
||||
OtherProved = held.Other is not null;
|
||||
if (held.Self is not { } self || held.Other is not { } other)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var names = await dbContext.UserAccounts.AsNoTracking()
|
||||
.Where(x => x.Id == self.UserAccountId || x.Id == other.UserAccountId)
|
||||
.ToDictionaryAsync(x => x.Id, x => x.DisplayName, cancellationToken);
|
||||
SelfName = names.GetValueOrDefault(self.UserAccountId);
|
||||
OtherName = names.GetValueOrDefault(other.UserAccountId);
|
||||
Stage = MergeStage.Choose;
|
||||
if (SideOf(Request.Query[KeepParameter].ToString()) is not { } survivorSide)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Keep = survivorSide == MergeSide.Self ? Self : Other;
|
||||
var (survivor, folded) = survivorSide == MergeSide.Self ? (self, other) : (other, self);
|
||||
var plan = await ledger.PlanAsync(survivor.UserAccountId, folded.UserAccountId, cancellationToken);
|
||||
Digest = plan.Digest(survivor.UserAccountId, folded.UserAccountId);
|
||||
PreviewLines = MergePreview.Lines(plan);
|
||||
BlockWords = [.. plan.Blocks.Select(MergePreview.Block)];
|
||||
switch (proofs.Peek(me, survivorSide))
|
||||
{
|
||||
case PeekResult.ProviderConflict conflict:
|
||||
BlockWords = [.. BlockWords, MergePreview.ProviderConflict(conflict.Providers.Select(ProviderName))];
|
||||
break;
|
||||
case PeekResult.Ready when plan.Blocks.Count == 0:
|
||||
Stage = MergeStage.Confirm;
|
||||
break;
|
||||
case PeekResult.LoginsUnproved unproved:
|
||||
ProvidersToProve = unproved.Providers;
|
||||
Stage = MergeStage.ProveRemaining;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts a fresh sign-in of the account signed in here.</summary>
|
||||
public Task<IActionResult> OnPostProveSelfAsync(string? via, CancellationToken cancellationToken) => BeginAsync(MergeSide.Self, via, cancellationToken);
|
||||
|
||||
/// <summary>Starts a fresh sign-in of the other account. The member names the provider: Bifrost never guesses one.</summary>
|
||||
public Task<IActionResult> OnPostProveOtherAsync(string? via, CancellationToken cancellationToken) => BeginAsync(MergeSide.Other, via, cancellationToken);
|
||||
|
||||
/// <summary>Starts the next sign-in the chosen folded account still owes; its side follows from the chosen direction.</summary>
|
||||
public Task<IActionResult> OnPostProveRemainingAsync(string? via, string? keep, CancellationToken cancellationToken) =>
|
||||
SideOf(keep) is { } survivorSide
|
||||
? BeginAsync(survivorSide == MergeSide.Self ? MergeSide.Other : MergeSide.Self, via, cancellationToken)
|
||||
: Task.FromResult(Refused(null, "bad_choice"));
|
||||
|
||||
/// <summary>
|
||||
/// The member's consent: both boxes, the direction and the digest of the preview they saw. A refusal writes nothing and keeps the
|
||||
/// proofs; only a Confirmed row spends them.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> OnPostConfirmAsync(string? keep, string? digest, bool consentSelf, bool consentOther, CancellationToken cancellationToken)
|
||||
{
|
||||
if (SideOf(keep) is not { } survivorSide)
|
||||
{
|
||||
return Refused(null, "bad_choice");
|
||||
}
|
||||
|
||||
if (!consentSelf || !consentOther)
|
||||
{
|
||||
return Refused(keep, "consent_needed");
|
||||
}
|
||||
|
||||
var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id;
|
||||
var held = proofs.Snapshot(me);
|
||||
var peeked = proofs.Peek(me, survivorSide);
|
||||
if (peeked is PeekResult.ProviderConflict)
|
||||
{
|
||||
return Refused(keep, "blocked");
|
||||
}
|
||||
|
||||
if (held.Self is not { } self || held.Other is not { } other || peeked is not PeekResult.Ready ready)
|
||||
{
|
||||
return Refused(keep, "proofs_needed");
|
||||
}
|
||||
|
||||
var (survivor, folded) = survivorSide == MergeSide.Self ? (self, other) : (other, self);
|
||||
var outcome = await saga.ConfirmAsync(
|
||||
new ConfirmMergeRequest(me, survivor.UserAccountId, folded.UserAccountId, digest ?? string.Empty, ready.Survivor, ready.Folded), cancellationToken);
|
||||
switch (outcome)
|
||||
{
|
||||
case ConfirmOutcome.Confirmed confirmed:
|
||||
proofs.Clear(me);
|
||||
try
|
||||
{
|
||||
await saga.AdvanceAsync(confirmed.MergeId, cancellationToken);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
// The stored state is the truth and the sweeper resumes it.
|
||||
logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", confirmed.MergeId, error.GetType().Name);
|
||||
}
|
||||
|
||||
return RedirectToPage("/Membership/MergeStatus");
|
||||
case ConfirmOutcome.Blocked:
|
||||
return Refused(keep, "blocked");
|
||||
case ConfirmOutcome.PreviewStale:
|
||||
return Refused(keep, "preview_stale");
|
||||
case ConfirmOutcome.NotParty:
|
||||
return Refused(keep, "not_party");
|
||||
default:
|
||||
return Refused(keep, "proof_account_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static MergeSide? SideOf(string? keep) => keep switch
|
||||
{
|
||||
Self => MergeSide.Self,
|
||||
Other => MergeSide.Other,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task<IActionResult> BeginAsync(MergeSide side, string? via, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var start = side == MergeSide.Self
|
||||
? (Func<string, Task<HeimdallAuthStartResult>>)(id => heimdallAuthService.StartMergeProveSelfAsync(
|
||||
id, via, ReturnPath, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken))
|
||||
: id => heimdallAuthService.StartMergeProveOtherAsync(
|
||||
id, via, ReturnPath, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
|
||||
var started = await start(AccountId!);
|
||||
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||
return Redirect(started.AuthorizationUrl.ToString());
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
logger.LogWarning("Merge proof start refused: {Reason}", error.Message);
|
||||
return RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) });
|
||||
}
|
||||
}
|
||||
|
||||
private const string ReturnPath = "/Membership/MergeAccounts";
|
||||
|
||||
private IActionResult Refused(string? keep, string code)
|
||||
{
|
||||
var query = new Dictionary<string, string?>();
|
||||
if (keep is Self or Other)
|
||||
{
|
||||
query[KeepParameter] = keep;
|
||||
}
|
||||
|
||||
query[MergeParameter] = code;
|
||||
return RedirectToPage(null, query);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
@page
|
||||
@model Bifrost.Web.Pages.Membership.MergeStatusModel
|
||||
@{
|
||||
ViewData["Title"] = "Merge status";
|
||||
}
|
||||
|
||||
<section class="panel">
|
||||
<span class="eyebrow">Account merge</span>
|
||||
<h1>Merge status</h1>
|
||||
<p class="panel-intro">
|
||||
A merge folds one account into another. The account it kept can undo it for seven days, with a fresh sign-in for
|
||||
every login that moved.
|
||||
</p>
|
||||
|
||||
@if (Model.ResultMessage is not null)
|
||||
{
|
||||
<div class="callout">@Model.ResultMessage</div>
|
||||
}
|
||||
|
||||
@if (Model.UndoMessage is not null)
|
||||
{
|
||||
<div class="callout">@Model.UndoMessage</div>
|
||||
}
|
||||
|
||||
@if (Model.Merges.Count == 0)
|
||||
{
|
||||
<p class="muted">There is no merge to show for this account.</p>
|
||||
}
|
||||
|
||||
@foreach (var merge in Model.Merges)
|
||||
{
|
||||
<article class="status-banner">
|
||||
<strong>@merge.State</strong>
|
||||
@if (merge.Why is not null)
|
||||
{
|
||||
<p class="muted">@merge.Why</p>
|
||||
}
|
||||
@if (merge.Outcome is not null)
|
||||
{
|
||||
<p class="muted">@merge.Outcome</p>
|
||||
}
|
||||
<ul>
|
||||
@if (merge.Confirmed is not null)
|
||||
{
|
||||
<li>Confirmed @merge.Confirmed</li>
|
||||
}
|
||||
@if (merge.Committed is not null)
|
||||
{
|
||||
<li>Merged @merge.Committed</li>
|
||||
}
|
||||
@if (merge.GraceEnds is not null)
|
||||
{
|
||||
<li>Undo window ends @merge.GraceEnds</li>
|
||||
}
|
||||
@if (merge.Final is not null)
|
||||
{
|
||||
<li>Final @merge.Final</li>
|
||||
}
|
||||
@if (merge.SurvivorProofs.Count > 0)
|
||||
{
|
||||
<li>Kept account proved with: @string.Join(", ", merge.SurvivorProofs)</li>
|
||||
}
|
||||
@if (merge.FoldedProofs.Count > 0)
|
||||
{
|
||||
<li>Folded account proved with: @string.Join(", ", merge.FoldedProofs)</li>
|
||||
}
|
||||
</ul>
|
||||
@if (merge.DaysLeft is not null)
|
||||
{
|
||||
<p><span class="pill warning">@merge.DaysLeft</span></p>
|
||||
}
|
||||
@if (merge.TierNote is not null)
|
||||
{
|
||||
<p class="muted">@merge.TierNote</p>
|
||||
}
|
||||
|
||||
@if (merge.CanUndo)
|
||||
{
|
||||
@if (merge.Ready)
|
||||
{
|
||||
<form method="post" asp-page-handler="Undo">
|
||||
<input type="hidden" name="mergeId" value="@merge.Id" />
|
||||
<button class="button" type="submit">Undo this merge</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">To undo, sign in again through each login that moved. The sign-ins are held in memory for a few minutes.</p>
|
||||
@foreach (var provider in merge.NeedProof)
|
||||
{
|
||||
<form method="post" asp-page-handler="ProveForUndo">
|
||||
<input type="hidden" name="via" value="@provider" />
|
||||
<button class="button secondary" type="submit">Sign in with @Bifrost.Web.Pages.Membership.LinkedAccountsModel.ProviderName(provider)</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Pages.Membership;
|
||||
|
||||
/// <summary>
|
||||
/// The member's own merge page: the merges that kept this account (up to five) and the one its frozen or folded standing names, in
|
||||
/// <see cref="MergeWording"/>'s closed words, and the undo. It reads merge rows and writes none: undo is the saga's
|
||||
/// <see cref="AccountMergeSaga.RequestUndoAsync"/> followed by its own <see cref="AccountMergeSaga.AdvanceAsync"/>, with fresh proofs
|
||||
/// of every moved login held by <see cref="MergeProofs"/>. A person who signed in through a login that moved is the survivor here, so
|
||||
/// there is no separate folded-side undo. Nothing the query, the form or the store holds is shown except as a closed code's words, and
|
||||
/// no account name, id, email or login of either side is rendered.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class MergeStatusModel(
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
BifrostDbContext dbContext,
|
||||
AccountMergeSaga saga,
|
||||
MergeProofs proofs,
|
||||
HeimdallAuthService heimdallAuthService,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<MergeStatusModel> logger) : PageModel
|
||||
{
|
||||
private const int MaxListed = 5;
|
||||
private const string UndoParameter = "undo";
|
||||
private const string LoginsUnproved = "logins_unproved";
|
||||
|
||||
public sealed record MergeRow(
|
||||
Guid Id,
|
||||
string State,
|
||||
string? Why,
|
||||
string? Outcome,
|
||||
string? Confirmed,
|
||||
string? Committed,
|
||||
string? GraceEnds,
|
||||
string? Final,
|
||||
string? DaysLeft,
|
||||
string? TierNote,
|
||||
IReadOnlyList<string> SurvivorProofs,
|
||||
IReadOnlyList<string> FoldedProofs,
|
||||
bool CanUndo,
|
||||
bool Ready,
|
||||
IReadOnlyList<string> NeedProof);
|
||||
|
||||
public IReadOnlyList<MergeRow> Merges { get; private set; } = [];
|
||||
|
||||
public string? ResultMessage { get; private set; }
|
||||
|
||||
public string? UndoMessage { get; private set; }
|
||||
|
||||
public static string ProviderName(string provider) =>
|
||||
HeimdallAuthService.LinkableProviders.Contains(provider) ? LinkedAccountsModel.ProviderName(provider) : provider == "passkey" ? "Passkey" : "another provider";
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
|
||||
UndoMessage = MergeWording.Undo(Request.Query[UndoParameter].ToString()) is { Length: > 0 } words ? words : null;
|
||||
|
||||
var actor = await actors.GetAsync(cancellationToken);
|
||||
var me = actor.UserAccount?.Id;
|
||||
var rows = me is { } id
|
||||
? await dbContext.AccountMerges.AsNoTracking().Where(x => x.SurvivorUserAccountId == id).OrderByDescending(x => x.CreatedUtc).Take(MaxListed).ToListAsync(cancellationToken)
|
||||
: [];
|
||||
if (actor.Merge is { } standing && rows.All(x => x.Id != standing.MergeId)
|
||||
&& await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == standing.MergeId, cancellationToken) is { } named)
|
||||
{
|
||||
rows.Add(named);
|
||||
}
|
||||
|
||||
Merges = [.. rows.Select(x => Row(x, me))];
|
||||
}
|
||||
|
||||
/// <summary>Starts a fresh sign-in of this account through <paramref name="via"/>, held in memory until the undo takes it.</summary>
|
||||
public async Task<IActionResult> OnPostProveForUndoAsync(string? via, CancellationToken cancellationToken)
|
||||
{
|
||||
if (User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is not { Length: > 0 } accountId)
|
||||
{
|
||||
return Result(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = await heimdallAuthService.StartMergeProveSelfAsync(
|
||||
accountId, via, HeimdallLinkReturn, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
|
||||
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||
return Redirect(started.AuthorizationUrl.ToString());
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
logger.LogWarning("Merge undo proof start refused: {Reason}", error.Message);
|
||||
return Result(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asks the saga to undo one merge with the proofs this member holds, then runs its step function once, as the sweeper would.</summary>
|
||||
public async Task<IActionResult> OnPostUndoAsync(string? mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actors.GetAsync(cancellationToken);
|
||||
if (actor.UserAccount is not { } me || !Guid.TryParse(mergeId, out var id)
|
||||
|| await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken) is not { } merge
|
||||
|| merge.SurvivorUserAccountId != me.Id)
|
||||
{
|
||||
return Undone("not_survivor");
|
||||
}
|
||||
|
||||
if (proofs.PeekUndo(me.Id, MovedIds(merge)) is not UndoPeekResult.Ready { Proofs.Count: > 0 } ready)
|
||||
{
|
||||
return Undone(LoginsUnproved);
|
||||
}
|
||||
|
||||
var outcome = await saga.RequestUndoAsync(id, me.Id, ready.Proofs, cancellationToken);
|
||||
if (outcome is UndoOutcome.Requested)
|
||||
{
|
||||
proofs.Clear(me.Id);
|
||||
try
|
||||
{
|
||||
await saga.AdvanceAsync(id, cancellationToken);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
// The saga's stored state is the truth and the sweeper resumes it.
|
||||
logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", id, error.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
return Undone(MergeWording.UndoCode(outcome));
|
||||
}
|
||||
|
||||
private const string HeimdallLinkReturn = "/Membership/MergeStatus";
|
||||
|
||||
private MergeRow Row(AccountMerge merge, Guid? me)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var open = merge.State == AccountMergeState.Committed && merge.GraceEndsUtc is { } end && now < end;
|
||||
var canUndo = open && me == merge.SurvivorUserAccountId;
|
||||
var ready = false;
|
||||
IReadOnlyList<string> need = [];
|
||||
if (canUndo)
|
||||
{
|
||||
switch (proofs.PeekUndo(me!.Value, MovedIds(merge)))
|
||||
{
|
||||
case UndoPeekResult.Ready { Proofs.Count: > 0 }:
|
||||
ready = true;
|
||||
break;
|
||||
case UndoPeekResult.LoginsUnproved { Providers.Count: > 0 } unproved:
|
||||
need = unproved.Providers;
|
||||
break;
|
||||
default:
|
||||
need = HeimdallAuthService.LinkableProviders;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var inGrace = merge.State is AccountMergeState.Committed or AccountMergeState.UndoRequested && merge.GraceEndsUtc > now;
|
||||
return new MergeRow(
|
||||
merge.Id,
|
||||
MergeWording.State(merge.State),
|
||||
merge.State == AccountMergeState.Failed ? MergeWording.Failure(merge.FailureCode) : null,
|
||||
merge.State is AccountMergeState.Committed or AccountMergeState.Failed or AccountMergeState.Confirmed or AccountMergeState.HeimdallMoved
|
||||
or AccountMergeState.UndoRequested or AccountMergeState.HeimdallReversed
|
||||
? MergeWording.Outcome(merge.LastOutcome)
|
||||
: null,
|
||||
merge.ConfirmedUtc is { } confirmed ? MergeWording.Date(confirmed) : null,
|
||||
merge.CommittedUtc is { } committed ? MergeWording.Date(committed) : null,
|
||||
merge.GraceEndsUtc is { } graceEnds ? MergeWording.Date(graceEnds) : null,
|
||||
merge.FinalizedUtc is { } final ? MergeWording.Date(final) : null,
|
||||
inGrace && merge.GraceEndsUtc is { } until ? MergeWording.DaysLeft(MergeWording.DaysUntil(until, now)) : null,
|
||||
inGrace && merge.GraceEndsUtc is { } by ? MergeWording.TierNote(merge.ProofFromTier, by) : null,
|
||||
merge.SurvivorProofProvider.Length > 0 ? [ProviderName(merge.SurvivorProofProvider)] : [],
|
||||
[.. merge.FoldedProofProviders.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ProviderName)],
|
||||
canUndo,
|
||||
ready,
|
||||
need);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> MovedIds(AccountMerge merge)
|
||||
{
|
||||
try
|
||||
{
|
||||
return merge.MovedIdentityIds is { } json ? JsonSerializer.Deserialize<string[]>(json) ?? [] : [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult Undone(string code) =>
|
||||
RedirectToPage(null, new Dictionary<string, string?> { [UndoParameter] = code });
|
||||
|
||||
private IActionResult Result(HeimdallLinkOutcome outcome) =>
|
||||
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
|
||||
}
|
||||
|
|
@ -24,22 +24,6 @@
|
|||
<p class="muted">@Model.StatusMessage</p>
|
||||
</div>
|
||||
|
||||
@if (Model.CanManageLogins)
|
||||
{
|
||||
<div class="panel-actions">
|
||||
<a class="button secondary" asp-page="/Membership/LinkedAccounts">Linked accounts</a>
|
||||
<span class="muted">See, add and remove the provider logins that sign in to this account.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.HasMerges)
|
||||
{
|
||||
<div class="panel-actions">
|
||||
<a class="button secondary" asp-page="/Membership/MergeStatus">Merge status</a>
|
||||
<span class="muted">See an account merge and, for seven days, undo it.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Invitation is not null)
|
||||
{
|
||||
<div class="callout success">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using System.Security.Claims;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
|
@ -14,12 +12,6 @@ public sealed class StatusModel(
|
|||
ICurrentBifrostActorAccessor actorAccessor,
|
||||
BifrostDbContext dbContext) : PageModel
|
||||
{
|
||||
/// <summary>Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage.</summary>
|
||||
public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
|
||||
|
||||
/// <summary>Whether this account is in, or was in, a merge: the link to Merge status is shown only then.</summary>
|
||||
public bool HasMerges { get; private set; }
|
||||
|
||||
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
||||
|
||||
public MembershipInvitation? Invitation { get; private set; }
|
||||
|
|
@ -53,9 +45,6 @@ public sealed class StatusModel(
|
|||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
HasMerges = Actor.Merge is not null
|
||||
|| (Actor.UserAccount is { } account
|
||||
&& await dbContext.AccountMerges.AnyAsync(x => x.SurvivorUserAccountId == account.Id || x.FoldedUserAccountId == account.Id, cancellationToken));
|
||||
|
||||
if (Actor.UserAccount is null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
@model string
|
||||
<aside class="merge-notice" role="status">
|
||||
<span>@Model</span>
|
||||
<a asp-page="/Membership/MergeStatus">Merge status</a>
|
||||
</aside>
|
||||
|
|
@ -57,11 +57,6 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
@await Component.InvokeAsync("MergeNotice")
|
||||
}
|
||||
|
||||
<main class="page-frame">
|
||||
@RenderBody()
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -64,8 +64,7 @@ builder.Services
|
|||
|
||||
builder.Services
|
||||
.AddOptions<HeimdallOptions>()
|
||||
.BindConfiguration(HeimdallOptions.SectionName)
|
||||
.PostConfigure<ILogger<HeimdallOptions>>(HeimdallOptions.ResolveAppSecret);
|
||||
.BindConfiguration(HeimdallOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<StripeOptions>()
|
||||
|
|
@ -93,7 +92,6 @@ builder.Services.AddAntiforgery(options =>
|
|||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
options.Conventions.AuthorizePage("/Membership/LinkedAccounts");
|
||||
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
|
||||
options.Conventions.AllowAnonymousToFolder("/Mill");
|
||||
});
|
||||
|
|
@ -139,13 +137,6 @@ builder.Services.AddSingleton<RegistrationChallengeService>();
|
|||
builder.Services.AddSingleton<RegistrationRateLimit>();
|
||||
builder.Services.AddScoped<NativeRegistration>();
|
||||
builder.Services.AddHostedService<SealedBallotCloser>();
|
||||
builder.Services.AddOptions<AccountMergeOptions>().BindConfiguration(AccountMergeOptions.SectionName);
|
||||
builder.Services.AddScoped<Bifrost.Web.Features.Membership.AccountMergeLedger>();
|
||||
builder.Services.AddScoped<Bifrost.Web.Features.Membership.AccountMergeSaga>();
|
||||
builder.Services.AddScoped<Bifrost.Web.Features.Membership.MergeNoticeFeed>();
|
||||
builder.Services.AddOptions<ChatNoticesOptions>().BindConfiguration(ChatNoticesOptions.SectionName);
|
||||
builder.Services.AddSingleton<Bifrost.Web.Features.Membership.MergeProofs>();
|
||||
builder.Services.AddHostedService<Bifrost.Web.Features.Membership.AccountMergeSweeper>();
|
||||
builder.Services.AddScoped<MotionEveSurfaceService>();
|
||||
builder.Services.AddScoped<PatronageService>();
|
||||
builder.Services.AddScoped<StripeWebhookService>();
|
||||
|
|
@ -158,13 +149,9 @@ builder.Services.AddScoped<ReadinessService>();
|
|||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
||||
// The app-secret and member-bearer clients and the mover: no redirects (see HeimdallClientServices).
|
||||
builder.Services.AddHeimdallClients();
|
||||
builder.Services.AddSingleton<LinkedAccountsSnapshots>();
|
||||
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
|
||||
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyCeremonies>();
|
||||
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRateLimit>();
|
||||
builder.Services.AddScoped<Bifrost.Web.Features.Passkeys.PasskeyService>();
|
||||
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
||||
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
||||
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
|
||||
builder.Services.AddSingleton<HeimdallTokenVerifier>();
|
||||
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
|
||||
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
|
||||
|
|
@ -280,15 +267,13 @@ if (oidcOptions is not null)
|
|||
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
|
||||
if (deskConfigured)
|
||||
{
|
||||
builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null);
|
||||
builder.Services.AddBifrostDesk();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
|
||||
|
||||
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
|
||||
// Resolved now so a misconfigured RP (not https, host differs, a port in ExpectedHost) is logged once at boot, not on the first ceremony.
|
||||
_ = app.Services.GetRequiredService<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
await scope.ServiceProvider
|
||||
|
|
@ -823,8 +808,6 @@ app.MapPost("/api/sealed-motions/{id:guid}/mind", async (
|
|||
: await SealedCall(async () => await service.HandleAsync(id, envelope, ct));
|
||||
}).AllowAnonymous();
|
||||
|
||||
MergeNoticeEndpoints.Map(app);
|
||||
|
||||
app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) =>
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
|
|
@ -839,53 +822,11 @@ app.MapPost("/auth/heimdall/complete", async (
|
|||
ILogger<HeimdallAuthService> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
// Every answer here sets or clears an attempt cookie or signs someone in: none may be stored.
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
|
||||
var attempt = form?["attempt"].ToString();
|
||||
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
|
||||
var linking = held is not null
|
||||
&& heimdallAuthService.PurposeOf(held.Value.Value, attempt) is not (null or HeimdallAttemptPurpose.SignIn);
|
||||
try
|
||||
{
|
||||
if (linking)
|
||||
{
|
||||
// A link step never signs anyone in: the session in this browser is the one being linked.
|
||||
var step = await heimdallAuthService.ContinueLinkAsync(
|
||||
hostOptions.Value.PublicBaseUrl,
|
||||
attempt,
|
||||
held!.Value.Name,
|
||||
held.Value.Value,
|
||||
form?["code"].ToString(),
|
||||
form?["pendingLinkId"].ToString(),
|
||||
// A folded or frozen account links nothing: the actor accessor's standing decides, not the raw claim.
|
||||
(await httpContext.RequestServices.GetRequiredService<ICurrentBifrostActorAccessor>().GetAsync(cancellationToken)).Merge is null
|
||||
? httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId)
|
||||
: null,
|
||||
cancellationToken);
|
||||
if (step.Next is not null)
|
||||
{
|
||||
httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options());
|
||||
return Results.Redirect(step.Next.ToString());
|
||||
}
|
||||
|
||||
if (step.EnrolTicket is not null)
|
||||
{
|
||||
// The ticket id goes only to the browser that completed the fresh sign-in; the server holds what it is bound to.
|
||||
httpContext.Response.Cookies.Append(BifrostCookies.PasskeyTicket, step.EnrolTicket, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = BifrostCookies.Path,
|
||||
MaxAge = Bifrost.Web.Features.Passkeys.PasskeyCeremonies.TicketLifetime
|
||||
});
|
||||
}
|
||||
|
||||
httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions());
|
||||
return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value, step.ReturnPath));
|
||||
}
|
||||
|
||||
var signIn = await heimdallAuthService.CompleteAsync(
|
||||
hostOptions.Value.PublicBaseUrl,
|
||||
attempt,
|
||||
|
|
@ -904,9 +845,7 @@ app.MapPost("/auth/heimdall/complete", async (
|
|||
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
||||
}
|
||||
|
||||
return linking
|
||||
? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error, heimdallAuthService.MergeReturnPathOf(held?.Value, attempt)))
|
||||
: Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
||||
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.ViewComponents;
|
||||
|
||||
/// <summary>
|
||||
/// The layout's merge notice for a signed-in actor: the survivor of a Committed or UndoRequested merge inside its grace (days left), a
|
||||
/// frozen account (a merge is in progress) or a folded one (where to sign in). It reads the merge row once per page view and keeps no
|
||||
/// state of its own; the words are <see cref="MergeWording"/>'s, and no account name or id is ever in them.
|
||||
/// </summary>
|
||||
public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbContext dbContext, TimeProvider timeProvider) : ViewComponent
|
||||
{
|
||||
public async Task<IViewComponentResult> InvokeAsync()
|
||||
{
|
||||
var actor = await actors.GetAsync(HttpContext.RequestAborted);
|
||||
var text = await TextAsync(actor, HttpContext.RequestAborted);
|
||||
return text is null ? Content(string.Empty) : View("Default", text);
|
||||
}
|
||||
|
||||
private async Task<string?> TextAsync(CurrentBifrostActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (actor.Merge is { } standing)
|
||||
{
|
||||
if (standing is MergeStanding.Frozen)
|
||||
{
|
||||
return MergeWording.FrozenNotice();
|
||||
}
|
||||
|
||||
var mergedOn = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => x.Id == standing.MergeId)
|
||||
.Select(x => x.CommittedUtc ?? x.CreatedUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return MergeWording.FoldedNotice(mergedOn);
|
||||
}
|
||||
|
||||
if (actor.UserAccount is not { } account)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var open = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => x.SurvivorUserAccountId == account.Id
|
||||
&& (x.State == AccountMergeState.Committed || x.State == AccountMergeState.UndoRequested)
|
||||
&& x.GraceEndsUtc > now)
|
||||
.OrderBy(x => x.GraceEndsUtc)
|
||||
.Select(x => new { x.State, x.GraceEndsUtc })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return open is null ? null
|
||||
: open.State == AccountMergeState.UndoRequested ? MergeWording.UndoRequestedNotice()
|
||||
: MergeWording.SurvivorNotice(MergeWording.DaysUntil(open.GraceEndsUtc!.Value, now));
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,6 @@
|
|||
"BaseUrl": "https://heimdall.gamecult.org",
|
||||
"AppSlug": "bifrost",
|
||||
"AppSecret": "",
|
||||
"AppSecretFile": "",
|
||||
"DiscordGuildId": "",
|
||||
"DiscordAllowedRoleIds": [],
|
||||
"PatreonTierTitle": "Inner Sanctum",
|
||||
|
|
|
|||
|
|
@ -8,30 +8,6 @@
|
|||
"resolved": "2.4.0",
|
||||
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
|
||||
},
|
||||
"Fido2": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.3.3, )",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "++G8ohXlhp9AH49FXIvYqziXUAEpuCtT+z5HBevX6+Pl6sYz9zfDHGTgvne6t3UEhM0hudesgMudyNG7RlpaEA==",
|
||||
"dependencies": {
|
||||
"Fido2.Models": "4.3.3",
|
||||
"Microsoft.Extensions.Http": "9.0.0",
|
||||
"Microsoft.IdentityModel.JsonWebTokens": "8.2.0",
|
||||
"NSec.Cryptography": "25.4.0",
|
||||
"System.Formats.Cbor": "9.0.24"
|
||||
}
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.12, )",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "3.1.12",
|
||||
"MessagePackAnalyzer": "3.1.12",
|
||||
"Microsoft.NET.StringTools": "17.11.4"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Design": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.0, )",
|
||||
|
|
@ -99,59 +75,17 @@
|
|||
"OpenIddict.Server": "7.7.1"
|
||||
}
|
||||
},
|
||||
"OpenIddict.Validation.AspNetCore": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.7.1, )",
|
||||
"resolved": "7.7.1",
|
||||
"contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==",
|
||||
"dependencies": {
|
||||
"OpenIddict.Validation": "7.7.1"
|
||||
}
|
||||
},
|
||||
"OpenIddict.Validation.ServerIntegration": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.7.1, )",
|
||||
"resolved": "7.7.1",
|
||||
"contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==",
|
||||
"dependencies": {
|
||||
"OpenIddict.Server": "7.7.1",
|
||||
"OpenIddict.Validation": "7.7.1"
|
||||
}
|
||||
},
|
||||
"System.Text.Json": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.5, )",
|
||||
"resolved": "8.0.5",
|
||||
"contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg=="
|
||||
},
|
||||
"Fido2.Models": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "G/Q8MYgptOwMrbWgDoGsy90u3J5GTdNAHub8aZRg/mQrUETzB5CqFzstGdTXE9UhL0qD6sRynOoPUnqCwKRnMw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.Memory": "9.0.14"
|
||||
}
|
||||
},
|
||||
"Humanizer.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.14.1",
|
||||
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.20.1",
|
||||
"contentHash": "pkiceEqJDQFHjbga3k/oPfTVX9g+GKJZ1VrkuiLhaymt9YNw1Dr7cX9hNuZuNaWcr9WD0moW55k4pMwzQ7JaZQ=="
|
||||
},
|
||||
"MessagePack.Annotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
|
||||
},
|
||||
"MessagePackAnalyzer": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
|
|
@ -165,11 +99,6 @@
|
|||
"System.Formats.Asn1": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Bcl.Memory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.14",
|
||||
"contentHash": "laQCcjTM2FnbyznkAu9hwBuQh3z2/OtJKwjGkJOUevylFpFXZIUVq/+MU2jM0HQm1n5wsjjwtLIDOiWJ6Nai3A=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.3.3",
|
||||
|
|
@ -257,43 +186,26 @@
|
|||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Binder": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
|
||||
"resolved": "8.0.1",
|
||||
"contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
|
|
@ -304,83 +216,37 @@
|
|||
"System.Text.Json": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "9.0.0",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Options": "9.0.0",
|
||||
"System.Diagnostics.DiagnosticSource": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Http": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Diagnostics": "9.0.0",
|
||||
"Microsoft.Extensions.Logging": "9.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Options": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
|
||||
"resolved": "8.0.1",
|
||||
"contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Options": "9.0.0"
|
||||
"Microsoft.Extensions.DependencyInjection": "8.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
|
||||
"Microsoft.Extensions.Options": "8.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||
"System.Diagnostics.DiagnosticSource": "9.0.0"
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Configuration.Binder": "9.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Options": "9.0.0",
|
||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
|
||||
},
|
||||
"Microsoft.IdentityModel.Abstractions": {
|
||||
"type": "Transitive",
|
||||
|
|
@ -403,14 +269,6 @@
|
|||
"Microsoft.IdentityModel.Abstractions": "8.19.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.19.2",
|
||||
"contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Tokens": "8.19.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Tokens": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.19.2",
|
||||
|
|
@ -421,11 +279,6 @@
|
|||
"Microsoft.IdentityModel.Logging": "8.19.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.StringTools": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.11.4",
|
||||
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
|
||||
},
|
||||
"Mono.TextTemplating": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.2.1",
|
||||
|
|
@ -434,14 +287,6 @@
|
|||
"System.CodeDom": "4.4.0"
|
||||
}
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "25.4.0",
|
||||
"contentHash": "Z3wPpG0xefMLmhn9T+Ka/EzAmMQPT0THL/5E4lf9cXI/N9rbGH0G23ZFpRcPnD0ast2k7ieG6QSVNIjmfPCXBQ==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.20.1, 1.0.21)"
|
||||
}
|
||||
},
|
||||
"OpenIddict.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "7.7.1",
|
||||
|
|
@ -478,17 +323,6 @@
|
|||
"OpenIddict.Abstractions": "7.7.1"
|
||||
}
|
||||
},
|
||||
"OpenIddict.Validation": {
|
||||
"type": "Transitive",
|
||||
"resolved": "7.7.1",
|
||||
"contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging": "8.0.1",
|
||||
"Microsoft.IdentityModel.JsonWebTokens": "8.19.2",
|
||||
"Microsoft.IdentityModel.Protocols": "8.19.2",
|
||||
"OpenIddict.Abstractions": "7.7.1"
|
||||
}
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.4.0",
|
||||
|
|
@ -550,21 +384,11 @@
|
|||
"System.Composition.Runtime": "6.0.0"
|
||||
}
|
||||
},
|
||||
"System.Diagnostics.DiagnosticSource": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw=="
|
||||
},
|
||||
"System.Formats.Asn1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q=="
|
||||
},
|
||||
"System.Formats.Cbor": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.24",
|
||||
"contentHash": "SEfEvHu6qXcXoDsDKagddLDf8A9iHIhHUD0hGemdmwlP206KLLCI5ksskOpeXMgNpQCs2diB0fOBKcnjySHFUQ=="
|
||||
},
|
||||
"System.IO.Pipelines": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.3",
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
// The Linked accounts page's passkey ceremonies (docs/passkeys.md). First-party, no dependency. It asks the server for
|
||||
// WebAuthn options, calls navigator.credentials, and posts back only base64url strings in a same-origin form. Nothing it
|
||||
// reads from the options is shown or evaluated; the server checks the origin, the challenge and the signature.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var status = document.getElementById("passkey-status");
|
||||
|
||||
function say(text) {
|
||||
if (status) {
|
||||
status.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
var padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (padded.length % 4) {
|
||||
padded += "=";
|
||||
}
|
||||
var raw = atob(padded);
|
||||
var bytes = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
bytes[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function toText(buffer) {
|
||||
var bytes = new Uint8Array(buffer);
|
||||
var raw = "";
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
raw += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function token(form) {
|
||||
var field = form.querySelector("input[name=__RequestVerificationToken]");
|
||||
return field ? field.value : "";
|
||||
}
|
||||
|
||||
// Posts to a handler of this page with the form's antiforgery token and returns the parsed JSON, or throws.
|
||||
function options(handler, form, fields) {
|
||||
return fetch(location.pathname + "?handler=" + handler, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "RequestVerificationToken": token(form), "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(fields)
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) {
|
||||
throw new Error(body && body.error ? body.error : "error");
|
||||
}
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function descriptors(list) {
|
||||
return (list || []).map(function (item) {
|
||||
var copy = { type: item.type, id: toBytes(item.id) };
|
||||
if (item.transports) {
|
||||
copy.transports = item.transports;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
function set(form, name, value) {
|
||||
form.elements[name].value = value;
|
||||
}
|
||||
|
||||
function failed(error) {
|
||||
var code = error && error.message ? error.message : "error";
|
||||
location.assign(location.pathname + "?result=" + (/^[a-z_]+$/.test(code) && code !== "error" ? encodeURIComponent(code) : "passkey_failed"));
|
||||
}
|
||||
|
||||
document.querySelectorAll("form[data-passkey-enrol]").forEach(function (form) {
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (form.dataset.ready === "1") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
say("Waiting for your device...");
|
||||
options("PasskeyEnrolOptions", form, {}).then(function (created) {
|
||||
created.challenge = toBytes(created.challenge);
|
||||
created.user.id = toBytes(created.user.id);
|
||||
created.excludeCredentials = descriptors(created.excludeCredentials);
|
||||
return navigator.credentials.create({ publicKey: created });
|
||||
}).then(function (credential) {
|
||||
set(form, "id", credential.id);
|
||||
set(form, "rawId", toText(credential.rawId));
|
||||
set(form, "attestationObject", toText(credential.response.attestationObject));
|
||||
set(form, "clientDataJson", toText(credential.response.clientDataJSON));
|
||||
set(form, "transports", credential.response.getTransports ? credential.response.getTransports().join(",") : "");
|
||||
form.dataset.ready = "1";
|
||||
form.submit();
|
||||
}).catch(failed);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("form[data-passkey-unlink]").forEach(function (form) {
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (form.dataset.ready === "1") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
say("Waiting for your device...");
|
||||
options("PasskeyChallenge", form, { identityId: form.elements.identityId.value }).then(function (requested) {
|
||||
requested.challenge = toBytes(requested.challenge);
|
||||
requested.allowCredentials = descriptors(requested.allowCredentials);
|
||||
return navigator.credentials.get({ publicKey: requested });
|
||||
}).then(function (credential) {
|
||||
set(form, "id", credential.id);
|
||||
set(form, "rawId", toText(credential.rawId));
|
||||
set(form, "authenticatorData", toText(credential.response.authenticatorData));
|
||||
set(form, "clientDataJson", toText(credential.response.clientDataJSON));
|
||||
set(form, "signature", toText(credential.response.signature));
|
||||
set(form, "userHandle", credential.response.userHandle ? toText(credential.response.userHandle) : "");
|
||||
form.dataset.ready = "1";
|
||||
form.submit();
|
||||
}).catch(failed);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,439 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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 folded member's browser and Heimdall's patron intake, over HTTP: the merge ledger is the single gate behind both.</summary>
|
||||
public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private sealed record Seeded(Guid Survivor, Guid Folded, Guid Motion, string FoldedHeimdallId, long SurvivorGitHubId, long FoldedGitHubId);
|
||||
|
||||
private async Task<Seeded> SeedAsync()
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var seed = Random.Shared.NextInt64(1_000_000, 900_000_000);
|
||||
UserAccount Make(string name, long gitHubId) => new()
|
||||
{
|
||||
DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = name, GitHubUserId = gitHubId, GitHubLogin = name, NormalizedGitHubLogin = name,
|
||||
HeimdallAccountId = "hd-" + name,
|
||||
MemberProfile = new MemberProfile { Nickname = name, Headline = "seed", UpdatedAtUtc = DateTimeOffset.UtcNow },
|
||||
Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow }
|
||||
};
|
||||
var survivor = Make($"surv-{seed}", seed);
|
||||
var folded = Make($"fold-{seed}", seed + 1);
|
||||
db.UserAccounts.AddRange(survivor, folded);
|
||||
var motion = new Motion
|
||||
{
|
||||
CreatedByUserAccountId = survivor.Id, Title = "Open motion", Status = MotionStatus.Open, OpensAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
ClosesAtUtc = DateTimeOffset.UtcNow.AddDays(2), CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Motions.Add(motion);
|
||||
await db.SaveChangesAsync();
|
||||
return new Seeded(survivor.Id, folded.Id, motion.Id, folded.HeimdallAccountId, seed, seed + 1);
|
||||
}
|
||||
|
||||
private HttpClient ClientFor(long gitHubId, string login)
|
||||
{
|
||||
var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost"), HandleCookies = true });
|
||||
client.DefaultRequestHeaders.Add("X-Test-GitHub-Id", gitHubId.ToString());
|
||||
client.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string> TokenAsync(HttpClient client)
|
||||
{
|
||||
using var page = await client.GetAsync("/App/Motions");
|
||||
var html = await page.Content.ReadAsStringAsync();
|
||||
var token = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value;
|
||||
Assert.NotEmpty(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> VoteAsync(HttpClient client, Guid motion, string token) =>
|
||||
client.PostAsync("/App/Motions?handler=Vote", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["motionId"] = motion.ToString(), ["choice"] = "For", ["comment"] = "yes", ["__RequestVerificationToken"] = token
|
||||
}));
|
||||
|
||||
private async Task<T> WithDb<T>(Func<BifrostDbContext, Task<T>> read)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
return await read(scope.ServiceProvider.GetRequiredService<BifrostDbContext>());
|
||||
}
|
||||
|
||||
private Task<int> VoteRowsAsync(Guid motion) => WithDb(db => db.Votes.IgnoreQueryFilters().CountAsync(x => x.MotionId == motion));
|
||||
|
||||
private async Task<Guid> MergeAsync(Seeded s, AccountMergeState state)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var merge = new AccountMerge { SurvivorUserAccountId = s.Survivor, FoldedUserAccountId = s.Folded, State = state, CreatedUtc = DateTimeOffset.UtcNow };
|
||||
db.AccountMerges.Add(merge);
|
||||
await db.SaveChangesAsync();
|
||||
return merge.Id;
|
||||
}
|
||||
|
||||
private async Task SetStateAsync(Guid mergeId, AccountMergeState state) =>
|
||||
await WithDb(async db =>
|
||||
{
|
||||
(await db.AccountMerges.SingleAsync(x => x.Id == mergeId)).State = state;
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task A_folded_session_cookie_acts_as_nobody_while_Frozen_and_after_Committed_and_the_survivor_still_votes()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}");
|
||||
var token = await TokenAsync(folded); // a live, valid form from before the merge: the cookie outlives the merge
|
||||
var merge = await MergeAsync(s, AccountMergeState.Confirmed);
|
||||
|
||||
using (var whileFrozen = await VoteAsync(folded, s.Motion, token))
|
||||
{
|
||||
Assert.NotEqual(HttpStatusCode.OK, whileFrozen.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(0, await VoteRowsAsync(s.Motion));
|
||||
using (var page = await folded.GetAsync("/App/Motions"))
|
||||
{
|
||||
Assert.NotEqual(HttpStatusCode.OK, page.StatusCode);
|
||||
}
|
||||
|
||||
await SetStateAsync(merge, AccountMergeState.HeimdallMoved);
|
||||
await SetStateAsync(merge, AccountMergeState.Committed);
|
||||
using (var afterCommit = await VoteAsync(folded, s.Motion, token))
|
||||
{
|
||||
Assert.NotEqual(HttpStatusCode.OK, afterCommit.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(0, await VoteRowsAsync(s.Motion));
|
||||
|
||||
using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}");
|
||||
using var control = await VoteAsync(survivor, s.Motion, await TokenAsync(survivor));
|
||||
Assert.Equal(HttpStatusCode.Redirect, control.StatusCode);
|
||||
Assert.Equal(1, await VoteRowsAsync(s.Motion));
|
||||
}
|
||||
|
||||
private async Task<HttpStatusCode> IntakeAsync(Seeded s, string eventId)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = eventId, kind = "OneTimeDonation",
|
||||
amount = 25m, currencyCode = "USD", externalSupportId = "CAPTURE-1", supportedAtUtc = DateTimeOffset.UtcNow, isCurrentRecurringSupport = false
|
||||
});
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("test-heimdall-intake-secret"));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events");
|
||||
request.Headers.Add("X-Heimdall-Signature-256", "sha256=" + Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant());
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var client = factory.CreateClient();
|
||||
using var response = await client.SendAsync(request);
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
private Task<List<Guid>> EventOwnersAsync(Seeded s) =>
|
||||
WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync());
|
||||
|
||||
/// <summary>Lands the merge through the ledger's own commit, as the saga will: the move done, then the Bifrost records reduced.</summary>
|
||||
private async Task<Guid> CommitMergeAsync(Seeded s)
|
||||
{
|
||||
var merge = await MergeAsync(s, AccountMergeState.HeimdallMoved);
|
||||
await WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Membership.AccountMergeLedger>().CommitAsync(merge, default);
|
||||
return 0;
|
||||
});
|
||||
return merge;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
await MergeAsync(s, AccountMergeState.Final);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}"));
|
||||
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_patron_event_that_lands_during_grace_is_journalled_once_and_undo_moves_it_back_to_the_folded_account()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
var eventId = $"WH-{Guid.NewGuid():N}";
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId));
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); // the provider retries: nothing doubles
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
Assert.Equal(1, await WithDb(db => db.AccountMergeEntries.CountAsync(x => x.MergeId == merge && x.Kind == AccountMergeEntryKind.PatronSupportEvent)));
|
||||
|
||||
await SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Membership.AccountMergeLedger>().UndoLocalAsync(merge, default);
|
||||
return 0;
|
||||
});
|
||||
|
||||
Assert.Equal([s.Folded], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
private Task<List<AccountMergeEntry>> PatronJournalAsync(Guid merge) =>
|
||||
WithDb(db => db.AccountMergeEntries.Where(x => x.MergeId == merge).OrderBy(x => x.Seq).ToListAsync());
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_patron_event_during_grace_is_journalled_by_its_own_delivery_and_undo_moves_it_back()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}")); // no retry follows
|
||||
|
||||
var journal = await PatronJournalAsync(merge);
|
||||
Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); // the new entry takes its own Seq
|
||||
await SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Membership.AccountMergeLedger>().UndoLocalAsync(merge, default);
|
||||
return 0;
|
||||
});
|
||||
Assert.Equal([s.Folded], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_provider_retry_journals_an_event_the_first_delivery_left_unjournalled()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
var eventId = $"WH-{Guid.NewGuid():N}";
|
||||
await WithDb(async db => // the first delivery recorded on the survivor, then died before journalling
|
||||
{
|
||||
db.PatronSupportEvents.Add(new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = s.Survivor, Provider = ExternalPatronProvider.PayPal, ProviderEventId = eventId, ExternalSupportId = "CAPTURE-1",
|
||||
Kind = PatronSupportEventKind.OneTimeDonation, Amount = 25m, SupportedAtUtc = DateTimeOffset.UtcNow, RecordedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
Assert.DoesNotContain(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId));
|
||||
|
||||
Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stripe_checkout_opened_by_an_account_that_folded_pays_the_survivor_and_is_journalled()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
using var response = await StripeCheckoutAsync(s);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
Assert.Equal(s.Folded, entry.PriorUserAccountId); // the journal names the folded account as the previous owner
|
||||
|
||||
await SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await UndoAsync(merge);
|
||||
Assert.Equal([s.Folded], await EventOwnersAsync(s)); // so an undo sends the donation back where it was opened
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> StripeCheckoutAsync(Seeded s)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = $"evt_{Guid.NewGuid():N}", type = "checkout.session.completed", created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
data = new { @object = new { id = $"cs_{Guid.NewGuid():N}", customer = "cus_1", currency = "usd", amount_total = 1900, payment_status = "paid",
|
||||
metadata = new Dictionary<string, string> { ["bifrost_user_account_id"] = s.Folded.ToString("N") } } }
|
||||
});
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("whsec_test_webhook_secret"));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/patronage/stripe/webhook");
|
||||
request.Headers.Add("Stripe-Signature", $"t={timestamp},v1={Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{payload}"))).ToLowerInvariant()}");
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private Task UndoAsync(Guid merge) =>
|
||||
WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Membership.AccountMergeLedger>().UndoLocalAsync(merge, default);
|
||||
return 0;
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task A_stripe_checkout_opened_by_an_account_that_folded_into_a_suspended_survivor_is_refused_as_not_configured_and_records_nothing()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
await CommitMergeAsync(s);
|
||||
await WithDb(async db =>
|
||||
{
|
||||
(await db.Memberships.SingleAsync(x => x.UserAccountId == s.Survivor)).Status = MembershipStatus.Suspended;
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
using var response = await StripeCheckoutAsync(s);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Empty(await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_admin_ledgers_manual_patron_record_for_a_folded_account_lands_on_the_survivor_and_is_journalled()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
|
||||
await WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Patronage.PatronageService>().RecordSupportEventAsync(
|
||||
s.Survivor, s.Folded, PatronSupportEventKind.OneTimeDonation, 10m, "USD", "manual-1", ExternalPatronProvider.Manual,
|
||||
string.Empty, string.Empty, string.Empty, DateTimeOffset.UtcNow, false, string.Empty, default); // as Pages/App/Ledger records it
|
||||
return 0;
|
||||
});
|
||||
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
Assert.Equal(s.Folded, entry.PriorUserAccountId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_provider_retry_of_an_event_stranded_on_the_folded_account_journals_nothing_because_nothing_moved()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
var eventId = $"WH-{Guid.NewGuid():N}";
|
||||
await WithDb(async db => // recorded on the folded account by a delivery that straddled the commit
|
||||
{
|
||||
db.PatronSupportEvents.Add(new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = s.Folded, Provider = ExternalPatronProvider.PayPal, ProviderEventId = eventId, ExternalSupportId = "CAPTURE-1",
|
||||
Kind = PatronSupportEventKind.OneTimeDonation, Amount = 25m, SupportedAtUtc = DateTimeOffset.UtcNow, RecordedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId));
|
||||
|
||||
Assert.DoesNotContain(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
Assert.Equal([s.Folded], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_folded_bootstrap_admins_sign_in_does_not_reactivate_the_merged_membership_and_an_approval_is_refused()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
await CommitMergeAsync(s);
|
||||
using var folded = ClientFor(s.FoldedGitHubId, "test-admin"); // the bootstrap login: every request would re-promote it
|
||||
|
||||
using (await folded.GetAsync("/"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.Equal((MembershipStatus.Merged, false), await WithDb(async db =>
|
||||
{
|
||||
var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded);
|
||||
return (membership.Status, membership.IsPlatformAdmin);
|
||||
}));
|
||||
|
||||
using var admin = ClientFor(Random.Shared.NextInt64(1_000_000_000, 2_000_000_000), "test-admin");
|
||||
var token = await TokenAsync(admin);
|
||||
using var approve = await admin.PostAsync("/App/Members?handler=Approve", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["userAccountId"] = s.Folded.ToString(), ["__RequestVerificationToken"] = token
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Redirect, approve.StatusCode);
|
||||
Assert.Equal(MembershipStatus.Merged, await WithDb(async db => (await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded)).Status));
|
||||
}
|
||||
|
||||
/// <summary>Forbid answers 403 or sends the browser to the access-denied page; either way the page and its Heimdall flows are not served.</summary>
|
||||
private static void AssertRefused(HttpResponseMessage response)
|
||||
{
|
||||
Assert.True(response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Found, response.StatusCode.ToString());
|
||||
Assert.DoesNotContain("heimdall", response.Headers.Location?.ToString() ?? "", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Committed)]
|
||||
public async Task A_frozen_or_folded_cookie_cannot_reach_the_linked_accounts_page_or_start_one_of_its_flows(AccountMergeState state)
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}");
|
||||
var token = await TokenAsync(folded);
|
||||
using (var control = await folded.GetAsync("/Membership/LinkedAccounts"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, control.StatusCode); // before the merge the page is theirs
|
||||
}
|
||||
|
||||
await MergeAsync(s, state);
|
||||
|
||||
using var page = await folded.GetAsync("/Membership/LinkedAccounts");
|
||||
AssertRefused(page);
|
||||
using var refresh = await folded.PostAsync("/Membership/LinkedAccounts?handler=Refresh", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["via"] = "google", ["__RequestVerificationToken"] = token
|
||||
}));
|
||||
AssertRefused(refresh);
|
||||
|
||||
using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}");
|
||||
using var other = await survivor.GetAsync("/Membership/LinkedAccounts");
|
||||
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The desk's bearer door judges standing the way the cookie gate and the chat refusal do.</summary>
|
||||
public sealed class AccountMergeDeskTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Committed)]
|
||||
public async Task A_frozen_or_folded_subjects_live_desk_token_is_forbidden(AccountMergeState state)
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
|
||||
Task<DeskReply> ListAsync() => factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token);
|
||||
|
||||
Assert.Equal(200, (await ListAsync()).Status); // the live member, before the merge
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var survivor = new UserAccount { BifrostIdentity = "desk-survivor", NormalizedBifrostIdentity = "desk-survivor", GitHubLogin = "desk-survivor", NormalizedGitHubLogin = "desk-survivor" };
|
||||
db.UserAccounts.Add(survivor);
|
||||
db.AccountMerges.Add(new AccountMerge
|
||||
{
|
||||
SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = Guid.Parse(DeskWebApplicationFactory.Alice), State = state, CreatedUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(403, (await ListAsync()).Status);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,981 +0,0 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Motions;
|
||||
using Bifrost.Web.Features.Patronage;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The merge tests that need a real server run only when BIFROST_TEST_PG holds a Postgres connection string (no Database=; a role that
|
||||
/// may create databases). Every other merge test runs on the in-memory provider, which enforces no unique index, transaction,
|
||||
/// concurrency token or trigger, so the rules that rest on those are proven here, on a database each test creates, builds through the
|
||||
/// real migrations and the class drops. See AGENTS.md (Live Postgres tests).
|
||||
/// </summary>
|
||||
public static class PostgresGate
|
||||
{
|
||||
public const string Variable = "BIFROST_TEST_PG";
|
||||
|
||||
public static string? Skip =>
|
||||
string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(Variable))
|
||||
? $"live Postgres tests: set {Variable} to a connection string without Database= (see AGENTS.md, Live Postgres tests)"
|
||||
: null;
|
||||
}
|
||||
|
||||
public sealed class PostgresFactAttribute : FactAttribute
|
||||
{
|
||||
public PostgresFactAttribute() => Skip = PostgresGate.Skip;
|
||||
}
|
||||
|
||||
public sealed class PostgresTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public PostgresTheoryAttribute() => Skip = PostgresGate.Skip;
|
||||
}
|
||||
|
||||
public sealed class MergePostgres : IAsyncLifetime
|
||||
{
|
||||
private readonly List<string> _databases = [];
|
||||
|
||||
private static string? Admin => Environment.GetEnvironmentVariable(PostgresGate.Variable);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <summary>A fresh database, built by the real migrations, and a world over it.</summary>
|
||||
public async Task<AccountMergeLedgerTests.World> NewWorldAsync()
|
||||
{
|
||||
var name = $"bifrost_merge_{Guid.NewGuid():N}";
|
||||
await using (var admin = new NpgsqlConnection(Admin))
|
||||
{
|
||||
await admin.OpenAsync();
|
||||
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{name}\"", admin);
|
||||
await create.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_databases.Add(name);
|
||||
var world = AccountMergeLedgerTests.World.OnPostgres(new NpgsqlConnectionStringBuilder(Admin) { Database = name }.ConnectionString);
|
||||
await using var db = world.Db();
|
||||
await db.Database.MigrateAsync();
|
||||
return world;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_databases.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
await using var admin = new NpgsqlConnection(Admin);
|
||||
await admin.OpenAsync();
|
||||
foreach (var name in _databases)
|
||||
{
|
||||
await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)", admin);
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFixture<MergePostgres>
|
||||
{
|
||||
private static PostgresException Pg(Exception error)
|
||||
{
|
||||
for (var current = error; current is not null; current = current.InnerException!)
|
||||
{
|
||||
if (current is PostgresException pg)
|
||||
{
|
||||
return pg;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException($"no PostgresException under {error.GetType().Name}");
|
||||
}
|
||||
|
||||
/// <summary>Runs an action once, just before the first save of a context: the rival writer that lands inside a read-then-save window.</summary>
|
||||
private sealed class BeforeFirstSave(Func<Task> rival) : SaveChangesInterceptor
|
||||
{
|
||||
private int _fired;
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _fired, 1) == 0)
|
||||
{
|
||||
await rival();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds each of two contexts at its save until both have arrived, so both have read before either writes.</summary>
|
||||
private sealed class Rendezvous : SaveChangesInterceptor
|
||||
{
|
||||
private readonly Barrier _barrier = new(2);
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Exception> Chain(Exception error)
|
||||
{
|
||||
for (var current = error; current is not null; current = current.InnerException!)
|
||||
{
|
||||
yield return current;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConnectionOf(AccountMergeLedgerTests.World world)
|
||||
{
|
||||
using var db = world.Db();
|
||||
return db.Database.GetConnectionString()!;
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(AccountMergeLedgerTests.World world, string sql)
|
||||
{
|
||||
await using var db = world.Db();
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
|
||||
// ---- commit atomicity ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_commit_that_fails_midway_leaves_nothing_applied_and_the_retry_commits_once()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.For);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.Against);
|
||||
var merge = await world.MergeAsync(survivor, folded);
|
||||
await ExecuteAsync(world, """
|
||||
CREATE FUNCTION fail_merged() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN IF NEW."Status" = 'Merged' THEN RAISE EXCEPTION 'injected fault'; END IF; RETURN NEW; END $$;
|
||||
CREATE TRIGGER fail_merged BEFORE UPDATE ON "Memberships" FOR EACH ROW EXECUTE FUNCTION fail_merged();
|
||||
""");
|
||||
|
||||
var fault = await Assert.ThrowsAsync<DbUpdateException>(() => world.CommitAsync(merge));
|
||||
Assert.Contains("injected fault", Pg(fault).MessageText);
|
||||
|
||||
await using (var check = world.Db())
|
||||
{
|
||||
Assert.Equal(AccountMergeState.HeimdallMoved, (await check.AccountMerges.SingleAsync()).State);
|
||||
Assert.Empty(check.AccountMergeEntries);
|
||||
Assert.All(await check.Votes.IgnoreQueryFilters().ToListAsync(), v => Assert.Null(v.VoidedByMergeId));
|
||||
Assert.Equal(MembershipStatus.Active, (await check.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status);
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, "DROP TRIGGER fail_merged ON \"Memberships\"");
|
||||
Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State);
|
||||
var journal = await world.JournalAsync(merge);
|
||||
Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote));
|
||||
Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); // a second commit is a no-op
|
||||
Assert.Equal(journal.Count, (await world.JournalAsync(merge)).Count);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_journal_refuses_a_second_entry_with_the_same_merge_and_seq()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var merge = await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"));
|
||||
await using var db = world.Db();
|
||||
AccountMergeEntry Entry() => new() { MergeId = merge.Id, Seq = 1, Kind = AccountMergeEntryKind.Vote, Action = AccountMergeEntryAction.Voided, RecordId = Guid.NewGuid(), AtUtc = AccountMergeLedgerTests.T0 };
|
||||
db.AccountMergeEntries.Add(Entry());
|
||||
await db.SaveChangesAsync();
|
||||
db.AccountMergeEntries.Add(Entry());
|
||||
|
||||
var error = await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
|
||||
|
||||
Assert.Equal(("23505", "IX_AccountMergeEntries_MergeId_Seq"), (Pg(error).SqlState, Pg(error).ConstraintName));
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_commits_of_the_same_merge_racing_apply_it_exactly_once()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.For);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.Against);
|
||||
var merge = await world.MergeAsync(survivor, folded);
|
||||
var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world));
|
||||
|
||||
async Task<Exception?> TryCommit()
|
||||
{
|
||||
try
|
||||
{
|
||||
await committers.CommitAsync(merge);
|
||||
return null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(Task.Run(TryCommit), Task.Run(TryCommit));
|
||||
|
||||
// The second commit waits on the folded account's exclusive lock, then finds the merge landed and returns its receipt.
|
||||
Assert.All(results, e => Assert.Null(e));
|
||||
var journal = await world.JournalAsync(merge);
|
||||
Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count());
|
||||
Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote));
|
||||
Assert.Single((await world.AllVotesAsync(motion)).Where(v => v.VoidedByMergeId == merge.Id));
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_voided_vote_leaves_every_tally_on_a_real_database()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var moderator = await world.AccountAsync("mod", moderator: true);
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.Against, 1m);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.For, 3m);
|
||||
|
||||
await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
var item = await world.StateOfAsync(motion, moderator);
|
||||
Assert.Equal((0m, 1m), (item.VotesFor, item.VotesAgainst));
|
||||
Assert.Equal(MotionStatus.Failed, await world.CloseAsync(motion, moderator));
|
||||
}
|
||||
|
||||
// ---- the sweeper against the saga ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_sweep_does_not_overwrite_an_UndoRequested_written_after_it_read_the_merge()
|
||||
{
|
||||
var plain = await postgres.NewWorldAsync();
|
||||
var merge = await plain.CommitAsync(await plain.MergeAsync(await plain.AccountAsync("survivor"), await plain.AccountAsync("folded")));
|
||||
var sweeper = AccountMergeLedgerTests.World.OnPostgres(
|
||||
ConnectionOf(plain), new BeforeFirstSave(() => plain.SetStateAsync(merge, AccountMergeState.UndoRequested)));
|
||||
|
||||
await using (var db = sweeper.Db())
|
||||
{
|
||||
Assert.Equal(0, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default));
|
||||
}
|
||||
|
||||
await using var read = plain.Db();
|
||||
var row = await read.AccountMerges.SingleAsync();
|
||||
Assert.Equal(AccountMergeState.UndoRequested, row.State);
|
||||
Assert.Null(row.FinalizedUtc);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task An_UndoRequested_written_from_a_stale_Committed_read_cannot_overwrite_Final()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded")));
|
||||
await using var saga = world.Db();
|
||||
var stale = await saga.AccountMerges.SingleAsync(x => x.Id == merge.Id); // the saga read Committed
|
||||
|
||||
await using (var sweep = world.Db())
|
||||
{
|
||||
Assert.Equal(1, await world.Ledger(sweep).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default));
|
||||
}
|
||||
|
||||
stale.State = AccountMergeState.UndoRequested;
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => saga.SaveChangesAsync());
|
||||
await using var read = world.Db();
|
||||
Assert.Equal(AccountMergeState.Final, (await read.AccountMerges.SingleAsync()).State);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_sweep_that_loses_one_merge_to_the_saga_still_finalizes_the_others()
|
||||
{
|
||||
var plain = await postgres.NewWorldAsync();
|
||||
var survivor = await plain.AccountAsync("survivor");
|
||||
await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-a")));
|
||||
await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-b")));
|
||||
var sweeper = AccountMergeLedgerTests.World.OnPostgres(
|
||||
ConnectionOf(plain), new BeforeFirstSave(async () =>
|
||||
{
|
||||
// Whichever merge the sweep saves first, the saga takes one of the two before it does.
|
||||
await using var rival = plain.Db();
|
||||
var taken = await rival.AccountMerges.Where(x => x.State == AccountMergeState.Committed).OrderBy(x => x.Id).FirstAsync();
|
||||
taken.State = AccountMergeState.UndoRequested;
|
||||
await rival.SaveChangesAsync();
|
||||
}));
|
||||
|
||||
await using (var db = sweeper.Db())
|
||||
{
|
||||
Assert.Equal(1, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default));
|
||||
}
|
||||
|
||||
await using var read = plain.Db();
|
||||
Assert.Equal([AccountMergeState.Final, AccountMergeState.UndoRequested], (await read.AccountMerges.ToListAsync()).Select(x => x.State).Order());
|
||||
}
|
||||
|
||||
// ---- overlapping merges, refused by the database ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_second_live_merge_folding_the_same_account_is_refused_and_a_finished_one_frees_it()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var s1 = await world.AccountAsync("acct-s1");
|
||||
var s2 = await world.AccountAsync("acct-s2");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
|
||||
var second = await world.MergeAsync(s2, folded, AccountMergeState.Proposed); // proposals may coexist
|
||||
var first = await world.MergeAsync(s1, folded, AccountMergeState.Confirmed);
|
||||
|
||||
var refused = await Assert.ThrowsAsync<DbUpdateException>(() => world.SetStateAsync(second, AccountMergeState.Confirmed));
|
||||
Assert.Equal(("23505", "UX_AccountMerges_FoldedUserAccountId_Live"), (Pg(refused).SqlState, Pg(refused).ConstraintName));
|
||||
var inserted = await Assert.ThrowsAsync<DbUpdateException>(() => world.MergeAsync(s2, folded, AccountMergeState.Final));
|
||||
Assert.Equal("23505", Pg(inserted).SqlState);
|
||||
|
||||
await world.SetStateAsync(first, AccountMergeState.Undone);
|
||||
await world.SetStateAsync(second, AccountMergeState.Confirmed); // the slot is free again
|
||||
}
|
||||
|
||||
[PostgresTheory]
|
||||
[InlineData(AccountMergeState.Confirmed, AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Final, AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Committed, AccountMergeState.HeimdallMoved)]
|
||||
public async Task A_chain_is_refused_in_either_arrival_order_and_the_same_survivor_twice_is_fine(AccountMergeState earlier, AccountMergeState later)
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
foreach (var survivorFirst in new[] { true, false })
|
||||
{
|
||||
var tag = survivorFirst ? "a" : "b";
|
||||
var s = await world.AccountAsync("acct-s" + tag);
|
||||
var f = await world.AccountAsync("acct-f" + tag);
|
||||
var g = await world.AccountAsync("acct-g" + tag);
|
||||
var other = await world.AccountAsync("other" + tag);
|
||||
|
||||
await world.MergeAsync(s, other, AccountMergeState.Confirmed); // one survivor may carry two folded accounts
|
||||
await world.MergeAsync(s, f, earlier);
|
||||
// f survives a live merge and folds into g, or s folds into g while it survives one: both are chains.
|
||||
var chain = await Assert.ThrowsAsync<DbUpdateException>(() => survivorFirst ? world.MergeAsync(f, g, later) : world.MergeAsync(g, s, later));
|
||||
Assert.Equal("23P01", Pg(chain).SqlState);
|
||||
}
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_transactions_racing_to_chain_the_same_account_cannot_both_commit()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var s = await world.AccountAsync("acct-s");
|
||||
var f = await world.AccountAsync("acct-f");
|
||||
var g = await world.AccountAsync("acct-g");
|
||||
await using var first = world.Db();
|
||||
await using var firstTransaction = await first.Database.BeginTransactionAsync();
|
||||
first.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = s.Id, FoldedUserAccountId = f.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 });
|
||||
await first.SaveChangesAsync();
|
||||
|
||||
await using var second = world.Db();
|
||||
await using var secondTransaction = await second.Database.BeginTransactionAsync();
|
||||
second.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = f.Id, FoldedUserAccountId = g.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 });
|
||||
var racing = second.SaveChangesAsync();
|
||||
|
||||
await Task.Delay(1500);
|
||||
Assert.False(racing.IsCompleted); // it waits on the first, which holds the lock on f
|
||||
await firstTransaction.CommitAsync();
|
||||
|
||||
var refused = await Assert.ThrowsAsync<DbUpdateException>(() => racing.WaitAsync(TimeSpan.FromSeconds(30)));
|
||||
Assert.Equal("23P01", Pg(refused).SqlState);
|
||||
}
|
||||
|
||||
// ---- rollback ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Reverting_the_migration_is_refused_while_any_merge_exists_even_one_that_voided_nothing_and_allowed_after()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
await world.CommitAsync(await world.MergeAsync(survivor, folded)); // moves and voids nothing: only the Merged membership remains
|
||||
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var error = await Assert.ThrowsAnyAsync<Exception>(() => db.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials"));
|
||||
Assert.Contains("roll-forward only", Pg(error).MessageText);
|
||||
// The acks migration reverts first (it holds only acks); the saga migration is the one that refuses.
|
||||
Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last());
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, "DELETE FROM \"AccountMergeEntries\"; DELETE FROM \"AccountMerges\"");
|
||||
await using var down = world.Db();
|
||||
await down.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials");
|
||||
Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last());
|
||||
await down.GetService<IMigrator>().MigrateAsync(); // and up again, trigger and index and all
|
||||
Assert.Equal("20261114140000_AddAccountMergeNoticeAcks", (await down.Database.GetAppliedMigrationsAsync()).Last());
|
||||
}
|
||||
|
||||
// ---- restage and intake journal ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_merges_into_one_survivor_restaging_at_once_leave_one_current_patron_snapshot_and_the_loser_commits_on_retry()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var first = await world.AccountAsync("first");
|
||||
var second = await world.AccountAsync("second");
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
foreach (var who in new[] { first, second })
|
||||
{
|
||||
db.PatronSupportEvents.Add(new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = who.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 1000m,
|
||||
SupportedAtUtc = AccountMergeLedgerTests.T0, RecordedAtUtc = AccountMergeLedgerTests.T0
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var one = await world.MergeAsync(survivor, first);
|
||||
var two = await world.MergeAsync(survivor, second);
|
||||
var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new Rendezvous());
|
||||
|
||||
async Task<Exception?> TryCommit(AccountMerge merge)
|
||||
{
|
||||
try
|
||||
{
|
||||
await committers.CommitAsync(merge);
|
||||
return null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(Task.Run(() => TryCommit(one)), Task.Run(() => TryCommit(two)));
|
||||
|
||||
Assert.Equal(1, results.Count(x => x is null));
|
||||
Assert.All(results.OfType<Exception>(), e => Assert.Contains(Chain(e), x => x is PostgresException { SqlState: "23505", ConstraintName: "UX_TierSnapshots_MembershipId_CurrentPatron" }));
|
||||
var loser = results[0] is null ? two : one;
|
||||
Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(loser)).State);
|
||||
await using var read = world.Db();
|
||||
var membership = await read.Memberships.Include(x => x.TierSnapshots).SingleAsync(x => x.UserAccountId == survivor.Id);
|
||||
Assert.Single(membership.TierSnapshots, x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent);
|
||||
Assert.All(await read.PatronSupportEvents.ToListAsync(), e => Assert.Equal(survivor.Id, e.UserAccountId));
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_patron_refresh_that_loses_the_restage_race_reads_the_winners_snapshot_and_derives_again()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var patron = await world.AccountAsync("patron");
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
db.PatronSupportEvents.Add(new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = patron.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 1000m,
|
||||
SupportedAtUtc = AccountMergeLedgerTests.T0, RecordedAtUtc = AccountMergeLedgerTests.T0
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var clock = world.Clock;
|
||||
var rival = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world));
|
||||
var loser = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () =>
|
||||
{
|
||||
await using var rivalDb = rival.Db();
|
||||
await new PatronageService(rivalDb, new AuditTrailService(rivalDb, clock), clock).RefreshPatronTierSnapshotAsync(null, patron.Id, default);
|
||||
}));
|
||||
|
||||
await using (var db = loser.Db())
|
||||
{
|
||||
var summary = await new PatronageService(db, new AuditTrailService(db, clock), clock).RefreshPatronTierSnapshotAsync(null, patron.Id, default);
|
||||
Assert.True(summary.VotingWeight > 0m);
|
||||
}
|
||||
|
||||
await using var read = world.Db();
|
||||
var snapshots = await read.TierSnapshots.Where(x => x.Kind == TierSnapshotKind.Patron).ToListAsync();
|
||||
Assert.Single(snapshots, x => x.IsCurrent);
|
||||
Assert.Equal(2, snapshots.Count); // the winner's, demoted by the retry, and the retry's own
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_migration_keeps_only_the_newest_of_duplicate_current_patron_snapshots_before_it_builds_the_index()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var patron = await world.AccountAsync("patron");
|
||||
await using var db = world.Db();
|
||||
await db.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials"); // the index does not exist yet
|
||||
var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == patron.Id);
|
||||
TierSnapshot Snapshot(string label, int minutes) => new()
|
||||
{
|
||||
MembershipId = membership.Id, Kind = TierSnapshotKind.Patron, Label = label, Weight = 1m, IsCurrent = true,
|
||||
EffectiveFromUtc = AccountMergeLedgerTests.T0, CapturedAtUtc = AccountMergeLedgerTests.T0.AddMinutes(minutes)
|
||||
};
|
||||
db.TierSnapshots.AddRange(Snapshot("older", 0), Snapshot("newer", 5));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.GetService<IMigrator>().MigrateAsync();
|
||||
|
||||
await using var read = world.Db();
|
||||
Assert.Equal("newer", (await read.TierSnapshots.SingleAsync(x => x.IsCurrent)).Label);
|
||||
Assert.Equal(2, await read.TierSnapshots.CountAsync());
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_journalled_patron_intake_takes_the_next_seq_after_the_commits_entries_and_is_idempotent()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, folded, VoteChoice.For);
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
var committed = (await world.JournalAsync(merge)).Count;
|
||||
Assert.True(committed > 0);
|
||||
|
||||
var events = new[] { $"WH-{Guid.NewGuid():N}", $"WH-{Guid.NewGuid():N}" };
|
||||
foreach (var id in events.Concat(events)) // each twice: the provider retries
|
||||
{
|
||||
await using var db = world.Db();
|
||||
await new PatronageService(db, new AuditTrailService(db, world.Clock), world.Clock).RecordSupportEventAsync(
|
||||
null, folded.Id, PatronSupportEventKind.OneTimeDonation, 1m, "USD", "ext", ExternalPatronProvider.PayPal, id,
|
||||
string.Empty, string.Empty, AccountMergeLedgerTests.T0, false, string.Empty, default);
|
||||
}
|
||||
|
||||
var journal = await world.JournalAsync(merge);
|
||||
Assert.Equal(committed + 2, journal.Count);
|
||||
Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count());
|
||||
}
|
||||
|
||||
[PostgresTheory]
|
||||
[InlineData(AccountMergeState.Proposed)]
|
||||
[InlineData(AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.HeimdallMoved)]
|
||||
[InlineData(AccountMergeState.Final)]
|
||||
[InlineData(AccountMergeState.UndoRequested)]
|
||||
[InlineData(AccountMergeState.HeimdallReversed)]
|
||||
[InlineData(AccountMergeState.Undone)]
|
||||
[InlineData(AccountMergeState.Failed)]
|
||||
public async Task Reverting_the_migration_is_refused_for_a_merge_in_any_state(AccountMergeState state)
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"), state);
|
||||
|
||||
await using var db = world.Db();
|
||||
var error = await Assert.ThrowsAnyAsync<Exception>(() => db.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials"));
|
||||
Assert.Contains("roll-forward only", Pg(error).MessageText);
|
||||
}
|
||||
|
||||
// ---- the write gate ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_vote_that_read_no_merge_saves_before_a_merge_can_freeze_the_account_and_the_commit_then_moves_it()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
var actor = await world.ActorAsync(folded.Id);
|
||||
Task<AccountMerge>? freeze = null;
|
||||
var blocked = false;
|
||||
var voters = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () =>
|
||||
{
|
||||
// The vote has read its standing (none) and is about to save: the freeze lands here and must wait for the save.
|
||||
freeze = Task.Run(() => world.MergeAsync(survivor, folded, AccountMergeState.Confirmed));
|
||||
await Task.Delay(1500);
|
||||
blocked = !freeze.IsCompleted;
|
||||
}));
|
||||
|
||||
await using (var db = voters.Db())
|
||||
{
|
||||
await voters.Motions(db).CastVoteAsync(actor, motion.Id, VoteChoice.For, null, default);
|
||||
}
|
||||
|
||||
var merge = await freeze!.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.True(blocked);
|
||||
await world.SetStateAsync(merge, AccountMergeState.HeimdallMoved);
|
||||
await world.CommitAsync(merge);
|
||||
|
||||
var votes = await world.AllVotesAsync(motion);
|
||||
Assert.Equal(survivor.Id, Assert.Single(votes).UserAccountId); // the commit saw the vote and moved it: no second voice stays on the folded account
|
||||
Assert.Single(await world.JournalAsync(merge), x => x.Kind == AccountMergeEntryKind.Vote);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_patron_event_that_read_a_Frozen_account_saves_before_the_commit_reduces_and_the_commit_moves_it()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var merge = await world.MergeAsync(survivor, folded); // HeimdallMoved: the folded account is Frozen
|
||||
Task<AccountMerge>? commit = null;
|
||||
var blocked = false;
|
||||
var recorders = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () =>
|
||||
{
|
||||
commit = Task.Run(() => world.CommitAsync(merge)); // takes the folded account's exclusive lock before it reduces
|
||||
await Task.Delay(1500);
|
||||
blocked = !commit.IsCompleted;
|
||||
}));
|
||||
|
||||
await using (var db = recorders.Db())
|
||||
{
|
||||
await new PatronageService(db, new AuditTrailService(db, recorders.Clock), recorders.Clock).RecordSupportEventAsync(
|
||||
null, folded.Id, PatronSupportEventKind.OneTimeDonation, 1m, "USD", "ext", ExternalPatronProvider.PayPal, "WH-frozen",
|
||||
string.Empty, string.Empty, AccountMergeLedgerTests.T0, false, string.Empty, default);
|
||||
}
|
||||
|
||||
await commit!.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.True(blocked);
|
||||
await using var read = world.Db();
|
||||
Assert.Equal(survivor.Id, (await read.PatronSupportEvents.SingleAsync()).UserAccountId); // the reduction found the event and moved it
|
||||
Assert.Single(await world.JournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_sealed_reveal_that_retries_on_a_conflict_keeps_its_lock_so_no_merge_freezes_the_account_between_attempts()
|
||||
{
|
||||
const string salt = "00112233445566778899aabbccddeeff";
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var other = await world.AccountAsync("other");
|
||||
var moderator = await world.AccountAsync("moderator", moderator: true);
|
||||
SealedBallotService Sealed(AccountMergeLedgerTests.World w, BifrostDbContext db) => new(db, new AuditTrailService(db, w.Clock), w.Clock);
|
||||
SealedBallotDocument Doc(Guid motionId, string reason) => new(SealedBallotDocument.SchemaId, motionId, 1, "Ana", reason, salt);
|
||||
|
||||
await using var setup = world.Db();
|
||||
var motionId = (await Sealed(world, setup).OpenAsync(await world.ActorAsync(moderator.Id),
|
||||
new OpenSealedMotionCommand("Who", "Blind.", ["Ana", "Bo"], AccountMergeLedgerTests.T0.AddHours(1), AccountMergeLedgerTests.T0.AddHours(2)), default)).Id;
|
||||
foreach (var (account, reason) in new[] { (folded, "mine"), (other, "theirs") })
|
||||
{
|
||||
await using var db = world.Db();
|
||||
await Sealed(world, db).CommitAsync(await world.ActorAsync(account.Id), motionId, SealedBallotDocument.Commit(Doc(motionId, reason), account.Id), default);
|
||||
}
|
||||
|
||||
Task<AccountMerge>? freeze = null;
|
||||
var blocked = false;
|
||||
var revealers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () =>
|
||||
{
|
||||
// The first attempt has read its standing (none). A freeze starts and must wait; another member's reveal then lands first,
|
||||
// so this attempt's save conflicts on RevealSeq and the reveal retries while the freeze still waits.
|
||||
freeze = Task.Run(() => world.MergeAsync(survivor, folded, AccountMergeState.Confirmed));
|
||||
await using var rival = world.Db();
|
||||
world.Clock.Now = AccountMergeLedgerTests.T0.AddMinutes(90);
|
||||
await Sealed(world, rival).RevealAsync(await world.ActorAsync(other.Id), motionId, Doc(motionId, "theirs"), default);
|
||||
await Task.Delay(1500);
|
||||
blocked = !freeze.IsCompleted;
|
||||
}));
|
||||
revealers.Clock.Now = AccountMergeLedgerTests.T0.AddMinutes(90);
|
||||
var actor = await world.ActorAsync(folded.Id);
|
||||
|
||||
await using (var db = revealers.Db())
|
||||
{
|
||||
Assert.True((await Sealed(revealers, db).RevealAsync(actor, motionId, Doc(motionId, "mine"), default)).Revealed);
|
||||
}
|
||||
|
||||
await freeze!.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.True(blocked);
|
||||
}
|
||||
|
||||
// ---- the saga: races the State token and the live-merge index carry ----
|
||||
|
||||
/// <summary>Holds the first save of each of two contexts until both have arrived: both have read, neither has written.</summary>
|
||||
private sealed class FirstSaveRendezvous : SaveChangesInterceptor
|
||||
{
|
||||
private readonly Barrier _barrier = new(2);
|
||||
private readonly HashSet<Guid> _seen = [];
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool first;
|
||||
lock (_seen)
|
||||
{
|
||||
first = _seen.Add(eventData.Context!.ContextId.InstanceId);
|
||||
}
|
||||
|
||||
if (first)
|
||||
{
|
||||
await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The fake Heimdall's account model behind the real move client, one key ring for every saga built over it.</summary>
|
||||
private sealed class Heimdall
|
||||
{
|
||||
private readonly EphemeralDataProtectionProvider _keys = new();
|
||||
private readonly HeimdallIdentityMoveClient _mover;
|
||||
|
||||
public Heimdall(AccountMergeLedgerTests.World clockOwner)
|
||||
{
|
||||
Fake = new FakeHeimdall { RequiredAppSecret = HeimdallHost.AppSecret, Now = () => clockOwner.Clock.Now };
|
||||
var options = new HeimdallOptions { BaseUrl = HeimdallShapes.Issuer, AppSecret = HeimdallHost.AppSecret };
|
||||
var client = new HttpClient(Fake);
|
||||
HeimdallAuthService.ConfigureClient(client, options);
|
||||
_mover = new HeimdallIdentityMoveClient(client, Options.Create(options), NullLogger<HeimdallIdentityMoveClient>.Instance);
|
||||
}
|
||||
|
||||
public FakeHeimdall Fake { get; }
|
||||
|
||||
public AccountMergeSaga Saga(AccountMergeLedgerTests.World world, BifrostDbContext db) =>
|
||||
new(db, world.Ledger(db), _mover, _keys, new AuditTrailService(db, world.Clock), world.Clock, NullLogger<AccountMergeSaga>.Instance);
|
||||
|
||||
/// <summary>A world account with one login at the fake Heimdall and a weak proof through it.</summary>
|
||||
public MergeProof Proof(UserAccount account, string provider) =>
|
||||
new(Fake.Moves.Issue(account.HeimdallAccountId, Fake.Moves.AddLogin(account.HeimdallAccountId, provider)), account.HeimdallAccountId, provider);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_concurrent_advances_of_one_confirmed_merge_make_one_move_commit_once_and_journal_once()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, folded, VoteChoice.For);
|
||||
var heimdall = new Heimdall(world);
|
||||
var survivorProof = heimdall.Proof(survivor, "twitch");
|
||||
var foldedProof = heimdall.Proof(folded, "discord");
|
||||
Guid mergeId;
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var saga = heimdall.Saga(world, db);
|
||||
var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id);
|
||||
mergeId = Assert.IsType<ConfirmOutcome.Confirmed>(await saga.ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, survivorProof, [foldedProof]), default)).MergeId;
|
||||
}
|
||||
|
||||
var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous());
|
||||
async Task<AccountMerge> Advance()
|
||||
{
|
||||
await using var db = racers.Db();
|
||||
return await heimdall.Saga(racers, db).AdvanceAsync(mergeId, default);
|
||||
}
|
||||
|
||||
var both = await Task.WhenAll(Task.Run(Advance), Task.Run(Advance));
|
||||
|
||||
Assert.All(both, x => Assert.Equal(AccountMergeState.Committed, x.State));
|
||||
Assert.Single(heimdall.Fake.Moves.Stored);
|
||||
var journal = await world.JournalAsync(new AccountMerge { Id = mergeId });
|
||||
Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count());
|
||||
Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.Vote);
|
||||
Assert.Equal(survivor.Id, Assert.Single(await world.AllVotesAsync(motion)).UserAccountId);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_confirms_folding_the_same_account_leave_one_row_and_the_other_is_blocked_by_the_database()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var first = await world.AccountAsync("first");
|
||||
var second = await world.AccountAsync("second");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var heimdall = new Heimdall(world);
|
||||
var proofs = new Dictionary<Guid, (MergeProof Survivor, string Digest)>();
|
||||
foreach (var survivor in new[] { first, second })
|
||||
{
|
||||
await using var db = world.Db();
|
||||
proofs[survivor.Id] = (heimdall.Proof(survivor, "twitch"), (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id));
|
||||
}
|
||||
|
||||
var foldedProof = heimdall.Proof(folded, "discord");
|
||||
var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous());
|
||||
async Task<ConfirmOutcome> Confirm(UserAccount survivor)
|
||||
{
|
||||
await using var db = racers.Db();
|
||||
return await heimdall.Saga(racers, db).ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, proofs[survivor.Id].Digest, proofs[survivor.Id].Survivor, [foldedProof]), default);
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(Task.Run(() => Confirm(first)), Task.Run(() => Confirm(second)));
|
||||
|
||||
Assert.Single(results.OfType<ConfirmOutcome.Confirmed>());
|
||||
var blocked = Assert.Single(results.OfType<ConfirmOutcome.Blocked>());
|
||||
Assert.Contains(AccountMergeSaga.MergeInFlight, blocked.Codes);
|
||||
await using var check = world.Db();
|
||||
Assert.Equal(1, await check.AccountMerges.CountAsync());
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_merges_cannot_share_a_Heimdall_move_id_or_a_reverse_move_id()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var one = await world.MergeAsync(survivor, await world.AccountAsync("one"), AccountMergeState.Confirmed);
|
||||
var two = await world.MergeAsync(survivor, await world.AccountAsync("two"), AccountMergeState.Confirmed);
|
||||
|
||||
foreach (var column in new[] { "HeimdallMoveId", "HeimdallReverseMoveId" })
|
||||
{
|
||||
await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{one.Id}'");
|
||||
var error = await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{two.Id}'"));
|
||||
Assert.Equal("23505", Pg(error).SqlState);
|
||||
}
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Confirm_holds_the_actors_write_lock_until_its_row_is_saved()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var heimdall = new Heimdall(world);
|
||||
var proof = heimdall.Proof(survivor, "twitch");
|
||||
var foldedProof = heimdall.Proof(folded, "discord");
|
||||
string digest;
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id);
|
||||
}
|
||||
|
||||
Task? exclusive = null;
|
||||
var blocked = false;
|
||||
var confirmers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () =>
|
||||
{
|
||||
// A writer that needs the actor's exclusive lock (a commit freezing it) arrives while the confirm is about to save.
|
||||
exclusive = Task.Run(async () =>
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(ConnectionOf(world));
|
||||
await connection.OpenAsync();
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
await using var command = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtextextended(@key, 0))", connection, transaction);
|
||||
command.Parameters.AddWithValue("key", survivor.Id.ToString());
|
||||
await command.ExecuteNonQueryAsync();
|
||||
});
|
||||
await Task.Delay(1500);
|
||||
blocked = !exclusive.IsCompleted;
|
||||
}));
|
||||
|
||||
await using (var db = confirmers.Db())
|
||||
{
|
||||
Assert.IsType<ConfirmOutcome.Confirmed>(await heimdall.Saga(confirmers, db).ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, proof, [foldedProof]), default));
|
||||
}
|
||||
|
||||
await exclusive!.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.True(blocked);
|
||||
}
|
||||
|
||||
/// <summary>A Confirmed merge with sealed proofs, stopped on the given outcome, and the operator who will settle it.</summary>
|
||||
private async Task<(AccountMergeLedgerTests.World World, Heimdall Heimdall, Guid Id, string MoveId, Guid Operator)> StuckConfirmedAsync(string outcome)
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var operatorId = (await world.AccountAsync("operator")).Id;
|
||||
var heimdall = new Heimdall(world);
|
||||
Guid id;
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id);
|
||||
id = Assert.IsType<ConfirmOutcome.Confirmed>(await heimdall.Saga(world, db).ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, heimdall.Proof(survivor, "twitch"), [heimdall.Proof(folded, "discord")]), default)).MergeId;
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'Confirmed', \"LastOutcome\" = '{outcome}' WHERE \"Id\" = '{id}'");
|
||||
await using var read = world.Db();
|
||||
var merge = await read.AccountMerges.AsNoTracking().SingleAsync();
|
||||
Assert.NotNull(merge.SealedProofs);
|
||||
return (world, heimdall, id, merge.HeimdallMoveId!, operatorId);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_settle_that_loses_to_a_rival_state_write_inside_its_save_window_writes_nothing_and_no_audit_row()
|
||||
{
|
||||
var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("move_id_conflict");
|
||||
var settler = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(() =>
|
||||
ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'HeimdallMoved' WHERE \"Id\" = '{id}'")));
|
||||
await using (var db = settler.Db())
|
||||
{
|
||||
Assert.IsType<SettleOutcome.Lost>(await heimdall.Saga(settler, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default));
|
||||
}
|
||||
|
||||
await using var read = world.Db();
|
||||
var merge = await read.AccountMerges.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(AccountMergeState.HeimdallMoved, merge.State);
|
||||
Assert.NotNull(merge.SealedProofs);
|
||||
Assert.Equal("move_id_conflict", merge.LastOutcome);
|
||||
Assert.Equal(0, await read.AuditEvents.CountAsync());
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_settle_whose_audit_insert_fails_rolls_the_transition_back()
|
||||
{
|
||||
var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("receipt_mismatch");
|
||||
await ExecuteAsync(world, """
|
||||
CREATE FUNCTION fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected audit fault'; END $$;
|
||||
CREATE TRIGGER fail_audit BEFORE INSERT ON "AuditEvents" FOR EACH ROW EXECUTE FUNCTION fail_audit();
|
||||
""");
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => heimdall.Saga(world, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default));
|
||||
}
|
||||
|
||||
await using var read = world.Db();
|
||||
var merge = await read.AccountMerges.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(AccountMergeState.Confirmed, merge.State);
|
||||
Assert.NotNull(merge.SealedProofs);
|
||||
Assert.Equal("receipt_mismatch", merge.LastOutcome);
|
||||
Assert.Equal(0, await read.AuditEvents.CountAsync());
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_concurrent_settles_of_one_stuck_merge_settle_once_lose_once_and_audit_once()
|
||||
{
|
||||
var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("proofs_unreadable");
|
||||
var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous());
|
||||
async Task<SettleOutcome> Settle()
|
||||
{
|
||||
await using var db = racers.Db();
|
||||
return await heimdall.Saga(racers, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default);
|
||||
}
|
||||
|
||||
var both = await Task.WhenAll(Task.Run(Settle), Task.Run(Settle));
|
||||
|
||||
Assert.Single(both.OfType<SettleOutcome.Settled>());
|
||||
Assert.Single(both.OfType<SettleOutcome.Lost>());
|
||||
await using var read = world.Db();
|
||||
Assert.Equal(1, await read.AuditEvents.CountAsync());
|
||||
Assert.Equal(AccountMergeState.Failed, (await read.AccountMerges.AsNoTracking().SingleAsync()).State);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"), AccountMergeState.Confirmed);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var error = await Assert.ThrowsAnyAsync<Exception>(() => db.GetService<IMigrator>().MigrateAsync("20261114120000_AddAccountMerges"));
|
||||
Assert.Contains("AddAccountMergeSaga cannot be reverted", Pg(error).MessageText);
|
||||
// The acks migration reverts first (it holds only acks); the saga migration is the one that refuses.
|
||||
Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last());
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, "DELETE FROM \"AccountMerges\"");
|
||||
await using var down = world.Db();
|
||||
await down.GetService<IMigrator>().MigrateAsync("20261114120000_AddAccountMerges");
|
||||
Assert.Equal(0, await ScalarAsync(world, "SELECT count(*) FROM information_schema.columns WHERE table_name = 'AccountMerges' AND column_name IN ('HeimdallMoveId','SealedProofs','Attempts')"));
|
||||
Assert.Equal(0, await ScalarAsync(world, "SELECT count(*) FROM pg_indexes WHERE tablename = 'AccountMerges' AND indexname LIKE 'IX_AccountMerges_Heimdall%'"));
|
||||
await down.GetService<IMigrator>().MigrateAsync();
|
||||
Assert.Equal(3, await ScalarAsync(world, "SELECT count(*) FROM information_schema.columns WHERE table_name = 'AccountMerges' AND column_name IN ('HeimdallMoveId','SealedProofs','Attempts')"));
|
||||
Assert.Equal(2, await ScalarAsync(world, "SELECT count(*) FROM pg_indexes WHERE tablename = 'AccountMerges' AND indexname LIKE 'IX_AccountMerges_Heimdall%'"));
|
||||
}
|
||||
|
||||
private static async Task<long> ScalarAsync(AccountMergeLedgerTests.World world, string sql)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(ConnectionOf(world));
|
||||
await connection.OpenAsync();
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
return (long)(await command.ExecuteScalarAsync())!;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,732 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The merge saga from its consumers' side: a request scope of the real host (DI, the move client's HttpClient, Data Protection, the shared
|
||||
/// TestClock) driving Confirm, Advance and RequestUndo against the fake Heimdall's account model (Heimdall 4fe367a's contract). Every
|
||||
/// operation takes a fresh scope, as a request or a sweeper tick would, so what survives is only what was saved.
|
||||
/// </summary>
|
||||
public sealed class AccountMergeSagaTests
|
||||
{
|
||||
private const string Passkey = "bifrost-passkey";
|
||||
|
||||
internal sealed record Party(Guid Id, string HeimdallId, IReadOnlyDictionary<string, string> Logins);
|
||||
|
||||
/// <summary>A host plus the helpers every test needs: accounts with logins at the fake Heimdall, proofs, a scope per operation.</summary>
|
||||
internal sealed class Rig(bool realSessions = false, bool accountMerge = false, string? chatNoticesToken = null) : IDisposable
|
||||
{
|
||||
public HeimdallHost Host { get; } = new(realSessions: realSessions, accountMerge: accountMerge, chatNoticesToken: chatNoticesToken);
|
||||
|
||||
public FakeMoves Moves => Host.Heimdall.Moves;
|
||||
|
||||
public TestClock Clock => Host.Clock;
|
||||
|
||||
public void Dispose() => Host.Dispose();
|
||||
|
||||
public async Task<Party> AccountAsync(string name, params string[] providers)
|
||||
{
|
||||
var heimdallId = HeimdallShapes.NewAccountId();
|
||||
var logins = providers.ToDictionary(x => x, x => Moves.AddLogin(heimdallId, x));
|
||||
using var scope = Host.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var account = new UserAccount
|
||||
{
|
||||
DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = BifrostIdentityService.NormalizeIdentity(name),
|
||||
HeimdallAccountId = heimdallId, CreatedAtUtc = Clock.Now, LastSeenAtUtc = Clock.Now,
|
||||
MemberProfile = new MemberProfile { Nickname = name, Headline = "seed", UpdatedAtUtc = Clock.Now }
|
||||
};
|
||||
account.Membership = new Membership { UserAccountId = account.Id, Status = MembershipStatus.Active };
|
||||
db.UserAccounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
return new Party(account.Id, heimdallId, logins);
|
||||
}
|
||||
|
||||
/// <summary>A fresh sign-in through the login of <paramref name="provider"/>: a weak proof of that login only.</summary>
|
||||
public MergeProof Weak(Party who, string provider) => new(Moves.Issue(who.HeimdallId, who.Logins[provider]), who.HeimdallId, provider);
|
||||
|
||||
/// <summary>A fresh sign-in of <paramref name="holder"/> through a login that came from <paramref name="origin"/> (a login the merge moved to it).</summary>
|
||||
public MergeProof Moved(Party holder, Party origin, string provider) => new(Moves.Issue(holder.HeimdallId, origin.Logins[provider]), holder.HeimdallId, provider);
|
||||
|
||||
/// <summary>A passkey assertion: a strong proof that speaks for the whole account.</summary>
|
||||
public MergeProof Strong(Party who) => new(Moves.Issue(who.HeimdallId, stepUpMethod: Passkey), who.HeimdallId, "passkey");
|
||||
|
||||
public async Task<T> InScopeAsync<T>(Func<IServiceProvider, Task<T>> work)
|
||||
{
|
||||
using var scope = Host.Services.CreateScope();
|
||||
return await work(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
public async Task<string> DigestAsync(Party survivor, Party folded) =>
|
||||
await InScopeAsync(async sp => (await sp.GetRequiredService<AccountMergeLedger>().PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id));
|
||||
|
||||
public async Task<ConfirmOutcome> ConfirmAsync(Party survivor, Party folded, MergeProof survivorProof, params MergeProof[] foldedProofs)
|
||||
{
|
||||
var digest = await DigestAsync(survivor, folded);
|
||||
return await InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, survivorProof, foldedProofs), default));
|
||||
}
|
||||
|
||||
public async Task<Guid> ConfirmedAsync(Party survivor, Party folded, MergeProof survivorProof, params MergeProof[] foldedProofs) =>
|
||||
Assert.IsType<ConfirmOutcome.Confirmed>(await ConfirmAsync(survivor, folded, survivorProof, foldedProofs)).MergeId;
|
||||
|
||||
public Task<AccountMerge> AdvanceAsync(Guid id) => InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().AdvanceAsync(id, default));
|
||||
|
||||
public Task<int> AdvanceDueAsync() => InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().AdvanceDueAsync(Clock.Now, default));
|
||||
|
||||
public Task<AccountMerge> MergeAsync(Guid id) =>
|
||||
InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().AccountMerges.AsNoTracking().SingleAsync(x => x.Id == id));
|
||||
|
||||
public Task<List<AccountMergeEntry>> JournalAsync(Guid id) =>
|
||||
InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().AccountMergeEntries.Where(x => x.MergeId == id).ToListAsync());
|
||||
|
||||
public Task<MergeStanding?> StandingAsync(Party who) =>
|
||||
InScopeAsync(sp => sp.GetRequiredService<AccountMergeLedger>().StandingAsync(who.Id, default));
|
||||
|
||||
public Task<int> MergeCountAsync() => InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().AccountMerges.CountAsync());
|
||||
|
||||
public async Task<Motion> MotionAsync(Party author)
|
||||
{
|
||||
using var scope = Host.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var motion = new Motion
|
||||
{
|
||||
CreatedByUserAccountId = author.Id, Title = "m", Status = MotionStatus.Open, Kind = MotionKind.Standard, OpensAtUtc = Clock.Now,
|
||||
ClosesAtUtc = Clock.Now.AddDays(3), CreatedAtUtc = Clock.Now, ApprovalThreshold = 0.5m
|
||||
};
|
||||
db.Motions.Add(motion);
|
||||
await db.SaveChangesAsync();
|
||||
return motion;
|
||||
}
|
||||
|
||||
public async Task VoteAsync(Motion motion, Party who, VoteChoice choice)
|
||||
{
|
||||
using var scope = Host.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
db.Votes.Add(new Vote { MotionId = motion.Id, UserAccountId = who.Id, Choice = choice, Weight = 1m, CastAtUtc = Clock.Now.AddHours(1) });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task<string[]> SeenMoveIdsAsync() =>
|
||||
Task.FromResult(Moves.Requests.Select(x => JsonDocument.Parse(x.Body).RootElement.GetProperty("moveId").GetString()!).ToArray());
|
||||
}
|
||||
|
||||
// ---- the happy path and the stored move id ----
|
||||
|
||||
[Fact]
|
||||
public async Task Two_weak_folded_proofs_and_a_survivor_proof_confirm_and_advance_to_committed_with_a_seven_day_grace_and_one_stored_move()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord", "patreon");
|
||||
|
||||
var digest = await rig.DigestAsync(survivor, folded);
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"), rig.Weak(folded, "patreon"));
|
||||
var confirmed = await rig.MergeAsync(id);
|
||||
Assert.Equal(AccountMergeState.Confirmed, confirmed.State);
|
||||
Assert.Equal(survivor.Id, confirmed.ConfirmedByUserAccountId);
|
||||
Assert.Equal(rig.Clock.Now, confirmed.ConfirmedUtc);
|
||||
Assert.Equal("twitch", confirmed.SurvivorProofProvider);
|
||||
Assert.Equal("discord,patreon", confirmed.FoldedProofProviders);
|
||||
Assert.Equal(digest, confirmed.PreviewDigest);
|
||||
Assert.NotNull(confirmed.SealedProofs);
|
||||
Assert.Matches("^[0-9a-f]{32}$", confirmed.HeimdallMoveId!);
|
||||
Assert.Empty(rig.Moves.Requests); // confirm calls no Heimdall
|
||||
Assert.IsType<MergeStanding.Frozen>(await rig.StandingAsync(folded));
|
||||
|
||||
var done = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, done.State);
|
||||
Assert.Equal(done.CommittedUtc + AccountMergeLedger.Grace, done.GraceEndsUtc);
|
||||
var stored = Assert.Single(rig.Moves.Stored);
|
||||
Assert.Equal(confirmed.HeimdallMoveId, stored.MoveId);
|
||||
var final = await rig.MergeAsync(id);
|
||||
Assert.Null(final.SealedProofs);
|
||||
Assert.Equal("weak", final.ProofFromTier);
|
||||
Assert.Equal("weak", final.ProofToTier);
|
||||
Assert.Equal(folded.Logins.Values.Order(), JsonSerializer.Deserialize<string[]>(final.MovedIdentityIds!)!.Order());
|
||||
Assert.IsType<MergeStanding.Folded>(await rig.StandingAsync(folded));
|
||||
Assert.Equal(["twitch", "discord", "patreon"], rig.Moves.Logins[survivor.HeimdallId].Select(x => x.Provider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_vote_on_the_same_open_motion_from_both_accounts_counts_only_the_survivors_after_the_saga_lands()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var motion = await rig.MotionAsync(survivor);
|
||||
await rig.VoteAsync(motion, survivor, VoteChoice.For);
|
||||
await rig.VoteAsync(motion, folded, VoteChoice.Against);
|
||||
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
await rig.AdvanceAsync(id);
|
||||
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<BifrostDbContext>();
|
||||
Assert.Equal(survivor.Id, (await db.Votes.SingleAsync(x => x.MotionId == motion.Id)).UserAccountId);
|
||||
var all = await db.Votes.IgnoreQueryFilters().Where(x => x.MotionId == motion.Id).ToListAsync();
|
||||
Assert.Equal(id, Assert.Single(all, x => x.UserAccountId == folded.Id).VoidedByMergeId);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Heimdall_sees_the_move_id_stored_on_the_row_and_it_is_not_derived_from_the_merge_id()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
Assert.NotEqual(id.ToString("N"), (await rig.MergeAsync(id)).HeimdallMoveId);
|
||||
const string overwritten = "overwritten0123456789abcdef0123456789";
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<BifrostDbContext>();
|
||||
(await db.AccountMerges.SingleAsync(x => x.Id == id)).HeimdallMoveId = overwritten;
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(overwritten, Assert.Single(rig.Moves.Stored).MoveId);
|
||||
}
|
||||
|
||||
// ---- crashes and lost answers ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_lost_answer_leaves_the_merge_confirmed_and_a_fresh_scope_after_the_proofs_expired_settles_it_by_replay()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
rig.Moves.MoveApplyThenFail = HttpStatusCode.InternalServerError;
|
||||
|
||||
var lost = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Confirmed, lost.State);
|
||||
Assert.Equal(1, lost.Attempts);
|
||||
Assert.Equal(rig.Clock.Now + TimeSpan.FromSeconds(15), lost.NextAttemptUtc);
|
||||
Assert.Equal("HTTP 500", lost.LastOutcome);
|
||||
Assert.Single(rig.Moves.Stored);
|
||||
rig.Moves.MoveApplyThenFail = null;
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(601)); // every proof is stale and the bearers expired by now; Heimdall answers a replay first
|
||||
|
||||
Assert.Equal(1, await rig.AdvanceDueAsync());
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State);
|
||||
Assert.Single(rig.Moves.Stored);
|
||||
Assert.Equal(2, (await rig.SeenMoveIdsAsync()).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_crash_on_the_save_that_writes_HeimdallMoved_is_resumed_with_the_same_move_id_and_one_stored_move()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
rig.Host.Fault.When = db => db.ChangeTracker.Entries<AccountMerge>().Any(x => x.Entity.State == AccountMergeState.HeimdallMoved);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => rig.AdvanceAsync(id));
|
||||
rig.Host.Fault.When = null;
|
||||
Assert.Equal(AccountMergeState.Confirmed, (await rig.MergeAsync(id)).State);
|
||||
Assert.NotNull((await rig.MergeAsync(id)).SealedProofs);
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
|
||||
|
||||
var seen = await rig.SeenMoveIdsAsync();
|
||||
Assert.Equal(2, seen.Length);
|
||||
Assert.Single(seen.Distinct());
|
||||
Assert.Single(rig.Moves.Stored);
|
||||
Assert.Equal(seen[0], (await rig.MergeAsync(id)).HeimdallMoveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_crash_on_the_Committed_save_leaves_HeimdallMoved_and_the_next_advance_commits_once_without_calling_Heimdall_again()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var motion = await rig.MotionAsync(survivor);
|
||||
await rig.VoteAsync(motion, folded, VoteChoice.For);
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
rig.Host.Fault.When = db => db.ChangeTracker.Entries<AccountMerge>().Any(x => x.Entity.State == AccountMergeState.Committed);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => rig.AdvanceAsync(id));
|
||||
rig.Host.Fault.When = null;
|
||||
var moved = await rig.MergeAsync(id);
|
||||
Assert.Equal(AccountMergeState.HeimdallMoved, moved.State);
|
||||
Assert.Null(moved.SealedProofs);
|
||||
Assert.Empty(await rig.JournalAsync(id));
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
|
||||
|
||||
Assert.Single(rig.Moves.Requests);
|
||||
var journal = await rig.JournalAsync(id);
|
||||
Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count());
|
||||
Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.Vote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_sweepers_own_loop_resumes_a_confirmed_merge_after_a_restart_and_finalizes_it_after_the_grace()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
|
||||
await RunOneSweepAsync(rig);
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State);
|
||||
|
||||
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1));
|
||||
await RunOneSweepAsync(rig);
|
||||
Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State);
|
||||
}
|
||||
|
||||
/// <summary>Starts the production sweeper (its first pass runs at once, before its 30 s timer) and stops it once the pass is over.</summary>
|
||||
private static async Task RunOneSweepAsync(Rig rig)
|
||||
{
|
||||
var sweeper = new AccountMergeSweeper(rig.Host.Services.GetRequiredService<IServiceScopeFactory>(), rig.Clock, NullLogger<AccountMergeSweeper>.Instance);
|
||||
await sweeper.StartAsync(default);
|
||||
var before = rig.Clock.Now;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(20);
|
||||
while (DateTime.UtcNow < deadline && sweeper.ExecuteTask is { IsCompleted: false } && await rig.InScopeAsync(async sp =>
|
||||
await sp.GetRequiredService<BifrostDbContext>().AccountMerges.AnyAsync(x =>
|
||||
x.State == AccountMergeState.Confirmed || x.State == AccountMergeState.HeimdallMoved || (x.State == AccountMergeState.Committed && x.GraceEndsUtc <= before))))
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
await sweeper.StopAsync(default);
|
||||
}
|
||||
|
||||
// ---- refusals ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("proof_stale")]
|
||||
[InlineData("identity_not_proved")]
|
||||
[InlineData("provider_conflict")]
|
||||
[InlineData("already_moved")]
|
||||
public async Task A_coded_refusal_fails_the_merge_wipes_the_proofs_and_changes_nothing_in_Bifrost(string code)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", code == "provider_conflict" ? ["discord"] : ["twitch"]);
|
||||
var folded = await rig.AccountAsync("folded", "discord", "patreon");
|
||||
var motion = await rig.MotionAsync(survivor);
|
||||
await rig.VoteAsync(motion, folded, VoteChoice.For);
|
||||
MergeProof[] foldedProofs = code == "identity_not_proved"
|
||||
? [rig.Weak(folded, "discord")] // the patreon login is not proved: no partial move
|
||||
: [rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")];
|
||||
var survivorProof = rig.Weak(survivor, code == "provider_conflict" ? "discord" : "twitch");
|
||||
if (code == "already_moved")
|
||||
{
|
||||
// Heimdall already moved the folded account elsewhere; Bifrost's merge does not know.
|
||||
var elsewhere = await rig.AccountAsync("elsewhere", "twitch2");
|
||||
var move = new HeimdallMoveRequest("preexisting-move-0000", [rig.Strong(folded).AccessToken], rig.Weak(elsewhere, "twitch2").AccessToken);
|
||||
Assert.IsType<HeimdallMoveOutcome.Moved>(await rig.InScopeAsync(sp => sp.GetRequiredService<HeimdallIdentityMoveClient>().MoveAsync(move, default)));
|
||||
foldedProofs = [rig.Strong(folded)];
|
||||
}
|
||||
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, survivorProof, foldedProofs);
|
||||
if (code == "proof_stale")
|
||||
{
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(400));
|
||||
}
|
||||
|
||||
var failed = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Failed, failed.State);
|
||||
Assert.Equal(code, failed.FailureCode);
|
||||
Assert.Equal(rig.Clock.Now, failed.FailedUtc);
|
||||
var stored = await rig.MergeAsync(id);
|
||||
Assert.Null(stored.SealedProofs);
|
||||
Assert.Empty(await rig.JournalAsync(id));
|
||||
Assert.Null(await rig.StandingAsync(folded));
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var vote = await sp.GetRequiredService<BifrostDbContext>().Votes.SingleAsync();
|
||||
Assert.Equal(folded.Id, vote.UserAccountId);
|
||||
return 0;
|
||||
});
|
||||
Assert.DoesNotContain(rig.Moves.Stored, x => x.MoveId == stored.HeimdallMoveId);
|
||||
}
|
||||
|
||||
// ---- confirm's consent checks ----
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_refuses_a_third_party_a_changed_plan_a_proof_of_another_account_and_a_blocked_pair_and_writes_no_row()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var stranger = await rig.AccountAsync("stranger", "github");
|
||||
var digest = await rig.DigestAsync(survivor, folded);
|
||||
var good = rig.Weak(survivor, "twitch");
|
||||
var foldedProof = rig.Weak(folded, "discord");
|
||||
|
||||
Task<ConfirmOutcome> Confirm(Guid actor, string digestSeen, MergeProof survivorProof, params MergeProof[] proofs) =>
|
||||
rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().ConfirmAsync(
|
||||
new ConfirmMergeRequest(actor, survivor.Id, folded.Id, digestSeen, survivorProof, proofs), default));
|
||||
|
||||
Assert.IsType<ConfirmOutcome.NotParty>(await Confirm(stranger.Id, digest, good, foldedProof));
|
||||
Assert.IsType<ConfirmOutcome.ProofAccountMismatch>(await Confirm(survivor.Id, digest, rig.Weak(stranger, "github"), foldedProof));
|
||||
Assert.IsType<ConfirmOutcome.ProofAccountMismatch>(await Confirm(survivor.Id, digest, good, rig.Weak(survivor, "twitch")));
|
||||
var motion = await rig.MotionAsync(survivor);
|
||||
await rig.VoteAsync(motion, folded, VoteChoice.For); // the plan the member saw is no longer the plan
|
||||
Assert.IsType<ConfirmOutcome.PreviewStale>(await Confirm(survivor.Id, digest, good, foldedProof));
|
||||
Assert.Equal(0, await rig.MergeCountAsync());
|
||||
|
||||
var blocked = Assert.IsType<ConfirmOutcome.Blocked>(await rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, survivor.Id, "x", good, [good]), default)));
|
||||
Assert.Contains("same_account", blocked.Codes);
|
||||
Assert.Equal(0, await rig.MergeCountAsync());
|
||||
|
||||
// a second confirm of the same fold, after the first
|
||||
var fresh = await rig.DigestAsync(survivor, folded);
|
||||
Assert.IsType<ConfirmOutcome.Confirmed>(await Confirm(survivor.Id, fresh, good, foldedProof));
|
||||
var second = Assert.IsType<ConfirmOutcome.Blocked>(await Confirm(survivor.Id, fresh, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")));
|
||||
Assert.Contains(AccountMergeSaga.MergeInFlight, second.Codes);
|
||||
Assert.Equal(1, await rig.MergeCountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_confirm_throws_before_any_row_and_neither_the_message_nor_the_proof_text_carry_a_token()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
const string canary = "CANARY-bearer-9f3a";
|
||||
var proof = new MergeProof(canary, folded.HeimdallId, "discord");
|
||||
Assert.DoesNotContain(canary, proof.ToString());
|
||||
Assert.DoesNotContain(folded.HeimdallId, proof.ToString());
|
||||
var request = new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, "d", new MergeProof(canary, survivor.HeimdallId, "twitch"), [proof]);
|
||||
Assert.DoesNotContain(canary, request.ToString());
|
||||
|
||||
var nine = Enumerable.Repeat(proof, 9).ToList();
|
||||
var tooMany = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().ConfirmAsync(request with { FoldedProofs = nine }, default)));
|
||||
var tooLong = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().ConfirmAsync(
|
||||
request with { FoldedProofs = [proof with { AccessToken = canary + new string('x', HeimdallIdentityMoveClient.MaxProofLength) }] }, default)));
|
||||
|
||||
Assert.DoesNotContain(canary, tooMany.ToString());
|
||||
Assert.DoesNotContain(canary, tooLong.ToString());
|
||||
Assert.Equal(0, await rig.MergeCountAsync());
|
||||
}
|
||||
|
||||
// ---- sealing ----
|
||||
|
||||
[Fact]
|
||||
public async Task Sealed_proofs_never_hold_a_bearer_in_the_clear_are_wiped_at_each_final_answer_and_no_log_carries_a_bearer_or_an_account()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var survivorProof = rig.Weak(survivor, "twitch");
|
||||
var foldedProof = rig.Weak(folded, "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, survivorProof, foldedProof);
|
||||
var sealedText = (await rig.MergeAsync(id)).SealedProofs!;
|
||||
Assert.DoesNotContain(survivorProof.AccessToken, sealedText);
|
||||
Assert.DoesNotContain(foldedProof.AccessToken, sealedText);
|
||||
Assert.DoesNotContain("bearer", sealedText);
|
||||
|
||||
await rig.AdvanceAsync(id);
|
||||
Assert.Null((await rig.MergeAsync(id)).SealedProofs);
|
||||
|
||||
var undoProof = rig.Moved(survivor, folded, "discord"); // the moved login now lives on the survivor
|
||||
Assert.IsType<UndoOutcome.Requested>(await RequestUndoAsync(rig, id, survivor, undoProof));
|
||||
var requested = (await rig.MergeAsync(id)).SealedProofs!;
|
||||
Assert.DoesNotContain(undoProof.AccessToken, requested);
|
||||
await rig.AdvanceAsync(id);
|
||||
Assert.Null((await rig.MergeAsync(id)).SealedProofs);
|
||||
|
||||
var log = string.Join('\n', rig.Host.Logs.Lines);
|
||||
foreach (var secret in new[] { survivorProof.AccessToken, foldedProof.AccessToken, undoProof.AccessToken, survivor.HeimdallId, folded.HeimdallId }
|
||||
.Concat(folded.Logins.Values).Concat(survivor.Logins.Values))
|
||||
{
|
||||
Assert.DoesNotContain(secret, log);
|
||||
}
|
||||
|
||||
Assert.Contains(id.ToString(), log);
|
||||
}
|
||||
|
||||
private static Task<UndoOutcome> RequestUndoAsync(Rig rig, Guid id, Party actor, params MergeProof[] proofs) =>
|
||||
rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeSaga>().RequestUndoAsync(id, actor.Id, proofs, default));
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_sealed_proofs_change_no_state_and_retry_in_an_hour_and_the_error_log_names_only_the_merge()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<BifrostDbContext>();
|
||||
(await db.AccountMerges.SingleAsync(x => x.Id == id)).SealedProofs = "CfDJ8-not-a-protected-payload";
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
var stuck = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Confirmed, stuck.State);
|
||||
Assert.Equal(AccountMergeSaga.ProofsUnreadable, stuck.LastOutcome);
|
||||
Assert.Equal(rig.Clock.Now + TimeSpan.FromHours(1), stuck.NextAttemptUtc);
|
||||
Assert.Empty(rig.Moves.Requests);
|
||||
Assert.Contains(rig.Host.Logs.Lines, x => x.Contains(id.ToString()) && x.Contains("unreadable"));
|
||||
Assert.Equal(0, await rig.AdvanceDueAsync()); // not due for an hour
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_receipt_for_another_account_is_recorded_wipes_the_proofs_and_is_never_retried()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<BifrostDbContext>();
|
||||
(await db.UserAccounts.SingleAsync(x => x.Id == survivor.Id)).HeimdallAccountId = HeimdallShapes.NewAccountId(); // Bifrost's own link drifted
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
var stuck = await rig.AdvanceAsync(id);
|
||||
var again = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Confirmed, stuck.State);
|
||||
Assert.Equal(AccountMergeSaga.ReceiptMismatch, stuck.LastOutcome);
|
||||
// The answer stays what Heimdall gave: a later sweep does not turn it into an hourly "proofs unreadable" retry.
|
||||
Assert.Equal(AccountMergeSaga.ReceiptMismatch, again.LastOutcome);
|
||||
Assert.Null(again.NextAttemptUtc);
|
||||
Assert.Null((await rig.MergeAsync(id)).SealedProofs);
|
||||
Assert.Single(rig.Moves.Requests);
|
||||
Assert.IsType<MergeStanding.Frozen>(await rig.StandingAsync(folded));
|
||||
}
|
||||
|
||||
// ---- Heimdall down ----
|
||||
|
||||
[Fact]
|
||||
public async Task While_Heimdall_is_down_the_merge_stays_confirmed_and_the_folded_account_frozen_then_commits_when_it_answers()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Strong(survivor), rig.Strong(folded)); // strong proofs outlive the outage below
|
||||
rig.Moves.MoveDown = new HttpRequestException("down");
|
||||
|
||||
Assert.Equal(1, await rig.AdvanceDueAsync());
|
||||
Assert.Equal(0, await rig.AdvanceDueAsync()); // backed off 15 s
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(16));
|
||||
Assert.Equal(1, await rig.AdvanceDueAsync());
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(61));
|
||||
Assert.Equal(1, await rig.AdvanceDueAsync());
|
||||
|
||||
var down = await rig.MergeAsync(id);
|
||||
Assert.Equal(AccountMergeState.Confirmed, down.State);
|
||||
Assert.Equal(3, down.Attempts);
|
||||
Assert.Equal(rig.Clock.Now + TimeSpan.FromMinutes(5), down.NextAttemptUtc);
|
||||
Assert.IsType<MergeStanding.Frozen>(await rig.StandingAsync(folded));
|
||||
Assert.Empty(rig.Moves.Stored);
|
||||
|
||||
rig.Moves.MoveDown = null;
|
||||
rig.Clock.Advance(TimeSpan.FromMinutes(5));
|
||||
Assert.Equal(1, await rig.AdvanceDueAsync());
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State);
|
||||
}
|
||||
|
||||
// ---- undo ----
|
||||
|
||||
[Fact]
|
||||
public async Task Undo_with_survivor_proofs_of_the_moved_logins_reverses_the_move_and_ends_Undone()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord", "patreon");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"), rig.Weak(folded, "patreon"));
|
||||
await rig.AdvanceAsync(id);
|
||||
var forward = (await rig.MergeAsync(id)).HeimdallMoveId;
|
||||
|
||||
Assert.IsType<UndoOutcome.Requested>(await RequestUndoAsync(rig, id, survivor, rig.Moved(survivor, folded, "discord"), rig.Moved(survivor, folded, "patreon")));
|
||||
var requested = await rig.MergeAsync(id);
|
||||
Assert.Equal(AccountMergeState.UndoRequested, requested.State);
|
||||
Assert.Matches("^[0-9a-f]{32}$", requested.HeimdallReverseMoveId!);
|
||||
Assert.NotEqual(forward, requested.HeimdallReverseMoveId);
|
||||
Assert.IsType<MergeStanding.Folded>(await rig.StandingAsync(folded));
|
||||
|
||||
var done = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Undone, done.State);
|
||||
Assert.NotNull((await rig.MergeAsync(id)).ReversedUtc);
|
||||
Assert.Equal(2, rig.Moves.Stored.Count);
|
||||
var reversal = Assert.Single(rig.Moves.Stored, x => x.ReversesMoveId is not null);
|
||||
Assert.Equal(forward, reversal.ReversesMoveId);
|
||||
Assert.Equal(requested.HeimdallReverseMoveId, reversal.MoveId);
|
||||
Assert.Equal(["discord", "patreon"], rig.Moves.Logins[folded.HeimdallId].Select(x => x.Provider).Order());
|
||||
Assert.Null(await rig.StandingAsync(folded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_undo_returns_the_merge_to_Committed_with_the_proofs_wiped_and_the_sweep_finalizes_it_after_the_grace()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
var committed = await rig.AdvanceAsync(id);
|
||||
rig.Moves.Logins[survivor.HeimdallId].RemoveAll(x => x.Provider == "discord"); // the survivor unlinked a moved login
|
||||
|
||||
Assert.IsType<UndoOutcome.Requested>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
|
||||
var back = await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, back.State);
|
||||
var stored = await rig.MergeAsync(id);
|
||||
Assert.Equal("undo_not_reversible", stored.LastOutcome);
|
||||
Assert.Null(stored.SealedProofs);
|
||||
Assert.Equal(committed.GraceEndsUtc, stored.GraceEndsUtc);
|
||||
Assert.IsType<MergeStanding.Folded>(await rig.StandingAsync(folded));
|
||||
|
||||
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1));
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
await sp.GetRequiredService<AccountMergeSaga>().AdvanceDueAsync(rig.Clock.Now, default);
|
||||
return await sp.GetRequiredService<AccountMergeLedger>().FinalizeDueAsync(rig.Clock.Now, default);
|
||||
});
|
||||
Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Undo_is_requested_only_inside_the_grace_only_by_the_survivor_and_only_from_Committed()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
Assert.IsType<UndoOutcome.NotCommitted>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
|
||||
Assert.IsType<UndoOutcome.NotCommitted>(await RequestUndoAsync(rig, Guid.NewGuid(), survivor, rig.Weak(survivor, "twitch")));
|
||||
await rig.AdvanceAsync(id);
|
||||
|
||||
Assert.IsType<UndoOutcome.NotSurvivor>(await RequestUndoAsync(rig, id, folded, rig.Weak(folded, "discord")));
|
||||
Assert.IsType<UndoOutcome.ProofAccountMismatch>(await RequestUndoAsync(rig, id, survivor, rig.Weak(folded, "discord")));
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State);
|
||||
|
||||
var proof = rig.Weak(survivor, "twitch");
|
||||
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromSeconds(1));
|
||||
Assert.IsType<UndoOutcome.GraceOver>(await RequestUndoAsync(rig, id, survivor, proof)); // Committed, but the grace ended
|
||||
await rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeLedger>().FinalizeDueAsync(rig.Clock.Now, default));
|
||||
Assert.IsType<UndoOutcome.GraceOver>(await RequestUndoAsync(rig, id, survivor, proof)); // Final
|
||||
Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspended_folded_account_into_a_memberless_survivor_confirms_on_the_digest_the_member_saw_and_commits()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<BifrostDbContext>();
|
||||
db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)); // the chat tier holds no membership
|
||||
(await db.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status = MembershipStatus.Suspended;
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
// The member is shown a plan and confirms it: the same unchanged world must show the same digest.
|
||||
Assert.Equal(await rig.DigestAsync(survivor, folded), await rig.DigestAsync(survivor, folded));
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
|
||||
Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
|
||||
var carried = await rig.InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().Memberships.SingleAsync(x => x.UserAccountId == survivor.Id));
|
||||
Assert.Equal(MembershipStatus.Suspended, carried.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Undo_is_refused_at_the_exact_instant_the_grace_ends_and_allowed_a_second_before()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
await rig.AdvanceAsync(id);
|
||||
var ends = (await rig.MergeAsync(id)).GraceEndsUtc!.Value;
|
||||
|
||||
rig.Clock.Advance(ends - rig.Clock.Now);
|
||||
Assert.IsType<UndoOutcome.GraceOver>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(-1));
|
||||
Assert.IsType<UndoOutcome.Requested>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_sealed_proofs_record_prints_no_bearer()
|
||||
{
|
||||
var seal = typeof(AccountMergeSaga).GetNestedType("ProofSeal", System.Reflection.BindingFlags.NonPublic)!;
|
||||
var instance = Activator.CreateInstance(seal, new List<string> { "bearer-folded-secret" }, "bearer-survivor-secret")!;
|
||||
|
||||
var text = instance.ToString()!;
|
||||
|
||||
Assert.DoesNotContain("secret", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_answer_to_the_reversal_is_replayed_under_the_same_reverse_id_even_past_the_grace()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var survivor = await rig.AccountAsync("survivor", "twitch");
|
||||
var folded = await rig.AccountAsync("folded", "discord");
|
||||
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
|
||||
await rig.AdvanceAsync(id);
|
||||
Assert.IsType<UndoOutcome.Requested>(await RequestUndoAsync(rig, id, survivor, rig.Moved(survivor, folded, "discord")));
|
||||
rig.Moves.MoveApplyThenFail = HttpStatusCode.BadGateway;
|
||||
|
||||
Assert.Equal(AccountMergeState.UndoRequested, (await rig.AdvanceAsync(id)).State);
|
||||
rig.Moves.MoveApplyThenFail = null;
|
||||
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromDays(1));
|
||||
await rig.AdvanceDueAsync();
|
||||
|
||||
Assert.Equal(AccountMergeState.Undone, (await rig.MergeAsync(id)).State);
|
||||
var seen = (await rig.SeenMoveIdsAsync()).Skip(1).ToArray();
|
||||
Assert.Equal(2, seen.Length);
|
||||
Assert.Single(seen.Distinct());
|
||||
Assert.Equal(2, rig.Moves.Stored.Count);
|
||||
}
|
||||
|
||||
// ---- the wiring ----
|
||||
|
||||
[Fact]
|
||||
public void The_host_registers_exactly_one_merge_loop_in_production_and_the_saga_beside_the_ledger()
|
||||
{
|
||||
var program = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Bifrost.Web", "Program.cs"));
|
||||
Assert.Single(System.Text.RegularExpressions.Regex.Matches(program, @"AddHostedService<.*Merge"));
|
||||
Assert.Contains("AddScoped<Bifrost.Web.Features.Membership.AccountMergeSaga>", program);
|
||||
}
|
||||
|
||||
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("repo root not found");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
using System.Net;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using static Bifrost.Web.Tests.AccountMergeSagaTests;
|
||||
using static Bifrost.Web.Tests.AccountMergeSettleTests;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>The operator's page for stuck merges, from browser sessions of the real host: admin only, the stored move id shown as the confirmation, results by closed code.</summary>
|
||||
public sealed class AccountMergeSettlePageTests
|
||||
{
|
||||
private const string Url = "/App/Merges";
|
||||
|
||||
private sealed class World : IDisposable
|
||||
{
|
||||
public Rig Rig { get; } = new(realSessions: true);
|
||||
|
||||
public Party Admin { get; private set; } = null!;
|
||||
|
||||
public string AdminSession { get; private set; } = string.Empty;
|
||||
|
||||
public string MemberSession { get; private set; } = string.Empty;
|
||||
|
||||
public Stuck Forward { get; private set; } = null!;
|
||||
|
||||
public static async Task<World> Make()
|
||||
{
|
||||
var world = new World();
|
||||
var rig = world.Rig;
|
||||
world.Admin = await rig.AccountAsync("operator", "github");
|
||||
var member = await rig.AccountAsync("member", "github");
|
||||
await rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<BifrostDbContext>();
|
||||
var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == world.Admin.Id);
|
||||
membership.IsPlatformAdmin = true;
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
world.AdminSession = await rig.Host.SignInSessionAsync(world.Admin.HeimdallId);
|
||||
world.MemberSession = await rig.Host.SignInSessionAsync(member.HeimdallId);
|
||||
world.Forward = await ConfirmedStuckAsync(rig, AccountMergeSaga.ReceiptMismatch);
|
||||
return world;
|
||||
}
|
||||
|
||||
public void Dispose() => Rig.Dispose();
|
||||
|
||||
public Task<(HttpResponseMessage Response, string Html)> Get(string session, string query = "") => MergePage.GetAsync(Rig.Host, session, query, Url);
|
||||
|
||||
public async Task<HttpResponseMessage> Post(string session, string handler, string mergeId, string moveId, MergePage.Antiforgery? token = null) =>
|
||||
await MergePage.PostAsync(Rig.Host, session, token ?? await MergePage.TokenAsync(Rig.Host, session, HeimdallHost.LinkedAccountsUrl), handler,
|
||||
new Dictionary<string, string> { ["mergeId"] = mergeId, ["moveId"] = moveId }, Url);
|
||||
}
|
||||
|
||||
private static bool Refused(HttpResponseMessage response) =>
|
||||
response.StatusCode == HttpStatusCode.Forbidden || (response.StatusCode == HttpStatusCode.Redirect && (response.Headers.Location?.OriginalString.Contains("/Membership/Status?ReturnUrl=", StringComparison.Ordinal) ?? false));
|
||||
|
||||
[Fact]
|
||||
public async Task A_member_who_is_not_a_platform_admin_is_refused_on_the_listing_and_on_both_posts_and_nothing_changes()
|
||||
{
|
||||
using var w = await World.Make();
|
||||
var merge = await w.Rig.MergeAsync(w.Forward.Id);
|
||||
var requests = w.Rig.Moves.Requests.Count;
|
||||
|
||||
var (get, html) = await w.Get(w.MemberSession);
|
||||
Assert.True(Refused(get), $"listing gave {(int)get.StatusCode} {MergePage.Location(get)}");
|
||||
Assert.DoesNotContain(merge.HeimdallMoveId!, html);
|
||||
Assert.True(Refused(await w.Post(w.MemberSession, "FailForward", w.Forward.Id.ToString(), merge.HeimdallMoveId!)));
|
||||
Assert.True(Refused(await w.Post(w.MemberSession, "AbandonUndo", w.Forward.Id.ToString(), merge.HeimdallMoveId!)));
|
||||
|
||||
var after = await w.Rig.MergeAsync(w.Forward.Id);
|
||||
Assert.Equal(AccountMergeState.Confirmed, after.State);
|
||||
Assert.Equal(AccountMergeSaga.ReceiptMismatch, after.LastOutcome);
|
||||
Assert.Equal(requests, w.Rig.Moves.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_listing_shows_stuck_merges_with_their_stored_move_id_and_no_Heimdall_account_or_provider_user_id()
|
||||
{
|
||||
using var w = await World.Make();
|
||||
var stuck = w.Forward;
|
||||
var merge = await w.Rig.MergeAsync(stuck.Id);
|
||||
// Heimdall unreachable for a long time: listed, though only the three stuck outcomes can be ended.
|
||||
var slow = await ConfirmedStuckAsync(w.Rig, AccountMergeSaga.MoveIdConflict);
|
||||
var slowMerge = await w.Rig.MergeAsync(slow.Id);
|
||||
var waiting = await ConfirmedStuckAsync(w.Rig, "transport");
|
||||
await EditMerge(w.Rig, waiting.Id, x => x.Attempts = 8);
|
||||
var healthy = await ConfirmedStuckAsync(w.Rig, "confirmed"); // one waiting for its first replay: not stuck
|
||||
await EditMerge(w.Rig, healthy.Id, x => x.Attempts = 1);
|
||||
var undo = await UndoStuckAsync(w.Rig);
|
||||
var undoMerge = await w.Rig.MergeAsync(undo.Id);
|
||||
|
||||
var (response, html) = await w.Get(w.AdminSession);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains(stuck.Id.ToString(), html);
|
||||
Assert.Contains(merge.HeimdallMoveId!, html);
|
||||
Assert.Contains(slow.Id.ToString(), html);
|
||||
Assert.Contains(slowMerge.HeimdallMoveId!, html);
|
||||
Assert.Contains(waiting.Id.ToString(), html);
|
||||
Assert.Contains(undo.Id.ToString(), html);
|
||||
Assert.Contains(undoMerge.HeimdallReverseMoveId!, html);
|
||||
Assert.DoesNotContain(undoMerge.HeimdallMoveId!, html); // the leg in flight is the reversal
|
||||
Assert.DoesNotContain(healthy.Id.ToString(), html);
|
||||
foreach (var party in new[] { stuck.Survivor, stuck.Folded, slow.Survivor, slow.Folded, undo.Survivor, undo.Folded })
|
||||
{
|
||||
Assert.DoesNotContain(party.HeimdallId, html);
|
||||
Assert.DoesNotContain(party.Id.ToString(), html);
|
||||
Assert.DoesNotContain(party.Logins.Values.First(), html);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_platform_admin_ends_a_stuck_leg_by_typing_its_move_id_and_a_wrong_one_changes_nothing()
|
||||
{
|
||||
using var w = await World.Make();
|
||||
var merge = await w.Rig.MergeAsync(w.Forward.Id);
|
||||
var token = await MergePage.TokenAsync(w.Rig.Host, w.AdminSession, Url);
|
||||
var requests = w.Rig.Moves.Requests.Count;
|
||||
|
||||
var wrong = await w.Post(w.AdminSession, "FailForward", w.Forward.Id.ToString(), "0123456789abcdef0123456789abcdef", token);
|
||||
Assert.Equal(Url + "?result=wrong_move_id", MergePage.Location(wrong));
|
||||
Assert.Equal(AccountMergeState.Confirmed, (await w.Rig.MergeAsync(w.Forward.Id)).State);
|
||||
|
||||
var otherLeg = await w.Post(w.AdminSession, "AbandonUndo", w.Forward.Id.ToString(), merge.HeimdallMoveId!, token);
|
||||
Assert.Equal(Url + "?result=not_stuck", MergePage.Location(otherLeg));
|
||||
|
||||
var done = await w.Post(w.AdminSession, "FailForward", w.Forward.Id.ToString(), merge.HeimdallMoveId!, token);
|
||||
Assert.Equal(Url + "?result=settled", MergePage.Location(done));
|
||||
var after = await w.Rig.MergeAsync(w.Forward.Id);
|
||||
Assert.Equal(AccountMergeState.Failed, after.State);
|
||||
await w.Rig.InScopeAsync(async sp =>
|
||||
{
|
||||
var audit = await sp.GetRequiredService<BifrostDbContext>().AuditEvents.AsNoTracking().SingleAsync(x => x.EntityId == w.Forward.Id.ToString());
|
||||
Assert.Equal(w.Admin.Id, audit.ActorUserAccountId);
|
||||
return 0;
|
||||
});
|
||||
Assert.Equal(requests, w.Rig.Moves.Requests.Count); // the page calls no Heimdall
|
||||
|
||||
var (_, html) = await w.Get(w.AdminSession, "?result=settled");
|
||||
Assert.DoesNotContain(w.Forward.Id.ToString(), html); // no longer stuck, so no longer listed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_platform_admin_gives_up_a_stuck_undo_by_typing_the_reverse_move_id()
|
||||
{
|
||||
using var w = await World.Make();
|
||||
var undo = await UndoStuckAsync(w.Rig);
|
||||
var merge = await w.Rig.MergeAsync(undo.Id);
|
||||
|
||||
var done = await w.Post(w.AdminSession, "AbandonUndo", undo.Id.ToString(), merge.HeimdallReverseMoveId!, await MergePage.TokenAsync(w.Rig.Host, w.AdminSession, Url));
|
||||
|
||||
Assert.Equal(Url + "?result=settled", MergePage.Location(done));
|
||||
Assert.Equal(AccountMergeState.Committed, (await w.Rig.MergeAsync(undo.Id)).State);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue