# ADR: Accounts and per-user bids

Status: accepted, 2026-09-03. Steve asked for the single anonymous
in-memory buyer to be replaced by signed-in users whose bids persist. The decision he
had already made, and which this follows: ASP.NET Core Identity with a JWT.

## In plain words

This page gives every visitor their own account and their own bids (ASP.NET Core Identity), so two people can bid against each other and the site can tell them apart. The sign-in is a signed token kept in a cookie the page's scripts cannot read (a JWT in an httpOnly cookie), and browsing stays open without an account.

What that is worth: a developer has a small, readable example of token sign-in done safely, and the organization's auction makes its central claim true: a bid belongs to someone and survives a restart.

## Context

ADR: The relational store gave bids somewhere to live. It did not give them an
owner. `BidRow` was keyed on the vehicle alone, `BidService` held one
dictionary, and the comment on it said what that meant: "the single anonymous
buyer's bids".

So "you are the high bidder" was a statement about the only person in the room,
and the only thing that could take a lead away was the simulated room from
ADR: Competing bidders. Two visitors on the live site shared one set of bids and
neither could tell.

## Decision

**Identity for the accounts, and nothing else from it.** `AddIdentityCore` gives
`UserManager<YardUser>`, the password hasher, the normalised lookups and the
seven tables that go with them. What it deliberately does not add is Identity's
own cookie authentication, because the session here is a token this service
signs and reads. Writing the account tables by hand would be a week of getting
the boring half of authentication subtly wrong.

**A JWT in an httpOnly cookie.** Three choices in one sentence, so each on its
own:

*A token rather than a server session*, because the API is stateless and a
signature is cheaper to check than a lookup. Nothing here needs to revoke a
session mid-flight; if it did, this would be the wrong answer.

*A cookie rather than the `Authorization` header*, because the browser sends it
without the page having to hold it.

*httpOnly rather than `localStorage`*, which is the part that matters. A bearer
token is the user: whoever holds it is them. `localStorage` hands it to every
script that runs on the page, including anything that ever gets injected into
one. httpOnly means the page never has it and cannot leak it, and the cost is
that the client code gets simpler rather than harder.

**The signing key is configuration, and its absence is a random key.** Without
`Auth:SigningKey` the process invents thirty-two random bytes and, since
1.0.0.109, logs a warning that it did; until then the comment promised the log
and nothing wrote it. The consequence is honest and stated in the log: a deploy
signs everybody out, which is what the deployed containers did on every
roll until 1.0.0.111, when the deploy began handing both containers one key
from a repository secret, substituted into the container spec at roll time
like the connection strings (ADR: Three readers with no memory of the
project). The alternative that a repository must never take is a committed
default, because a signing key in source control is every session forever,
for anyone who reads the repository.

**Two indexes over the same bids.** `BidService` keeps them by vehicle and by
user, because two questions are asked at very different rates. What does this
vehicle stand at is asked a hundred thousand times per listing request; what
have I bid is asked once. The first is the hot path and stays a single
dictionary lookup, exactly as it was when there was one buyer.

**One price for everybody.** `Apply` takes no user on purpose. A listing shows
one number, and a price that depended on who was looking would be a different
auction per visitor.

**Reads stay open; the three endpoints that change something refuse.** Placing a
bid, buying now, and the reset all require an account, and so does the bid
history. `GET /api/bids` answers an empty map when signed out rather than 401,
because the page asks for it on every load and "you have no bids" is the true
answer for somebody who has not signed in. An auction nobody can watch without
signing up is a worse demo and no safer.

**Wrong password and no such account get the same answer.** Two messages would
be an endpoint that tells a stranger which email addresses are registered here.

**The password rule is length and nothing else.** Eight characters, no required
symbol, no required digit, no required case. The composition rules mostly teach
people to write the password down, which NIST 800-63B says at more length than
this record has room for.

## In the code

Issuing the session (`api/TheYard.Api/Tokens.cs`):

```csharp
/// <summary>
/// The claims are the user's id, the name to greet them by, and the store
/// the account lives in, and nothing else. A token is sent on every
/// request and is readable by anyone holding it, so anything in here is
/// both bandwidth and disclosure; everything the application needs beyond
/// identity it can look up.
/// </summary>
public string Issue(string userId, string email, string store) => Issue(userId, email, store, _lifetime);

/// <summary>The same token with a lifetime of the caller's choosing; the tests use it to make a token that is already a few days old.</summary>
public string Issue(string userId, string email, string store, TimeSpan lifetime)
{
    var handler = new JsonWebTokenHandler();
    return handler.CreateToken(new SecurityTokenDescriptor
    {
        Issuer = Issuer,
        Audience = Audience,
        Subject = new ClaimsIdentity(
        [
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Name, email),
            new Claim(StoreClaim, store),
        ]),
        Expires = DateTime.UtcNow.Add(lifetime),
        SigningCredentials = _credentials,
    });
}

// #region reset-tokens
/// <summary>
/// A password reset link's token: the same signature as a session, a
/// different audience so the session pipeline refuses it, an hour of
/// life, and a fingerprint of the password hash it was minted against so
/// it dies the moment the password changes (ADR: Accounts and per-user
/// bids, addendum). No table of tokens to keep, no token provider that
/// needs a key ring the container does not have.
/// </summary>
private const string ResetAudience = "theyard-reset";
public static readonly TimeSpan ResetLifetime = TimeSpan.FromHours(1);

public string IssueReset(string userId, string store, string? passwordHash)
{
    var handler = new JsonWebTokenHandler();
    return handler.CreateToken(new SecurityTokenDescriptor
    {
        Issuer = Issuer,
        Audience = ResetAudience,
        Subject = new ClaimsIdentity(
        [
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(StoreClaim, store),
            new Claim("fingerprint", Fingerprint(passwordHash)),
        ]),
        Expires = DateTime.UtcNow.Add(ResetLifetime),
        SigningCredentials = _credentials,
    });
}

/// <summary>The claims of a valid, unexpired reset token, or null for anything else, a session token included.</summary>
public async Task<(string UserId, string Store, string Fingerprint)?> ReadResetAsync(string? token)
{
    if (string.IsNullOrWhiteSpace(token))
    {
        return null;
    }

    var handler = new JsonWebTokenHandler();
    var parameters = Validation.Clone();
    parameters.ValidAudience = ResetAudience;
    var result = await handler.ValidateTokenAsync(token, parameters);
    if (!result.IsValid)
    {
        return null;
    }

    // The handler writes the long claim type as its short name and, on
    // its own, reads it back short; the bearer pipeline maps it long. Both
    // spellings are read so this does not depend on which handler minted it.
    string? id = (result.ClaimsIdentity.FindFirst(ClaimTypes.NameIdentifier) ?? result.ClaimsIdentity.FindFirst("nameid"))?.Value;
    string? store = result.ClaimsIdentity.FindFirst(StoreClaim)?.Value;
    string? fingerprint = result.ClaimsIdentity.FindFirst("fingerprint")?.Value;
    return id is null || store is null || fingerprint is null ? null : (id, store, fingerprint);
}

/// <summary>Sixteen hex characters of the password hash's SHA-256; the hash itself never leaves the store.</summary>
public static string Fingerprint(string? passwordHash) =>
    Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(passwordHash ?? "")).AsSpan(0, 8));
// #endregion reset-tokens

/// <summary>
/// The session cookie's options: HttpOnly, and Secure when the browser reached
/// us over TLS. Behind the edge this process is spoken to over plain HTTP; the
/// forwarded-headers middleware reads X-Forwarded-Proto from the proxies it
/// trusts and sets the request's scheme, so `IsHttps` is true on a request that
/// was HTTPS the whole way to the visitor (ADR: The order of the request pipeline).
/// Until then this read the header itself (ADR: Edge deploy economics).
/// </summary>
/// <param name="context">The request the cookie answers.</param>
/// <param name="lifetime">How long the cookie lives.</param>
public static CookieOptions CookieFor(HttpContext context, TimeSpan lifetime) => new()
{
    HttpOnly = true,
    Secure = context.Request.IsHttps,
    SameSite = SameSiteMode.Lax,
    Path = "/",
    MaxAge = lifetime,
};
```
*Live from [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Tokens.cs#L119-L225), region `issue`, read from this build at 1d6b50b.*

Reading it back, and who is asking (`api/TheYard.Api/Tokens.cs`):

```csharp
/// <summary>
/// The caller's account id on an endpoint that required one. Throwing here
/// would mean authorization let through a token with no subject, which is
/// not a case to handle gracefully; it is a case to find out about.
/// </summary>
public static string UserId(this HttpContext context) =>
    context.User.FindFirstValue(ClaimTypes.NameIdentifier)
    ?? throw new InvalidOperationException("An authorized request carried no account id");

/// <summary>The caller's account id, or null where signing in is optional.</summary>
public static string? UserIdOrNull(this HttpContext context) =>
    context.User.FindFirstValue(ClaimTypes.NameIdentifier);
```
*Live from [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Tokens.cs#L233-L244), region `who`, read from this build at 1d6b50b.*

Two indexes, one fact (`api/TheYard.Application/BidService.cs`):

```csharp
/// <summary>
/// Both indexes, from one fact. The standing only moves up: a later bid
/// from somebody else at a lower number does not exist, because BidRules
/// rejected it before this was called, and a replayed bid from the store
/// arriving out of order should not be able to lower the price either.
/// </summary>
private void Record(string userId, string vehicleId, BidState state)
{
    _byUser.GetOrAdd(userId, _ => new ConcurrentDictionary<string, BidState>(StringComparer.Ordinal))[vehicleId] =
        state;

    _standing.AddOrUpdate(
        vehicleId,
        _ => new VehicleStanding(state.Amount, state.BidCount, userId, state.WonBuyNow, state.AtMs),
        (_, held) => state.Amount > held.Amount
            ? new VehicleStanding(
                state.Amount,
                Math.Max(held.BidCount, state.BidCount),
                userId,
                held.SoldBuyNow || state.WonBuyNow,
                state.AtMs)
            : held with
            {
                BidCount = Math.Max(held.BidCount, state.BidCount),
                SoldBuyNow = held.SoldBuyNow || state.WonBuyNow,
            });
}
```
*Live from [`api/TheYard.Application/BidService.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/BidService.cs#L171-L197), region `record`, read from this build at 1d6b50b.*

What a badge is told (`api/TheYard.Application/Auction.cs`):

```csharp
/// <summary>
/// One buyer's bids, each with everybody else's answer folded in. A vehicle bought outright is
/// never outbid, whatever anyone does afterwards: the sale already happened.
/// <para>The buyer's own bids are read first and everybody else's second. The reads are not
/// atomic, so a bid placed between them shows in one and not the other; reading the others
/// second means the stale answer is "you have been outbid" rather than "you are winning", and
/// only the second of those can cost somebody an auction.</para>
/// </summary>
public IReadOnlyDictionary<string, BidView> BidsOf(string userId)
{
    var mine = bids.SnapshotFor(userId);
    var standing = bids.Standing();
    var room = market.Snapshot();
    var views = new Dictionary<string, BidView>(StringComparer.Ordinal);
    foreach (var (id, bid) in mine)
    {
        standing.TryGetValue(id, out var held);
        room.TryGetValue(id, out var against);

        int highest = Math.Max(held?.Amount ?? bid.Amount, against?.Amount ?? 0);
        bool someoneElseHolds = held is not null && held.Amount > bid.Amount;
        bool roomHolds = against is not null && against.Amount > bid.Amount;
        bool outbid = !bid.WonBuyNow && (someoneElseHolds || roomHolds);

        views[id] = new BidView(
            bid.Amount,
            // The count everybody has added to, not one buyer's, so the count never falls
            // when a buyer retakes a lead somebody else raised.
            Math.Max(Math.Max(bid.BidCount, held?.BidCount ?? 0), against?.BidCount ?? 0),
            bid.WonBuyNow,
            bid.AtMs,
            outbid,
            against?.Amount,
            highest);
    }
    return views;
}

/// <summary>
/// One buyer's history for the account page, newest first: each vehicle's title beside the
/// buyer's standing on it, or "(withdrawn)" when the vehicle has left the catalogue.
/// </summary>
public BidHistory HistoryOf(string userId)
{
    var entries = BidsOf(userId)
        .OrderByDescending(entry => entry.Value.AtMs)
        .Select(entry => new BidHistoryEntry(
            entry.Key,
            inventory.GetById(entry.Key) is { } v ? $"{v.Year} {v.Make} {v.Model}" : "(withdrawn)",
            entry.Value))
        .ToList();
    return new BidHistory(entries.Count, entries);
}
```
*Live from [`api/TheYard.Application/Auction.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/Auction.cs#L105-L157), region `views`, read from this build at 1d6b50b.*

The tests, including the two accounts and the restart
(`api/TheYard.Tests/AuthTests.cs`):

```csharp
/// <summary>
/// Accounts, and the bids that belong to them (ADR: Accounts and per-user
/// bids). The auction was one anonymous buyer until this file existed, which
/// made "you are the high bidder" a statement about the only person in the
/// room.
///
/// These tests share one database file on purpose: the interesting claims are
/// about two people and about a restart, and neither can be made by one
/// process with a scratch database.
///
/// <para>Three classes over one base rather than one class, because xUnit
/// runs a class's tests one after another and the other classes beside it,
/// and these tests boot a host each: as one class they were the suite's
/// longest single line on the document store, eighty-six seconds while the
/// rest of the suite finished around them (ADR: The five-minute gate).
/// Nothing else changed; the sign-ins, the lockout and the bids are the
/// same tests under the same names.</para>
/// </summary>
public abstract class AuthTestBase : IDisposable
{
    private readonly string _file =
        Path.Combine(Path.GetTempPath(), $"theyard-auth-{Guid.NewGuid():N}.db");

    private string Connection => $"Data Source={_file};Pooling=False";

    // Unique per test instance, because xUnit makes a new instance per test
    // and the store is not always a fresh file: run with every application
    // booted on the document store, the whole class shares one users container,
    // and the second test to register "first@example.com" would be registering
    // a duplicate (ADR: A second store on Cosmos DB, and what it costs).
    protected readonly string _first = $"first-{Guid.NewGuid():N}@example.com";
    protected readonly string _second = $"second-{Guid.NewGuid():N}@example.com";

    protected WebApplicationFactory<Program> Api() =>
        new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
        {
            builder.UseSetting("ConnectionStrings:Yard", Connection);
            // A fixed key, so a token minted by one process is still valid in
            // the next one. Without it every restart invents a new key and the
            // restart test would be measuring the key rather than the bids.
            builder.UseSetting("Auth:SigningKey", "a-signing-key-for-tests-only-not-a-secret");
        });

    protected static async Task<HttpResponseMessage> Register(HttpClient client, string email) =>
        await client.PostAsJsonAsync("/api/auth/register", new { email, password = "correct horse" });

    protected static async Task<HttpResponseMessage> LogIn(HttpClient client, string email) =>
        await client.PostAsJsonAsync("/api/auth/login", new { email, password = "correct horse" });

    /// <summary>
    /// Everything a rejected sign-in tells the caller, as one comparable
    /// string. Two request identifiers are left out and nothing else is: the
    /// W3C `trace_id` the telemetry attaches (ADR: Observability) and the
    /// `traceId` ProblemDetails adds of its own accord. Both are a fresh value
    /// on every response of every kind, and neither says anything about the
    /// account. Everything else has to match, which is a stronger claim than
    /// comparing a chosen few fields would be.
    /// </summary>
    protected static readonly string[] RequestIds = ["trace_id", "traceId"];

    protected static async Task<string> Told(HttpResponseMessage response)
    {
        using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
        return string.Join(
            "&",
            body.RootElement.EnumerateObject()
                .Where(property => !RequestIds.Contains(property.Name))
                .Select(property => $"{property.Name}={property.Value}")
                .Order(StringComparer.Ordinal));
    }

    /// <summary>
    /// A bid the server accepted, or a failure that says why it did not. Every
    /// rejection carries its sentence in `detail` (ADR: Error handling).
    /// </summary>
    protected static async Task<HttpResponseMessage> Bid(
        HttpClient client, string vehicleId, int amount)
    {
        var placed = await client.PostAsJsonAsync(
            $"/api/vehicles/{vehicleId}/bids", new { amount });
        Assert.True(
            placed.IsSuccessStatusCode,
            $"the bid of {amount} on {vehicleId} was refused: "
                + await placed.Content.ReadAsStringAsync());
        return placed;
    }

    /// <summary>
    /// A live vehicle with time left on it. Sorted by most bids rather than
    /// the default, which is EndingSoonest and therefore hands back the one
    /// auction in the dataset with the least time on it. AuctionClock's NowMs
    /// is real wall-clock read per request, so that vehicle can close between
    /// the read and the bid, and under the full suite it does.
    /// </summary>
    protected static async Task<(string Id, int MinNext)> ALiveVehicle(HttpClient client)
    {
        using var page = JsonDocument.Parse(
            await client.GetStringAsync(
                $"/api/vehicles?status=live&sort=most-bids&limit=25"));
        var live = page.RootElement.GetProperty("vehicles");
        Assert.True(live.GetArrayLength() > 0, "the schedule always has a live auction");
        // Not one anybody has bought: a sold vehicle takes no bid from anyone
        // (ADR: Accounts and per-user bids, the addendum on the second buyer),
        // and on the document store the test containers remember yesterday's
        // runs.
        var vehicle = live.EnumerateArray().First(row => !row.GetProperty("sold").GetBoolean());
        return (vehicle.GetProperty("id").GetString()!, vehicle.GetProperty("min_next_bid").GetInt32());
    }

    /// <summary>
    /// A live vehicle with a Buy Now price above its standing bid that nobody
    /// has bought, and not the most-bid one: the bidding tests open that one,
    /// and a purchase here would refuse their bids for as long as the store
    /// remembered it. Above its standing bid, because the synthetic catalogue
    /// holds vehicles already bid past their Buy Now price, and the sale price
    /// this test asserts is only the price shown when the purchase raised it.
    /// </summary>
    protected static async Task<(string Id, int BuyNow)> AVehicleToBuy(HttpClient client)
    {
        using var page = JsonDocument.Parse(
            await client.GetStringAsync(
                $"/api/vehicles?status=live&sort=most-bids&limit=100"));
        var vehicle = page.RootElement.GetProperty("vehicles").EnumerateArray()
            .Skip(1)
            .LastOrDefault(row =>
                row.GetProperty("buy_now_price").ValueKind == JsonValueKind.Number
                && !row.GetProperty("sold").GetBoolean()
                && (row.GetProperty("current_bid").ValueKind != JsonValueKind.Number
                    || row.GetProperty("current_bid").GetInt32() < row.GetProperty("buy_now_price").GetInt32()));
        Assert.True(
            vehicle.ValueKind == JsonValueKind.Object,
            "the live page should hold a priced vehicle nobody has bought");
        return (vehicle.GetProperty("id").GetString()!, vehicle.GetProperty("buy_now_price").GetInt32());
    }

    public void Dispose()
    {
        foreach (string leftover in new[] { _file, _file + "-wal", _file + "-shm" })
        {
            try
            {
                File.Delete(leftover);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // A test's scratch file that outlives the test is litter, not a failure.
            }
        }
        GC.SuppressFinalize(this);
    }
}

/// <summary>Registering, signing in, signing out, and the password rule.</summary>
public class AuthTests : AuthTestBase
{
    [Fact]
    public async Task Registering_signs_you_in_and_never_hands_the_page_the_token()
    {
        await using var api = Api();
        var client = api.CreateClient();

        var response = await Register(client, _first);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        string body = await response.Content.ReadAsStringAsync();
        Assert.Contains(_first, body, StringComparison.Ordinal);

        string cookie = Assert.Single(response.Headers.GetValues("Set-Cookie"));
        // httpOnly is the whole reason the token is in a cookie rather than in
        // the response: a page that can read its own token can leak it.
        Assert.Contains("httponly", cookie, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("samesite=lax", cookie.ToLowerInvariant(), StringComparison.Ordinal);
        Assert.DoesNotContain("token", body, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Wrong_credentials_say_the_same_thing_as_no_account()
    {
        await using var api = Api();
        var client = api.CreateClient();
        await Register(client, _first);

        var stranger = api.CreateClient();
        var noSuchAccount = await stranger.PostAsJsonAsync(
            "/api/auth/login", new { email = "nobody@example.com", password = "correct horse" });
        var wrongPassword = await stranger.PostAsJsonAsync(
            "/api/auth/login", new { email = _first, password = "not the password" });

        Assert.Equal(HttpStatusCode.Unauthorized, noSuchAccount.StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, wrongPassword.StatusCode);
        // Two different messages would be an endpoint that tells a stranger
        // which email addresses have accounts here. Everything the caller is
        // told is compared, minus the trace id, which is a fresh request id on
        // every response by design (ADR: Error handling) and says nothing
        // about whether the account exists.
        Assert.Equal(await Told(noSuchAccount), await Told(wrongPassword));
    }

    [Fact]
    public async Task Signing_out_ends_the_session()
    {
        await using var api = Api();
        var client = api.CreateClient();
        await Register(client, _first);
        Assert.Contains(_first, await client.GetStringAsync("/api/auth/me"), StringComparison.Ordinal);

        await client.PostAsync("/api/auth/logout", content: null);

        string me = await client.GetStringAsync("/api/auth/me");
        Assert.Contains("\"signed_in\":false", me, StringComparison.Ordinal);
        var (id, amount) = await ALiveVehicle(client);
        var refused = await client.PostAsJsonAsync(
            $"/api/vehicles/{id}/bids", new { amount });
        Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode);
    }

    [Fact]
    public async Task A_short_password_is_refused_with_something_a_person_can_act_on()
    {
        await using var api = Api();
        var client = api.CreateClient();

        var response = await client.PostAsJsonAsync(
            "/api/auth/register", new { email = _first, password = "short" });

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
        using var problem = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
        Assert.Contains(
            "Eight characters",
            problem.RootElement.GetProperty("detail").GetString()!,
            StringComparison.Ordinal);
    }
}

/// <summary>The lockout, whose sign-ins are the most round trips of any test here, so it runs beside the others rather than after them.</summary>
public class AuthLockoutTests : AuthTestBase
{
    // #region lockout
    [Fact]
    public async Task Five_wrong_passwords_lock_the_account_and_the_right_one_stops_working()
    {
        await using var api = Api();
        var client = api.CreateClient();
        string email = $"locked-{Guid.NewGuid():N}@example.com";
        var registered = await client.PostAsJsonAsync(
            "/api/auth/register", new { email, password = "correct horse battery" });
        Assert.Equal(HttpStatusCode.OK, registered.StatusCode);

        for (int attempt = 1; attempt <= 5; attempt++)
        {
            var wrong = await client.PostAsJsonAsync("/api/auth/login", new { email, password = "not it" });
            Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
        }

        // The real password, now refused. This is the assertion that proves the
        // lockout is doing something: without it the sixth guess is as cheap as
        // the first, and CheckPasswordAsync on its own never counts a failure.
        var right = await client.PostAsJsonAsync(
            "/api/auth/login", new { email, password = "correct horse battery" });
        Assert.Equal(HttpStatusCode.Unauthorized, right.StatusCode);

        // And it says the same thing it says to a wrong password, because a
        // reply that distinguishes them tells a stranger the address is real.
        Assert.Contains(
            "do not match an account",
            await right.Content.ReadAsStringAsync(),
            StringComparison.Ordinal);
    }

    [Fact]
    public async Task A_correct_password_clears_the_count_before_it_reaches_the_limit()
    {
        await using var api = Api();
        var client = api.CreateClient();
        string email = $"nearly-{Guid.NewGuid():N}@example.com";
        await client.PostAsJsonAsync("/api/auth/register", new { email, password = "correct horse battery" });

        for (int attempt = 1; attempt <= 4; attempt++)
        {
            await client.PostAsJsonAsync("/api/auth/login", new { email, password = "not it" });
        }

        var right = await client.PostAsJsonAsync(
            "/api/auth/login", new { email, password = "correct horse battery" });
        Assert.Equal(HttpStatusCode.OK, right.StatusCode);

        // Four more wrong guesses do not lock it, because the success reset the
        // count. Without the reset, five wrong guesses spread over a week would
        // eventually lock somebody out of their own account.
        for (int attempt = 1; attempt <= 4; attempt++)
        {
            await client.PostAsJsonAsync("/api/auth/login", new { email, password = "not it" });
        }

        var again = await client.PostAsJsonAsync(
            "/api/auth/login", new { email, password = "correct horse battery" });
        Assert.Equal(HttpStatusCode.OK, again.StatusCode);
    }
    // #endregion lockout
}

/// <summary>Bids that belong to accounts, two people in one room, and the restart.</summary>
public class AuthBidTests : AuthTestBase
{
    [Fact]
    public async Task Bidding_without_an_account_is_refused()
    {
        await using var api = Api();
        var client = api.CreateClient();
        var (id, amount) = await ALiveVehicle(client);

        var bid = await client.PostAsJsonAsync(
            $"/api/vehicles/{id}/bids", new { amount });
        var buyNow = await client.PostAsJsonAsync(
            $"/api/vehicles/{id}/buy-now", new { });
        var reset = await client.DeleteAsync("/api/bids");
        var history = await client.GetAsync("/api/bids/history");
        // The room, too. This one was open, and it is the one that mattered
        // most: it takes no user and it advances state derived from every
        // account, so a stranger with curl and a loop could counter-bid every
        // auction a signed-in visitor was winning, and nothing recorded who
        // (ADR: The room needs an account too).
        var tick = await client.PostAsJsonAsync("/api/market/tick", new { });

        Assert.Equal(HttpStatusCode.Unauthorized, bid.StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, buyNow.StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, reset.StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, history.StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, tick.StatusCode);

        // Reading is still open. An auction nobody can watch without signing up
        // is a worse demo and no safer.
        Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/bids")).StatusCode);
        Assert.Equal("{}", (await client.GetStringAsync("/api/bids")).Trim());
    }

    [Fact]
    public async Task A_second_account_outbids_the_first_and_both_are_told_the_truth()
    {
        await using var api = Api();

        var first = api.CreateClient();
        await Register(first, _first);
        var (id, opening) = await ALiveVehicle(first);
        await Bid(first, id, opening);

        // A different client is a different browser: its own cookie jar, its
        // own account.
        var second = api.CreateClient();
        await Register(second, _second);
        using var detail = JsonDocument.Parse(
            await second.GetStringAsync($"/api/vehicles/{id}"));
        int nextUp = detail.RootElement.GetProperty("min_next_bid").GetInt32();
        Assert.True(nextUp > opening, "the second account has to clear the first");
        await Bid(second, id, nextUp);

        using var mineFirst = JsonDocument.Parse(await first.GetStringAsync("/api/bids"));
        using var mineSecond = JsonDocument.Parse(await second.GetStringAsync("/api/bids"));

        var one = mineFirst.RootElement.GetProperty(id);
        var two = mineSecond.RootElement.GetProperty(id);
        Assert.True(one.GetProperty("outbid").GetBoolean(), "the first account has been outbid");
        Assert.False(two.GetProperty("outbid").GetBoolean(), "the second account holds it");
        Assert.Equal(opening, one.GetProperty("amount").GetInt32());
        Assert.Equal(nextUp, two.GetProperty("amount").GetInt32());
        // Both are told the same thing about the vehicle, whatever they hold.
        Assert.Equal(nextUp, one.GetProperty("highest_amount").GetInt32());
        Assert.Equal(nextUp, two.GetProperty("highest_amount").GetInt32());

        // And each sees only their own.
        Assert.Single(mineFirst.RootElement.EnumerateObject());
        Assert.Single(mineSecond.RootElement.EnumerateObject());
    }

    [Fact]
    public async Task Both_accounts_and_both_bids_survive_a_restart()
    {
        string id;
        int firstAmount;
        int secondAmount;

        await using (var api = Api())
        {
            var first = api.CreateClient();
            await Register(first, _first);
            (id, firstAmount) = await ALiveVehicle(first);
            await Bid(first, id, firstAmount);

            var second = api.CreateClient();
            await Register(second, _second);
            using var detail = JsonDocument.Parse(
                await second.GetStringAsync($"/api/vehicles/{id}"));
            secondAmount = detail.RootElement.GetProperty("min_next_bid").GetInt32();
            await Bid(second, id, secondAmount);
        }

        await using var restarted = Api();
        var backAsFirst = restarted.CreateClient();
        // The account outlived the process, so signing in again is a login and
        // not a registration.
        Assert.Equal(HttpStatusCode.OK, (await LogIn(backAsFirst, _first)).StatusCode);

        using var mine = JsonDocument.Parse(await backAsFirst.GetStringAsync("/api/bids"));
        var restoredBid = mine.RootElement.GetProperty(id);
        Assert.Equal(firstAmount, restoredBid.GetProperty("amount").GetInt32());
        Assert.True(restoredBid.GetProperty("outbid").GetBoolean(), "the second account still holds it");
        Assert.Equal(secondAmount, restoredBid.GetProperty("highest_amount").GetInt32());
    }

    [Fact]
    public async Task The_history_endpoint_lists_mine_and_names_the_vehicle()
    {
        await using var api = Api();
        var client = api.CreateClient();
        await Register(client, _first);
        var (id, amount) = await ALiveVehicle(client);
        await Bid(client, id, amount);

        using var history = JsonDocument.Parse(await client.GetStringAsync("/api/bids/history"));

        Assert.Equal(1, history.RootElement.GetProperty("count").GetInt32());
        var entry = history.RootElement.GetProperty("bids")[0];
        Assert.Equal(id, entry.GetProperty("vehicle_id").GetString());
        Assert.False(
            string.IsNullOrWhiteSpace(entry.GetProperty("title").GetString()),
            "a history nobody can read is a list of identifiers");
        Assert.Equal(amount, entry.GetProperty("bid").GetProperty("amount").GetInt32());
    }

    // #region session-per-store
    /// <summary>
    /// A session bids where its account is. The header can put one request on
    /// the other store, and until 1.0.0.111 a bid there landed under an id
    /// that store has no account for: a 500 from the relational store's
    /// foreign key, silence from the document store (ADR: Three readers with
    /// no memory of the project). The token names the store that opened it,
    /// so a token minted for another store is refused with a sentence, and
    /// the same account's own session on this store still bids.
    /// </summary>
    [Fact]
    public async Task A_session_opened_on_another_store_cannot_bid_here()
    {
        await using var api = Api();
        var client = api.CreateClient();
        var registered = await Register(client, _first);
        Assert.True(registered.IsSuccessStatusCode, await registered.Content.ReadAsStringAsync());
        string userId;
        using (var scope = api.Services.CreateScope())
        {
            // The account's id, from the store the registration went to: a scope
            // with no request on it resolves the container's default store.
            var users = scope.ServiceProvider.GetRequiredService<UserManager<YardUser>>();
            userId = (await users.FindByEmailAsync(_first))!.Id;
        }
        using var stores = JsonDocument.Parse(await client.GetStringAsync("/api/stores"));
        string thisStore = stores.RootElement.GetProperty("current").GetString()!;
        string otherStore = thisStore == "sql" ? "cosmos" : "sql";
        var (id, amount) = await ALiveVehicle(client);

        // The same key the test host signs with, the same account, the other
        // store's name in the claim: a valid token that is on the wrong store.
        var issuer = new TokenIssuer("a-signing-key-for-tests-only-not-a-secret", TimeSpan.FromDays(7));
        var elsewhere = api.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false });
        string token = issuer.Issue(userId, _first, otherStore);
        elsewhere.DefaultRequestHeaders.Add("Cookie", $"{TokenIssuer.CookieName}={token}");

        var refused = await elsewhere.PostAsJsonAsync(
            $"/api/vehicles/{id}/bids", new { amount });
        var refusedPurchase = await elsewhere.PostAsJsonAsync(
            $"/api/vehicles/{id}/buy-now", new { });

        foreach (var answer in new[] { refused, refusedPurchase })
        {
            Assert.Equal(HttpStatusCode.Unauthorized, answer.StatusCode);
            using var problem = JsonDocument.Parse(await answer.Content.ReadAsStringAsync());
            Assert.Contains("opened on another store", problem.RootElement.GetProperty("detail").GetString());
        }
        Assert.Equal("{}", (await elsewhere.GetStringAsync("/api/bids")).Trim());
        // And the account's own session, on this store, bids as it always did.
        await Bid(client, id, amount);
    }
    // #endregion session-per-store

    // #region sold
    /// <summary>
    /// One vehicle, one buyer, and everybody else told so. The second account
    /// reads `sold` on the vehicle, in the detail and in the listing, and its
    /// bid at the buy-now price and its own Buy Now are both refused with the
    /// sentence the rules keep; until 1.0.0.110 both were a second purchase of
    /// the same vehicle (ADR: Accounts and per-user bids, the addendum on the
    /// second buyer). The buyer's start-over undoes the sale at the end, which is
    /// also what keeps the shared containers on the document store from
    /// carrying a sold vehicle into tomorrow's runs.
    /// </summary>
    [Fact]
    public async Task A_vehicle_bought_outright_is_sold_to_everybody()
    {
        await using var api = Api();

        var first = api.CreateClient();
        await Register(first, _first);
        var (id, price) = await AVehicleToBuy(first);
        try
        {
            var bought = await first.PostAsJsonAsync($"/api/vehicles/{id}/buy-now", new { });
            string boughtBody = await bought.Content.ReadAsStringAsync();
            Assert.True(bought.IsSuccessStatusCode, boughtBody);
            using var answer = JsonDocument.Parse(boughtBody);
            Assert.Equal("won", answer.RootElement.GetProperty("kind").GetString());
            Assert.True(
                answer.RootElement.GetProperty("vehicle").GetProperty("sold").GetBoolean(),
                "the buyer's own answer carries the sale");

            var second = api.CreateClient();
            await Register(second, _second);
            using var detail = JsonDocument.Parse(
                await second.GetStringAsync($"/api/vehicles/{id}"));
            Assert.True(detail.RootElement.GetProperty("sold").GetBoolean(), "a stranger reads sold on the vehicle");
            Assert.Equal(price, detail.RootElement.GetProperty("current_bid").GetInt32());

            var bid = await second.PostAsJsonAsync(
                $"/api/vehicles/{id}/bids", new { amount = price });
            var again = await second.PostAsJsonAsync($"/api/vehicles/{id}/buy-now", new { });
            foreach (var refused in new[] { bid, again })
            {
                Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
                using var problem = JsonDocument.Parse(await refused.Content.ReadAsStringAsync());
                Assert.Equal(BidRules.SoldReason, problem.RootElement.GetProperty("detail").GetString());
            }
            Assert.Equal("{}", (await second.GetStringAsync("/api/bids")).Trim());

            // The listing says so too, to anybody, signed in or not.
            using var page = JsonDocument.Parse(
                await api.CreateClient().GetStringAsync(
                    $"/api/vehicles?status=live&sort=most-bids&limit=100"));
            var row = page.RootElement.GetProperty("vehicles").EnumerateArray()
                .Single(vehicle => vehicle.GetProperty("id").GetString() == id);
            Assert.True(row.GetProperty("sold").GetBoolean(), "the listing row carries the sale");
        }
        finally
        {
            await first.DeleteAsync("/api/bids");
        }

        using var after = JsonDocument.Parse(await first.GetStringAsync($"/api/vehicles/{id}"));
        Assert.False(after.RootElement.GetProperty("sold").GetBoolean(), "the buyer's start-over undoes the sale");
    }
    // #endregion sold
}
```
*Live from [`api/TheYard.Tests/AuthTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/AuthTests.cs#L14-L563), region `auth-tests`, read from this build at 1d6b50b.*

## What the suite found on the way

Giving the tests accounts turned two of them intermittently red, about one run
in three, with nothing to go on but "Expected: OK, Actual: BadRequest". The
first fix was to make the assertion carry the server's own sentence, which
every rejection on this API already has (ADR: Error handling). The reason it
reported was the auction had ended.

`AuctionClock` carries two instants, and only one of them is the anchor a test
pins. `NowMs` is real wall-clock, read per request, and liveness is judged
against it. The default sort is `EndingSoonest`, so `status=live&limit=1`
returns the auction with the least time left in the whole dataset. Under the
full suite several hosts expand a hundred thousand vehicles at once, the gap
between reading `min_next_bid` and posting the bid stretches to seconds, and
that vehicle closes inside it.

`BidFlowIntegrationTests` never had this, because it already sorted by most
bids, and so did the browser suite, whose comment says it plainly: "the default
sort's first card can expire within seconds". Three API tests had not learned
it. They have now, and the reason is written where the query is rather than in
a commit message.

Six consecutive runs is what settled it. One green run after a change that
touched the failing area proves nothing about a test that was already passing
one run in three.

## What this is not

**Not production authentication.** There is no email verification, no password
reset, no lockout after repeated failures, no refresh token, and no roles. A
real deployment would put this behind Auth0 or the organisation's SSO and keep
none of it, which is how it was run at Conway. What is here is the smallest
thing that makes the auction's central claim true: that two people can bid
against each other and the system can tell them apart.

**Not a cookie that is Secure everywhere it should be.** The origin is reached
from the edge over plain HTTP, which ADR: Edge economics records as the price of
the free tier, so `Request.IsHttps` is false on a request that was HTTPS the
whole way to the visitor. The forwarded header is what carries that fact across
the hop, and the cookie is marked Secure when it says https. The hop itself is
still plain, and a session cookie inherits that exposure along with everything
else on it; the fix is the same one that record already names.

## Consequences

- A bid belongs to somebody, survives a restart, and can be lost to another
  person rather than only to the simulated room.
- Without the store there are no accounts, so a container that falls back to the
  file readers (ADR: The relational store) is browse-only. The health check says
  which mode it is in, and the account endpoints answer 503 rather than 500,
  because nothing is broken and a dependency is missing.
- The demo's reset button cleared everybody's bids at the time, not just the
  caller's, since the simulated room's are shared and a reset that left half an
  auction standing read as a bug however carefully it was explained (superseded:
  ADR: Reset is one person's start-over).
- `BidServiceTests` changed in one mechanical way, an account argument, and in
  no other: what those tests assert about the bidding rules did not move, which
  is the evidence that the rules and the ownership are separate things.
- Two flakes that predate this work are gone with it. The API tests now bid on
  a vehicle with time left on it, and the search benchmark measures the best of
  five runs against a quarter of headroom rather than asserting `with <=
  without` under a doc comment claiming to be loose.

## Addendum, 2026-09-08: the answer that arrived late

The browser suite runs three times before a version ships, and on the second
run of 1.0.0.92 the account test went red once with nothing to go on but the
rail not showing the address. The page snapshot Playwright keeps said more:
the registration had answered 200, the heading with the address had been
visible, and by the time the test looked at the rail the whole view had gone
back to "Sign in to bid" with an empty form. Something had signed the new
account out again, and nothing had asked it to.

The page asks who is signed in once, when it opens, because the session is an
httpOnly cookie the page cannot read. That question went out to a server that
was running the whole suite at once, so it answered slowly, and in the gap the
test filled the form and registered. "Nobody" was the true answer when the
question was asked and a stale one by the time it landed, and it was applied
as if it were current. A visitor on a slow connection who types fast can do
the same thing to themselves; the suite only made it likely.

The fix is that the question remembers how many times the page has changed the
account itself, and its answer applies only if that count still stands. Every
sign-in, registration and sign-out goes through one function that bumps the
count, so the question can tell. There is no timestamp to compare and no
request to cancel, and the test that holds it is three lines: ask, change,
answer, and nothing applied.

```ts
/**
 * The page asks who is signed in once, when it opens, and the answer can land
 * after the visitor has already signed in through the form. Applied then, it
 * signs them out again: "nobody" was true when the question went out and is
 * stale by the time it comes back. The browser suite caught this once, under
 * load, where the form is filled faster than the server answers, and the
 * failure said only that the rail did not show the address.
 *
 * So the question remembers how many times the page has changed the account
 * itself, and its answer applies only if that count still stands. The count
 * is the whole mechanism: no timestamps, no cancelling, nothing to clean up.
 */
export interface AccountQuestion {
  /** The page changed the account itself: a sign-in, a registration, a sign-out. */
  changed(): void;
  /** Ask the server, and apply the answer only if nothing changed while it was out. */
  ask(apply: (account: Account) => void, signal?: AbortSignal): Promise<void>;
}

export function accountQuestion(): AccountQuestion {
  let changes = 0;
  return {
    changed: () => {
      changes += 1;
    },
    ask: async (apply, signal) => {
      const asked = changes;
      const answer = await fetchAccount(signal);
      if (changes === asked) apply(answer);
    },
  };
}
```
*Live from [`src/lib/auth.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/auth.ts#L65-L96), region `late-answer`, read from this build at 1d6b50b.*

```ts
// Who is signed in, if anyone. The session is an httpOnly cookie (one that
// page scripts cannot read), so the page has to ask the API (ADR-037). A
// failure leaves the visitor signed out, which is the safe answer.
//
// The answer is dropped if the account changed while the question was out.
// Otherwise a visitor who registers quickly could be signed out again by a
// late "nobody" answer. Every change the page makes goes through
// changeAccount, which tells the question.
const [whoIsSignedIn] = useState(accountQuestion);
const changeAccount = useCallback(
  (next: Account) => {
    whoIsSignedIn.changed();
    setAccount(next);
  },
  [whoIsSignedIn]
);
useEffect(() => {
  whoIsSignedIn.ask(setAccount).catch(() => {});
}, [whoIsSignedIn]);
```
*Live from [`src/app/hooks/useAccount.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useAccount.ts#L15-L33), region `who`, read from this build at 1d6b50b.*

The first fix considered was to make the form wait for the question before it
could submit, which would have hidden the defect behind a spinner and made
every account view a little slower for a race that lasts milliseconds. The
second was to cancel the question when the form submits, which handles the one
order of events that had been seen and not the sign-out that follows a
registration inside the same window. Counting changes handles every order the
same way.

## Addendum, 2026-09-09: the second buyer

Three readers with no memory of the project were handed the checkout on 2026-09-09
and asked to find fault, and two of them, the interviewer and the junior
developer, put the same finding at the top of their lists. It is the question
this record should have asked itself when bids got owners: what happens to the
auction when somebody buys the vehicle? The answer, read off the code, was
nothing. Buy Now recorded the sale on the buyer's own bid,
`WonBuyNow`, and the standing that everybody's overlay reads carried a
`SoldBuyNow` that the room consulted before bidding and nothing else did. So the
buyer's page said Sold, everybody else's page showed a live auction standing at
the Buy Now price, and a second account that bid that price was resolved by the
same shortcut that had sold it the first time and told it had won. Two buyers,
one vehicle, each told the truth as their own bids had it.

That is what "two visitors can outbid each other and both be told the truth"
looks like when one of the truths was never put on the wire. The sale was a
fact about the vehicle, stored as a fact about a bid.

The fix is one boolean, asked in the right place, three times. The rules take
it as a parameter and check it before everything else, including the shortcut:

```csharp
/// <summary>
/// The sentence a sold vehicle answers every bid and every purchase with.
/// One string, because the store's fact and the page's words should not be
/// able to drift apart.
/// </summary>
public const string SoldReason = "This vehicle has been sold.";

/// <summary>
/// <paramref name="sold"/> is whether anybody has bought this vehicle
/// outright, which the rules cannot know from the vehicle alone: the
/// dataset has no such field and the schedule has no such state. The caller
/// that holds everybody's standing says so, and it is asked before the
/// clock and before the buy-now shortcut, so a second account bidding the
/// buy-now price on a vehicle already bought is refused, not told it has
/// won (ADR: Accounts and per-user bids, the addendum on the second buyer).
/// </summary>
public static BidOutcome ResolveBid(Vehicle vehicle, int amount, AuctionClock clock, bool sold)
{
    if (sold)
    {
        return BidOutcome.Rejected(SoldReason);
    }
    var status = AuctionSchedule.StatusFor(vehicle.Id, clock);
    if (status == AuctionStatus.Live && vehicle.BuyNowPrice is { } buyNow && amount >= buyNow)
    {
        // Instant win, charged the buy-now price rather than the overbid.
        return BidOutcome.Won(buyNow);
    }
    if (status == AuctionStatus.Upcoming)
    {
        return BidOutcome.Rejected("This auction has not started yet.");
    }
    if (status == AuctionStatus.Ended)
    {
        return BidOutcome.Rejected("This auction has ended.");
    }

    int min = MinNextBid(vehicle);
    return amount < min
        ? BidOutcome.Rejected($"Bid must be at least ${min.ToString("N0", CultureInfo.InvariantCulture)}.")
        : BidOutcome.Accepted(amount);
}

public static BidOutcome ResolveBuyNow(Vehicle vehicle, AuctionClock clock, bool sold)
{
    if (sold)
    {
        return BidOutcome.Rejected(SoldReason);
    }
    if (vehicle.BuyNowPrice is not { } buyNow)
    {
        return BidOutcome.Rejected("This vehicle has no Buy Now price.");
    }
    return AuctionSchedule.StatusFor(vehicle.Id, clock) == AuctionStatus.Live
        ? BidOutcome.Won(buyNow)
        : BidOutcome.Rejected("Buy Now is only available while the auction is live.");
}
```
*Live from [`api/TheYard.Domain/BidRules.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/BidRules.cs#L44-L100), region `sold`, read from this build at 1d6b50b.*

The service supplies it from the standing it already kept, under the same gate
as the write that makes it true, so two purchases cannot both find it false:

```csharp
/// <summary>
/// Whether anybody has bought this vehicle outright. One dictionary read,
/// because it is asked once per vehicle on every listing answer, the way
/// Apply is. The fact lives in the standing, and what makes it a rule is
/// that it is asked before the next bid is taken, so a sold vehicle has no
/// second buyer (ADR: Accounts and per-user bids, the addendum on the
/// second buyer).
/// </summary>
public bool IsSold(string vehicleId) =>
    _standing.TryGetValue(vehicleId, out var held) && held.SoldBuyNow;
```
*Live from [`api/TheYard.Application/BidService.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/BidService.cs#L122-L131), region `sold`, read from this build at 1d6b50b.*

And the wire carries it on every vehicle, in every listing and every detail, as
a fact beside the clock's status rather than folded into it, because the browser
recomputes the status from the window as time passes and would overwrite a
status that said otherwise. The parameter has no default, so a caller that
forgets it does not compile:

```csharp
/// <summary>
/// A vehicle as the auction hands it out, already at its standing price and with its sold
/// flag (Auction.Find and Auction.Search), so a caller cannot show a price without the room
/// or a bought vehicle as open to everybody but its buyer.
/// </summary>
public static VehicleView ToWire(StandingVehicle standing, AuctionClock clock)
{
    var vehicle = standing.Vehicle;
    var window = standing.WindowAt(clock);
    return new VehicleView(
        vehicle.Id,
        vehicle.Vin,
        vehicle.Year,
        vehicle.Make,
        vehicle.Model,
        vehicle.Trim,
        vehicle.BodyStyle,
        vehicle.ExteriorColor,
        vehicle.InteriorColor,
        vehicle.Engine,
        vehicle.Transmission,
        vehicle.Drivetrain,
        vehicle.OdometerKm,
        vehicle.FuelType,
        vehicle.ConditionGrade,
        vehicle.ConditionReport,
        vehicle.DamageNotes,
        vehicle.TitleStatus,
        vehicle.Province,
        vehicle.City,
        vehicle.AuctionStart,
        vehicle.StartingBid,
        vehicle.BuyNowPrice,
        vehicle.Images,
        vehicle.SellingDealership,
        vehicle.Lot,
        vehicle.CurrentBid,
        vehicle.BidCount,
        window.StartsAtMs,
        window.EndsAtMs,
        // The auction worked out the status and the next bid; the wire only
        // copies them, and sold rides beside the status as its own fact.
        standing.StatusAt(clock).ToString().ToLowerInvariant(),
        standing.MinNextBid,
        // From the vehicle after the overlays, so the state matches the
        // standing bid this same answer carries.
        ReserveState(vehicle.Reserve),
        standing.Sold);
}
```
*Live from [`api/TheYard.Api/VehicleWire.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/VehicleWire.cs#L110-L158), region `sold`, read from this build at 1d6b50b.*

The browser treats `sold` the way it already treated the buyer's own purchase:
the countdown becomes a Sold chip, the price is labelled a purchase price, and
the panel offers no form. A stranger who had been bidding reads that somebody
bought it.

Three tests hold it, one per layer. The rules refuse a sold vehicle at the Buy
Now price, above it, at the minimum and through Buy Now, and still sell the same
vehicle unsold, so the new check is in front of the old rule and not instead of
it. The service refuses a second account through every door after the first has
bought, records nothing of the stranger's and leaves the buyer holding it, sells
the vehicle the same way when the purchase was a bid at the Buy Now price, and
learns the sale from the store on a replay. And two accounts against the API:

```csharp
/// <summary>
/// One vehicle, one buyer, and everybody else told so. The second account
/// reads `sold` on the vehicle, in the detail and in the listing, and its
/// bid at the buy-now price and its own Buy Now are both refused with the
/// sentence the rules keep; until 1.0.0.110 both were a second purchase of
/// the same vehicle (ADR: Accounts and per-user bids, the addendum on the
/// second buyer). The buyer's start-over undoes the sale at the end, which is
/// also what keeps the shared containers on the document store from
/// carrying a sold vehicle into tomorrow's runs.
/// </summary>
[Fact]
public async Task A_vehicle_bought_outright_is_sold_to_everybody()
{
    await using var api = Api();

    var first = api.CreateClient();
    await Register(first, _first);
    var (id, price) = await AVehicleToBuy(first);
    try
    {
        var bought = await first.PostAsJsonAsync($"/api/vehicles/{id}/buy-now", new { });
        string boughtBody = await bought.Content.ReadAsStringAsync();
        Assert.True(bought.IsSuccessStatusCode, boughtBody);
        using var answer = JsonDocument.Parse(boughtBody);
        Assert.Equal("won", answer.RootElement.GetProperty("kind").GetString());
        Assert.True(
            answer.RootElement.GetProperty("vehicle").GetProperty("sold").GetBoolean(),
            "the buyer's own answer carries the sale");

        var second = api.CreateClient();
        await Register(second, _second);
        using var detail = JsonDocument.Parse(
            await second.GetStringAsync($"/api/vehicles/{id}"));
        Assert.True(detail.RootElement.GetProperty("sold").GetBoolean(), "a stranger reads sold on the vehicle");
        Assert.Equal(price, detail.RootElement.GetProperty("current_bid").GetInt32());

        var bid = await second.PostAsJsonAsync(
            $"/api/vehicles/{id}/bids", new { amount = price });
        var again = await second.PostAsJsonAsync($"/api/vehicles/{id}/buy-now", new { });
        foreach (var refused in new[] { bid, again })
        {
            Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
            using var problem = JsonDocument.Parse(await refused.Content.ReadAsStringAsync());
            Assert.Equal(BidRules.SoldReason, problem.RootElement.GetProperty("detail").GetString());
        }
        Assert.Equal("{}", (await second.GetStringAsync("/api/bids")).Trim());

        // The listing says so too, to anybody, signed in or not.
        using var page = JsonDocument.Parse(
            await api.CreateClient().GetStringAsync(
                $"/api/vehicles?status=live&sort=most-bids&limit=100"));
        var row = page.RootElement.GetProperty("vehicles").EnumerateArray()
            .Single(vehicle => vehicle.GetProperty("id").GetString() == id);
        Assert.True(row.GetProperty("sold").GetBoolean(), "the listing row carries the sale");
    }
    finally
    {
        await first.DeleteAsync("/api/bids");
    }

    using var after = JsonDocument.Parse(await first.GetStringAsync($"/api/vehicles/{id}"));
    Assert.False(after.RootElement.GetProperty("sold").GetBoolean(), "the buyer's start-over undoes the sale");
}
```
*Live from [`api/TheYard.Tests/AuthTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/AuthTests.cs#L499-L561), region `sold`, read from this build at 1d6b50b.*

The integration test buys a vehicle other than the one the bidding tests open,
and undoes the purchase at the end with the buyer's own start-over. That is not
tidiness: the test containers on the document store keep every run's bids for a
day (ADR: A second store on Cosmos DB, and what it costs), and a sold vehicle
left there would refuse tomorrow's runs with the sentence this addendum added.
The helper the bidding tests share now skips sold vehicles for the same reason.

What this does not do, and is recorded as open: the sale is still a row on the
buyer's bid rather than a state of the vehicle, so a buyer's start-over
un-sells the vehicle, which is the right answer for a demo and the wrong one for
an auction house; and the check-and-write is serialised by the process's own
gate, which two containers running the same store do not share (ADR: One
container, both stores). Both are the same shape as the retry that overwrites,
and belong to the record that takes that on.

## Where it sits

Accounts reach most of the onion: Tokens.cs and the account endpoints in the host Api, YardUser in Infrastructure, BidService's two indexes and the IBidStore port in Application, and the sold check in Domain's BidRules, with src/lib/auth.ts and BidPanel in the front end. The handlers receive RequestAccounts as a parameter instead of reaching into the service container, which OnionTests holds. The cost is a signing key to keep secret and a sign-in with no roles (Identity's lockout, five tries then five minutes, arrived later in ADR: A password guess should cost something), and a container on the file fallback cannot sign anyone in. A real deployment would hand authentication to the organization's SSO and keep none of this code.

## Files

- [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Tokens.cs): the token, the cookie, and who is asking.
- [`api/TheYard.Api/Accounts.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Accounts.cs): what a register or login answers with.
- [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/AuthRegistration.cs): the composition.
- [`api/TheYard.Api/Endpoints/AccountEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AccountEndpoints.cs): the endpoints.
- [`api/TheYard.Api/Endpoints/BidEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/BidEndpoints.cs): the three that refuse.
- [`api/TheYard.Application/BidService.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/BidService.cs): the two indexes.
- [`api/TheYard.Application/Ports.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Ports.cs): the store port, now keyed on the pair.
- [`api/TheYard.Infrastructure/YardUser.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/YardUser.cs): the one field Identity does not already have.
- [`api/TheYard.Tests/AuthTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AuthTests.cs): the proof, and the second buyer refused.
- [`api/TheYard.Domain/BidRules.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/BidRules.cs) and [`api/TheYard.Api/VehicleWire.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/VehicleWire.cs): sold, asked first and said on every vehicle.
- [`docs/decisions/ADR-038-identity-explained.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-038-identity-explained.md): the same setup, walked at a new developer's level.
- [`docs/decisions/ADR-033-relational-store.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-033-relational-store.md): where bids got somewhere to live.
- [`src/lib/auth.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/auth.ts): the account seam in the browser, and the question that ignores a late answer.
- [`src/lib/auth.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/auth.test.ts): the seam's tests, including the late answer.
- [`tests/e2e/account.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/account.spec.ts): the form end to end, the run that found the late answer.
- [`src/components/vehicle/BidPanel/BidPanel.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/vehicle/BidPanel/BidPanel.tsx): the form, rendered only to a signed-in visitor.

## Addendum, 2026-09-13: a login lasts a year past the last visit

Steve asked for sign-ins to be permanent. A cookie has to expire somewhere,
so "permanent" is made of two numbers rather than one: the session token is
a year long, and the first request on any day a token is more than a day
into its life gets a fresh token with the same claims and a fresh year. A
visitor who comes back within a year is still signed in and stays signed in
for a year past that visit; a visitor who stays away a year is not. Once a
day rather than on every request, because a cookie written on every
response is bandwidth spent on nothing. The renewal is a middleware between
authentication and the endpoints, only on the API and never on the way out,
so signing out still ends the session. The lifetime is configuration
(`Auth:SessionDays`, 365) so a test can shorten it. The rule is held without
a request and the cookie is held on the wire: a token days into its life is
re-issued, a fresh one is left alone, and a sign-out sets the empty cookie
and nothing else. A reset does not end a session already signed in elsewhere;
that is the revocation this record chose not to have, and a security stamp
claim is the fix if it is ever needed.

## Addendum, 2026-09-13: a password reset, in two halves

The same message asked for a password reset. A reset is two halves.
The second half, choosing a new password from a link, is the same whoever
hands the link over; the first half, handing it over, is where an email
sender would go, and a sender is a new Azure resource, which is his to
authorize. So the second half shipped with an operator in the first half's
place, and an emailed "Forgot password" is that same second half with a
sender in front of it.

**The link.** The operator mints it from the Admin tab, behind the key, for
an account on the site's store: `POST /api/admin/reset-links` with the
address, answered with a URL for this site as the visitor reaches it. The
token in it is signed like a session and unlike one: a different audience,
so the session pipeline refuses it and the reset endpoint refuses a session;
an hour of life; and a fingerprint of the password hash it was minted
against, so the link dies the moment the password changes, which is what
makes it one-use with no table of used tokens to keep. There is no Identity
token provider behind it, because that provider needs a key ring the
container does not keep across a roll.

**The reset.** `POST /api/auth/reset` with the token and the new password:
the token is read, the account is loaded, the fingerprint is checked, the
new password is validated before the old one is removed (a refused password
leaves an account with a password rather than none), and the visitor is
signed in with a fresh session. The account view shows the form when the
address bar carries `reset=`, read once at load like the other parameters
the app drops from the address bar on its first render. One sentence for
every way a link can be wrong.

**Tests.** A link needs the key and an account; a link changes the password
once and signs the visitor in, the old password stops working, the new one
works, the same link a second time is refused; a session token is not a
reset token and a reset token is not a session.

**The emailed half, the same evening.** Steve asked for the password
reset email to stay simple and not be over-built. So: one Azure
Communication Services resource with its Email service and an Azure-managed
sender domain, created by the runner in the resource group; the containers'
own identity (the one that already reads both stores) allowed to send
through it, so there is no key anywhere; two plain settings on each
container, the endpoint and the sender address; one endpoint,
`POST /api/auth/forgot`, that answers one sentence whether or not the
address has an account here and mails the same link the operator would have
minted; one email per address per five minutes, whoever asks; and one
button on the sign-in form. A container with no sender configured answers
503 with a sentence that points at the operator's link, which is what the
browser suite sees and holds. The emailed half is held by the API tests
with a sender made of a list: the known address gets the link, a stranger's
gets the same sentence and no mail, the same address twice gets one mail,
and the mailed link resets the password. Cost: about $0.0003 an email and
no fixed charge. The sender's credential is the store's own rule,
`CosmosStore.CredentialFor`, rather than a second one: the host project's
Azure.Core duplicates the credential types the pinned Azure.Identity has,
naming one beside the email client did not compile, and the first fix, a newer
Azure.Identity, was refused by the pin test the 1.0.0.90 incident left
behind (ADR: A second store on Cosmos DB, and what it costs, the addendum
of the same day).


## Addendum, 2026-09-14: what the link looks like

The first emailed link reached Steve's phone as the container's own
address, `theyard-cosmos-ss-...azurecontainer.io:8080`, followed by the
whole signed token, three hundred characters of it. His words, in substance:
a friendly address and a clean GUID. Both were defects in the shape of the
link rather than in what it did, and both are gone.

**The address is the site's own.** Behind the edge, the request a container
sees carries the origin's host, not the domain a visitor typed, and the
link was built from the request. Each container now carries `Site:Url`, the
domain it answers on (`https://theyard.stevenstout.biz` and
`https://theyard-cosmos.stevenstout.biz`), and a link is built from that
first; the request's host is the fallback, which is right on a developer's
machine and on the test host, where a test holds that a configured address
is the one the link starts with.

**The link carries a GUID and nothing else.** The signed token still
exists, still names the account, the site and the password's fingerprint,
and still expires in an hour; it just never travels. It is kept under a
fresh GUID in a `resets` container on the document store, both sites'
links in the one place, with the container's time-to-live as the hour, so
an expired link names nothing without anything having to run. Using a link
reads the token by its GUID, checks it exactly as before, and deletes the
document right before the password changes: two takers get one change,
and the second is told the link is not valid any more. A container with no
document store keeps the GUIDs in memory for its own lifetime, which is the
test host's shape. The store is a port with two implementations and one
held contract; the memory one is held with a clock the test moves.

What it costs: three point operations per link, a write, a read and a
delete, a handful of request units inside the free tier, and one more
container defined in `infra/cosmos/resets.json` beside the others, indexed
on nothing because it is only ever read by id.

## Addendum, 2026-09-17: the page asks the server's question first

Steve, 9/17: the vehicle page let a signed-out visitor fill in an amount and
submit a bid, and the visitor learned from the server's 401 that it needed an
account. The server was right, in the sense that every write here needs a
session and `AuthTests` holds the 401; the page was late, in the sense that
it rendered the form to a visitor it already knew was nobody. `BidPanel`
took the vehicle, the clock, the visitor's standing on that vehicle and two
callbacks, and nothing about the account, so it could not have asked.

Now it is told, the way the proof card is told (ADR: Same performance,
proven, addendum of 13 September). Signed out on a live auction the panel
renders one control, "Sign in to bid", that opens the account view, which
is where the site signs a visitor in; the amount field, the Place bid
button and the Buy now button are not rendered. Signed in, the panel is as
it was. The precedent decides the shape as much as the rule does: a
disabled form with an instruction on it reads as broken on a phone, and the
proof card's button sat that way for four days before Steve, from his phone,
said so. A
control that is the instruction, and goes where the instruction points, is
the shape that survived.

What the page does not do is decide anything. Whether a bid is allowed is
still the server's, and still answered 401 when a request arrives with no
session; the panel is reading the same answer the rail reads, from
`/api/auth/me`, and rendering the form only once it says somebody. A visitor
whose session lands after the page has drawn sees the control for the
moment the rail shows "Sign in", and then the form, which is the rail's own
behaviour and the cost of a page that does not hold the cookie.

```tsx
{/* A bid belongs to an account, and the server refuses one from nobody
    with a 401 (ADR-037). A panel that did not know who was looking let a
    signed-out visitor type an amount, press the button and learn from
    the server's refusal. So the panel asks the one
    question the server will ask, and asks it first: signed out, the form
    and the buy-now button are not rendered at all, and the one control
    left says what it needs and goes there, the way the proof card's does
    (ADR: Same performance, proven, addendum). Not a disabled form with an
    instruction on it: on a phone that reads as broken. */}
{status === 'live' && !signedIn && (
  <button
    type="button"
    className={styles.bidButton}
    onClick={onOpenAccount}
    data-testid="bid-sign-in"
  >
    Sign in to bid
  </button>
)}
```
*Live from [`src/components/vehicle/BidPanel/BidPanel.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/components/vehicle/BidPanel/BidPanel.tsx#L200-L218), region `signed-out`, read from this build at 1d6b50b.*

`account.spec.ts` holds it on both stores: signed out, the region named
Auction has no amount field, no Place bid and no Buy now, the one control
reads "Sign in to bid" and clicking it lands on `?view=account`; after a
registration through the form the same vehicle offers the amount field and
the control is gone. Shipped as 1.0.0.139.

## Addendum, 2026-10-03: where the account code sits now

The badge's answer moved from `BidViews.cs` in the host to `Auction.BidsOf` in the Application ring, and the live block above follows it. The account handlers no longer look `UserManager` up from the service container: they take `RequestAccounts` (`api/TheYard.Api/RequestAccounts.cs`), which answers null when the request's store keeps no accounts. The password reset's steps moved out of the handler into `PasswordReset` (`api/TheYard.Api/PasswordReset.cs`), in the same order this record describes, so the handler only reads the request and writes the answer. The behaviour is unchanged and the tests that hold it are the same ones.

## Addendum, 2026-10-09 (1.0.3.111): the account spec bids where the room is not

The test that a bid belongs to the account opened the vehicle with the most bids and bid its minimum once, reading the figure off the form with no second read. That vehicle is the one the simulated room is busiest on, and `market.spec.ts` bids on it too from a parallel worker through `bidTheMinimum`, which reads the minimum again when the server refuses. On 8 October a round landed between this test's read and its click: the page offered CA$49,000, the server asked for $49,500, and the gate went red twice on a test whose subject was never the room. The two specs now take different vehicles. `aQuietVehicle` in `tests/e2e/bidding.ts` picks a live vehicle nobody has bid on, unsold and five minutes from its end, and this test opens that one; the room is silent on a vehicle until somebody bids, so the minimum the form shows is the minimum the server holds. Nothing the test asserts changed. The first run of the moved test was red for a different reason, and the right one: on a vehicle nobody has bid on the form's Place bid button was disabled, because the panel's stale-minimum rule read the opening ask as a stale minimum. That defect, and the fix in `src/lib/auction.ts`, are in ADR: Competing bidders, the addendum of 9 October; this test is the browser test that fails before the fix.

```ts
/** The fields of a listing row that say whether anybody, the room included, has touched it. */
interface Quiet {
  id: string;
  current_bid: number | null;
  sold: boolean;
  auction_ends_at: number;
}

/**
 * A live vehicle nobody has bid on, with time left: the one to open when the
 * test is about the bid belonging to an account and not about the room.
 *
 * The room answers every bid within eight seconds (ADR: Competing bidders), and
 * it is busiest on the vehicle with the most bids, which is the card the account
 * spec used to open. Two specs in parallel workers then bid on the same vehicle:
 * market.spec through bidTheMinimum, which reads the minimum again when the
 * server refuses, and account.spec once, with no second read, so a round that
 * landed between its read and its click left it posting a stale minimum. The
 * page offered one figure and the server asked for the next, and the gate went
 * red twice on a test whose subject was never the room. A vehicle with no bid
 * has no room on it until this test bids, so the minimum it reads is the
 * minimum the server holds. Five minutes on the clock is the same margin
 * roomCanAnswer keeps.
 */
export async function aQuietVehicle(page: Page): Promise<string> {
  const listing = await page.request.get('/api/vehicles?status=live&limit=100');
  expect(listing.ok(), await listing.text()).toBe(true);
  const { vehicles } = (await listing.json()) as { vehicles: Quiet[] };
  const now = Date.now();
  const quiet = vehicles.find(
    (vehicle) =>
      vehicle.current_bid === null && !vehicle.sold && vehicle.auction_ends_at - now > 5 * 60_000
  );
  expect(
    quiet,
    'none of 100 live vehicles is unbid, unsold and five minutes from its end'
  ).toBeDefined();
  return quiet!.id;
}
```
*Live from [`tests/e2e/bidding.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tests/e2e/bidding.ts#L57-L95), region `a-quiet-vehicle`, read from this build at 1d6b50b.*
