Skip to content

Claiming: sign in with a passkey, publish a token, be verified - #16

Merged
HarryCordewener merged 3 commits into
feat/reference-pagesfrom
feat/claiming
Jul 31, 2026
Merged

Claiming: sign in with a passkey, publish a token, be verified#16
HarryCordewener merged 3 commits into
feat/reference-pagesfrom
feat/claiming

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

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. IssueAsync returns the existing token rather than replacing it, so a refresh cannot invalidate what an operator has just finished pasting into mush.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 CoreIUserStore + IUserPasskeyStore is a page of SQL, and migrations/0007_ownership.sql stays 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 make UserManager start offering flows this site has no pages for.

One script file, and it is the only JavaScript on this site. navigator.credentials has 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

  • Presence establishes, absence never revokes (§8.4). Two timestamps, because they are two facts. Absence-revokes hands revocation to any transient failure — this project has watched MCCP swallow a connection's payload whole.
  • Three guarantees live in the schema, not a handler: a claim carries a NOT NULL account; 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.
  • DNS TXT is deferred, and not for want of a resolver: it proves control of a hostname, and MU* hosting routinely puts many unrelated games on one domain separated only by port. The host's operator could claim all of them.
  • Accounts exist only with a database. The claim surfaces are absent rather than broken over the demo fixture — half a flow over invented games would have an operator publishing a token for a listing that is not their game.

Two gaps closed rather than worked around

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

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; the assertion endpoint returns a real WebAuthn challenge.

Five suites, 652 tests, zero warnings.

Known and deliberate

Passkeys:ServerDomain is 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

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2c687ee9-7e8d-43c3-89f4-683d2577b015

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@HarryCordewener
HarryCordewener changed the base branch from main to chore/park-the-importer July 31, 2026 15:18
HarryCordewener and others added 3 commits July 31, 2026 10:31
…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
HarryCordewener changed the base branch from chore/park-the-importer to feat/reference-pages July 31, 2026 15:32
@HarryCordewener
HarryCordewener merged commit 0854234 into main Jul 31, 2026
1 check passed
@HarryCordewener
HarryCordewener deleted the feat/claiming branch July 31, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant