Repository navigation
Preview change sets with tokens - #2577
Merged
Merged
Conversation
# Conflicts: # docs/api/delivery.md # src/FluentCMS.Api/DeliveryEndpoints.cs # src/FluentCMS.Core/Delivery/IPublishedContent.cs # src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs # tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs # tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs
… reviewed read endpoints
# Conflicts: # CONTRIBUTING.md # src/FluentCMS.Api/DeliveryEndpoints.cs # src/FluentCMS.Core/Delivery/IPublishedContent.cs # tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs
# Conflicts: # src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs
4 tasks done
# Conflicts: # src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs
# Conflicts: # src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs
# Conflicts: # docs/api/delivery.md # src/FluentCMS.Api/DeliveryEndpoints.cs # src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs
pournasserian
added a commit
that referenced
this pull request
Oct 19, 2026
) Closes #2583 ## What The last F-API (#2425) slice, on the delivery endpoints of #2563, #2571 and #2577: rate limits, input validation and the read performance target (SEC-07, PERF-03). - **Rate limits** (`FluentCMS.Api`): `DeliveryRateLimit` is a fixed-window policy per client address. It covers every delivery endpoint and `/llms.txt`. - The limit is `Delivery__RateLimit__PermitLimit` (600 by default) per `Delivery__RateLimit__Window` (one minute by default). A limit below 1, or a window that isn't positive, stops the host at startup. - The policy has its own rejection: a 429 problem with `Retry-After` and the code `rate_limited`. The sign-in forms keep their own policy and their plain-text answer. - `AddFluentCmsApi` now takes the configuration and registers the policy and its options. The host passes the configuration in. - An IPv6 client is counted by its /64 prefix, so it cannot take a fresh address for each request. - **Trusted proxies** (`FluentCMS.Host`): - `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` list the proxies whose `X-Forwarded-For` names the client, before the rate limiters run. This covers the sign-in limit too. - With none listed, the header is ignored, so no client can choose the address it is counted by. - An entry that is not an address or a network stops the host. - **Input validation**: a content type in the path that cannot be a slug is a 404 problem, without a database query. Every query parameter is parsed before any read (#2563). The host's `nosniff` and content security policy headers are now tested on delivery responses. - **PERF-03**: a test, running alone in its own collection, loads 10,000 published entries: 50 products and pages that resolve their price. It reads 200 random resolved pages through `IPublishedContent`, after 10 warm-up reads, and asserts the p95 is under 100 ms on SQLite and on PostgreSQL. No new index is needed: reads use the entry primary key, versions by entry and version, and references by entry and version. - **Docs**: CONTRIBUTING lists the rate limit and proxy settings. `docs/api/delivery.md` has a Limits section, and its error table has `rate_limited`. ## Why SEC-07 asks for rate limits and input validation on the delivery API. The API is anonymous, so a per-address limit is what keeps one client from exhausting the host. PERF-03 sets the bar for a single resolved entry, and the test keeps it measured on both providers, so a regression shows up in CI. Choices worth a look: - **Rows are seeded directly** for the 10,000 entries, as the directory stores them. Loading them through the directory would take minutes, and this test measures reads, not writes. - **CORS and response caching are out of scope**, as #2583 says. Server-rendered frontends need neither. A correct ETag would also have to include the published versions of the referenced entities. ## Tests - Over HTTP through the host: - With a limit of 3, the fourth delivery request gets a 429 problem with `Retry-After` and `rate_limited`. `/llms.txt` shares the limit, and `/health` stays reachable. - Two client addresses fall into different partitions. IPv6 addresses in one /64 share a partition, and an IPv4-mapped address counts as its IPv4 address. - Behind a trusted proxy, six clients named by `X-Forwarded-For` each get their own limit. Without a trusted proxy, the header is ignored and the fourth request is refused. - A proxy entry that is not an address, or a network that is not one, stops the host. - Through the endpoints alone, a type that cannot be a slug is a 404 without the reader being asked. - `Product-Page`, `product_page` and `a b` as a type are 404 problems that carry `nosniff` and the content security policy. - A permit limit of 0 stops the host from starting, and the message names the setting. - PERF-03 on SQLite and PostgreSQL, as described above. The full suite ran locally against a `postgres:17` container as well as SQLite, and markdownlint is clean. There is no migration and no new package. ## Review An independent review blocked this PR. Behind a reverse proxy, every client shared the proxy's address. Honouring `X-Forwarded-For` from anyone would have let a client pick a fresh bucket. Both are fixed with trusted proxies from configuration. Its other findings are fixed too: - IPv6 clients are grouped by /64; - the slug check is tested before any read; - the p95 test now runs in a non-parallel collection, so the rest of the suite does not load the machine while it measures. A second review gave MERGE. Its Low finding is fixed in the docs, which now say that one forwarded hop is read and how to set up a proxy chain. The sign-in limit's IPv6 grouping, which was already there before this PR, is tracked in #2640. ## Checklist - [x] Linked to an issue a maintainer has confirmed - [x] Targets `v2` - [x] Tests or docs updated where behaviour changed - [x] No tool-generated co-author trailers or footers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2572
What
The next F-API (#2425) slice, on the read endpoints of #2563 and the Markdown views of #2571: a preview token lets a frontend read a change set's content resolved as it would be published, before approval (API-04).
FluentCMS.Core.Delivery):IPreviewTokens:IssueAsync(changeSetId)returns the token once.RevokeAsync(tokenId)revokes it.ListAsync(changeSetId)lists the set's tokens, newest first, without secrets.UserInitiatormay issue or revoke. Agents, the system and anonymous callers getpreview_by_people_only.change_set_not_previewable.IPreviewContent:GetAsync(token, entryId)returns the version the set proposes. An entry the set unpublishes is not found, and an entry the set leaves alone reads at its published version.ChangesAsync(token)returns the entries the set creates or updates, in operation order.NotPublished.PreviewTokenKeys: 32 random bytes with anfcmsp_prefix. Only the SHA-256 is stored, as for agent keys (SEC-01).preview_token_invalid. Reads write nothing.preview_token.issuedandpreview_token.revoked, with the token as subject and the change set id, appended in the same unit of work as the write.PreviewTokenstable with a unique token hash and foreign keys to the change set and the site, with migrations for SQLite and PostgreSQL.EntryResolutiontakes the entity versions a set proposes.PublishedContentReaderserves both the published and the preview reads.Delivery__PreviewTokenLifetimedefaults to 7 days. A value outside zero to 365 days stops the host at startup. It is documented in CONTRIBUTING.GET /api/v1/preview/entries/{id}andGET /api/v1/preview/changes.Authorization: Bearer, never in the URL.Cache-Control: no-store.docs/api/delivery.mdhas a Preview section.Why
API-04 and S1: a frontend shows reviewers the pages with the new prices before anyone approves the price change. One resolution code path serves both views, so a preview is what publishing would show.
Choices worth a look:
Tests
Integration, each on SQLite and PostgreSQL:
S1: with the product at 24.99 and a new page title in an open set, the token reads the page at version 2 with the title and 24.99, while the published read still shows 19.99.
ChangesAsynclists the product and the page in operation order. After the set publishes, the token is refused.The token stops working in each of these cases, with the same code:
ListAsyncshows the set's three tokens newest first, the revoked one with its time. The audit has fourpreview_token.issuedevents and onepreview_token.revoked, with the token and the set.Refused, each with its own code:
Only the hash is stored.
A set that unpublishes the product shows the page with a
NotPublishedprice and the product as not found. A draft outside the set stays hidden.HTTP, with stubs:
Cache-Control: no-store.The full suite ran locally against a
postgres:17container as well as SQLite. One known load-sensitive job queue test (#2536) failed once and passed on rerun. Both migrations apply, revert and apply again. markdownlint is clean, and no new package was added.Review
An independent review blocked this PR on one Medium finding. Revoking a token that was already revoked was only refused inside the unit of work, which broke a caller's own unit of work. It is now refused before the unit of work with
preview_token_already_revoked, and a test revokes twice inside a caller's unit of work that still commits.Its Low findings are fixed too:
Bearerscheme inWWW-Authenticate;Checklist
v2