From 3dd9e03ca12ad9aba68f6dfd99edb1970d18d4eb Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:39:03 -0300 Subject: [PATCH 1/2] docs(identity): document the profile ETag/If-Match precondition `PUT /identity/profile` gained an optimistic concurrency precondition: `GET /profile` returns the user's `ConcurrencyStamp` as a strong `ETag`, `PUT` honours `If-Match`, and a stale token is answered with `412` instead of overwriting the newer write. Documents the optional-header behaviour, the `*` and weak-validator rules, why a malformed header is a `400`, the guard running ahead of any storage call, and the retry-once guidance for clients (the stamp rotates on writes a user does not perceive as profile edits). Also notes the CORS requirement, since the contract is invisible to a browser on another origin without the `ETag` exposure and the `if-match` allow-entry. Accompanies fullstackhero/dotnet-starter-kit#1366. --- src/content/docs/changelog/index.mdx | 7 +++++- src/content/docs/modules/identity.mdx | 32 ++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 411e58d4..46820106 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-07-13 +lastUpdated: 2026-08-17 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -11,6 +11,11 @@ seo: Notable changes to the kit, newest first. +## 2026-08-17 + +- **Identity: two people editing the same profile no longer silently overwrite each other (fix).** `PUT /api/v1/identity/profile` replaces the whole representation and carried no concurrency token, so overlapping saves resolved as last-write-wins with the losing change gone and no error raised anywhere - and because both front-ends compensate with a client-side read-modify-write, a save built on a stale read confidently echoed every old field back over the newer write. `GET /profile` now returns the user's `ConcurrencyStamp` as a strong `ETag`, and `PUT /profile` honours `If-Match`: a stale token is answered with `412 Precondition Failed` and nothing is written. The header is optional, so existing callers are unaffected; `If-Match: *` is accepted, a weak validator never matches (`If-Match` mandates strong comparison), and a malformed header is a `400` rather than a `412` that would trap a client in an unwinnable retry loop. The precondition runs before any storage call, so a rejected update never orphans an uploaded avatar nor clears the current one. Two smaller fixes came with it: Identity's own `ConcurrencyFailure` result, previously surfacing as a generic `500`, now maps to the same `412`, and the sign-in refresh no longer runs when the update failed. No migration - `AspNetUsers.ConcurrencyStamp` was already an EF Core concurrency token. The tenant dashboard sends the header and retries once on `412`, since the stamp also rotates on writes a user doesn't think of as profile edits (a password change, a new avatar, a failed sign-in). See [Identity](/docs/modules/identity/). +- **CORS: the framework now exposes `ETag` and allows `If-Match`.** An `ETag`/`If-Match` contract is invisible to a browser on another origin unless the server says so: `ETag` is not a CORS-safelisted response header, and the kit's policy never called `WithExposedHeaders`, so a front-end read `null` and silently stopped sending the precondition. The policy now exposes `ETag` on both the permissive and restricted branches, and `if-match` ships in `CorsOptions.AllowedHeaders` in `appsettings.json` and `appsettings.Production.json`. If you replace the policy with your own, keep both, otherwise the profile precondition above degrades back to a lost update with no error to notice. + ## 2026-07-13 - **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview - mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based - a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form). diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index fef3d19d..bf1ea04f 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -1,6 +1,6 @@ --- title: Identity module -lastUpdated: 2026-06-11 +lastUpdated: 2026-08-17 description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement. sidebar: label: Identity @@ -145,6 +145,32 @@ endpoints.MapPost("/users", handler) `EnrollTwoFactorCommand` returns a QR code + secret; `VerifyEnrollTwoFactorCommand(code)` activates; `DisableTwoFactorCommand(currentPassword)` turns it off. +### Profile concurrency + +`PUT /profile` replaces the whole representation, so two overlapping saves used to silently overwrite each other: whoever wrote last won, and the other person's change was gone with no error anywhere. The endpoint now supports an optimistic precondition. + +`GET /profile` returns the user's `ConcurrencyStamp` as a strong `ETag`. ASP.NET Identity already treats that column as an EF Core concurrency token and rotates it on every write, so no extra column and no migration were needed. Echo the tag back as `If-Match` on the `PUT` and the write is rejected with `412 Precondition Failed` when the stored profile has moved on: + +```http +GET /api/v1/identity/profile +→ 200 OK + ETag: "6f9619ff-8b86-d011-b42d-00cf4fc964ff" + +PUT /api/v1/identity/profile +If-Match: "6f9619ff-8b86-d011-b42d-00cf4fc964ff" +→ 200 OK # nobody else wrote in between +→ 412 # someone did; nothing was written +``` + +Details worth knowing: + +- **The header is optional.** A `PUT` without `If-Match` behaves exactly as before, so existing callers keep working. `If-Match: *` is accepted for the same reason: it matches any current representation. +- **Weak validators never match.** `If-Match` mandates the strong comparison function, so `W/"..."` is answered with `412`. +- **A malformed header is a `400`, not a `412`.** A `412` would send a well-behaved client into a refetch-and-retry loop it can never win, because the broken header is its own bug. +- **Nothing is written or deleted before the check.** The precondition runs ahead of the storage calls, so a rejected update never orphans an uploaded avatar and never clears the current one. +- **The token also rotates on writes the user does not think of as profile edits**, such as a password change, a new avatar, or a failed sign-in. A client should therefore refetch and retry once on `412` rather than surface it as a failed save, which is what `clients/dashboard` does. +- **CORS is already configured for it.** `ETag` is not a CORS-safelisted response header, so the framework's CORS policy exposes it explicitly and `if-match` ships in `CorsOptions.AllowedHeaders`. Without both, a browser on another origin cannot read the tag and silently stops sending the precondition. Keep them in place if you replace the policy with your own. + ## Endpoints All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table: @@ -153,8 +179,8 @@ All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy c |---|---|---| | POST | `/token/issue` | Login | | POST | `/token/refresh` | Rotate refresh token | -| GET | `/profile` | Current user profile | -| PUT | `/profile` | Update own profile | +| GET | `/profile` | Current user profile (returns a strong `ETag`) | +| PUT | `/profile` | Update own profile (honours `If-Match`, answers `412` on a stale token) | | PUT | `/profile/image` | Set / clear profile image | | GET | `/permissions` | Current user permissions | | GET | `/permissions/catalog` | All registered permissions (catalog) | From 632e8d875295fc762bbc1f1d0de6b3cd94e15d96 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:42:51 -0300 Subject: [PATCH 2/2] docs(identity): do not auto-retry a 412 on the profile precondition The page told clients to refetch and retry once on 412, and the changelog said the tenant dashboard does exactly that. Both recreate the bug the status code prevents: the only body a client holds is the one built from the values the user saw, so resending it against a freshly fetched tag performs the overwrite the 412 rejected. Replaced with what fullstackhero/dotnet-starter-kit#1387 actually ships: take the tag from the read that populated the form, never from a read inside the save, and on 412 keep the user's edits, adopt the current version, and ask for a deliberate re-save. --- src/content/docs/changelog/index.mdx | 4 ++-- src/content/docs/modules/identity.mdx | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 46820106..84b54004 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-08-17 +lastUpdated: 2026-09-17 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -13,7 +13,7 @@ Notable changes to the kit, newest first. ## 2026-08-17 -- **Identity: two people editing the same profile no longer silently overwrite each other (fix).** `PUT /api/v1/identity/profile` replaces the whole representation and carried no concurrency token, so overlapping saves resolved as last-write-wins with the losing change gone and no error raised anywhere - and because both front-ends compensate with a client-side read-modify-write, a save built on a stale read confidently echoed every old field back over the newer write. `GET /profile` now returns the user's `ConcurrencyStamp` as a strong `ETag`, and `PUT /profile` honours `If-Match`: a stale token is answered with `412 Precondition Failed` and nothing is written. The header is optional, so existing callers are unaffected; `If-Match: *` is accepted, a weak validator never matches (`If-Match` mandates strong comparison), and a malformed header is a `400` rather than a `412` that would trap a client in an unwinnable retry loop. The precondition runs before any storage call, so a rejected update never orphans an uploaded avatar nor clears the current one. Two smaller fixes came with it: Identity's own `ConcurrencyFailure` result, previously surfacing as a generic `500`, now maps to the same `412`, and the sign-in refresh no longer runs when the update failed. No migration - `AspNetUsers.ConcurrencyStamp` was already an EF Core concurrency token. The tenant dashboard sends the header and retries once on `412`, since the stamp also rotates on writes a user doesn't think of as profile edits (a password change, a new avatar, a failed sign-in). See [Identity](/docs/modules/identity/). +- **Identity: two people editing the same profile no longer silently overwrite each other (fix).** `PUT /api/v1/identity/profile` replaces the whole representation and carried no concurrency token, so overlapping saves resolved as last-write-wins with the losing change gone and no error raised anywhere - and because both front-ends compensate with a client-side read-modify-write, a save built on a stale read confidently echoed every old field back over the newer write. `GET /profile` now returns the user's `ConcurrencyStamp` as a strong `ETag`, and `PUT /profile` honours `If-Match`: a stale token is answered with `412 Precondition Failed` and nothing is written. The header is optional, so existing callers are unaffected; `If-Match: *` is accepted, a weak validator never matches (`If-Match` mandates strong comparison), and a malformed header is a `400` rather than a `412` that would trap a client in an unwinnable retry loop. The precondition runs before any storage call, so a rejected update never orphans an uploaded avatar nor clears the current one. Two smaller fixes came with it: Identity's own `ConcurrencyFailure` result, previously surfacing as a generic `500`, now maps to the same `412`, and the sign-in refresh no longer runs when the update failed. No migration - `AspNetUsers.ConcurrencyStamp` was already an EF Core concurrency token. The tenant dashboard sends the tag from the read that seeded the form and deliberately does **not** auto-retry on `412`: resending the body built from the values the user saw, against a freshly fetched tag, performs the very overwrite the `412` rejected. It keeps the edits on screen, adopts the current version, and asks for a deliberate re-save. See [Identity](/docs/modules/identity/). - **CORS: the framework now exposes `ETag` and allows `If-Match`.** An `ETag`/`If-Match` contract is invisible to a browser on another origin unless the server says so: `ETag` is not a CORS-safelisted response header, and the kit's policy never called `WithExposedHeaders`, so a front-end read `null` and silently stopped sending the precondition. The policy now exposes `ETag` on both the permissive and restricted branches, and `if-match` ships in `CorsOptions.AllowedHeaders` in `appsettings.json` and `appsettings.Production.json`. If you replace the policy with your own, keep both, otherwise the profile precondition above degrades back to a lost update with no error to notice. ## 2026-07-13 diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index bf1ea04f..e4211fd6 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -1,6 +1,6 @@ --- title: Identity module -lastUpdated: 2026-08-17 +lastUpdated: 2026-09-17 description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement. sidebar: label: Identity @@ -168,7 +168,11 @@ Details worth knowing: - **Weak validators never match.** `If-Match` mandates the strong comparison function, so `W/"..."` is answered with `412`. - **A malformed header is a `400`, not a `412`.** A `412` would send a well-behaved client into a refetch-and-retry loop it can never win, because the broken header is its own bug. - **Nothing is written or deleted before the check.** The precondition runs ahead of the storage calls, so a rejected update never orphans an uploaded avatar and never clears the current one. -- **The token also rotates on writes the user does not think of as profile edits**, such as a password change, a new avatar, or a failed sign-in. A client should therefore refetch and retry once on `412` rather than surface it as a failed save, which is what `clients/dashboard` does. +- **Do not auto-retry a `412`.** The obvious reflex - refetch the profile and resend against the fresh tag - performs exactly the overwrite the `412` rejected, because the only body the client holds is the one built from the values the user saw. Tell the user the profile changed, keep their edits on screen, adopt the current version, and let them save again deliberately. That is what `clients/dashboard` does. + +- **Take the tag from the read that populated the form**, not from a read issued inside the save. The lost update this guards against happens between the moment the user saw the values and the moment they save, so a tag fetched at save time is never stale and therefore never catches anything. + +- **The token also rotates on writes the user does not think of as profile edits**, such as a password change, a new avatar, or a failed sign-in. Those show up as a `412` on an otherwise innocent save, which is why the message the user sees should read as "this changed, review and save again" rather than as an error. - **CORS is already configured for it.** `ETag` is not a CORS-safelisted response header, so the framework's CORS policy exposes it explicitly and `if-match` ships in `CorsOptions.AllowedHeaders`. Without both, a browser on another origin cannot read the tag and silently stops sending the precondition. Keep them in place if you replace the policy with your own. ## Endpoints