Skip to content

Preview change sets with tokens - #2577

Merged
pournasserian merged 23 commits into
v2from
2572-preview-tokens
Oct 19, 2026
Merged

pournasserian merged 23 commits into
v2from
2572-preview-tokens

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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).

  • Core (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.
      • Only a UserInitiator may issue or revoke. Agents, the system and anonymous callers get preview_by_people_only.
      • A set that is published, rejected or reverted gets 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.
      • References to entity fields resolve to the versions the set proposes. An entity the set unpublishes resolves as NotPublished.
    • PreviewTokenKeys: 32 random bytes with an fcmsp_ prefix. Only the SHA-256 is stored, as for agent keys (SEC-01).
  • A token stops working when it is revoked or expires, or when its set is published, rejected or reverted. Unknown and malformed tokens are refused the same way. All of these give one preview_token_invalid. Reads write nothing.
  • Audit: preview_token.issued and preview_token.revoked, with the token as subject and the change set id, appended in the same unit of work as the write.
  • Infrastructure:
    • PreviewTokens table with a unique token hash and foreign keys to the change set and the site, with migrations for SQLite and PostgreSQL.
    • EntryResolution takes the entity versions a set proposes.
    • PublishedContentReader serves both the published and the preview reads.
    • Delivery__PreviewTokenLifetime defaults to 7 days. A value outside zero to 365 days stops the host at startup. It is documented in CONTRIBUTING.
  • Api: GET /api/v1/preview/entries/{id} and GET /api/v1/preview/changes.
    • The token goes in Authorization: Bearer, never in the URL.
    • Responses carry Cache-Control: no-store.
    • A missing or failing token gets a 401 problem.
  • Docs: docs/api/delivery.md has 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:

  • People only. A token shows unapproved content to whoever holds it, so no agent can mint one. The desk lane can add the issue and revoke screen on these services.
  • One error for every failing token, so a holder cannot probe which tokens exist or why one stopped working.
  • Expiry is compared in memory after the hash lookup, because SQLite cannot compare stored offsets in a query.
  • Entries the set does not touch read as published, so a preview of one page links to the rest of the site as it is live.

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. ChangesAsync lists 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:

    • after it is revoked;
    • after seven days;
    • when its set is rejected;
    • when it is well formed but unknown;
    • when it is malformed.

    ListAsync shows the set's three tokens newest first, the revoked one with its time. The audit has four preview_token.issued events and one preview_token.revoked, with the token and the set.

  • Refused, each with its own code:

    • an agent issuing or revoking;
    • a published set;
    • an unknown set;
    • an unknown token to revoke.

    Only the hash is stored.

  • A set that unpublishes the product shows the page with a NotPublished price and the product as not found. A draft outside the set stays hidden.

HTTP, with stubs:

  • A preview with the bearer token returns the entry and the changes with Cache-Control: no-store.
  • Without a token, or with a refused one, the response is a 401 problem.

The full suite ran locally against a postgres:17 container 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:

  • a 401 now names the Bearer scheme in WWW-Authenticate;
  • previews are tested against a soft-deleted and an archived page outside the set;
  • a token lifetime that is not positive or exceeds a year is tested to stop the host.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:api labels Oct 19, 2026
# 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
# 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
@pournasserian
pournasserian merged commit fab71b7 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2572-preview-tokens branch October 19, 2026 10:05
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preview tokens that resolve an unpublished change set

1 participant