Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e1105c4
Serve published entries and entities over a read-only HTTP API
pournasserian Oct 19, 2026
8e5bff3
Serve each published entry as Markdown and add the site index
pournasserian Oct 19, 2026
b2794f2
Preview change sets with tokens
pournasserian Oct 19, 2026
129ff4b
Merge remote-tracking branch 'origin/v2' into 2564-markdown-and-index
pournasserian Oct 19, 2026
fc739d9
Serve each published entry as Markdown and add the site index, on the…
pournasserian Oct 19, 2026
d08f21b
Merge branch '2564-markdown-and-index' into 2572-preview-tokens
pournasserian Oct 19, 2026
34717a8
Preview change sets with tokens, on the reviewed Markdown endpoints
pournasserian Oct 19, 2026
08477f0
Escape plain text in Markdown and read each version against its own d…
pournasserian Oct 19, 2026
84a8452
Merge remote-tracking branch 'origin/v2' into 2564-markdown-and-index
pournasserian Oct 19, 2026
0f0ed4f
Merge branch '2564-markdown-and-index' into 2572-preview-tokens
pournasserian Oct 19, 2026
52c364d
List endpoint_not_found among the delivery errors
pournasserian Oct 19, 2026
887b325
Quote rich text in Markdown, give every fact its source, and keep URL…
pournasserian Oct 19, 2026
a2a0dd1
Merge remote-tracking branch 'origin/v2' into 2564-markdown-and-index
pournasserian Oct 19, 2026
0126ce9
Merge branch '2564-markdown-and-index' into 2572-preview-tokens
pournasserian Oct 19, 2026
7f024ee
Regenerate the preview token migrations on top of v2
pournasserian Oct 19, 2026
418fd23
Keep list items in their bullets and escape extension block openers
pournasserian Oct 19, 2026
fadcfc4
Merge remote-tracking branch 'origin/v2' into 2564-markdown-and-index
pournasserian Oct 19, 2026
1324665
Merge branch '2564-markdown-and-index' into 2572-preview-tokens
pournasserian Oct 19, 2026
617c95c
Regenerate the preview token migrations on top of v2
pournasserian Oct 19, 2026
4c61e3f
Merge remote-tracking branch 'origin/v2' into 2572-preview-tokens
pournasserian Oct 19, 2026
ebf2770
Refuse a second revoke before the unit of work and name the bearer sc…
pournasserian Oct 19, 2026
0277f56
Merge remote-tracking branch 'origin/v2' into 2572-preview-tokens
pournasserian Oct 19, 2026
c559e7b
Merge remote-tracking branch 'origin/v2' into 2572-preview-tokens
pournasserian Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F

The browser tests drive the admin in headless Chromium through Playwright: keyboard-only review on the desk and automated WCAG 2.1 AA checks with axe-core. They run when `FLUENTCMS_TEST_BROWSER` is set. Install the browser once after a build with `pwsh tests/FluentCMS.IntegrationTests/bin/Debug/net10.0/playwright.ps1 install chromium`; CI installs it and sets the variable.

To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default), and from an optional `appsettings.json` next to the binary.
To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default), and from an optional `appsettings.json` next to the binary.

Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. Agents follow review with the read-only desk tools: `get_desk_counts`, `list_desk_queue` and `get_change_set_review`, which see only the change sets the agent opened.

Expand Down
21 changes: 20 additions & 1 deletion docs/api/delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,30 @@ Each published entry has a Markdown representation at `/api/v1/entries/{id}.md`

The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first.

## Preview

A preview token lets a frontend show a change set's content resolved as it would be published, before anyone approves it (API-04). A person issues one for a set; agents cannot. Send it in the `Authorization` header, never in the URL:

```http
GET /api/v1/preview/entries/0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e
Authorization: Bearer fcmsp_...
```

| Request | Returns |
| --- | --- |
| `GET /api/v1/preview/entries/{id}` | The entry as the set would publish it: the version the set proposes, or the published version when the set leaves the entry alone. An entry the set unpublishes is a 404. |
| `GET /api/v1/preview/changes` | `changeSet` and the `items` the set creates or updates, in the set's order. |

References to entity fields resolve to the values the set proposes, so a page previews the new price of its product.

A token stops working when it is revoked or expires (`Delivery__PreviewTokenLifetime`, 7 days by default), and when its set is published, rejected or reverted. Every one of these is the same 401 with `preview_token_invalid`. Preview responses carry `Cache-Control: no-store`.

## Errors

Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`application/problem+json`). `errors` lists each error with a stable `code`:

| Status | Codes |
| --- | --- |
| 400 | `limit_invalid`, `parameter_invalid` |
| 404 | `entry_not_found`, `content_type_not_found` |
| 401 | `preview_token_invalid` |
| 404 | `entry_not_found`, `content_type_not_found`, `endpoint_not_found` |
46 changes: 46 additions & 0 deletions src/FluentCMS.Api/DeliveryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpo
api.MapGet("/entries/{id}.md", GetMarkdownAsync);
api.MapGet("/index", IndexAsync);
api.MapGet("/index.md", IndexMarkdownAsync);
api.MapGet("/preview/entries/{id}", PreviewEntryAsync);
api.MapGet("/preview/changes", PreviewChangesAsync);

// Ids are parsed by the handlers and every other path under the prefix is answered here, so no API call falls through to the
// admin's not-found page.
Expand Down Expand Up @@ -76,6 +78,47 @@ private static async Task<IResult> ListAsync(
: Problem(page.Errors);
}

// Unpublished content is never cached on the way, so it disappears with the token (API-04).
private static async Task<IResult> PreviewEntryAsync(string id, HttpContext http, IPreviewContent preview, CancellationToken cancellationToken)
{
http.Response.Headers.CacheControl = "no-store";
var errors = new List<Error>();
if (Id(id, "id", "the id of an entry", errors) is not { } entryId)
{
return Problem(errors);
}

var item = await preview.GetAsync(BearerToken(http), entryId, cancellationToken);
return item.IsSuccess ? Results.Json(Item(item.Value)) : PreviewProblem(http, item.Errors);
}

private static async Task<IResult> PreviewChangesAsync(HttpContext http, IPreviewContent preview, CancellationToken cancellationToken)
{
http.Response.Headers.CacheControl = "no-store";
var changes = await preview.ChangesAsync(BearerToken(http), cancellationToken);
return changes.IsSuccess
? Results.Json(new ChangesResponse(changes.Value.ChangeSetId, [.. changes.Value.Items.Select(Item)]))
: PreviewProblem(http, changes.Errors);
}

// A 401 names the scheme it expects (RFC 9110).
private static IResult PreviewProblem(HttpContext http, IReadOnlyList<Error> errors)
{
if (errors[0].Code == DeliveryErrorCodes.PreviewTokenInvalid)
{
http.Response.Headers.WWWAuthenticate = "Bearer";
}

return Problem(errors);
}

// The token travels in the Authorization header, never in the URL, so it stays out of logs and browser history.
private static string BearerToken(HttpContext http)
{
var header = http.Request.Headers.Authorization.ToString();
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : string.Empty;
}

private static async Task<IResult> GetAsync(string id, IPublishedContent content, CancellationToken cancellationToken)
{
var errors = new List<Error>();
Expand Down Expand Up @@ -173,6 +216,7 @@ private static IResult Problem(IReadOnlyList<Error> errors)
{
ContentErrorCodes.EntryNotFound or SchemaErrorCodes.ContentTypeNotFound or DeliveryErrorCodes.EndpointNotFound => StatusCodes.Status404NotFound,
AgentPermissionErrorCodes.ScopeDenied => StatusCodes.Status403Forbidden,
DeliveryErrorCodes.PreviewTokenInvalid => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status400BadRequest,
};

Expand All @@ -187,6 +231,8 @@ private static IResult Problem(IReadOnlyList<Error> errors)

private sealed record PageResponse(IReadOnlyList<ItemResponse> Items, Guid? Next);

private sealed record ChangesResponse(Guid ChangeSet, IReadOnlyList<ItemResponse> Items);

private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList<UnresolvedResponse> Unresolved);

private sealed record IndexResponse(IReadOnlyList<IndexEntryResponse> Items);
Expand Down
77 changes: 77 additions & 0 deletions src/FluentCMS.Core/Delivery/IPreviewTokens.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Buffers;
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Results;

namespace FluentCMS.Core.Delivery;

// Preview tokens for change sets (API-04). Only a person issues or revokes one, so unapproved content never leaves through an agent;
// each write appends its audit event in the same unit of work (AUD-01).
public interface IPreviewTokens
{
// The token is in the result once and never stored; only its hash is.
Task<Result<IssuedPreviewToken>> IssueAsync(Guid changeSetId, CancellationToken cancellationToken = default);

Task<Result> RevokeAsync(Guid tokenId, CancellationToken cancellationToken = default);

// Newest first, without their secrets.
Task<Result<IReadOnlyList<PreviewToken>>> ListAsync(Guid changeSetId, CancellationToken cancellationToken = default);
}

// Reads with a preview token: entries as the token's change set would publish them. A token that is unknown, malformed, expired,
// revoked, or whose set has been published, rejected or reverted fails alike, so its holder learns nothing about why.
public interface IPreviewContent
{
Task<Result<PublishedItem>> GetAsync(string token, Guid entryId, CancellationToken cancellationToken = default);

// Every entry the set creates or updates, in the order of its operations.
Task<Result<PreviewChanges>> ChangesAsync(string token, CancellationToken cancellationToken = default);
}

public sealed record IssuedPreviewToken(Guid Id, Guid ChangeSetId, string Token, DateTimeOffset ExpiresAt);

public sealed record PreviewToken(Guid Id, Guid ChangeSetId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt);

public sealed record PreviewChanges(Guid ChangeSetId, IReadOnlyList<PublishedItem> Items);

// A token is 32 random bytes, so its SHA-256 is stored and looked up directly, as for agent keys (SEC-01). The prefix lets people
// and secret scanners recognise one.
public static class PreviewTokenKeys
{
public const string Prefix = "fcmsp_";

private const int SecretBytes = 32;

private static readonly SearchValues<char> Alphabet =
SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_");

public static int Length { get; } = Prefix.Length + Base64Url.GetEncodedLength(SecretBytes);

// The states in which a set's content can still change or publish; a token works only while its set is in one.
public static IReadOnlyList<ChangeSetState> Previewable { get; } =
[ChangeSetState.Open, ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed, ChangeSetState.AwaitingReview, ChangeSetState.Approved];

public static string Generate()
{
return Prefix + Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(SecretBytes));
}

// The alphabet is checked first, because the base64url check skips whitespace.
public static bool IsWellFormed(string? token)
{
return token is not null
&& token.Length == Length
&& token.StartsWith(Prefix, StringComparison.Ordinal)
&& !token.AsSpan(Prefix.Length).ContainsAnyExcept(Alphabet)
&& Base64Url.IsValid(token.AsSpan(Prefix.Length));
}

public static byte[] Hash(string token)
{
ArgumentNullException.ThrowIfNull(token);

return SHA256.HashData(Encoding.UTF8.GetBytes(token));
}
}
5 changes: 5 additions & 0 deletions src/FluentCMS.Core/Delivery/IPublishedContent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,9 @@ public static class DeliveryErrorCodes
public const string LimitInvalid = "limit_invalid";
public const string ParameterInvalid = "parameter_invalid";
public const string EndpointNotFound = "endpoint_not_found";
public const string PreviewTokenInvalid = "preview_token_invalid";
public const string PreviewByPeopleOnly = "preview_by_people_only";
public const string PreviewTokenNotFound = "preview_token_not_found";
public const string PreviewTokenAlreadyRevoked = "preview_token_already_revoked";
public const string ChangeSetNotPreviewable = "change_set_not_previewable";
}
39 changes: 33 additions & 6 deletions src/FluentCMS.Infrastructure/Content/EntryResolution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ internal static class EntryResolution
// Until multi-site (MST-01), every entry belongs to the one site (SCH-07).
private static readonly Guid SiteId = Site.Default.Id;

// In the order given; every version must be stored.
// In the order given; every version must be stored. EntityVersions replaces an entity's published version with the one a change
// set proposes, or with none where it unpublishes the entity, so a preview resolves as the set would publish (API-04).
public static async Task<IReadOnlyList<ResolvedEntry>> ResolveAsync(
FluentCmsDbContext context,
IReadOnlyList<VersionToResolve> versions,
IReadOnlyDictionary<Guid, int?> entityVersions,
CancellationToken cancellationToken)
{
if (versions.Count == 0)
Expand Down Expand Up @@ -49,7 +51,11 @@ public static async Task<IReadOnlyList<ResolvedEntry>> ResolveAsync(
.Where(row => wanted.Contains((row.EntryId, row.Version)))
.ToLookup(row => (row.EntryId, row.Version));

var published = await PublishedFieldsAsync(context, references.SelectMany(group => group).Select(row => row.TargetId).Distinct().ToList(), cancellationToken);
var published = await PublishedFieldsAsync(
context,
references.SelectMany(group => group).Select(row => row.TargetId).Distinct().ToList(),
entityVersions,
cancellationToken);

return versions.Select(version =>
{
Expand All @@ -76,10 +82,11 @@ public static async Task<IReadOnlyList<ResolvedEntry>> ResolveAsync(
}).ToList();
}

// Each entity's published fields, read in one query: none when it is deleted or has no published version.
// Each entity's published fields: none when it is deleted or has no published version.
private static async Task<Dictionary<Guid, (bool Deleted, JsonElement? Fields)>> PublishedFieldsAsync(
FluentCmsDbContext context,
List<Guid> ids,
IReadOnlyDictionary<Guid, int?> entityVersions,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
Expand All @@ -96,9 +103,29 @@ from version in context.EntryVersions.AsNoTracking()
select new { entity.Id, Deleted = entity.DeletedAt != null, Fields = version == null ? null : version.Fields })
.ToListAsync(cancellationToken);

return rows.ToDictionary(
row => row.Id,
row => (row.Deleted, row.Deleted || row.Fields is null ? (JsonElement?)null : JsonSerializer.Deserialize<JsonElement>(row.Fields)));
var fields = rows.ToDictionary(row => row.Id, row => (row.Deleted, Fields: (string?)row.Fields));

var proposed = ids.Where(entityVersions.ContainsKey).ToList();
if (proposed.Count > 0)
{
var numbers = proposed.Select(id => entityVersions[id]).OfType<int>().Distinct().ToList();
var stored = (await context.EntryVersions
.AsNoTracking()
.Where(version => proposed.Contains(version.EntryId) && numbers.Contains(version.Version))
.Select(version => new { version.EntryId, version.Version, version.Fields })
.ToListAsync(cancellationToken))
.Where(version => entityVersions[version.EntryId] == version.Version)
.ToDictionary(version => version.EntryId, version => version.Fields);

foreach (var id in proposed.Where(fields.ContainsKey))
{
fields[id] = (fields[id].Deleted, stored.GetValueOrDefault(id));
}
}

return fields.ToDictionary(
pair => pair.Key,
pair => (pair.Value.Deleted, pair.Value.Deleted || pair.Value.Fields is null ? (JsonElement?)null : JsonSerializer.Deserialize<JsonElement>(pair.Value.Fields)));
}

// An entity stores each field as a fact; the view shows the fact's value (SCH-03).
Expand Down
4 changes: 3 additions & 1 deletion src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ internal sealed class ResolvedEntries(FluentCmsDbContext context, IAgentPermissi
// Until multi-site (MST-01), every entry belongs to the one site (SCH-07).
private static readonly Guid SiteId = Site.Default.Id;

private static readonly Dictionary<Guid, int?> NoProposals = [];

public async Task<Result<ResolvedEntry>> ResolveAsync(Guid entryId, int? version = null, CancellationToken cancellationToken = default)
{
var entry = await (
Expand Down Expand Up @@ -40,7 +42,7 @@ join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals typ
$"Entry {entryId} has no version {number}; its latest version is {entry.LatestVersion}.");
}

var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(entryId, entry.Slug, number)], cancellationToken);
var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(entryId, entry.Slug, number)], NoProposals, cancellationToken);
return Result<ResolvedEntry>.Success(resolved[0]);
}
}
22 changes: 22 additions & 0 deletions src/FluentCMS.Infrastructure/Delivery/DeliveryOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.Extensions.Options;

namespace FluentCMS.Infrastructure.Delivery;

public sealed class DeliveryOptions
{
public const string SectionName = "Delivery";

// How long a preview token works unless it is revoked or its change set ends first (API-04).
public TimeSpan PreviewTokenLifetime { get; set; } = TimeSpan.FromDays(7);
}

internal sealed class DeliveryOptionsValidation : IValidateOptions<DeliveryOptions>
{
public ValidateOptionsResult Validate(string? name, DeliveryOptions options)
{
return options.PreviewTokenLifetime > TimeSpan.Zero && options.PreviewTokenLifetime <= TimeSpan.FromDays(365)
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(
$"Delivery__PreviewTokenLifetime is {options.PreviewTokenLifetime}; it must be longer than zero and at most 365 days.");
}
}
Loading
Loading