diff --git a/docs/plans/2026-08-31-slice2a-plan6-account-settings.md b/docs/plans/2026-08-31-slice2a-plan6-account-settings.md
new file mode 100644
index 0000000..9a9666a
--- /dev/null
+++ b/docs/plans/2026-08-31-slice2a-plan6-account-settings.md
@@ -0,0 +1,2651 @@
+# Slice 2a Plan 6 — Account settings: implementation plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
+> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
+> checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A signed-in user can change their password and change the address they sign in with,
+the second confirmed from a link sent to the new address — without ever leaving an abandoned
+address squatted behind them.
+
+**Architecture:** Three minimal-API handlers added to the existing `AuthEndpoints` group
+(`/change-password` and `/change-email` authenticated, `/confirm-email-change` anonymous), a
+change-email token provider with its own one-hour lifespan, two more methods on the
+`IAuthEmailSender` seam, and two hand-authored vanilla-JS MVC screens — an Account screen with no
+URL, mounted from Settings, and a `/confirm-email-change` screen on its own anonymous route.
+Nothing is restructured; every piece mirrors a shape Plan 3, 4 or 5 already established.
+
+**Tech stack:** .NET 10, ASP.NET Core minimal APIs, ASP.NET Core Identity (`AddIdentityCore` +
+cookie auth), EF Core 10 on PostgreSQL 17, NUnit 4 with `WebApplicationFactory`, vanilla ES modules
+with no build step.
+
+**Design spec:**
+[`docs/2026-08-13-wend-slice2a-plan6-account-settings-design.md`](../2026-08-13-wend-slice2a-plan6-account-settings-design.md)
+— signed off, stress-tested, one critical finding consciously deferred to Plan 8, nine fixes folded
+in. **Read it before Task 1.** The reviewer's checklist that pairs with it is
+[`docs/2026-08-13-wend-review-guide.md`](../2026-08-13-wend-review-guide.md) § *Plan 6 review
+checklist*.
+
+## Global Constraints
+
+- **Start PostgreSQL before anything:** the service is Manual start, so connection refused and EF
+ timeouts mean it is stopped, not a code bug. **Since a PostgreSQL update reset the service
+ descriptor, starting it needs elevation** — run
+ `Start-Process pwsh -Verb RunAs -Wait -ArgumentList '-NoProfile','-Command','Start-Service postgresql-x64-17'`
+ and approve the UAC prompt.
+- **Kill the app before any build or test run:** the process is `Wend.Api`, **not** `Wend`. A
+ running instance holds the DLL and produces MSB3021/3027, which is a copy lock, not a test failure.
+- **Test baseline is 257 green** (measured on this tree, 2026-08-31). Check the running total
+ against each task's stated total. A paste-driven edit once silently dropped three tests behind a
+ green suite.
+- **No AI attribution** in commit messages or the PR body — no `Co-Authored-By`, no "Generated
+ with" trailer.
+- **`design-system/` is read-only.** No task here needs a change in it; every class used already
+ exists (`.btn`, `.btn-primary`, `.btn-ghost`, `.input`, `.alert`, `.alert-danger`,
+ `.alert-success`).
+- **`escapeHtml` at every user-content interpolation**, `#status` stays outside `#app`, and focus
+ never drops to `
`.
+- **Password policy is 12 characters, no composition rules** (`Program.cs:47-51`). Test passwords
+ use `"correct horse battery staple"`, as every existing auth test does.
+- **Emailed links are built from `Wend:PublicBaseUrl`**, never from `http.Host`.
+- **Logging carries error codes only** — never an address, never a token, never a password.
+- **Every browser check hard-reloads** (Ctrl+Shift+R or `cache: 'reload'`): `UseStaticFiles` sends
+ no `Cache-Control`, so ES-module imports are served stale otherwise.
+- **Squash-merge**, single-author branch. Henry reviews and merges.
+
+---
+
+## Open items — resolved at plan time
+
+The design closed with eight items to verify "against the .NET 10 source rather than assumed".
+All eight were checked against `dotnet/aspnetcore` `release/10.0` on 2026-08-31 before this plan was
+written. Three of the answers changed what the code below does.
+
+| Open item | Answer | Where it lands |
+|---|---|---|
+| Does `RefreshSignInAsync` preserve the cookie's `IsPersistent`? | **Yes.** `RefreshSignInCoreAsync` re-reads the ticket with `Context.AuthenticateAsync` and hands `auth.Properties` to `SignInWithClaimsAsync`, so `IsPersistent`, `IssuedUtc` and `ExpiresUtc` all carry forward. `CookieAuthenticationHandler` writes `Expires` only `if (Properties.IsPersistent)`. **Caveat:** the reissued cookie inherits the *original* absolute expiry rather than a fresh `ExpireTimeSpan`; sliding renewal still extends it later. | Task 5, asserted rather than trusted. |
+| Does `ChangeEmailAsync` return a distinguishable `DuplicateEmail`? | **Yes.** `ChangeEmailCoreAsync` returns `ErrorDescriber.InvalidToken()` (code `"InvalidToken"`) on a token failure; otherwise it reaches `UpdateUserAsync` → `ValidateUserAsync` → `UserValidator`, whose duplicate branches use `Code = nameof(DuplicateUserName)` and `Code = nameof(DuplicateEmail)`. The 409-vs-400 split works off the code. No pre-check has to move into the confirm handler. | Task 4, step 3. |
+| Does `SetUserNameAsync` rotate the security stamp a second time? | **Yes** — `Store.SetUserNameAsync` → `UpdateSecurityStampInternal` → `UpdateUserAndRecordMetricAsync`. Harmless (every session is already gone from the first rotation), but it means two writes and two rotations, and it is why the second call must use the **same** `user` instance. | Task 4, step 4, and the comment there. |
+| Does `GetUserAsync` throw for a deleted account? | **No.** It returns `null` when `GetUserId` is null or `FindByIdAsync` misses. Step 2 of both authenticated handlers is written for null and gets a test. | Tasks 2 and 3. |
+| The exact 400/409 body shape | `Results.BadRequest(new { error = "..." })` / `Results.Conflict(new { error = "..." })`, matching `/reset-password` exactly. Not problem details. | Tasks 2–4. |
+| Should the old-address notice also fire on a *failed* attempt? | **No.** Deliberate: a failed attempt is unauthenticated noise an attacker can generate at will, so a notice on failure is an email-bomb vector aimed at the victim, using the victim's own account. Recorded here so the silence is a decision. | Task 4, step 5. |
+| Is the Account link on Settings only? | **Yes, Settings only** for this plan. The header stays at two controls. Discoverability is a question for the dashboard work, not for a plan that has no other surface to put it on. | Task 6. |
+| `showAccount()` in `main.js` or an `onAccount` callback? | **`onAccount` callback**, decided against the code: `showSettings()` already passes `{ onBack }` into `createSettingsController`, so a second callback matches the wiring that exists rather than adding a second style beside it. | Task 6. |
+
+**Two further findings from the same pass, neither of which the design anticipated:**
+
+- **`RefreshSignInAsync` returns `Task`, not `Task`.** The design's step 5 says "if
+ it fails, still 204" — but there is no result to inspect, so the only failure it can report is an
+ exception. Task 2 wraps it in `try`/`catch`, which is what "if it fails" has to mean.
+- **Under the `Test` auth scheme, `RefreshSignInAsync` is a silent no-op.** It calls
+ `Context.AuthenticateAsync(IdentityConstants.ApplicationScheme)`, finds no cookie, logs
+ *"RefreshSignInAsync prevented because the user is not currently authenticated"* and returns
+ without signing anyone in. It does not throw. That is not a bug — but it means **no test-scheme
+ test can prove the acting session survives**, and neither can a test-scheme test prove another
+ session dies, because `SecurityStampValidator` hangs off the cookie and never runs there. Both
+ assertions live in Task 5's real-cookie suite, which is why Task 5 exists at all.
+
+---
+
+## File structure
+
+**Created**
+
+| File | Responsibility |
+|---|---|
+| `Wend.Api/ChangeEmailTokenProvider.cs` | A `DataProtectorTokenProvider` subclass plus its options, so change-email tokens carry a one-hour lifespan independent of every other Identity token. Third instance of the pattern. |
+| `Wend.Tests/AuthChangePasswordTests.cs` | `/api/auth/change-password` — the two 400 codes, the validator ordering, and the lockout accounting `ChangePasswordAsync` does not do. |
+| `Wend.Tests/AuthChangeEmailTests.cs` | `/api/auth/change-email` — the four branches behind one 204, the `FindByNameAsync` check with self excluded, token non-revocation. |
+| `Wend.Tests/AuthConfirmEmailChangeTests.cs` | `/api/auth/confirm-email-change` — the token/taken split, the `200 { email }` body, the old-address notice, and **the `UserName` regression**. |
+| `Wend.Tests/RealCookieAccountTests.cs` | The browser-shaped walk on the genuine cookie scheme: acting session survives, others die, persistence survives. Opens with the test-scheme canary. |
+| `Wend.Api/wwwroot/js/auth/account/{model,view,controller}.js` | The Account screen — current address plus two independent forms. |
+| `Wend.Api/wwwroot/js/auth/confirm-email/{model,view,controller}.js` | The `/confirm-email-change` screen — POSTs on mount, four states. |
+
+**Modified**
+
+| File | Change |
+|---|---|
+| `Wend.Core/IAuthEmailSender.cs` | `SendEmailChangeConfirmationAsync` and `SendEmailChangedNoticeAsync` on the seam. |
+| `Wend.Api/FileAuthEmailSender.cs` | Implements both. |
+| `Wend.Tests/FakeAuthEmailSender.cs` | Implements both, recording kinds `"change-email"` and `"changed-notice"`. |
+| `Wend.Api/Program.cs:68-83` | Registers the change-email provider and points `options.Tokens.ChangeEmailTokenProvider` at it. |
+| `Wend.Api/AuthEndpoints.cs` | Three handlers, one link-builder, three request records. |
+| `Wend.Tests/AuthConfigurationTests.cs:83-102` | The lifespans test grows a third provider rather than gaining a third test. |
+| `Wend.Api/wwwroot/js/main.js` | Six imports, two mount functions, one new route, one `onAccount` callback. |
+| `Wend.Api/wwwroot/js/settings/view.js` | An Account button and its `data-action`. |
+| `Wend.Api/wwwroot/js/settings/controller.js` | Forwards it to `onAccount`. |
+| `Wend.Api/wwwroot/css/app.css` | An `.account-*` block; the forms reuse `.auth-form`. |
+
+**Test totals:** 257 baseline → 257 (T1, no new tests by design) → 266 (T2) → 276 (T3) → 287 (T4)
+→ **291** (T5). Tasks 6–8 add no automated tests, because this repo has no JS test harness; they
+carry scripted manual checks instead.
+
+---
+
+## Deviations to record in the PR body
+
+Four inherited from the design, five introduced here. Task 8 writes them up; they are listed now so
+no one has to reconstruct them at the end.
+
+1. **Two endpoints for change-email**, where the parent spec's table shows one `POST /change-email`
+ row. A token that has to reach the new address and come back cannot be one round trip.
+2. **Lockout accounting on `/change-password`**, beyond the parent spec's one-line description.
+ `ChangePasswordAsync` does none, so without it a stolen session gets unlimited guesses.
+3. **`SetUserNameAsync` alongside `ChangeEmailAsync`** — the plan's correctness requirement, not
+ tidiness.
+4. **`/confirm-email-change` returns `200 { email }`** — the only endpoint in `/api/auth/*` with a
+ body, so the success screen can name the address from the database rather than from the URL.
+5. **Task 5 is a real-cookie suite the design's seven-task list does not have.** Added because two
+ of the design's own required assertions cannot run under the `Test` scheme at all (see *Open
+ items*). Same shape as Plan 5's Task 4.
+6. **The change-email success message does not say the link went to the address that was typed.**
+ The endpoint answers 204 for both "free" and "held by someone else", so a screen claiming a link
+ was sent would leak exactly what the 204 protects. Wording is *"If that address is free, we've
+ sent it a confirmation link"* — the mirror of the forgot screen's.
+7. **The Account view renders its two forms into two independently-replaced sections** rather than
+ through one whole-screen `innerHTML` rebuild. It makes the design's two-form rules structural
+ instead of carefully maintained: one form's repaint cannot touch the other's error region,
+ focus, or typed input.
+8. **`RefreshSignInAsync` is wrapped in `try`/`catch`** rather than checked for a failed result,
+ because it returns no result.
+9. **A 401 on either Account form is shown in that form's error region rather than bouncing to the
+ sign-in screen.** The design does not cover the screen's 401 branch. `/change-password` answers
+ 401 for a locked-out account as well as a dead session, and a locked-out user's cookie still
+ works — so signing them out would be a lie that also loses whatever they had typed. One message
+ covers both without naming which.
+
+---
+
+### Task 1: Change-email token provider and the email seam
+
+**Files:**
+- Create: `Wend.Api/ChangeEmailTokenProvider.cs`
+- Modify: `Wend.Core/IAuthEmailSender.cs`
+- Modify: `Wend.Api/FileAuthEmailSender.cs`
+- Modify: `Wend.Tests/FakeAuthEmailSender.cs`
+- Modify: `Wend.Api/Program.cs:68-83`
+- Test: `Wend.Tests/AuthConfigurationTests.cs:83-102`
+
+**Interfaces:**
+- Consumes: `PasswordResetTokenProvider` and `PasswordResetTokenProviderOptions` as the shape
+ to mirror.
+- Produces: `ChangeEmailTokenProvider`; `ChangeEmailTokenProviderOptions` (name
+ `"WendChangeEmailTokenProvider"`, lifespan 1 hour); the provider-map key `"WendChangeEmail"`;
+ `IAuthEmailSender.SendEmailChangeConfirmationAsync(string newEmail, string link)` and
+ `IAuthEmailSender.SendEmailChangedNoticeAsync(string oldEmail, string newEmail)`;
+ `FakeAuthEmailSender` kinds `"change-email"` and `"changed-notice"`.
+
+**No new tests.** The existing lifespans test grows a third provider instead of gaining a third
+test: the failure being guarded is one provider's lifespan silently dragging another's, and that is
+one property across three values, not three properties. Total stays **257**.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace `Reset_tokens_last_an_hour_and_confirmation_tokens_still_last_a_day` in
+`Wend.Tests/AuthConfigurationTests.cs` with:
+
+```csharp
+ [Test]
+ public void Change_email_and_reset_tokens_last_an_hour_and_confirmation_still_lasts_a_day()
+ {
+ var identity = _factory.Services.GetRequiredService>().Value;
+ var confirmation = _factory.Services
+ .GetRequiredService>().Value;
+ var reset = _factory.Services
+ .GetRequiredService>().Value;
+ var changeEmail = _factory.Services
+ .GetRequiredService>().Value;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(changeEmail.TokenLifespan, Is.EqualTo(TimeSpan.FromHours(1)));
+ Assert.That(reset.TokenLifespan, Is.EqualTo(TimeSpan.FromHours(1)));
+ // All three, in one test, on purpose: the failure this grouping exists to prevent is a
+ // change to any one lifespan silently dragging the others with it.
+ Assert.That(confirmation.TokenLifespan, Is.EqualTo(TimeSpan.FromHours(24)));
+ Assert.That(identity.Tokens.ChangeEmailTokenProvider, Is.EqualTo("WendChangeEmail"));
+ Assert.That(identity.Tokens.PasswordResetTokenProvider, Is.EqualTo("WendPasswordReset"));
+ Assert.That(identity.Tokens.EmailConfirmationTokenProvider,
+ Is.EqualTo("WendEmailConfirmation"));
+ });
+ }
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `dotnet test --filter "FullyQualifiedName~AuthConfigurationTests"`
+
+Expected: a **compile** error — `ChangeEmailTokenProviderOptions` does not exist. That is the
+failing state for this step; nothing else in the suite runs until Step 3 lands.
+
+- [ ] **Step 3: Create the provider**
+
+`Wend.Api/ChangeEmailTokenProvider.cs`:
+
+```csharp
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+
+namespace Wend.Api;
+
+///
+/// Change-email tokens with their own lifespan. The third instance of the pattern
+/// EmailConfirmationTokenProvider and PasswordResetTokenProvider exist to enforce: without one
+/// provider per token type, the global DataProtectionTokenProviderOptions governs all of them, and
+/// the hour this one wants would silently become the lifespan of every confirmation link.
+///
+public class ChangeEmailTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger> logger)
+ : DataProtectorTokenProvider(dataProtectionProvider, options, logger)
+ where TUser : class;
+
+public class ChangeEmailTokenProviderOptions : DataProtectionTokenProviderOptions
+{
+ public ChangeEmailTokenProviderOptions()
+ {
+ Name = "WendChangeEmailTokenProvider";
+ // One hour, matching reset rather than confirmation's 24: the user typed the address
+ // seconds ago and has one mailbox to reach. The link also repoints an account's login
+ // identity, which is worth as much to an attacker as a reset link.
+ TokenLifespan = TimeSpan.FromHours(1);
+ }
+}
+```
+
+- [ ] **Step 4: Register it**
+
+In `Wend.Api/Program.cs`, immediately after the `WendPasswordReset` lines inside
+`AddIdentityCore`'s options lambda (currently lines 74-76):
+
+```csharp
+ // Third provider, same reason as the second. Identity's default ChangeEmailTokenProvider
+ // is the shared "Default" one, so without this line a lifespan set anywhere would govern
+ // all of them.
+ options.Tokens.ProviderMap.Add("WendChangeEmail",
+ new TokenProviderDescriptor(typeof(ChangeEmailTokenProvider)));
+ options.Tokens.ChangeEmailTokenProvider = "WendChangeEmail";
+```
+
+And beside the other two transients (currently line 83):
+
+```csharp
+builder.Services.AddTransient>();
+```
+
+- [ ] **Step 5: Extend the email seam**
+
+`Wend.Core/IAuthEmailSender.cs` — add to the interface:
+
+```csharp
+ Task SendEmailChangeConfirmationAsync(string newEmail, string link);
+
+ ///
+ /// Tells the OLD address that the account's sign-in address was changed. Takes both addresses
+ /// because a notice that does not name what the address was changed to tells the owner
+ /// something happened without telling them enough to act on it.
+ ///
+ Task SendEmailChangedNoticeAsync(string oldEmail, string newEmail);
+```
+
+`Wend.Api/FileAuthEmailSender.cs` — add both, in the shape the existing two use:
+
+```csharp
+ public async Task SendEmailChangeConfirmationAsync(string newEmail, string link)
+ {
+ var entry = $"[{DateTime.UtcNow:u}] change-email {newEmail}{Environment.NewLine} {link}{Environment.NewLine}";
+ await File.AppendAllTextAsync(path, entry);
+ Console.WriteLine(entry);
+ }
+
+ public async Task SendEmailChangedNoticeAsync(string oldEmail, string newEmail)
+ {
+ var entry = $"[{DateTime.UtcNow:u}] email-changed {oldEmail} -> {newEmail}{Environment.NewLine}";
+ await File.AppendAllTextAsync(path, entry);
+ Console.WriteLine(entry);
+ }
+```
+
+`Wend.Tests/FakeAuthEmailSender.cs` — add both:
+
+```csharp
+ public Task SendEmailChangeConfirmationAsync(string newEmail, string link)
+ {
+ Sent.Add((newEmail, link, "change-email"));
+ return Task.CompletedTask;
+ }
+
+ // The notice has no link, so Link carries the NEW address here — several tests turn on the
+ // notice naming what the address was changed to, and this keeps the tuple shape every other
+ // test in the suite already reads.
+ public Task SendEmailChangedNoticeAsync(string oldEmail, string newEmail)
+ {
+ Sent.Add((oldEmail, newEmail, "changed-notice"));
+ return Task.CompletedTask;
+ }
+```
+
+- [ ] **Step 6: Run the tests**
+
+Run: `dotnet test`
+
+Expected: PASS, **257 total**. The count is unchanged on purpose — if it went up, a test was added
+that this task did not ask for.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add Wend.Api/ChangeEmailTokenProvider.cs Wend.Api/Program.cs Wend.Core/IAuthEmailSender.cs Wend.Api/FileAuthEmailSender.cs Wend.Tests/FakeAuthEmailSender.cs Wend.Tests/AuthConfigurationTests.cs
+git commit -m "Add a change-email token provider and two email-seam methods"
+```
+
+---
+
+### Task 2: `POST /api/auth/change-password`
+
+**Files:**
+- Modify: `Wend.Api/AuthEndpoints.cs` (one handler after `/reset-password`, one request record)
+- Test: `Wend.Tests/AuthChangePasswordTests.cs` (create)
+
+**Interfaces:**
+- Consumes: nothing from Task 1.
+- Produces: `POST /api/auth/change-password`, authenticated, body
+ `{ currentPassword, newPassword }`; `204`, `400 { error: "password" }`,
+ `400 { error: "current" }`, or `401`. Record
+ `ChangePasswordRequest(string CurrentPassword, string NewPassword)`.
+
+**Nine new tests.** 257 → **266**.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Wend.Tests/AuthChangePasswordTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Wend.Core;
+
+namespace Wend.Tests;
+
+///
+/// /api/auth/change-password. The two 400 codes are asserted by name because the Account screen
+/// branches on them: one blames the new password, the other the current one, and swapping them
+/// produces a screen that tells the user to fix the field they got right.
+///
+/// The Test auth scheme is deliberate here — this file is about the handler's logic. What the
+/// scheme cannot show (the acting session surviving, other sessions dying, persistence carrying
+/// across) is in RealCookieAccountTests, because RefreshSignInAsync is a silent no-op without a
+/// real cookie and SecurityStampValidator never runs at all.
+///
+public class AuthChangePasswordTests
+{
+ private const string GoodPassword = "correct horse battery staple";
+ private const string NewPassword = "a different long passphrase";
+
+ private WendApiFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _factory = new WendApiFactory();
+ _client = _factory.CreateClient();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _client.Dispose();
+ _factory.Dispose();
+ }
+
+ ///
+ /// Registers and confirms an account, then points the Test scheme at it. Everything after this
+ /// call acts as that user. NOTE: never call CreateClient() again afterwards — ConfigureClient
+ /// resets CurrentUser to the factory's default user.
+ ///
+ private async Task ArrangeSignedIn(string email)
+ {
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { email, password = GoodPassword, displayName = "Malin" });
+
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = (await users.FindByEmailAsync(email))!;
+ await users.ConfirmEmailAsync(user, await users.GenerateEmailConfirmationTokenAsync(user));
+
+ _factory.CurrentUser.UserId = user.Id;
+ return user.Id;
+ }
+
+ private Task Change(string currentPassword, string newPassword) =>
+ _client.PostAsJsonAsync("/api/auth/change-password",
+ new { currentPassword, newPassword });
+
+ private static async Task ErrorCode(HttpResponseMessage response)
+ {
+ // The length guard is load-bearing: /change-email's malformed-address branch answers a
+ // BARE 400 with no body at all, and ReadFromJsonAsync throws on empty content rather than
+ // returning null.
+ if (response.Content.Headers.ContentLength is null or 0) return null;
+ var body = await response.Content.ReadFromJsonAsync>();
+ return body?.GetValueOrDefault("error");
+ }
+
+ private async Task PasswordWorks(string email, string password)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = await users.FindByEmailAsync(email);
+ return await users.CheckPasswordAsync(user!, password);
+ }
+
+ private async Task Reload(string email)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ return (await users.FindByEmailAsync(email))!;
+ }
+
+ [Test]
+ public async Task An_anonymous_caller_is_refused()
+ {
+ _factory.CurrentUser.UserId = null;
+
+ var response = await Change(GoodPassword, NewPassword);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task A_live_session_whose_account_is_gone_is_401_not_500()
+ {
+ // GetUserAsync returns null rather than throwing (verified against release/10.0). Plan 7
+ // makes this ordinary; today it is reachable by pointing the scheme at an id nobody owns.
+ _factory.CurrentUser.UserId = Guid.NewGuid().ToString();
+
+ var response = await Change(GoodPassword, NewPassword);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task The_new_password_replaces_the_old_one()
+ {
+ await ArrangeSignedIn("changer@example.test");
+
+ var response = await Change(GoodPassword, NewPassword);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
+ Assert.That(await PasswordWorks("changer@example.test", NewPassword), Is.True, "new");
+ Assert.That(await PasswordWorks("changer@example.test", GoodPassword), Is.False, "old");
+ });
+ }
+
+ [Test]
+ public async Task A_wrong_current_password_is_refused_with_the_current_code()
+ {
+ await ArrangeSignedIn("changer@example.test");
+
+ var response = await Change("not the password at all", NewPassword);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(response), Is.EqualTo("current"));
+ Assert.That(await PasswordWorks("changer@example.test", GoodPassword), Is.True);
+ });
+ }
+
+ [Test]
+ public async Task A_weak_new_password_is_refused_before_the_current_one_is_ever_checked()
+ {
+ await ArrangeSignedIn("changer@example.test");
+
+ // Deliberately BOTH wrong: a weak new password AND a wrong current one. If the handler
+ // called ChangePasswordAsync first, this would spend a lockout attempt and answer
+ // "current". The ordering is the assertion.
+ var response = await Change("also wrong", "short");
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(response), Is.EqualTo("password"));
+ Assert.That((await Reload("changer@example.test")).AccessFailedCount, Is.Zero,
+ "a weak new password must not cost an attempt at the current one");
+ Assert.That(await PasswordWorks("changer@example.test", GoodPassword), Is.True);
+ });
+ }
+
+ [Test]
+ public async Task Five_wrong_current_passwords_lock_the_account()
+ {
+ await ArrangeSignedIn("guessed@example.test");
+
+ for (var attempt = 0; attempt < 5; attempt++)
+ await Change("wrong wrong wrong wrong", NewPassword);
+
+ var user = await Reload("guessed@example.test");
+ Assert.Multiple(() =>
+ {
+ Assert.That(user.LockoutEnd, Is.Not.Null);
+ Assert.That(user.LockoutEnd, Is.GreaterThan(DateTimeOffset.UtcNow));
+ });
+ }
+
+ [Test]
+ public async Task A_locked_out_account_is_refused_without_its_password_being_checked()
+ {
+ await ArrangeSignedIn("guessed@example.test");
+ for (var attempt = 0; attempt < 5; attempt++)
+ await Change("wrong wrong wrong wrong", NewPassword);
+
+ // The CORRECT current password, while locked. 401, and nothing changes — otherwise anyone
+ // holding a stolen session sidesteps lockout by guessing here instead of at /login.
+ var response = await Change(GoodPassword, NewPassword);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ Assert.That(await PasswordWorks("guessed@example.test", GoodPassword), Is.True);
+ Assert.That(await PasswordWorks("guessed@example.test", NewPassword), Is.False);
+ });
+ }
+
+ [Test]
+ public async Task A_successful_change_clears_the_failed_count()
+ {
+ await ArrangeSignedIn("recovered@example.test");
+ await Change("wrong wrong wrong wrong", NewPassword);
+ await Change("wrong wrong wrong wrong", NewPassword);
+ var before = (await Reload("recovered@example.test")).AccessFailedCount;
+
+ await Change(GoodPassword, NewPassword);
+
+ var after = (await Reload("recovered@example.test")).AccessFailedCount;
+ Assert.Multiple(() =>
+ {
+ Assert.That(before, Is.EqualTo(2), "two failures were recorded");
+ Assert.That(after, Is.Zero, "a successful change clears them");
+ });
+ }
+
+ [Test]
+ public async Task Change_password_binds_json_only()
+ {
+ await ArrangeSignedIn("changer@example.test");
+ var form = new FormUrlEncodedContent([
+ new KeyValuePair("currentPassword", GoodPassword),
+ new KeyValuePair("newPassword", NewPassword),
+ ]);
+
+ var response = await _client.PostAsync("/api/auth/change-password", form);
+
+ // As in AuthResetTests and AuthForgotTests: 404 is the measured behaviour.
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `dotnet test --filter "FullyQualifiedName~AuthChangePasswordTests"`
+
+Expected: all nine FAIL — the route does not exist, so every call returns 404 and even the two
+`Unauthorized` tests fail.
+
+- [ ] **Step 3: Write the handler**
+
+In `Wend.Api/AuthEndpoints.cs`, immediately after the `/reset-password` handler (currently ends at
+line 226):
+
+```csharp
+ // Authenticated, and the first endpoint in this group that can afford to say what went
+ // wrong: the caller has already proved who they are, so there is no account existence left
+ // to leak. One code blames the new password, the other the current one.
+ group.MapPost("/change-password", async (ChangePasswordRequest req,
+ UserManager users, SignInManager signIn, ClaimsPrincipal principal,
+ ILoggerFactory loggerFactory) =>
+ {
+ var newPassword = req.NewPassword ?? "";
+
+ // A live cookie whose account no longer exists — ordinary once Plan 7 ships.
+ // GetUserAsync returns null rather than throwing (verified against release/10.0), so
+ // this is a 401 and not a 500.
+ if (await users.GetUserAsync(principal) is not { } user) return Results.Unauthorized();
+
+ // Policy on the NEW password, before the current one is checked and before any lockout
+ // accounting can fire. The ordering is the reverse of /reset-password's on purpose:
+ // that endpoint is anonymous and validates before the lookup so an early 400 cannot
+ // become an existence oracle. Here the caller is already known, so the user is resolved
+ // first and the validators are handed the REAL user — which is what a future policy
+ // like "your password may not contain your email address" would need.
+ // AuthChangePasswordTests guards this: a weak new password must not cost an attempt at
+ // the current one. Do not reorder.
+ foreach (var validator in users.PasswordValidators)
+ {
+ if (!(await validator.ValidateAsync(users, user, newPassword)).Succeeded)
+ return Results.BadRequest(new { error = "password" });
+ }
+
+ // A locked account is locked for this too. Without it, lockout is trivially sidestepped
+ // by anyone holding a session: they stop guessing at /login and start guessing here.
+ if (await users.IsLockedOutAsync(user)) return Results.Unauthorized();
+
+ // ChangePasswordAsync verifies the current password and does NO lockout bookkeeping
+ // whatsoever (verified against release/10.0). Without the two calls around it, somebody
+ // holding a stolen cookie gets unlimited attempts at the current password — and a
+ // correct guess converts a session that dies on its own into a permanent takeover.
+ // Five attempts is the same budget login allows, applied to the same secret.
+ if (!(await users.ChangePasswordAsync(user, req.CurrentPassword ?? "", newPassword))
+ .Succeeded)
+ {
+ await users.AccessFailedAsync(user);
+ return Results.BadRequest(new { error = "current" });
+ }
+
+ await users.ResetAccessFailedCountAsync(user);
+
+ // AFTER the change, so the rotated stamp lands in the reissued cookie. The password
+ // write rotates the security stamp, and at ValidationInterval.Zero that refuses every
+ // live cookie for this user on its next request — including the browser that just
+ // submitted the form. Every OTHER session dying is the point; this one dying is a bug
+ // the user experiences as being logged out for changing their password.
+ //
+ // RefreshSignInAsync returns no result, so the only failure it can report is an
+ // exception. Under the Test auth scheme it finds no application cookie and is a silent
+ // no-op, which is why the assertion that this works lives in RealCookieAccountTests.
+ try
+ {
+ await signIn.RefreshSignInAsync(user);
+ }
+ catch (Exception ex)
+ {
+ // Still 204: the password genuinely changed, and telling the user otherwise would
+ // send them round the loop for nothing. The degraded outcome is that their next
+ // request 401s and they sign in again with the new password, which works. Same rule
+ // /reset-password applies to a failed lockout clear. Type name only, never a
+ // message — an exception message can carry data.
+ loggerFactory.CreateLogger("Wend.Api.AuthEndpoints")
+ .LogWarning("Password changed but the session was not refreshed: {Error}",
+ ex.GetType().Name);
+ }
+
+ return Results.NoContent();
+ }).RequireAuthorization();
+```
+
+And with the other request records at the bottom of the file:
+
+```csharp
+public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `dotnet test`
+
+Expected: PASS, **266 total**.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Wend.Api/AuthEndpoints.cs Wend.Tests/AuthChangePasswordTests.cs
+git commit -m "Add POST /api/auth/change-password with lockout accounting"
+```
+
+---
+
+### Task 3: `POST /api/auth/change-email`
+
+**Files:**
+- Modify: `Wend.Api/AuthEndpoints.cs` (one handler after `/change-password`, one link-builder, one
+ request record)
+- Test: `Wend.Tests/AuthChangeEmailTests.cs` (create)
+
+**Interfaces:**
+- Consumes: `IAuthEmailSender.SendEmailChangeConfirmationAsync` and the `WendChangeEmail` provider
+ from Task 1.
+- Produces: `POST /api/auth/change-email`, authenticated, body `{ newEmail }`; `204`, a bare `400`,
+ `400 { error: "same" }`, or `401`. Record `ChangeEmailRequest(string NewEmail)`. Private helper
+ `BuildChangeEmailLinkAsync(WendUser, string, UserManager, HttpRequest, string?)`
+ returning the `/confirm-email-change?userId=…&newEmail=…&code=…` link.
+
+**Ten new tests.** 266 → **276**.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Wend.Tests/AuthChangeEmailTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Web;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Wend.Core;
+
+namespace Wend.Tests;
+
+///
+/// /api/auth/change-email — the request half. Four different outcomes fall out of the same 204,
+/// and which of them is which is invisible from outside on purpose: this endpoint is authenticated
+/// and feels private, which is exactly why a 409 for a taken address looks reasonable here. It
+/// would let any account holder walk the user table one address at a time from their own settings
+/// screen.
+///
+public class AuthChangeEmailTests
+{
+ private const string GoodPassword = "correct horse battery staple";
+
+ private WendApiFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _factory = new WendApiFactory();
+ _client = _factory.CreateClient();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _client.Dispose();
+ _factory.Dispose();
+ }
+
+ /// Registers and confirms an account WITHOUT pointing the Test scheme at it.
+ private async Task Seed(string email)
+ {
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { email, password = GoodPassword, displayName = "Malin" });
+
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = (await users.FindByEmailAsync(email))!;
+ await users.ConfirmEmailAsync(user, await users.GenerateEmailConfirmationTokenAsync(user));
+ return user.Id;
+ }
+
+ ///
+ /// Seeds an account and acts as it. NOTE: never call CreateClient() after this —
+ /// ConfigureClient resets CurrentUser to the factory's default user.
+ ///
+ private async Task ArrangeSignedIn(string email)
+ {
+ var id = await Seed(email);
+ _factory.CurrentUser.UserId = id;
+ return id;
+ }
+
+ private Task Request(string newEmail) =>
+ _client.PostAsJsonAsync("/api/auth/change-email", new { newEmail });
+
+ private static async Task ErrorCode(HttpResponseMessage response)
+ {
+ // The length guard is load-bearing: /change-email's malformed-address branch answers a
+ // BARE 400 with no body at all, and ReadFromJsonAsync throws on empty content rather than
+ // returning null.
+ if (response.Content.Headers.ContentLength is null or 0) return null;
+ var body = await response.Content.ReadFromJsonAsync>();
+ return body?.GetValueOrDefault("error");
+ }
+
+ private static (string UserId, string NewEmail, string Code) ReadLink(string link)
+ {
+ var query = HttpUtility.ParseQueryString(new Uri(link).Query);
+ return (query["userId"]!, query["newEmail"]!, query["code"]!);
+ }
+
+ [Test]
+ public async Task An_anonymous_caller_is_refused()
+ {
+ _factory.CurrentUser.UserId = null;
+
+ var response = await Request("elsewhere@example.test");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task A_live_session_whose_account_is_gone_is_401_not_500()
+ {
+ // The same guard as /change-password, on a different handler. A reviewer can reject one
+ // while approving the other, so both are asserted.
+ _factory.CurrentUser.UserId = Guid.NewGuid().ToString();
+
+ var response = await Request("elsewhere@example.test");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task The_address_the_account_already_has_is_refused_as_same()
+ {
+ await ArrangeSignedIn("malin@example.test");
+ _factory.Email.Sent.Clear();
+
+ // Cased differently on purpose: the comparison is normalised, not a raw string compare.
+ var response = await Request("MALIN@example.test");
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(response), Is.EqualTo("same"));
+ Assert.That(_factory.Email.Sent, Is.Empty);
+ });
+ }
+
+ [Test]
+ public async Task A_malformed_empty_or_over_length_address_is_a_bare_400()
+ {
+ await ArrangeSignedIn("malin@example.test");
+ _factory.Email.Sent.Clear();
+
+ var empty = await Request(" ");
+ var malformed = await Request("not-an-address");
+ var tooLong = await Request(new string('a', 250) + "@example.test");
+
+ // Bare, with no code: the screen validates format client-side, so reaching this means a
+ // caller bypassing the form, and there is no screen state that needs to tell them apart.
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(empty.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "empty");
+ Assert.That(await ErrorCode(empty), Is.Null, "empty carries no code");
+ Assert.That(malformed.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "malformed");
+ Assert.That(tooLong.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "over 254");
+ Assert.That(_factory.Email.Sent, Is.Empty);
+ });
+ }
+
+ [Test]
+ public async Task An_address_another_account_holds_gets_a_silent_204_with_no_mail()
+ {
+ await Seed("taken@example.test");
+ await ArrangeSignedIn("malin@example.test");
+ _factory.Email.Sent.Clear();
+
+ var response = await Request("taken@example.test");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
+ Assert.That(_factory.Email.Sent, Is.Empty,
+ "a mail here would answer 'does this address exist' for any account holder");
+ });
+ }
+
+ [Test]
+ public async Task An_address_another_account_holds_only_as_a_user_name_gets_the_same_silent_204()
+ {
+ var otherId = await Seed("other@example.test");
+ await MoveUserNameOnly(otherId, "stranded@example.test");
+ await ArrangeSignedIn("malin@example.test");
+ _factory.Email.Sent.Clear();
+
+ var response = await Request("stranded@example.test");
+
+ // FindByEmailAsync alone would call this free and mint a token for an address that then
+ // fails DuplicateUserName at confirm time — a link that cannot work, sent to a real inbox.
+ Assert.Multiple(() =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
+ Assert.That(_factory.Email.Sent, Is.Empty);
+ });
+ }
+
+ [Test]
+ public async Task The_callers_own_stranded_user_name_is_not_treated_as_taken()
+ {
+ // The 409 half-changed state, arranged directly: Email is the new address, UserName is
+ // still the old one. Re-requesting the OLD address is the repair path out of it, and
+ // excluding self from the lookup is the only thing that makes it reachable — without it
+ // the user gets a silent 204 forever, on the one address they most want back.
+ var id = await ArrangeSignedIn("malin@example.test");
+ await MoveEmailOnly(id, "moved@example.test");
+ _factory.Email.Sent.Clear();
+
+ var response = await Request("malin@example.test");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
+ Assert.That(_factory.Email.Sent.Single().Kind, Is.EqualTo("change-email"));
+ Assert.That(_factory.Email.Sent.Single().Email, Is.EqualTo("malin@example.test"));
+ });
+ }
+
+ [Test]
+ public async Task A_free_address_gets_exactly_one_confirmation_to_the_new_address()
+ {
+ await ArrangeSignedIn("malin@example.test");
+ _factory.Email.Sent.Clear();
+
+ var response = await Request("newer@example.test");
+
+ var sent = _factory.Email.Sent.Single();
+ var (userId, newEmail, code) = ReadLink(sent.Link);
+ Assert.Multiple(() =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
+ Assert.That(sent.Kind, Is.EqualTo("change-email"));
+ Assert.That(sent.Email, Is.EqualTo("newer@example.test"),
+ "the confirmation goes to the NEW address, never the old one");
+ Assert.That(sent.Link, Does.Contain("/confirm-email-change"));
+ Assert.That(userId, Is.Not.Empty);
+ Assert.That(newEmail, Is.EqualTo("newer@example.test"));
+ Assert.That(code, Is.Not.Empty);
+ });
+ }
+
+ [Test]
+ public async Task An_older_token_still_works_after_a_newer_one_is_issued()
+ {
+ // Nothing rotates on request, so two requests leave two live tokens and whichever link is
+ // clicked first wins. Someone who re-requests BECAUSE they think the first link was seen
+ // has revoked nothing. This test writes that down rather than leaving it to be discovered;
+ // if it ever starts failing, the revocation model has changed and the design is wrong.
+ var id = await ArrangeSignedIn("malin@example.test");
+ _factory.Email.Sent.Clear();
+ await Request("newer@example.test");
+ var first = ReadLink(_factory.Email.Sent.Single().Link);
+ await Request("newer@example.test");
+
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = (await users.FindByIdAsync(id))!;
+ var token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(first.Code));
+ var redeemed = await users.ChangeEmailAsync(user, "newer@example.test", token);
+
+ Assert.That(redeemed.Succeeded, Is.True, "the first link still works");
+ }
+
+ [Test]
+ public async Task Change_email_binds_json_only()
+ {
+ await ArrangeSignedIn("malin@example.test");
+ var form = new FormUrlEncodedContent([
+ new KeyValuePair("newEmail", "newer@example.test"),
+ ]);
+
+ var response = await _client.PostAsync("/api/auth/change-email", form);
+
+ // As in AuthResetTests and AuthForgotTests: 404 is the measured behaviour.
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ }
+
+ /// Leaves an account holding as a UserName only.
+ private async Task MoveUserNameOnly(string userId, string userName)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = (await users.FindByIdAsync(userId))!;
+ var result = await users.SetUserNameAsync(user, userName);
+ Assert.That(result.Succeeded, Is.True, "arrangement failed");
+ }
+
+ ///
+ /// Writes Email straight through the context, leaving UserName behind — the half-changed state
+ /// /confirm-email-change's 409 branch produces. Deliberately corrupt, and deliberately not
+ /// built through UserManager, which would keep the two in step.
+ ///
+ private async Task MoveEmailOnly(string userId, string email)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var row = await db.Users.SingleAsync(u => u.Id == userId);
+ row.Email = email;
+ row.NormalizedEmail = email.ToUpperInvariant();
+ await db.SaveChangesAsync();
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `dotnet test --filter "FullyQualifiedName~AuthChangeEmailTests"`
+
+Expected: all ten FAIL — the route does not exist, so every call returns 404.
+
+- [ ] **Step 3: Write the handler**
+
+In `Wend.Api/AuthEndpoints.cs`, after the `/change-password` handler:
+
+```csharp
+ // Authenticated. One 204 covers four different outcomes — free, held by another account as
+ // an email, held by another account as a user name, and held by the caller themselves in
+ // the half-changed state — because an authenticated endpoint that names which addresses
+ // exist is a user-table oracle with a login in front of it. The one 400 with a code names a
+ // property of the caller's OWN account, which they already know.
+ group.MapPost("/change-email", async (ChangeEmailRequest req, UserManager users,
+ IAuthEmailSender email, HttpRequest http, ClaimsPrincipal principal) =>
+ {
+ var address = req.NewEmail?.Trim() ?? "";
+
+ // Input-only checks first, per the standing rule: refusing malformed input is safe, but
+ // it must happen BEFORE any existence lookup or the 400 becomes an oracle. The bare 400
+ // carries no code deliberately — the screen validates format client-side, so reaching
+ // it means a caller bypassing the form.
+ if (address.Length is 0 or > MaxEmailLength) return Results.BadRequest();
+ if (!new EmailAddressAttribute().IsValid(address)) return Results.BadRequest();
+
+ if (await users.GetUserAsync(principal) is not { } user) return Results.Unauthorized();
+
+ // Normalised, not a raw string compare: "MALIN@example.test" is the address they have.
+ // Naming this leaks nothing — the caller is authenticated and already knows their own
+ // address — and a silent 204 here would promise an inbox that never receives anything.
+ if (string.Equals(users.NormalizeEmail(address), user.NormalizedEmail,
+ StringComparison.Ordinal))
+ return Results.BadRequest(new { error = "same" });
+
+ // BOTH lookups. An address can be occupied as a UserName while free as an Email — that
+ // is exactly the desync SetUserNameAsync exists to prevent — and minting a token for
+ // one of those would produce a confirmation link that fails at confirm time, sent to a
+ // real inbox.
+ //
+ // Self is excluded, and self is reachable: in the half-changed state Email is the new
+ // address while UserName is still the old one, so FindByNameAsync(old) returns the
+ // caller. Excluding self lets them re-request their old address and repair it; not
+ // excluding it hands them a silent 204 forever, on the one address they most want back.
+ var byEmail = await users.FindByEmailAsync(address);
+ var byName = await users.FindByNameAsync(address);
+ if ((byEmail is not null && byEmail.Id != user.Id)
+ || (byName is not null && byName.Id != user.Id))
+ {
+ return Results.NoContent();
+ }
+
+ // Nothing is written. The account keeps its old address until the link is clicked, so
+ // an abandoned request leaves no state to clean up and no pending-change indicator to
+ // render — the trade accepted by putting the pending address in the query string.
+ var link = await BuildChangeEmailLinkAsync(user, address, users, http, publicBaseUrl);
+ await email.SendEmailChangeConfirmationAsync(address, link);
+
+ return Results.NoContent();
+ }).RequireAuthorization();
+```
+
+The link builder, beside `BuildResetLinkAsync`:
+
+```csharp
+ ///
+ /// Mints a change-email token and builds the link to the SPA's /confirm-email-change screen.
+ /// The token is bound to (user, new address, security stamp), so the address has to travel with
+ /// it — which is why this link carries a third parameter the other two do not.
+ ///
+ /// Same configured origin as the other two, and it matters more here: a link that repoints an
+ /// account's login identity is worth as much to an attacker as a reset link, so building the
+ /// origin from http.Host would let anyone who can set that header have Wend email a victim a
+ /// genuine-looking link pointing at their own server.
+ ///
+ private static async Task BuildChangeEmailLinkAsync(WendUser user, string newEmail,
+ UserManager users, HttpRequest http, string? publicBaseUrl)
+ {
+ var token = await users.GenerateChangeEmailTokenAsync(user, newEmail);
+ var code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
+ var origin = publicBaseUrl?.TrimEnd('/') ?? $"{http.Scheme}://{http.Host}";
+ return $"{origin}/confirm-email-change" +
+ $"?userId={Uri.EscapeDataString(user.Id)}" +
+ $"&newEmail={Uri.EscapeDataString(newEmail)}" +
+ $"&code={Uri.EscapeDataString(code)}";
+ }
+```
+
+And the request record:
+
+```csharp
+public record ChangeEmailRequest(string NewEmail);
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `dotnet test`
+
+Expected: PASS, **276 total**.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Wend.Api/AuthEndpoints.cs Wend.Tests/AuthChangeEmailTests.cs
+git commit -m "Add POST /api/auth/change-email behind a generic 204"
+```
+
+---
+
+### Task 4: `POST /api/auth/confirm-email-change`
+
+**Files:**
+- Modify: `Wend.Api/AuthEndpoints.cs` (one handler after `/change-email`, one request record)
+- Test: `Wend.Tests/AuthConfirmEmailChangeTests.cs` (create)
+
+**Interfaces:**
+- Consumes: the link built by Task 3, and `IAuthEmailSender.SendEmailChangedNoticeAsync` from
+ Task 1.
+- Produces: `POST /api/auth/confirm-email-change`, **anonymous**, body
+ `{ userId, newEmail, code }`; `200 { email }`, `400 { error: "token" }`, or
+ `409 { error: "taken" }`. Record `ConfirmEmailChangeRequest(string UserId, string NewEmail,
+ string Code)`.
+
+**Eleven new tests.** 276 → **287**.
+
+**This is the task that carries the plan's one genuinely dangerous failure.** Step 4 below is the
+correctness requirement, and `Registering_the_old_address_afterwards_still_works` is the only test
+in the suite that catches its absence.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Wend.Tests/AuthConfirmEmailChangeTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http.Json;
+using System.Web;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Wend.Core;
+
+namespace Wend.Tests;
+
+///
+/// /api/auth/confirm-email-change — the half that applies the change. Anonymous, because the link
+/// lands in a mailbox that may be open in a different browser and possession of a token bound to
+/// (user, new address, stamp) is the proof.
+///
+/// The load-bearing test here is Registering_the_old_address_afterwards_still_works. Without
+/// SetUserNameAsync it fails and every other test in this repo still passes.
+///
+public class AuthConfirmEmailChangeTests
+{
+ private const string GoodPassword = "correct horse battery staple";
+
+ private WendApiFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _factory = new WendApiFactory();
+ _client = _factory.CreateClient();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _client.Dispose();
+ _factory.Dispose();
+ }
+
+ private async Task Seed(string email)
+ {
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { email, password = GoodPassword, displayName = "Malin" });
+
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = (await users.FindByEmailAsync(email))!;
+ await users.ConfirmEmailAsync(user, await users.GenerateEmailConfirmationTokenAsync(user));
+ return user.Id;
+ }
+
+ /// Seeds an account, acts as it, requests a change, and returns the emailed link.
+ private async Task<(string UserId, string NewEmail, string Code)> ArrangeLink(
+ string from, string to)
+ {
+ _factory.CurrentUser.UserId = await Seed(from);
+ _factory.Email.Sent.Clear();
+ await _client.PostAsJsonAsync("/api/auth/change-email", new { newEmail = to });
+ return ReadLink(_factory.Email.Sent.Single().Link);
+ }
+
+ private static (string UserId, string NewEmail, string Code) ReadLink(string link)
+ {
+ var query = HttpUtility.ParseQueryString(new Uri(link).Query);
+ return (query["userId"]!, query["newEmail"]!, query["code"]!);
+ }
+
+ private Task Confirm(string userId, string newEmail, string code) =>
+ _client.PostAsJsonAsync("/api/auth/confirm-email-change",
+ new { userId, newEmail, code });
+
+ private static async Task ErrorCode(HttpResponseMessage response)
+ {
+ // The length guard is load-bearing: /change-email's malformed-address branch answers a
+ // BARE 400 with no body at all, and ReadFromJsonAsync throws on empty content rather than
+ // returning null.
+ if (response.Content.Headers.ContentLength is null or 0) return null;
+ var body = await response.Content.ReadFromJsonAsync>();
+ return body?.GetValueOrDefault("error");
+ }
+
+ private async Task Reload(string userId)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var users = scope.ServiceProvider.GetRequiredService>();
+ return (await users.FindByIdAsync(userId))!;
+ }
+
+ [Test]
+ public async Task A_valid_link_changes_both_fields_and_returns_the_stored_address()
+ {
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+
+ var response = await Confirm(userId, newEmail, code);
+
+ var body = await response.Content.ReadFromJsonAsync>();
+ var user = await Reload(userId);
+ Assert.Multiple(() =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ // Read back off the row after BOTH writes, so the screen reports what the database
+ // holds rather than what the caller asked for.
+ Assert.That(body?.GetValueOrDefault("email"), Is.EqualTo("newer@example.test"));
+ Assert.That(user.Email, Is.EqualTo("newer@example.test"), "Email");
+ Assert.That(user.UserName, Is.EqualTo("newer@example.test"), "UserName");
+ });
+ }
+
+ [Test]
+ public async Task Registering_the_old_address_afterwards_still_works()
+ {
+ // THE regression test. ChangeEmailAsync does not touch UserName (verified against
+ // release/10.0), and RequireUniqueEmail switches on UserValidator's UserName uniqueness
+ // check as well as the email one — so without SetUserNameAsync the abandoned address stays
+ // occupied. A later registration to it then fails DuplicateUserName, which /register
+ // answers with 204 and a code-only log line. The caller sees success, no mail is ever sent,
+ // and the failure lands on a stranger months later.
+ //
+ // Login would keep working either way (it resolves through NormalizedEmail), which is why
+ // nothing else in this suite catches it.
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ await Confirm(userId, newEmail, code);
+ _factory.Email.Sent.Clear();
+
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { email = "malin@example.test", password = GoodPassword, displayName = "Someone" });
+
+ Assert.That(_factory.Email.Sent.Where(s => s.Kind == "confirm"), Is.Not.Empty,
+ "the old address is squatted as a UserName — SetUserNameAsync is missing");
+ }
+
+ [Test]
+ public async Task Sign_in_uses_the_new_address_and_the_old_one_is_refused()
+ {
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ await Confirm(userId, newEmail, code);
+
+ var withNew = await _client.PostAsJsonAsync("/api/auth/login",
+ new { email = "newer@example.test", password = GoodPassword });
+ var withOld = await _client.PostAsJsonAsync("/api/auth/login",
+ new { email = "malin@example.test", password = GoodPassword });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(withNew.StatusCode, Is.EqualTo(HttpStatusCode.NoContent), "new address");
+ Assert.That(withOld.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "old address");
+ });
+ }
+
+ [Test]
+ public async Task A_reused_link_is_a_token_error()
+ {
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ await Confirm(userId, newEmail, code);
+
+ // Single-use comes from stamp rotation, not from a guard: the first confirmation rotated
+ // the stamp the token was bound to.
+ var second = await Confirm(userId, newEmail, code);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(second), Is.EqualTo("token"));
+ });
+ }
+
+ [Test]
+ public async Task A_tampered_new_email_is_a_token_error()
+ {
+ var (userId, _, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+
+ // The token is bound to the address, so swapping the address in the URL invalidates it.
+ var response = await Confirm(userId, "attacker@example.test", code);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(response), Is.EqualTo("token"));
+ Assert.That((await Reload(userId)).Email, Is.EqualTo("malin@example.test"));
+ });
+ }
+
+ [Test]
+ public async Task A_token_minted_for_one_user_cannot_change_another()
+ {
+ var (_, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ var victimId = await Seed("victim@example.test");
+
+ var response = await Confirm(victimId, newEmail, code);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(response), Is.EqualTo("token"));
+ Assert.That((await Reload(victimId)).Email, Is.EqualTo("victim@example.test"));
+ });
+ }
+
+ [Test]
+ public async Task A_missing_or_unknown_user_id_is_a_token_error()
+ {
+ var (_, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+
+ var missing = await Confirm("", newEmail, code);
+ var unknown = await Confirm(Guid.NewGuid().ToString(), newEmail, code);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(missing.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "missing");
+ Assert.That(await ErrorCode(missing), Is.EqualTo("token"));
+ Assert.That(unknown.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "unknown");
+ Assert.That(await ErrorCode(unknown), Is.EqualTo("token"));
+ });
+ }
+
+ [Test]
+ public async Task An_address_taken_since_the_request_is_409_taken()
+ {
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ // Somebody else claims it in the minutes between the request and the click. This is where
+ // the parent spec's "uniqueness re-checked at confirm time" actually happens: UserValidator
+ // firing inside UpdateUserAsync. This plan's job is only to tell the two failures apart.
+ await Seed("newer@example.test");
+
+ var response = await Confirm(userId, newEmail, code);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
+ // Not "token". A dead token means "get a new link"; a taken address means "pick a
+ // different address". Collapsing them tells someone their link expired when the link
+ // was fine.
+ Assert.That(await ErrorCode(response), Is.EqualTo("taken"));
+ Assert.That((await Reload(userId)).Email, Is.EqualTo("malin@example.test"));
+ });
+ }
+
+ [Test]
+ public async Task One_notice_goes_to_the_old_address_after_success_and_none_on_failure()
+ {
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ _factory.Email.Sent.Clear();
+
+ await Confirm(userId, newEmail, code);
+ var afterSuccess = _factory.Email.Sent.Where(s => s.Kind == "changed-notice").ToList();
+ _factory.Email.Sent.Clear();
+ await Confirm(userId, newEmail, code); // the replay: a token failure
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(afterSuccess, Has.Count.EqualTo(1));
+ // Sent to the OLD address, naming the new one — the only mechanism by which an owner
+ // learns that somebody with a live session repointed their account.
+ Assert.That(afterSuccess[0].Email, Is.EqualTo("malin@example.test"), "recipient");
+ Assert.That(afterSuccess[0].Link, Is.EqualTo("newer@example.test"), "names the new one");
+ Assert.That(_factory.Email.Sent.Where(s => s.Kind == "changed-notice"), Is.Empty,
+ "a notice on failure is an email bomb an attacker aims at the victim");
+ });
+ }
+
+ [Test]
+ public async Task A_password_change_between_the_request_and_the_confirmation_kills_the_link()
+ {
+ // Correct behaviour, and the kind of coupling that only shows up if something looks for it:
+ // both flows rotate the same security stamp, and the change-email token is bound to it.
+ var (userId, newEmail, code) = await ArrangeLink("malin@example.test", "newer@example.test");
+ await _client.PostAsJsonAsync("/api/auth/change-password",
+ new { currentPassword = GoodPassword, newPassword = "a different long passphrase" });
+
+ var response = await Confirm(userId, newEmail, code);
+
+ await Assert.MultipleAsync(async () =>
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(await ErrorCode(response), Is.EqualTo("token"));
+ });
+ }
+
+ [Test]
+ public async Task Confirm_email_change_binds_json_only()
+ {
+ var form = new FormUrlEncodedContent([
+ new KeyValuePair("userId", "whoever"),
+ new KeyValuePair("newEmail", "newer@example.test"),
+ new KeyValuePair("code", "whatever"),
+ ]);
+
+ var response = await _client.PostAsync("/api/auth/confirm-email-change", form);
+
+ // As in AuthResetTests and AuthForgotTests: 404 is the measured behaviour.
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `dotnet test --filter "FullyQualifiedName~AuthConfirmEmailChangeTests"`
+
+Expected: all eleven FAIL — the route does not exist.
+
+- [ ] **Step 3: Write the handler**
+
+In `Wend.Api/AuthEndpoints.cs`, after the `/change-email` handler. **Anonymous** — the group has no
+`RequireAuthorization()`, so leaving the attribute off is what makes it so; do not add one.
+
+```csharp
+ // POST, not GET, even though this arrives from an emailed link — the same reasoning /verify
+ // carries, and it bites harder here. Corporate mail scanners and link-preview bots follow
+ // GET links automatically, so a GET that applied the change would be fired by a robot
+ // before the human ever clicked, silently repointing an account's login identity. The
+ // emailed link therefore points at the SPA shell, and the screen POSTs the values back.
+ //
+ // Anonymous, like /verify and /reset-password: the link lands in a mailbox the user may
+ // open in a different browser, possession of a token bound to (user, new address, stamp) is
+ // the proof, and the change completes even if the session expired in the meantime.
+ group.MapPost("/confirm-email-change", async (ConfirmEmailChangeRequest req,
+ UserManager users, IAuthEmailSender email, HttpResponse response,
+ ILoggerFactory loggerFactory) =>
+ {
+ if (req.UserId is not { Length: > 0 } id)
+ return Results.BadRequest(new { error = "token" });
+ if (await users.FindByIdAsync(id) is not { } user)
+ return Results.BadRequest(new { error = "token" });
+
+ var newEmail = req.NewEmail?.Trim() ?? "";
+ // Captured BEFORE the write, because the notice has to name the address that is losing
+ // the account and `user` is mutated in place two lines further down.
+ var oldEmail = user.Email!;
+
+ string token;
+ try
+ {
+ token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(req.Code ?? ""));
+ }
+ catch (FormatException)
+ {
+ return Results.BadRequest(new { error = "token" });
+ }
+
+ // Verifies the token, writes Email and EmailConfirmed, rotates the security stamp, then
+ // runs UserValidator through UpdateUserAsync — which is where the parent spec's
+ // "uniqueness re-checked at confirm time" actually happens. All verified against
+ // release/10.0. It does NOT touch UserName; that is the next call's whole reason.
+ var changed = await users.ChangeEmailAsync(user, newEmail, token);
+ if (!changed.Succeeded)
+ {
+ // Two codes, not one. A dead token means "get a new link"; a taken address means
+ // "pick a different address". Collapsing them produces a screen that says the link
+ // expired when the link was fine and somebody took the address four minutes ago.
+ return changed.Errors.Any(e => e.Code == "DuplicateEmail")
+ ? Results.Conflict(new { error = "taken" })
+ : Results.BadRequest(new { error = "token" });
+ }
+
+ // The plan's correctness requirement, not tidiness. Wend sets UserName = Email at
+ // registration and ChangeEmailAsync leaves UserName holding the OLD address forever.
+ // Login keeps working — it resolves through NormalizedEmail — so the bug passes every
+ // obvious test. But RequireUniqueEmail switches on UserValidator's UserName uniqueness
+ // check too, so the abandoned address stays occupied, and the next registration to it
+ // fails DuplicateUserName, which /register answers with 204 and a log line. Silent,
+ // delayed, and it lands on a stranger.
+ //
+ // The SAME instance, not a reload: ChangeEmailAsync has just refreshed this user's
+ // concurrency stamp and reloading here would work from a stale one. Same two-call
+ // pattern as /reset-password's lockout clear. This call rotates the security stamp a
+ // second time (verified), which is harmless — every session died at the first rotation.
+ var renamed = await users.SetUserNameAsync(user, newEmail);
+ if (!renamed.Succeeded)
+ {
+ // The narrowest path in the plan, and it still needs its branch: /change-email
+ // checks both lookups precisely so an address free as one is free as the other, so
+ // what is left is a genuine race between two confirmations. The account is now
+ // half-changed, which is the state this endpoint exists to prevent, so it must not
+ // report success. A retry fails the token check because the stamp already rotated,
+ // which is what the screen tells the user. Error CODES only.
+ loggerFactory.CreateLogger("Wend.Api.AuthEndpoints")
+ .LogWarning("Email changed but the user name was not: {Errors}",
+ string.Join("; ", renamed.Errors.Select(e => e.Code)));
+ return Results.Conflict(new { error = "taken" });
+ }
+
+ // Off the response path, like login's nudge. Success only: a notice on a failed attempt
+ // would be an email bomb an attacker aims at the victim, from the victim's own account.
+ response.OnCompleted(async () =>
+ await email.SendEmailChangedNoticeAsync(oldEmail, newEmail));
+
+ // The only endpoint in /api/auth/* that returns a body, and it earns it. The success
+ // screen has to name the new address, and the only other sources are the query string —
+ // a caller-controlled value on an anonymous page, which is the reflected-XSS shape the
+ // frontend rule forbids — and nothing at all, which leaves the user trusting that what
+ // they typed ten minutes ago is what landed. Read off the user AFTER both writes.
+ //
+ // No RefreshSignInAsync, and there must not be one: this request is anonymous and may
+ // be arriving from a different browser than the one holding the session, so "refresh
+ // the acting session" has no meaning here. Every live session is refused on its next
+ // request and the user signs in with the new address.
+ return Results.Ok(new { email = user.Email });
+ });
+```
+
+And the request record:
+
+```csharp
+public record ConfirmEmailChangeRequest(string UserId, string NewEmail, string Code);
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `dotnet test`
+
+Expected: PASS, **287 total**.
+
+- [ ] **Step 5: Prove the regression test actually bites**
+
+Comment out the `SetUserNameAsync` block (keeping `return Results.Ok(...)`), then run:
+
+`dotnet test --filter "FullyQualifiedName~AuthConfirmEmailChangeTests"`
+
+Expected: **exactly one failure** —
+`Registering_the_old_address_afterwards_still_works`. If it passes with the call removed, the test
+is not testing what it claims and this task is not done. Restore the block and re-run.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Wend.Api/AuthEndpoints.cs Wend.Tests/AuthConfirmEmailChangeTests.cs
+git commit -m "Add POST /api/auth/confirm-email-change, keeping UserName in step with Email"
+```
+
+---
+
+### Task 5: The real-cookie walk
+
+**Files:**
+- Test: `Wend.Tests/RealCookieAccountTests.cs` (create)
+
+**Interfaces:**
+- Consumes: all three endpoints from Tasks 2–4. Adds no production code.
+
+**Four new tests.** 287 → **291**.
+
+**Why this task exists at all.** Three of the design's required assertions cannot run under the
+`Test` auth scheme, and a test-scheme version of any of them would pass while testing nothing:
+
+- `RefreshSignInAsync` calls `Context.AuthenticateAsync(IdentityConstants.ApplicationScheme)`,
+ finds no cookie under the test scheme, logs an error and returns — so "the acting session
+ survives" is vacuously true there.
+- `SecurityStampValidator` hangs off the **cookie's** `OnValidatePrincipal`, so "another session
+ dies" never fires under the test scheme either.
+- The test scheme issues no cookie at all, so there is no `Set-Cookie` to check for `expires=`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Wend.Tests/RealCookieAccountTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http.Json;
+using System.Web;
+
+namespace Wend.Tests;
+
+///
+/// Account settings on the genuine cookie scheme, no test auth anywhere: which sessions survive a
+/// password change, which die, and whether a remembered cookie stays remembered. Every assertion
+/// here is one the Test scheme cannot make — see the note in the plan.
+///
+public class RealCookieAccountTests
+{
+ private const string GoodPassword = "correct horse battery staple";
+ private const string NewPassword = "a different long passphrase";
+
+ private WendApiFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _factory = new WendApiFactory(useTestAuth: false);
+ _client = _factory.CreateClient();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _client.Dispose();
+ _factory.Dispose();
+ }
+
+ [Test]
+ public async Task The_test_scheme_really_is_off()
+ {
+ // The canary, and deliberately the first test in the file. The factory seeds a default user
+ // and points CurrentUser at it on every CreateClient(), so a suite that forgot
+ // useTestAuth: false is authenticated before it does anything — and every assertion below
+ // would pass while testing nothing. This repo has been bitten by that shape twice.
+ var response = await _client.GetAsync("/api/auth/me");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ /// Registers and confirms an account over HTTP, the way a browser would.
+ private async Task Arrange(HttpClient client, string email)
+ {
+ _factory.Email.Sent.Clear();
+ await client.PostAsJsonAsync("/api/auth/register",
+ new { email, password = GoodPassword, displayName = "Malin" });
+ var query = HttpUtility.ParseQueryString(new Uri(_factory.Email.Sent.Last().Link).Query);
+ await client.PostAsJsonAsync("/api/auth/verify",
+ new { userId = query["userId"], code = query["code"] });
+ }
+
+ private static Task Login(
+ HttpClient client, string email, bool rememberMe = false) =>
+ client.PostAsJsonAsync("/api/auth/login",
+ new { email, password = GoodPassword, rememberMe });
+
+ private static Task ChangePassword(HttpClient client, string newPassword) =>
+ client.PostAsJsonAsync("/api/auth/change-password",
+ new { currentPassword = GoodPassword, newPassword });
+
+ // Last(), not Single(): a change-password response can carry more than one wend.session
+ // Set-Cookie — RefreshSignInAsync writes one, and SlidingExpiration's renewal can write another.
+ // The last one is the cookie the browser ends up holding.
+ private static string SessionCookie(HttpResponseMessage response) =>
+ response.Headers.GetValues("Set-Cookie").Last(c => c.StartsWith("wend.session="));
+
+ [Test]
+ public async Task The_acting_session_survives_a_password_change_while_another_one_dies()
+ {
+ await Arrange(_client, "walker@example.test");
+ await Login(_client, "walker@example.test");
+
+ // A second browser, signed in as the same person. Its cookie is what the password change
+ // is supposed to kill.
+ using var elsewhere = _factory.CreateClient();
+ await Login(elsewhere, "walker@example.test");
+ var elsewhereBefore = await elsewhere.GetAsync("/api/boards");
+
+ var changed = await ChangePassword(_client, NewPassword);
+
+ var actingAfter = await _client.GetAsync("/api/boards");
+ var elsewhereAfter = await elsewhere.GetAsync("/api/boards");
+
+ // The PAIR is the point. Either assertion alone is satisfiable by a wrong implementation:
+ // drop RefreshSignInAsync and the second passes while the first fails; skip the stamp
+ // rotation and the reverse.
+ Assert.Multiple(() =>
+ {
+ Assert.That(changed.StatusCode, Is.EqualTo(HttpStatusCode.NoContent), "change");
+ Assert.That(elsewhereBefore.StatusCode, Is.EqualTo(HttpStatusCode.OK), "other, before");
+ Assert.That(actingAfter.StatusCode, Is.EqualTo(HttpStatusCode.OK),
+ "the browser that changed the password must NOT be signed out");
+ Assert.That(elsewhereAfter.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized),
+ "every other session must be");
+ });
+ }
+
+ [Test]
+ public async Task A_remembered_session_is_still_remembered_after_a_password_change()
+ {
+ await Arrange(_client, "remembered@example.test");
+ await Login(_client, "remembered@example.test", rememberMe: true);
+
+ using var plain = _factory.CreateClient();
+ await Arrange(plain, "forgotten@example.test");
+ await Login(plain, "forgotten@example.test");
+
+ var remembered = await ChangePassword(_client, NewPassword);
+ var session = await ChangePassword(plain, NewPassword);
+
+ // Both directions, because "always writes expires=" would satisfy the first alone. If the
+ // reissued cookie loses its persistence, a remembered login silently becomes a session
+ // cookie and the user is signed out a day later with nothing to blame.
+ Assert.Multiple(() =>
+ {
+ Assert.That(SessionCookie(remembered), Does.Contain("expires=").IgnoreCase,
+ "remember-me must survive the reissue");
+ Assert.That(SessionCookie(session), Does.Not.Contain("expires=").IgnoreCase,
+ "and a session cookie must stay one");
+ });
+ }
+
+ [Test]
+ public async Task Every_session_dies_after_an_email_change_including_the_one_that_asked()
+ {
+ await Arrange(_client, "walker@example.test");
+ await Login(_client, "walker@example.test");
+ var before = await _client.GetAsync("/api/boards");
+
+ _factory.Email.Sent.Clear();
+ await _client.PostAsJsonAsync("/api/auth/change-email",
+ new { newEmail = "newer@example.test" });
+ var query = HttpUtility.ParseQueryString(new Uri(_factory.Email.Sent.Single().Link).Query);
+
+ // Confirmed from a THIRD client with no session, which is the realistic shape: the link
+ // lands in a mailbox that may be open in a different browser entirely.
+ using var mailbox = _factory.CreateClient();
+ var confirmed = await mailbox.PostAsJsonAsync("/api/auth/confirm-email-change",
+ new { userId = query["userId"], newEmail = query["newEmail"], code = query["code"] });
+
+ var after = await _client.GetAsync("/api/boards");
+
+ // No carve-out here, unlike change-password, and that is deliberate: this request is
+ // anonymous and may not be coming from the session that asked, so there is no acting
+ // session to refresh.
+ Assert.Multiple(() =>
+ {
+ Assert.That(before.StatusCode, Is.EqualTo(HttpStatusCode.OK), "before");
+ Assert.That(confirmed.StatusCode, Is.EqualTo(HttpStatusCode.OK), "confirm");
+ Assert.That(after.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "after");
+ });
+ }
+}
+```
+
+- [ ] **Step 2: Run them**
+
+Run: `dotnet test`
+
+Expected: PASS, **291 total**. These test code that already exists, so they should pass first time —
+if any of them fails, the failure is real and belongs to Task 2 or Task 4, not to this test file.
+
+- [ ] **Step 3: Prove `RefreshSignInAsync` is load-bearing**
+
+Comment out the `try { await signIn.RefreshSignInAsync(user); }` call in `/change-password`, then
+run: `dotnet test --filter "FullyQualifiedName~RealCookieAccountTests"`
+
+Expected: **exactly one failure** —
+`The_acting_session_survives_a_password_change_while_another_one_dies`, on the `actingAfter`
+assertion. Restore the call and re-run.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Wend.Tests/RealCookieAccountTests.cs
+git commit -m "Cover account settings on the real cookie scheme"
+```
+
+---
+
+### Task 6: The Account screen
+
+**Files:**
+- Create: `Wend.Api/wwwroot/js/auth/account/model.js`
+- Create: `Wend.Api/wwwroot/js/auth/account/view.js`
+- Create: `Wend.Api/wwwroot/js/auth/account/controller.js`
+- Modify: `Wend.Api/wwwroot/js/main.js` (three imports, `showAccount`, the `onAccount` callback)
+- Modify: `Wend.Api/wwwroot/js/settings/view.js` (the Account button)
+- Modify: `Wend.Api/wwwroot/js/settings/controller.js` (forwards it)
+- Modify: `Wend.Api/wwwroot/css/app.css` (an `.account-*` block)
+
+**Interfaces:**
+- Consumes: `POST /api/auth/change-password` (Task 2), `POST /api/auth/change-email` (Task 3),
+ `GET /api/auth/me` for the current address.
+- Produces: `createAccountModel()`, `createAccountView(root)`,
+ `createAccountController(model, view, announce, { onBack })`; `showAccount()` in `main.js`;
+ `createSettingsController(model, view, announce, { onBack, onAccount })`.
+
+**No automated tests** — this repo has no JS test harness. Step 7 is a scripted manual walk, and
+every browser check hard-reloads. Total stays **291**.
+
+**The three two-form rules, and how the code makes them structural.** Every auth screen so far has
+had exactly one form and one error region, so the announcer and the focus helpers have never had to
+disambiguate. Rather than maintain the rules by hand in one whole-screen repaint, the view renders
+the shell once and gives each form its own independently-replaced body:
+
+1. **Each form owns its own error region** — its own element, its own alert, its own
+ disable-while-in-flight. A submit in one never clears the other's, because a submit in one never
+ touches the other's DOM.
+2. **Focus after any submit stays inside the submitting form** — its own success alert, its own
+ error alert, or its own `
`. Never ``, which is what a whole-screen `innerHTML` repaint
+ would produce.
+3. **Each form has its own `
` and is associated with it via `aria-labelledby`**, so a screen
+ reader lands in a password-shaped form and is told which of the two it is.
+
+- [ ] **Step 1: Write the model**
+
+`Wend.Api/wwwroot/js/auth/account/model.js`:
+
+```js
+import { api } from "../../api.js";
+
+// State only. Two sub-states that never touch each other: a failed password change must not
+// disturb whatever the email form is showing, and vice versa. `what` tells the controller which
+// half changed, so it can repaint and focus that half alone.
+export function createAccountModel() {
+ let state = {
+ email: "",
+ password: { status: "editing", errors: [] },
+ emailChange: { status: "editing", errors: [], newEmail: "" },
+ };
+ const subscribers = [];
+ const notify = (what) => subscribers.forEach((fn) => fn(state, what));
+
+ return {
+ subscribe(fn) {
+ subscribers.push(fn);
+ fn(state, "all");
+ },
+
+ // Called once, at mount. The address comes from the server rather than from anything cached,
+ // so a screen opened after a change in another tab still shows the truth.
+ async load() {
+ const me = await api("/api/auth/me");
+ state = { ...state, email: me?.email ?? "" };
+ notify("all");
+ },
+
+ async changePassword({ currentPassword, newPassword }) {
+ if (state.password.status === "sending") return;
+ state = { ...state, password: { status: "sending", errors: [] } };
+ notify("password");
+ try {
+ await api("/api/auth/change-password", {
+ method: "POST",
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+ state = { ...state, password: { status: "done", errors: [] } };
+ } catch (error) {
+ state = { ...state, password: { status: "editing", errors: [passwordError(error)] } };
+ }
+ notify("password");
+ },
+
+ async changeEmail({ newEmail }) {
+ if (state.emailChange.status === "sending") return;
+ state = { ...state, emailChange: { status: "sending", errors: [], newEmail } };
+ notify("email");
+ try {
+ await api("/api/auth/change-email", {
+ method: "POST",
+ body: JSON.stringify({ newEmail }),
+ });
+ // 204 for a free address and for one somebody else holds. The screen must not claim a link
+ // went to THIS address, because it has no idea — same discipline as the forgot screen.
+ state = { ...state, emailChange: { status: "sent", errors: [], newEmail } };
+ } catch (error) {
+ state = {
+ ...state,
+ emailChange: { status: "editing", errors: [emailError(error)], newEmail },
+ };
+ }
+ notify("email");
+ },
+ };
+}
+
+function passwordError(error) {
+ const reason = error?.status === 400 ? error?.body?.error : null;
+ if (reason === "password") return "That password is too short. Use at least 12 characters.";
+ if (reason === "current") return "That isn't your current password.";
+ // 401 is either a session that ended or an account locked by five wrong attempts, and the
+ // endpoint deliberately does not say which. One message that is true of both, and no bounce to
+ // the login screen: a locked-out user's cookie still works, so signing them out would be a lie
+ // that also loses whatever they had typed.
+ if (error?.status === 401) {
+ return "We can't change your password right now. After five wrong attempts an account is "
+ + "locked for fifteen minutes — wait, then try again.";
+ }
+ return "Something went wrong. Please try again.";
+}
+
+function emailError(error) {
+ const reason = error?.status === 400 ? error?.body?.error : null;
+ if (reason === "same") return "That's already your sign-in address.";
+ if (error?.status === 400) return "That doesn't look like an email address.";
+ if (error?.status === 401) return "We can't change your address right now. Please sign in again.";
+ return "Something went wrong. Please try again.";
+}
+```
+
+- [ ] **Step 2: Write the view**
+
+`Wend.Api/wwwroot/js/auth/account/view.js`:
+
+```js
+import { escapeHtml } from "../../escape.js";
+
+// Renders the Account screen. The shell is built once; each form's body is replaced on its own.
+// That is what makes the two-form rules structural: a password submit physically cannot clear the
+// email form's error, steal its focus, or wipe what was typed into it.
+//
+// Both forms reuse .auth-form and .field-hint — this is the same form shape the auth screens use,
+// and a second copy of that CSS would be a second source of truth for the same spacing.
+export function createAccountView(root) {
+ let h = {};
+
+ function render(state) {
+ root.innerHTML = `
+
If that address is free, we've sent it a confirmation link. It lasts one hour, and your
+ sign-in address doesn't change until you open it.
+
` : ""}
+ ${errors.length ? `
+
+
${escapeHtml(errors[0])}
+
` : ""}
+ `;
+ }
+
+ // Written long-hand throughout: `el?.focus() ?? fallback()` looks equivalent and is not —
+ // focus() returns undefined, so the fallback would fire every time and drag focus off the thing
+ // it had just landed on.
+ function focusHeading() { root.querySelector(".account-heading")?.focus(); }
+
+ function focusPasswordOutcome() {
+ const done = root.querySelector(".account-password-done");
+ if (done) { done.focus(); return; }
+ const error = root.querySelector(".account-password-errors");
+ if (error) { error.focus(); return; }
+ // Still inside the submitting form's section — never , and never the other form.
+ root.querySelector("#account-password-heading")?.focus();
+ }
+
+ function focusEmailOutcome() {
+ const sent = root.querySelector(".account-email-sent");
+ if (sent) { sent.focus(); return; }
+ const error = root.querySelector(".account-email-errors");
+ if (error) { error.focus(); return; }
+ root.querySelector("#account-email-heading")?.focus();
+ }
+
+ function setPasswordBusy(busy) {
+ const button = root.querySelector('[data-role="change-password"]');
+ if (!button) return;
+ button.disabled = busy;
+ button.textContent = busy ? "Changing…" : "Change password";
+ }
+
+ function setEmailBusy(busy) {
+ const button = root.querySelector('[data-role="change-email"]');
+ if (!button) return;
+ button.disabled = busy;
+ button.textContent = busy ? "Sending…" : "Send the link";
+ }
+
+ // Delegated on root, so it survives either section's body being replaced.
+ function bindActions(handlers) {
+ h = handlers;
+ root.addEventListener("click", (e) => {
+ if (e.target.closest('[data-action="back"]')) h.back();
+ });
+ root.addEventListener("submit", (e) => {
+ const form = e.target.closest("form[data-action]");
+ if (!form) return;
+ e.preventDefault();
+ const data = new FormData(form);
+ if (form.dataset.action === "change-password") {
+ h.changePassword({
+ currentPassword: data.get("currentPassword") ?? "",
+ newPassword: data.get("newPassword") ?? "",
+ });
+ } else if (form.dataset.action === "change-email") {
+ h.changeEmail({ newEmail: data.get("newEmail") ?? "" });
+ }
+ });
+ }
+
+ return {
+ render, renderPassword, renderEmail, focusHeading,
+ focusPasswordOutcome, focusEmailOutcome, setPasswordBusy, setEmailBusy, bindActions,
+ };
+}
+```
+
+- [ ] **Step 3: Write the controller**
+
+`Wend.Api/wwwroot/js/auth/account/controller.js`:
+
+```js
+// Wires the Account screen. Each half repaints, focuses and announces on its own — the `what` the
+// model passes is what keeps one form's outcome out of the other form's business.
+export function createAccountController(model, view, announce, { onBack } = {}) {
+ view.bindActions({
+ back: () => onBack?.(),
+ changePassword: (fields) => model.changePassword(fields),
+ changeEmail: (fields) => model.changeEmail(fields),
+ });
+
+ model.subscribe((state, what) => {
+ if (what === "all") {
+ view.render(state);
+ // Every whole-shell render refocuses the heading, not just the first. There are exactly two
+ // (mount, then load resolving), and the second rebuilds the element the first focused — so
+ // without this, focus lands on a moment after arriving.
+ view.focusHeading();
+ return;
+ }
+
+ if (what === "password") {
+ if (state.password.status === "sending") {
+ view.setPasswordBusy(true);
+ announce("Changing your password…");
+ return;
+ }
+ // Repaints the password section ONLY. Success clears both password fields, which is why
+ // this repaints at all — and why focus has to be placed deliberately afterwards.
+ view.renderPassword(state.password);
+ view.setPasswordBusy(false);
+ view.focusPasswordOutcome();
+ if (state.password.status === "done") {
+ announce("Your password has been changed. Your other devices have been signed out.");
+ } else if (state.password.errors?.length) {
+ announce(state.password.errors[0]);
+ }
+ return;
+ }
+
+ if (what === "email") {
+ if (state.emailChange.status === "sending") {
+ view.setEmailBusy(true);
+ announce("Sending the confirmation link…");
+ return;
+ }
+ view.renderEmail(state.emailChange);
+ // Re-enabled on success too: somebody who mistyped the new address learns nothing from the
+ // response, so retrying must cost nothing but typing.
+ view.setEmailBusy(false);
+ view.focusEmailOutcome();
+ if (state.emailChange.status === "sent") {
+ announce("If that address is free, we've sent it a confirmation link. Check that inbox.");
+ } else if (state.emailChange.errors?.length) {
+ announce(state.emailChange.errors[0]);
+ }
+ }
+ });
+}
+```
+
+- [ ] **Step 4: Wire it into `main.js`**
+
+Add the imports beside the other auth ones (after the reset trio, currently lines 28-30):
+
+```js
+import { createAccountModel } from "./auth/account/model.js";
+import { createAccountView } from "./auth/account/view.js";
+import { createAccountController } from "./auth/account/controller.js";
+```
+
+Add `showAccount` directly after `showSettings` (currently ends at line 146), and give
+`createSettingsController` its second callback:
+
+```js
+function showSettings() {
+ mount((root) => {
+ const model = createSettingsModel();
+ const view = createSettingsView(root);
+ createSettingsController(model, view, announce, {
+ onBack: () => showOverview(null, true),
+ onAccount: showAccount,
+ });
+ view.focusHeading(); // house pattern: mounting focuses the screen's heading
+ });
+}
+
+// No route, on purpose. Settings has none either, so this matches the one precedent that exists —
+// and it keeps every route in boot()'s switch anonymous, which is what stops the next person
+// adding an authenticated one to it by pattern-match and shipping a screen that renders for a
+// signed-out visitor and 401s on first use. The cost is no deep link, and a refresh landing on the
+// board overview: the same trade Settings already makes.
+function showAccount() {
+ mount((root) => {
+ const model = createAccountModel();
+ const view = createAccountView(root);
+ createAccountController(model, view, announce, { onBack: showSettings });
+ model.load().catch(reportLoadFailure);
+ });
+}
+```
+
+- [ ] **Step 5: Add the link on Settings**
+
+In `Wend.Api/wwwroot/js/settings/view.js`, inside `.settings-view`, immediately after the
+`
` line:
+
+```html
+
+
+
+
+```
+
+And in the same file's `bindActions` click listener, beside the existing `back` branch:
+
+```js
+ if (e.target.closest('[data-action="account"]')) h.account();
+```
+
+In `Wend.Api/wwwroot/js/settings/controller.js`, widen the signature and forward it:
+
+```js
+export function createSettingsController(model, view, announce, { onBack, onAccount } = {}) {
+ view.bindActions({
+ back: () => onBack?.(),
+ account: () => onAccount?.(),
+```
+
+- [ ] **Step 6: Add the CSS**
+
+Append to `Wend.Api/wwwroot/css/app.css`, after the auth block. Mobile-first, and every custom
+property is a real design-system token:
+
+```css
+/* Account screen. Reuses .auth-form for both forms — same shape, and a second copy of that
+ spacing would be a second source of truth. Only the outer layout and the section rule are new. */
+.account-view {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-4);
+}
+
+.account-address {
+ margin: 0;
+ font-size: var(--text-sm);
+ color: var(--text-muted);
+}
+
+/* The rule is what tells a sighted user the two forms are separate things — the aria-labelledby
+ on each is what tells everyone else. --border, not --control-border: this is a
+ decorative divider, and --control-border exists to carry the 3:1 boundary a CONTROL owes
+ (SC 1.4.11). Borrowing it here would make the token mean two things. */
+.account-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding-block-start: var(--space-4);
+ border-block-start: 1px solid var(--border);
+}
+
+.account-section-heading {
+ margin: 0;
+}
+
+@media (min-width: 768px) {
+ .account-view {
+ max-width: 32rem;
+ margin-inline: auto;
+ }
+}
+```
+
+- [ ] **Step 7: Walk it in a real browser**
+
+Start PostgreSQL, then `dotnet run --project Wend.Api` with
+`$env:ASPNETCORE_ENVIRONMENT = "Development"` set first, and open `http://127.0.0.1:5174`.
+**Hard-reload every time** (Ctrl+Shift+R) — `UseStaticFiles` sends no `Cache-Control`, so a normal
+reload serves stale ES modules.
+
+- [ ] Sign in, open Settings, click **Account**. The heading is focused on arrival, and the screen
+ names the address you signed in with.
+- [ ] **Both forms' error regions are independent, checked in BOTH directions.** Submit the password
+ form with a wrong current password; leave the error showing. Now submit the email form with your
+ own address. The password error must still be on screen, unchanged, and focus must land in the
+ **email** section. Then repeat the other way round.
+- [ ] **Nothing typed in one form is lost when the other submits.** Type a new address, do not
+ submit it; submit the password form. The address must still be in the field.
+- [ ] **`minlength="12"` with real key events**, never a scripted `.value` assignment — `tooShort`
+ only fires on a user-edited value, so a scripted check records a false pass. Type eleven
+ characters and submit: the browser's own message, on the field.
+- [ ] **A successful change-password leaves you on the Account screen.** This is the one manual
+ check that catches a missing `RefreshSignInAsync`; if it is absent you are bounced to sign-in.
+ Both password fields are cleared, and focus is on the success message inside the password section.
+- [ ] **Focus never lands on ``** after any outcome. Check with the keyboard: after each
+ submit, press Tab once and confirm you move to the next control *inside that form's section*.
+- [ ] **Each form is identifiable by screen reader** — two `
`s, each `` associated with
+ its own via `aria-labelledby`.
+- [ ] **Every control is 44×44.** Both submit buttons, both text inputs, the Account button on
+ Settings, and the back link.
+- [ ] **At ~375px wide**, nothing overflows horizontally and the two sections stack.
+- [ ] **In light theme as well as dark** — the section rule uses `--border`, which is defined in
+ both blocks of `tokens/colors.css`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add Wend.Api/wwwroot/js/auth/account Wend.Api/wwwroot/js/main.js Wend.Api/wwwroot/js/settings Wend.Api/wwwroot/css/app.css
+git commit -m "Add the Account screen with independent password and email forms"
+```
+
+---
+
+### Task 7: The `/confirm-email-change` screen
+
+**Files:**
+- Create: `Wend.Api/wwwroot/js/auth/confirm-email/model.js`
+- Create: `Wend.Api/wwwroot/js/auth/confirm-email/view.js`
+- Create: `Wend.Api/wwwroot/js/auth/confirm-email/controller.js`
+- Modify: `Wend.Api/wwwroot/js/main.js` (three imports, `showConfirmEmailChange`, one route)
+
+**Interfaces:**
+- Consumes: `POST /api/auth/confirm-email-change` (Task 4).
+- Produces: `createConfirmEmailModel()`, `createConfirmEmailView(root)`,
+ `createConfirmEmailController(model, view, announce, { userId, newEmail, code })`;
+ `showConfirmEmailChange()` and the `/confirm-email-change` case in `boot()`.
+
+**No automated tests.** Total stays **291**.
+
+**The one security rule this screen must not break.** `newEmail` arrives from a query string on an
+anonymous page anybody can link to, and every view here renders through a template literal into
+`innerHTML`. So it follows Plan 5's rule exactly: **the query-string values live in the controller's
+closure, are never handed to the view, and are never rendered.** The success state renders the
+`email` field from the **`200` response body** instead, escaped — which is the only reason that
+endpoint returns a body at all.
+
+- [ ] **Step 1: Write the model**
+
+`Wend.Api/wwwroot/js/auth/confirm-email/model.js`:
+
+```js
+import { api } from "../../api.js";
+
+// Maps the endpoint's three status codes onto the states the screen renders. The token trio is NOT
+// held here — the controller owns it and passes it on the one submit, so it can never reach the
+// view and never reach the DOM.
+export function createConfirmEmailModel() {
+ let state = { status: "checking" };
+ const subscribers = [];
+ const notify = () => subscribers.forEach((fn) => fn(state));
+
+ return {
+ subscribe(fn) {
+ subscribers.push(fn);
+ fn(state);
+ },
+ // Arrived with nothing to confirm — a reload, a bookmark, or a back-navigation after
+ // replaceState stripped the query string. Deliberately NOT routed through confirm(), which
+ // would post empty values, collect a 400, and tell the user their link expired when they never
+ // presented one.
+ noLink() {
+ state = { status: "nolink" };
+ notify();
+ },
+ async confirm({ userId, newEmail, code }) {
+ try {
+ const body = await api("/api/auth/confirm-email-change", {
+ method: "POST",
+ body: JSON.stringify({ userId, newEmail, code }),
+ });
+ // The address comes from the RESPONSE, never from the query string the controller is
+ // holding: the response value is what the database actually stores, and the query-string
+ // value is a caller-controlled string on a page anybody can link to.
+ state = { status: "done", email: body?.email ?? "" };
+ } catch (error) {
+ if (error?.status === 409) state = { status: "taken" };
+ else if (error?.status === 400) state = { status: "expired" };
+ else state = { status: "failed" };
+ }
+ notify();
+ },
+ };
+}
+```
+
+- [ ] **Step 2: Write the view**
+
+`Wend.Api/wwwroot/js/auth/confirm-email/view.js`:
+
+```js
+import { escapeHtml } from "../../escape.js";
+
+// Renders the five states. Every one is a real screen with a heading — never a raw error.
+//
+// This view is never given userId, newEmail or code, and must never be: they come off the query
+// string of an anonymous page anybody can link to, and everything here goes through a template
+// literal into innerHTML. The success heading interpolates the address the SERVER returned, which
+// is a stored value, and escapes it anyway.
+export function createConfirmEmailView(root) {
+ const BODIES = {
+ checking: `
+
Confirming your new address…
+
One moment.
`,
+ // A link with no parameters is not a broken one — most often it is a reload after confirming.
+ nolink: `
+
Nothing to confirm
+
Open the link from your email to finish changing your address. Links last one hour.