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
40 changes: 36 additions & 4 deletions docs/specs/2026-07-30-mu-directory-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -842,10 +842,42 @@ 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.
**Claiming is wired end to end, and it was not for a while after it shipped.** A verified probe now
sets `game.is_claimed`, so the `claimed` badge in the listing and `ArchivePolicy`'s
ceiling-grace-for-claimed-games (§7.5) are both reachable. What kept them dark was not the claim
logic, which was complete and tested, but the **composition** — and the two are worth telling apart,
because the second has no compiler and no unit test looking at it:

- The site has two compositions of the same objects. `mui-crawl` builds the crawl loop by hand and
passes every collaborator explicitly; the deployed site assembles it through DI. `CrawlCycle` takes
its `ClaimService` as an *optional* parameter — a crawl with no database behind it should do
slightly less rather than refuse to run — so a composition that omits it settles no beacons and
says nothing about it. The crawl graph omitted it, and the site only had one because the accounts
module happened to register the same type for the dashboard.
- `ClaimService` was registered scoped while the crawl loop that needs it is a singleton
`BackgroundService`. With scope validation on, which is what `dotnet run` does, **the container
refused to build**: the site would not start with a connection string set. Production leaves
validation off, so there it worked — by accident, and only there.
- `IClaimStore` was registered nowhere at all. The dashboard service-locates it and reads a null as
"this site has no database", so every operator's list of claimed games was empty on a site that
had them, and the on-demand check endpoint threw on request.

The lesson generalises past claiming: **an optional dependency and a service-located one both fail
silently when the composition is wrong**, and this codebase has one of each on the claim path.
`CompositionTests` resolves the graph `Program` builds — literally, by calling the same
`AddMuiSite` the deployable calls, because a harness that restated the registrations would be a
second copy that agrees with the first only until somebody edits one of them — under scope
validation, in both environments, and asserts the services these paths need are really there. A
wiring test, because the wiring is what was broken while every part it joined was correct.

**The on-demand check was the same shape of gap and is fixed with it.** §8.1 offers a claimant one
requested probe per few minutes; `RequestCheckAsync` wrote `last_checked_at` and a `check_requested`
event, and due-ness comes only from `crawl_target.next_probe_at`, so nothing was ever probed and the
page said the button dialled a real server. A rate limiter on an action that does not happen is the
most convincing possible no-op. `IOnDemandProbes` brings the game's targets forward instead —
`LEAST`, so an ask can only make a probe sooner — and the crawl loop still does the dialling under
`CRAWL DELAY` and §7.2's address gate, which is what keeps a button on a page from becoming a way to
make us connect to a stranger's server.

## 9. Site surface, v1

Expand Down
27 changes: 27 additions & 0 deletions src/MUI.Catalog/Claims.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,30 @@ public enum ClaimVerdict
/// <summary>A token we issued, matching a claim that has expired or been revoked.</summary>
Stale,
}

/// <summary>
/// Brings a game's next probe forward, for §8.1's on-demand check.
/// </summary>
/// <remarks>
/// <para>
/// An interface here and an implementation over <c>crawl_target</c> in <c>MUI.Crawler</c>, because
/// due-ness is the crawl registry's business and <c>MUI.Catalog</c> may not know a socket exists.
/// <see cref="ClaimService"/> decides <em>whether</em> a claimant may ask; this decides nothing and
/// only moves the schedule.
/// </para>
/// <para>
/// <b>It brings a probe forward; it does not dial.</b> The crawler's own loop still does the
/// dialling, still honours <c>CRAWL DELAY</c> and still refuses a target outside scope — so a button
/// on a page cannot become a way to make us connect to a stranger's server on demand. What §8.1 asks
/// for is that an operator who has just edited <c>mush.cnf</c> is not left waiting on the scheduler,
/// and that is exactly this and no more.
/// </para>
/// </remarks>
public interface IOnDemandProbes
{
/// <summary>
/// Asks for <paramref name="gameId"/> to be probed no later than <paramref name="at"/>.
/// </summary>
/// <returns>Whether any target was brought forward.</returns>
Task<bool> BringForwardAsync(Guid gameId, DateTimeOffset at, CancellationToken cancellationToken = default);
}
33 changes: 31 additions & 2 deletions src/MUI.Catalog/Persistence/ClaimService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ namespace MUI.Catalog.Persistence;
/// hands this a string and a channel.
/// </para>
/// </remarks>
public sealed class ClaimService(IClaimStore claims, IGameStore games, TimeProvider time)
public sealed class ClaimService(
IClaimStore claims,
IGameStore games,
TimeProvider time,
IOnDemandProbes? probes = null)
{
/// <summary>
/// How often a claimant may ask us to look again (spec §8.1).
Expand Down Expand Up @@ -153,7 +157,24 @@ public bool MayRecheck(GameClaim claim)
return claim.LastCheckedAt is not { } last || time.GetUtcNow() - last >= RecheckInterval;
}

/// <summary>Records that a check was asked for. The dialling itself belongs to the crawler.</summary>
/// <summary>
/// Brings the game's next probe forward, and records that the claimant asked.
/// </summary>
/// <remarks>
/// <para>
/// <b>It moves the schedule; the crawler still does the dialling.</b> Keeping the two apart is
/// what stops a button on a page becoming a way to make us connect to a stranger's server on
/// demand: the rate limit is per claim, a claim cannot exist for a game nobody has been offered,
/// and <c>CRAWL DELAY</c> and the address gate both still bind when the loop gets there.
/// </para>
/// <para>
/// This recorded the ask and moved nothing for as long as it existed. Due-ness comes only from
/// <c>crawl_target.next_probe_at</c>, so <c>last_checked_at</c> and the <c>check_requested</c>
/// event went into the database and no probe ever came of it, while the page told the operator
/// the button dialled a real server. A rate limiter on an action that does not happen is the
/// most convincing possible no-op.
/// </para>
/// </remarks>
public async Task<bool> RequestCheckAsync(Guid claimId, CancellationToken cancellationToken = default)
{
if (await claims.FindAsync(claimId, cancellationToken) is not { } claim || !MayRecheck(claim))
Expand All @@ -163,11 +184,19 @@ public async Task<bool> RequestCheckAsync(Guid claimId, CancellationToken cancel

var now = time.GetUtcNow();

// Recorded whether or not a target moved. An ask is a thing that happened, and a claim on a
// game the registry has no target for — merged away, or added by hand — is not the
// claimant's mistake to be told about in an audit log.
await claims.UpdateAsync(claim with { LastCheckedAt = now }, cancellationToken);
await claims.RecordEventAsync(
new ClaimEvent(claim.Id, now, ClaimEventKind.CheckRequested),
cancellationToken);

if (probes is not null)
{
await probes.BringForwardAsync(claim.GameId, now, cancellationToken);
}

return true;
}

Expand Down
15 changes: 15 additions & 0 deletions src/MUI.Crawler/CrawlerServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ public static IServiceCollection AddMuiCrawlerCore(this IServiceCollection servi
services.TryAddSingleton<IAvailabilityStore>(s => s.GetRequiredService<NpgsqlAvailabilityStore>());
services.TryAddSingleton<IReachableHistory>(s => s.GetRequiredService<NpgsqlAvailabilityStore>());

// §8's claim settling. Every probe of a game whose owner has published a token completes the
// claim, and every probe of a claimed game refreshes beacon_last_seen_at — both on the
// ordinary schedule, which is why this belongs to the crawl graph rather than to the web
// tier that mints the tokens.
//
// The consumer is the in-process CrawlCycle of the one deployable (§4.11) — this method has
// exactly one caller, MUI.Web's Program, and mui-crawl builds its own graph by hand. It was
// missing here, and CrawlCycle takes its ClaimService as an OPTIONAL parameter, so the site
// settled beacons only insofar as some other registration happened to supply one. That is
// the shape of the bug rather than an argument for a deployment nobody builds.
services.TryAddSingleton<IClaimStore>(s => new NpgsqlClaimStore(s.GetRequiredService<NpgsqlDataSource>()));
services.TryAddSingleton<IOnDemandProbes>(
s => new NpgsqlOnDemandProbes(s.GetRequiredService<NpgsqlDataSource>()));
services.TryAddSingleton<ClaimService>();

// The three writers of §6.5, plus the field registry they judge staleness against.
services.TryAddSingleton<IFieldRegistry>(FieldRegistry.Instance);
services.TryAddSingleton<IPresenceWriter, PresenceWriter>();
Expand Down
49 changes: 49 additions & 0 deletions src/MUI.Crawler/Persistence/NpgsqlOnDemandProbes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Dapper;

using MUI.Catalog;

using Npgsql;

namespace MUI.Crawler.Persistence;

/// <summary>
/// §8.1's on-demand check, against the crawl registry: bring this game's next probe forward.
/// </summary>
/// <remarks>
/// <para>
/// <c>LEAST</c> rather than an assignment, so an ask can only ever make a probe sooner. A target
/// already overdue stays where it is in the queue rather than being pushed back to now, and a
/// claimant pressing the button twice cannot walk their own game backwards.
/// </para>
/// <para>
/// Every endpoint of the game moves, because a game is claimed and a target is an address: an
/// operator who has just edited <c>mush.cnf</c> does not know or care which of their listeners we
/// happen to have a row for.
/// </para>
/// <para>
/// It schedules and does not dial. The crawl loop picks the target up on its next pass, where
/// <c>CRAWL DELAY</c>, the concurrency cap and §7.2's resolved-address gate all still apply — none
/// of which a page may bypass.
/// </para>
/// </remarks>
public sealed class NpgsqlOnDemandProbes(NpgsqlDataSource source) : IOnDemandProbes
{
public async Task<bool> BringForwardAsync(
Guid gameId,
DateTimeOffset at,
CancellationToken cancellationToken = default)
{
await using var connection = await source.OpenConnectionAsync(cancellationToken);

var moved = await connection.ExecuteAsync(new CommandDefinition(
"""
UPDATE crawl_target
SET next_probe_at = LEAST(next_probe_at, @at)
WHERE game_id = @gameId
""",
new { gameId, at = at.ToUniversalTime() },
cancellationToken: cancellationToken));

return moved > 0;
}
}
39 changes: 30 additions & 9 deletions src/MUI.Web/Accounts/Passkeys.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection.Extensions;

using MUI.Catalog;
using MUI.Catalog.Persistence;
Expand Down Expand Up @@ -52,10 +53,26 @@ public static IServiceCollection AddMuiAccounts(
s.GetRequiredService<NpgsqlDataSource>(),
s.GetRequiredService<TimeProvider>()));

services.AddScoped<ClaimService>(s => new ClaimService(
new NpgsqlClaimStore(s.GetRequiredService<NpgsqlDataSource>()),
new NpgsqlGameStore(s.GetRequiredService<NpgsqlDataSource>()),
s.GetRequiredService<TimeProvider>()));
// Claiming's own two services, and both are singletons for a reason that is not tidiness.
//
// The dashboard resolves IClaimStore from the container and treats a null as "this site has
// no database" — so an unregistered store did not throw, it made every operator's list of
// claimed games empty on a site that had them. Nothing registered it, here or anywhere.
//
// ClaimService was scoped, and the crawl loop that settles beacons is a singleton
// BackgroundService: a scoped dependency is one CrawlCycle can never legally be given. With
// scope validation on — which is what `dotnet run` does — the container refused to build at
// all, so the site did not start with a connection string set. Both are stateless over a
// pooled NpgsqlDataSource, so a singleton is what they should always have been.
//
// TryAdd, because AddMuiCrawler registers the same pair for the crawl loop and this method
// registers it for the dashboard — one deployable calls both (§4.11), and neither may end up
// with a second instance or a second lifetime.
services.TryAddSingleton<IClaimStore>(s => new NpgsqlClaimStore(
s.GetRequiredService<NpgsqlDataSource>()));
services.TryAddSingleton<IGameStore>(s => new NpgsqlGameStore(
s.GetRequiredService<NpgsqlDataSource>()));
services.TryAddSingleton<ClaimService>();

services.Configure<IdentityPasskeyOptions>(options =>
{
Expand Down Expand Up @@ -196,11 +213,15 @@ await signIn.MakePasskeyRequestOptionsAsync(user: null),
: Results.Unauthorized();
});

// §8.1's on-demand check. It does not dial anything itself — it records that the claimant
// asked, and the crawler's own scheduler is what brings the probe forward. Keeping the two
// apart is what stops a button on a public page becoming a way to make us connect to a
// stranger's server on demand: the rate limit is per claim, and a claim cannot exist for a
// game nobody has been offered.
// §8.1's on-demand check. It does not dial anything itself — it brings the game's crawl
// targets forward and the crawler's own loop does the dialling, under CRAWL DELAY and §7.2's
// address gate. Keeping the two apart is what stops a button on a public page becoming a way
// to make us connect to a stranger's server on demand: the rate limit is per claim, and a
// claim cannot exist for a game nobody has been offered.
//
// For as long as this existed it moved nothing at all — due-ness comes only from
// crawl_target.next_probe_at, and this wrote last_checked_at and an audit event. See
// ClaimService.RequestCheckAsync and IOnDemandProbes.
app.MapPost("/g/{slug}/claim/check", async (
HttpContext context,
UserManager<MuiUser> users,
Expand Down
27 changes: 19 additions & 8 deletions src/MUI.Web/Components/Pages/Claim.razor
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,25 @@ else

<form method="post" action="/g/@Slug/claim/check">
<AntiforgeryToken />
<button type="submit" disabled="@(!CanCheck)">Check now</button>
@if (!CanCheck)
{
<span class="faint">
Just looked. You can ask again in a few minutes — the button dials a real
server, so it is rationed.
</span>
}
<button type="submit" disabled="@(!CanCheck)">Look sooner</button>
@*
It moves your game to the front of the crawl queue; the crawler still does the
dialling, on its own schedule and under CRAWL DELAY. Saying it "dials a real
server" was both an overstatement and — for as long as nothing moved the
schedule — a description of something that did not happen at all.
*@
<span class="faint">
@if (CanCheck)
{
@("Brings your game to the front of the queue. We dial on our own schedule, "
+ "so this is sooner rather than now.")
}
else
{
@("Just asked. You can ask again in a few minutes — it is rationed because it "
+ "makes us connect to a real server sooner than we would have.")
}
</span>
</form>
}
</section>
Expand Down
29 changes: 23 additions & 6 deletions src/MUI.Web/Data/PostgresData.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using MUI.Catalog;
using MUI.Catalog.Persistence;

using Microsoft.Extensions.DependencyInjection.Extensions;

using Npgsql;

namespace MUI.Web.Data;
Expand Down Expand Up @@ -49,13 +52,27 @@ public static class PostgresData
/// </remarks>
public static void AddPostgresCatalogue(this IServiceCollection services, string connectionString)
{
services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
ArgumentNullException.ThrowIfNull(services);

// TryAdd throughout, and the availability store registered once and exposed through its
// interfaces rather than newed per interface. Both matter because AddMuiCrawler registers
// the same objects for the crawl loop and one deployable calls both (§4.11): with AddSingleton
// this method won the IAvailabilityStore registration while the crawler's TryAdd was skipped,
// leaving the concrete type and IReachableHistory pointing at a SECOND instance. Harmless on
// one pool, and a direct contradiction of the comment in the crawler that says the store is
// registered once because two would be two connection paths answering one question.
services.TryAddSingleton(_ => NpgsqlDataSource.Create(connectionString));

// The registry is a lookup table with no state, so the shared instance rather than a second
// one — which is also what the crawler registers.
services.TryAddSingleton<IFieldRegistry>(FieldRegistry.Instance);

services.TryAddSingleton(s => new NpgsqlAvailabilityStore(s.GetRequiredService<NpgsqlDataSource>()));
services.TryAddSingleton<IAvailabilityStore>(s => s.GetRequiredService<NpgsqlAvailabilityStore>());
services.TryAddSingleton<IReachableHistory>(s => s.GetRequiredService<NpgsqlAvailabilityStore>());
services.TryAddSingleton<IAvailabilityHistory, StoredAvailabilityHistory>();

services.AddSingleton<IFieldRegistry, FieldRegistry>();
services.AddSingleton<IAvailabilityStore>(s =>
new NpgsqlAvailabilityStore(s.GetRequiredService<NpgsqlDataSource>()));
services.AddSingleton<IAvailabilityHistory, StoredAvailabilityHistory>();
services.AddSingleton<IGameQueries>(s => new NpgsqlGameQueries(
services.TryAddSingleton<IGameQueries>(s => new NpgsqlGameQueries(
s.GetRequiredService<NpgsqlDataSource>(),
s.GetRequiredService<IFieldRegistry>()));
}
Expand Down
Loading