Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 117 additions & 12 deletions docs/specs/2026-07-30-mu-directory-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,21 +703,126 @@ so, and §11's opt-out is the honest way to say it entirely.

## 8. Claiming and ownership

The site issues a token. The owner proves control by emitting it in one of three places — an MSSP
field, a line on the connect screen, or a DNS TXT record on the hostname. All three require server
or DNS access; all three are verified by the crawler that already exists; none requires the site to
send mail or trust a third-party registry.

The claim token doubles as a permanent identity beacon (§7.3), which gives owners a concrete
technical reason to claim beyond editing their listing.

Owner dashboard: enrichment fields (fandom/IP, RP enforcement, application process, consent tools),
connect-screen suppression, WHO-format override, opt-out, and the MSSP linter scorecard —
continuous rather than one-shot, flagging missing fields, wrong types and non-standard values.
Multi-owner, transfer, and an audit log.
### 8.1 The order is: sign in, then claim

An account exists **before** any token does, and the claim binds to the account rather than to the
token. That ordering is not a convenience; it is what makes the scheme sound.

1. The visitor signs in. A session cookie now identifies a durable account.
2. They press *claim this game*. The site mints a token and stores a **pending claim** keyed
`(account, game)`.
3. They publish the token where a probe can read it.
4. The next probe compares. On a match the claim completes, bound to **the account that minted the
token** — never to whoever holds it.

**The token is a nonce, not a credential, and it cannot be anything else.** We ask an operator to
publish it on a connect screen or in an MSSP field, which every anonymous connection reads —
including every other crawler. A bearer-secret model, where holding the token confers the claim, is
therefore broken the instant it succeeds. What the token proves is that *somebody with write access
to that server published it*; the account binding answers the separate question of *who asked*.

Mallory reading Alice's token off the connect screen can do nothing with it: it verifies only against
Alice's pending claim. To take the game she must publish her own token on that server, which is
precisely the control being tested.

**Nobody has to write anything down.** The pending claim is durable server-side state, shown on the
claimant's dashboard for as long as it is pending, with each channel's exact line ready to copy. Close
the tab, come back next week, it is still there. A token that had to be captured in one sitting would
put a transcription error between an owner and their listing.

Bounds: one pending token per `(account, game)`, expiring after 30 days — otherwise abandoned tokens
accumulate on connect screens and linger as identity beacons for claims nobody completed. Verification
is idempotent and re-runnable; a non-match is *not yet* rather than a failure, and the page says when
we last looked and when we will look again.

**Verification is asked for, not polled at.** A claimant may request one on-demand probe per pending
claim per few minutes — enough that an operator who has just edited `mush.cnf` is not waiting on the
scheduler, and bounded so that the button is not a free way to make us dial a stranger. `CRAWL DELAY`
still binds, and the target must already be one we crawl.

### 8.2 Sign-in is passkeys, and v1 has nothing else

**Passkeys only** (WebAuthn/FIDO2, native to ASP.NET Core Identity in .NET 10). No passwords, no
email, no federated provider, no third party of any kind. We hold a public key; the private key never
leaves the operator's authenticator.

Three properties earned rather than assumed. There is **no password database to breach** — what we
store is public by construction. Sign-in is **phishing-resistant structurally**, because the browser
binds the credential to our domain and will not release a signature to a look-alike. And replay is
caught by the credential's own signature counter.

**The hard part of passwordless does not apply here.** Account recovery is what usually forces a
password, an email flow or recovery codes onto a passkey deployment. Our recovery path is: make a new
account, publish a fresh token on your game, verify. **The root of trust is the server the operator
controls, not the credential** — so losing every device is recoverable without us knowing an email
address, and an account is worth almost nothing to steal.

Three consequences to hold onto:

- **Sign-in requires JavaScript, and it is the only thing on this site that does.** `navigator
.credentials` has no scripting-off path. The public catalogue — listing, game pages, archive,
plain mode, the API — stays fully functional without scripting, and that boundary is a design
constraint rather than an accident: the part that requires JS is the part used by people who
administer a game server.
- **A passkey is bound to a domain**, and §15.1's open domain question therefore has a deadline.
`IdentityPasskeyOptions.ServerDomain` is set explicitly rather than inferred from the host header
(the inference is a credential-scoping risk), and no untrusted content is ever served on a
subdomain of it. Passkeys registered before the domain settles must be re-registered after a move;
either settle it before claiming opens or accept a one-time re-enrolment and say so on the page.
- **Enrolment is still a minority behaviour** across the web — a reason to expect federated options
to be added later, and not a reason to add them now. Every person who can complete a claim already
has shell access to a MU\* server; this is the audience most able to use a passkey.

Federated sign-in (Discord, a forge, the fediverse) is a **later** addition, and one that also
restores a scripting-free login path, since OAuth is redirects. It is deliberately out of v1.

### 8.3 The channels a token may be published in

**MSSP** (`MUINDEX CLAIM`, with `MUINDEX_CLAIM` and `CONTACT_TOKEN` also accepted — an MSSP variable
name does not reliably survive a config file, and an operator who did exactly what they were told must
not be told their claim failed) and **the connect screen** (`MUINDEX-CLAIM: muidx-…`). Both are read
by the probe that already exists.

**DNS TXT is deferred, and not merely for lack 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 only by port. The host's operator could claim all of them, and a game running on
somebody else's domain could never use the channel at all. If it returns it needs a port qualifier.
The two channels above prove control of *that listener*, which is the thing being claimed.

### 8.4 Presence establishes; absence never revokes

A verified claim survives the token being removed. The alternative — absence revokes — hands
revocation to any transient failure: a server restart, an MSSP hiccup, a compression bug eating a
subnegotiation. This project has already watched MCCP swallow a connection's payload whole, and a
silent unclaiming on that basis would be indistinguishable from an owner walking away.

So two timestamps, because they are two facts: `claimed_at`, written once when verification succeeds,
and `beacon_last_seen_at`, updated whenever a probe still sees the token. Revocation is explicit, or
the consequence of a **counter-claim** — a different account proving control *now* — which is also
the correct handling of a game changing hands. The published token keeps earning its keep meanwhile
as §7.3's decisive identity signal, which is the concrete technical reason to leave it in place.

### 8.5 What a claim grants, and the line it may not cross

Enrichment fields (fandom/IP, RP enforcement, application process, consent tools), connect-screen
suppression, `WHO`-format override, opt-out, and the MSSP linter scorecard — continuous rather than
one-shot, flagging missing fields, wrong types and non-standard values. Multi-owner, transfer, and an
audit log; one account may hold many games, and a game may have several owners, each having verified
a token of their own.

**An owner may never edit a measurement.** They can add `FANDOM`; they cannot touch a player count, a
capability matrix, or a reachability history. The writable set *is* the field registry's
`OwnerEnrichable` flag, and a write to any other field is refused out loud rather than dropped — a
silent no-op teaches an owner that the site is broken, and a successful one would make the whole site
a self-report with extra steps.

Owner-published outputs: a live player-count SVG badge and a JSON endpoint for the game's own site.

**Claiming lights up two paths that are currently unreachable**, and that is worth knowing when
testing it: nothing sets `game.is_claimed` today, so the `claimed` badge in the listing and
`ArchivePolicy`'s ceiling-grace-for-claimed-games (§7.5) have never once been exercised against real
data.

## 9. Site surface, v1

**Game listing.** Faceted search over the MSSP taxonomy plus derived facets: activity band,
Expand Down
180 changes: 180 additions & 0 deletions migrations/0007_ownership.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
-- spec §8 — accounts, passkeys, and the claims that bind one to a game.
--
-- Everything before this migration is what a probe produced or what decides who gets probed. This is
-- the first table a *person* writes to, and the ordering of §8.1 is enforced here rather than trusted
-- in a handler: a claim carries a NOT NULL account, so a token that verified without anybody having
-- asked for it has nowhere to go.

-- §8.2 — an account, and deliberately almost nothing about a person.
--
-- NO EMAIL COLUMN, NO PASSWORD HASH, AND NEITHER IS AN OVERSIGHT. Sign-in is passkeys only, so there
-- is no password to store and no address to recover to; §8.2's recovery path is to make a new account
-- and re-verify through the game, because the root of trust is the server the operator controls. An
-- account here is a durable handle to hang a claim on, and it is worth almost nothing to steal.
--
-- §11 refuses to persist player names. An owner is not a player, but the same default applies: what
-- is stored is what the site cannot work without.
CREATE TABLE app_user (
id uuid PRIMARY KEY,

-- What the account calls itself. Chosen by the account holder, shown only to them unless they
-- publish a contact on a game they own. Not an identity claim and never verified as one.
display_name text NOT NULL,

-- Identity's own case/diacritic-insensitive lookup key. Unique so two accounts cannot collide on
-- a name a human would read as the same.
normalised_name text NOT NULL,

-- Identity's optimistic-concurrency token. Ours to store, not ours to interpret.
security_stamp text NOT NULL,
concurrency_stamp text NOT NULL,

created_at timestamptz NOT NULL,
last_signed_in_at timestamptz,

CONSTRAINT app_user_name_is_not_blank CHECK (btrim(display_name) <> ''),
CONSTRAINT app_user_normalised_name_is_canonical CHECK (
normalised_name = upper(normalised_name) AND normalised_name = btrim(normalised_name))
);

CREATE UNIQUE INDEX app_user_normalised_name_idx ON app_user (normalised_name);

-- §8.2 — one WebAuthn credential.
--
-- The public key is public by construction, which is the property that makes this table uninteresting
-- to steal and is the whole argument for passkeys over passwords here.
--
-- The full attestation object is kept because it carries the AAGUID identifying the authenticator
-- model: when a model is found to be compromised, the affected credentials have to be findable. We do
-- not validate attestation statements (ASP.NET Core Identity does not by default, and for a consumer
-- site that is the right default), so the AAGUID is evidence rather than proof — which is a reason to
-- keep it, not a reason to discard it.
CREATE TABLE user_passkey (
credential_id bytea PRIMARY KEY,
user_id uuid NOT NULL REFERENCES app_user (id) ON DELETE CASCADE,

public_key bytea NOT NULL,

-- Replay protection. A credential that presents a counter no higher than the stored one has been
-- cloned or replayed; the authenticator is expected to increment it.
sign_count bigint NOT NULL DEFAULT 0,

-- Whether the credential is synced to a provider (backed up) or lives on one device. A passkey
-- that is NOT backed up is one lost phone away from being gone, which is what the dashboard reads
-- to suggest adding a second one.
is_backed_up boolean NOT NULL DEFAULT false,
is_backup_eligible boolean NOT NULL DEFAULT false,

-- Identity hands these back as a string array; the column is one so that a round trip through
-- storage cannot silently reorder or re-delimit what the authenticator reported.
transports text[],

-- Whether the authenticator verified a human at registration (biometric or PIN) rather than
-- merely detecting a touch. Identity carries it per credential, so it is stored per credential.
is_user_verified boolean NOT NULL DEFAULT false,

attestation_object bytea,

-- Kept because Identity's UserPasskeyInfo carries it and a store that drops half a record hands
-- back something that is not what was registered.
client_data_json bytea,

-- "My phone", "the yubikey in the drawer". A person with three passkeys needs to know which is
-- which before they can revoke one, so this is bounded rather than free — see §8's note on
-- resource limits.
name text,

created_at timestamptz NOT NULL,
last_used_at timestamptz,

CONSTRAINT user_passkey_name_is_bounded CHECK (name IS NULL OR length(name) <= 64),
CONSTRAINT user_passkey_sign_count_is_not_negative CHECK (sign_count >= 0)
);

CREATE INDEX user_passkey_user_idx ON user_passkey (user_id);

-- §8.1 — a claim, pending or verified, always bound to the account that started it.
--
-- ONE ROW COVERS BOTH STATES ON PURPOSE. A pending claim and a verified one are the same fact at two
-- moments, and splitting them into two tables would make "did this account already ask?" a question
-- with two places to look — which is how a second pending token gets minted while the first is still
-- printed on somebody's connect screen.
--
-- THE TOKEN IS NOT A SECRET AND MUST NEVER BE TREATED AS ONE (§8.1). We ask an operator to publish it
-- where every anonymous connection reads it. It is stored in the clear because hashing it would imply
-- a confidentiality it cannot have, and because the crawler compares what it read against what we
-- issued. What the token proves is that somebody with write access to that server published it; the
-- account column answers who asked.
CREATE TABLE game_claim (
id uuid PRIMARY KEY,
game_id uuid NOT NULL REFERENCES game (id),
user_id uuid NOT NULL REFERENCES app_user (id),

token text NOT NULL,

-- Written once, when a probe first matched. NULL means pending.
claimed_at timestamptz,

-- §8.4 — a second timestamp because there are two facts. Absence of the beacon never revokes a
-- claim; a transient MSSP failure or a compression bug eating a subnegotiation would otherwise
-- unclaim somebody silently. This says only when we last still saw it.
beacon_last_seen_at timestamptz,

-- Which channel the token was read from, for the audit trail and for telling an owner what we
-- actually saw. NULL while pending.
verified_via text,

issued_at timestamptz NOT NULL,

-- §8.1 — a pending token expires so that abandoned tokens do not accumulate on connect screens
-- and linger as identity beacons for claims nobody completed. A *verified* claim does not expire.
expires_at timestamptz NOT NULL,

-- Explicit revocation, or the loser of a counter-claim (§8.4). Never written because a beacon
-- went missing.
revoked_at timestamptz,
revoked_reason text,

-- When the claimant last asked us to look, so the on-demand check can be rate-limited per claim
-- rather than per source address, which is the bound that actually matters (§8.1).
last_checked_at timestamptz,

CONSTRAINT game_claim_token_is_not_blank CHECK (btrim(token) <> ''),
CONSTRAINT game_claim_verified_names_its_channel CHECK (
(claimed_at IS NULL AND verified_via IS NULL) OR
(claimed_at IS NOT NULL AND verified_via IS NOT NULL)),
CONSTRAINT game_claim_channel_vocabulary CHECK (
verified_via IS NULL OR verified_via IN ('mssp', 'connect_screen')),
CONSTRAINT game_claim_expires_after_it_is_issued CHECK (expires_at > issued_at)
);

-- §8.1 — one pending token per (account, game). Partial, so the same account may hold a verified
-- claim and nothing else: a second attempt while one is outstanding must reuse the token already
-- published rather than mint a rival.
CREATE UNIQUE INDEX game_claim_one_pending_per_account_idx
ON game_claim (game_id, user_id)
WHERE claimed_at IS NULL AND revoked_at IS NULL;

-- The token the crawler compares against. Unique across the table, because a collision would let one
-- game's published token complete another game's claim.
CREATE UNIQUE INDEX game_claim_token_idx ON game_claim (token);

CREATE INDEX game_claim_game_idx ON game_claim (game_id);
CREATE INDEX game_claim_user_idx ON game_claim (user_id);

-- §8.5 — the audit log. Append-only by convention: every row is something that happened, and nothing
-- here is ever updated or deleted, for the same reason §7.4 never deletes a game.
CREATE TABLE claim_event (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
claim_id uuid NOT NULL REFERENCES game_claim (id),
at timestamptz NOT NULL,

kind text NOT NULL,
detail text,

CONSTRAINT claim_event_kind_vocabulary CHECK (kind IN (
'issued', 'reissued', 'verified', 'beacon_seen', 'beacon_missing', 'revoked', 'expired',
'counter_claimed', 'check_requested'))
);

CREATE INDEX claim_event_claim_idx ON claim_event (claim_id, at);
Loading