Claiming: sign in with a passkey, publish a token, be verified - #16
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…eans Spec §8 rewritten around one decision -- sign in first, then claim -- and the storage and crawler halves built against it. The web half (passkeys, the pages) is not here yet. The ordering is not a convenience. We ask an operator to publish the token on a connect screen or in an MSSP field, where every anonymous connection reads it, so a design in which holding the token confers the claim is broken the instant it succeeds. The token is a NONCE: it proves somebody with write access to that server published it, and the account column answers the separate question of who asked. Mallory reading Alice's token off the connect screen can do nothing with it, and there is a test that says so by name. That also answers what happens between minting and verifying. The pending claim is durable server-side state shown on the claimant's dashboard, so nobody has to write a token down or finish in one sitting -- a scheme that punished the person who closed the tab would put a transcription error between an owner and their listing. Sign-in will be passkeys and nothing else (§8.2). No passwords, no email, no federated provider. The usual blocker for passwordless is account recovery, and it does not apply here: the root of trust is the server the operator controls, so losing every device is recoverable by publishing a fresh token. Two consequences are written down rather than discovered later -- sign-in will be the only part of this site needing JavaScript, and a passkey is bound to a domain, which gives §15.1's open domain question a deadline. §8.4 is the other rule with teeth: presence establishes, absence never revokes. Two timestamps, because they are two facts. Absence-revokes would hand revocation to any transient failure, and this project has watched MCCP swallow a connection's payload whole -- a silent unclaiming on that basis is indistinguishable from an owner walking away. Three guarantees are in the schema rather than in a handler, because a constraint that fires is a guarantee and a branch in C# is an intention: a claim carries a NOT NULL account, one account holds at most one pending claim per game (partial unique index), and a token is unique across the table -- without which one game's published token could complete another game's claim. DNS TXT is deferred, and not for want of a resolver: a TXT record proves control of a hostname, and a hostname is not a game. MU* hosting routinely puts many unrelated games on one domain separated by port, so the host's operator could claim all of them and a game on somebody else's domain could not use the channel at all. Also here, because it was in the way: PostgresFixture ran Postgres at its default 100 connections, and this suite passed that the day it grew past roughly that many tests. The failure was not a clean one -- whichever tests happened to be starting failed with 53300, in twenty unrelated places, reading as flakiness rather than as a ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ASP.NET Core Identity's default store is EF Core. Bringing it in for four tables would be the wrong trade in a codebase that has kept its SQL visible and its migrations hand-numbered, and the whole of what Identity needs is two interfaces -- IUserStore and IUserPasskeyStore -- which is a page of SQL. migrations/0007 stays the single description of these tables. Only what this app uses is implemented. There is no IUserPasswordStore, no IUserEmailStore, no lockout and no two-factor store, because there are no passwords, no addresses and no second factor -- a passkey is a primary factor (§8.2). An unimplemented interface here is a feature we do not have rather than a gap: adding the password store would make UserManager start offering flows this site has no pages for. The schema gained two columns after reading Identity's actual contract rather than guessing at it: UserPasskeyInfo carries client_data_json and is_user_verified, and a store that drops half a record hands back something that is not what was registered. Transports is a text[] so a round trip cannot re-delimit what the authenticator reported. Four schema guarantees are now pinned where they are enforced. Deleting an account takes its passkeys and is refused while it still owns a claim -- the asymmetry is deliberate, because a passkey is a way in and a claim is what tells the world a game is owned. The pending-claim index is partial, so a verified or revoked claim does not lock an account out of a game it can still prove control of. And the audit vocabulary is checked in both directions: a CHECK that refuses a value the code can produce fails in production rather than in a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ified
The web half of §8. Sign-in is passkeys and nothing else -- no passwords, no
email, no federated provider -- over ASP.NET Core Identity's built-in WebAuthn
support, with the Dapper stores from the previous commit behind it.
Four endpoints and one script file. That script is the only JavaScript on this
site, and the boundary is deliberate: navigator.credentials has no scripting-off
path, so the part that needs it is the part used by people who administer a game
server, while the catalogue, the game pages, the archive, plain mode and the API
all keep working with scripting disabled.
The claim page answers the question the design turns on -- what happens between
minting a token and verifying it. Nothing needs writing down. The pending claim
is durable server-side state shown for as long as it is pending, with each
channel's line ready to copy, and IssueAsync returns the existing token rather
than replacing it, so a refresh cannot invalidate what the operator has just
finished pasting into mush.cnf.
Everything to do with accounts is registered only when a connection string is,
and the surfaces are absent rather than broken without one. Half a claim flow
over invented games would be worse than none: an operator following it would
publish a token on a real server for a listing that is not their game.
Two things were missing and are now here rather than worked around.
IGameQueries had no by-id lookup, so an owner page had to reach past the
interface to a store or resolve a slug it was never given -- a claim is bound to
the game, not to a name a rename can move. And the test harness had never
rendered a whole page; Render.PageAsync wires the fixture and deliberately not
the account services, which is the condition every claim-surface test is about.
Verified against a real database rather than a build: migration 0007 applies,
/account/sign-in, /g/{slug}/claim and /account all answer, the game page offers
the claim only when there is somewhere to put it, and the assertion endpoint
returns a real WebAuthn challenge.
Five suites, 652 tests, zero warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HarryCordewener
changed the base branch from
chore/park-the-importer
to
feat/reference-pages
July 31, 2026 15:32
HarryCordewener
force-pushed
the
feat/claiming
branch
from
July 31, 2026 15:32
f3cfcd7 to
4d06c87
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #11. Spec §8 rewritten around one decision — sign in first, then claim — and built end to end.
The decision that shapes everything
The token cannot be a secret, because we ask operators to publish it on a connect screen or in an MSSP field, where every anonymous connection reads it — including every other crawler. So a bearer model, where holding the token confers the claim, is broken the instant it succeeds.
It is a nonce. It proves somebody with write access to that server published it; the account it is bound to answers who asked. Mallory reads Alice's token off the connect screen and can do nothing with it — there is a test named for exactly that.
That also answers what happens between minting and verifying: nothing needs writing down. The pending claim is durable server-side state, shown for as long as it is pending, with each channel's line ready to copy.
IssueAsyncreturns the existing token rather than replacing it, so a refresh cannot invalidate what an operator has just finished pasting intomush.cnf.Sign-in is passkeys and nothing else
No passwords, no email, no federated provider. The usual blocker for passwordless is account recovery, and it does not apply here: the root of trust is the server the operator controls, not the credential. Lose every device, publish a fresh token, done. An account is correspondingly worth almost nothing to steal.
Identity runs over Dapper, not EF Core —
IUserStore+IUserPasskeyStoreis a page of SQL, andmigrations/0007_ownership.sqlstays the single description of these tables.IUserPasswordStore,IUserEmailStore, lockout and two-factor are deliberately unimplemented: each absent interface is a feature we do not have, and adding the password store would makeUserManagerstart offering flows this site has no pages for.One script file, and it is the only JavaScript on this site.
navigator.credentialshas no scripting-off path, so the boundary is drawn at sign-in; the catalogue, game pages, archive, plain mode and API all keep working with scripting disabled.Rules with teeth
NOT NULLaccount; one account holds at most one pending claim per game (partial index, so a verified or revoked claim does not lock an account out of a game it can still prove control of); a token is unique table-wide, without which one game's published token could complete another's.Two gaps closed rather than worked around
IGameQuerieshad no by-id lookup, so an owner page had to reach past the interface or resolve a slug it was never given — a claim binds to the game, not to a name a rename can move. And the test harness had never rendered a whole page;Render.PageAsyncwires the fixture and deliberately not the account services, which is the condition every claim-surface test is about.Verified against a real database
Migration 0007 applies;
/account/sign-in,/g/{slug}/claimand/accountall answer; the game page offers the claim only when there is somewhere to put it; the assertion endpoint returns a real WebAuthn challenge.Five suites, 652 tests, zero warnings.
Known and deliberate
Passkeys:ServerDomainis unset in development, so the RP ID falls back to the host. It must be configured before deployment — a passkey is bound to a domain, which gives §15.1's open domain question a deadline: credentials registered before the domain settles have to be registered again after it moves.🤖 Generated with Claude Code
https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk