From e1105c46b0db3cec2e2df5b7e724aaa7f9f72b77 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 23:39:22 -0400 Subject: [PATCH 01/15] Serve published entries and entities over a read-only HTTP API --- docs/README.md | 1 + docs/api/delivery.md | 58 ++++++ .../ApiServiceCollectionExtensions.cs | 2 +- src/FluentCMS.Api/DeliveryEndpoints.cs | 115 ++++++++++++ src/FluentCMS.Api/FluentCMS.Api.csproj | 4 +- .../Delivery/IPublishedContent.cs | 36 ++++ src/FluentCMS.Host/FluentCmsHost.cs | 1 + .../Content/EntryResolution.cs | 116 ++++++++++++ .../Content/ResolvedEntries.cs | 70 +------ .../Delivery/PublishedContentReader.cs | 106 +++++++++++ ...frastructureServiceCollectionExtensions.cs | 3 + .../Delivery/DeliveryEndpointsTests.cs | 147 +++++++++++++++ .../Delivery/PublishedContentTests.cs | 174 ++++++++++++++++++ 13 files changed, 763 insertions(+), 70 deletions(-) create mode 100644 docs/api/delivery.md create mode 100644 src/FluentCMS.Api/DeliveryEndpoints.cs create mode 100644 src/FluentCMS.Core/Delivery/IPublishedContent.cs create mode 100644 src/FluentCMS.Infrastructure/Content/EntryResolution.cs create mode 100644 src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs diff --git a/docs/README.md b/docs/README.md index 2c65c44ab..293d11ea1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ | [`requirements/`](requirements/) | What v2 must do. Owned by the product side; changed through issues, not feature pull requests. | | [`adr/`](adr/README.md) | Why v2 is built the way it is. Architecture decision records, one file per decision. | | [`spikes/`](spikes/) | Time-boxed experiments that settled an open point in an ADR, with their evidence. | +| [`api/`](api/delivery.md) | How frontends and agents read published content over HTTP. | ## Requirements diff --git a/docs/api/delivery.md b/docs/api/delivery.md new file mode 100644 index 000000000..5b8122d04 --- /dev/null +++ b/docs/api/delivery.md @@ -0,0 +1,58 @@ +# Delivery API + +The delivery API serves published content to frontends and agents over HTTP (API-01). It is read-only and anonymous: anyone who can reach the host can read what is published, and nothing else. + +## What it serves + +Only the published version of each entry is served, and only while the entry is not deleted. Drafts, unpublished entries, archived entries and deleted entries answer 404, the same as an id that never existed. + +Each item is a resolved view (CNT-04): + +- A reference to an entity field holds that entity's current published value. A newer draft of the entity changes nothing until it is published. +- A reference that has no published value is `null`, and `unresolved` says why: `notPublished`, `noValue` or `deleted`. +- References to whole entries and to media stay ids. Read a referenced entry at `/api/v1/entries/{id}`. + +Entities are content too, so their fields hold facts as stored: each value with its source, owner and validity. + +## Endpoints + +| Request | Returns | +| --- | --- | +| `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | +| `GET /api/v1/entries/{id}` | One published entry. | + +`GET /api/v1/content/{type}` takes these query parameters: + +| Parameter | Meaning | +| --- | --- | +| `limit` | Items per page, from 1 to 100. Defaults to 20. | +| `after` | The `next` cursor of the previous page. | +| `references` | Keeps the entries whose published version points at this entry, entity or media asset, such as every product page that shows one product. | + +A list looks like this: + +```json +{ + "items": [ + { + "id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", + "type": "product-page", + "version": 3, + "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "unresolved": [] + } + ], + "next": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e" +} +``` + +`next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. + +## 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` | diff --git a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs index 64e34f94c..1e8c53a30 100644 --- a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs +++ b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ namespace FluentCMS.Api; public static class ApiServiceCollectionExtensions { - // The delivery API arrives with F-API; until then this registers nothing. + // The delivery API reads through Core services that Infrastructure registers, so it adds none of its own yet. public static IServiceCollection AddFluentCmsApi(this IServiceCollection services) { return services; diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs new file mode 100644 index 000000000..e9d2fb30c --- /dev/null +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.Json; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FluentCMS.Api; + +// The read-only delivery API (API-01). Anyone may read published content, so no endpoint asks who the caller is. +public static class DeliveryEndpoints +{ + public const string Prefix = "/api/v1"; + + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) + { + var api = endpoints.MapGroup(Prefix).AllowAnonymous(); + api.MapGet("/content/{type}", ListAsync); + api.MapGet("/entries/{id:guid}", GetAsync); + return api; + } + + private static async Task ListAsync( + string type, + string? limit, + string? after, + string? references, + IPublishedContent content, + CancellationToken cancellationToken) + { + var errors = new List(); + var size = PublishedQuery.DefaultLimit; + if (limit is not null && !int.TryParse(limit, NumberStyles.None, CultureInfo.InvariantCulture, out size)) + { + errors.Add(new Error(DeliveryErrorCodes.LimitInvalid, $"'limit' is '{limit}'; it must be a whole number from 1 to {PublishedQuery.MaxLimit}.")); + } + + var cursor = Id(after, "after", "the next cursor of a previous page", errors); + var target = Id(references, "references", "the id of an entry, entity or media asset", errors); + if (errors.Count > 0) + { + return Problem(errors); + } + + var page = await content.ListAsync(new PublishedQuery(type, size, cursor, target), cancellationToken); + return page.IsSuccess + ? Results.Json(new PageResponse([.. page.Value.Items.Select(Item)], page.Value.Next)) + : Problem(page.Errors); + } + + private static async Task GetAsync(Guid id, IPublishedContent content, CancellationToken cancellationToken) + { + var item = await content.GetAsync(id, cancellationToken); + return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); + } + + private static Guid? Id(string? value, string name, string form, List errors) + { + if (value is null) + { + return null; + } + + if (Guid.TryParse(value, out var id)) + { + return id; + } + + errors.Add(new Error(DeliveryErrorCodes.ParameterInvalid, $"'{name}' is '{value}'; it must be {form}, a UUID.")); + return null; + } + + private static ItemResponse Item(PublishedItem item) + { + return new ItemResponse( + item.Id, + item.ContentType, + item.Version, + item.Fields, + [.. item.Unresolved.Select(reference => new UnresolvedResponse( + reference.Path, + reference.EntityId, + reference.Field, + JsonNamingPolicy.CamelCase.ConvertName(reference.Reason.ToString())))]); + } + + // RFC 9457 problem details with every error, so a body is always written and the admin's not-found page never answers instead. + private static IResult Problem(IReadOnlyList errors) + { + var status = errors[0].Code switch + { + ContentErrorCodes.EntryNotFound or SchemaErrorCodes.ContentTypeNotFound => StatusCodes.Status404NotFound, + AgentPermissionErrorCodes.ScopeDenied => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status400BadRequest, + }; + + return Results.Problem( + statusCode: status, + detail: string.Join(" ", errors.Select(error => error.Message)), + extensions: new Dictionary + { + ["errors"] = errors.Select(error => new { error.Code, error.Message }).ToList(), + }); + } + + private sealed record PageResponse(IReadOnlyList Items, Guid? Next); + + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + + private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); +} diff --git a/src/FluentCMS.Api/FluentCMS.Api.csproj b/src/FluentCMS.Api/FluentCMS.Api.csproj index 460b63b29..9fe0bd32d 100644 --- a/src/FluentCMS.Api/FluentCMS.Api.csproj +++ b/src/FluentCMS.Api/FluentCMS.Api.csproj @@ -1,11 +1,11 @@ - + - + diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs new file mode 100644 index 000000000..7f9650374 --- /dev/null +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using FluentCMS.Core.Content; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Delivery; + +// What the delivery API serves (API-01): the published version of each entry that has one and is not deleted, as a resolved view +// (CNT-04). Drafts, unpublished and deleted entries are not found, so a reader cannot tell them from ids that never existed. A read: +// it appends no audit event. +public interface IPublishedContent +{ + // The published entries of one content type, by id, which follows the order of creation. + Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); + + Task> GetAsync(Guid id, CancellationToken cancellationToken = default); +} + +// After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or +// media asset, read from the reference index (ADR-0005). +public sealed record PublishedQuery(string ContentType, int Limit = PublishedQuery.DefaultLimit, Guid? After = null, Guid? References = null) +{ + public const int DefaultLimit = 20; + + public const int MaxLimit = 100; +} + +// Next is null on the last page. +public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); + +public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); + +public static class DeliveryErrorCodes +{ + public const string LimitInvalid = "limit_invalid"; + public const string ParameterInvalid = "parameter_invalid"; +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index fb11e874a..f3d39ee5c 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -67,6 +67,7 @@ public static WebApplication BuildWebApplication(string[] args, Action> ResolveAsync( + FluentCmsDbContext context, + IReadOnlyList versions, + CancellationToken cancellationToken) + { + if (versions.Count == 0) + { + return []; + } + + var ids = versions.Select(version => version.EntryId).Distinct().ToList(); + var numbers = versions.Select(version => version.Version).Distinct().ToList(); + var wanted = versions.Select(version => (version.EntryId, version.Version)).ToHashSet(); + + // Every pair of a wanted entry and a wanted number is fetched, and the pairs not asked for are dropped here. + var stored = (await context.EntryVersions + .AsNoTracking() + .Where(row => ids.Contains(row.EntryId) && numbers.Contains(row.Version)) + .Select(row => new { row.EntryId, row.Version, row.Fields }) + .ToListAsync(cancellationToken)) + .Where(row => wanted.Contains((row.EntryId, row.Version))) + .ToDictionary(row => (row.EntryId, row.Version), row => row.Fields); + + var references = (await context.EntryReferences + .AsNoTracking() + .Where(row => ids.Contains(row.EntryId) + && numbers.Contains(row.Version) + && row.TargetKind == ReferenceKind.Entry + && row.TargetField != null) + .Select(row => new { row.EntryId, row.Version, row.Path, row.TargetId, Field = row.TargetField! }) + .ToListAsync(cancellationToken)) + .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); + + return versions.Select(version => + { + var fields = stored.GetValueOrDefault((version.EntryId, version.Version)) + ?? throw new InvalidOperationException($"Entry {version.EntryId} has no stored version {version.Version}."); + var values = new Dictionary(StringComparer.Ordinal); + var unresolved = new List(); + + foreach (var reference in references[(version.EntryId, version.Version)].OrderBy(reference => reference.Path, StringComparer.Ordinal)) + { + var (deleted, entity) = published.GetValueOrDefault(reference.TargetId); + var value = entity is { } fieldsOfEntity ? FactValue(fieldsOfEntity, reference.Field) : null; + values[reference.Path] = value; + + if (value is null) + { + var reason = deleted ? UnresolvedReason.Deleted : entity is null ? UnresolvedReason.NotPublished : UnresolvedReason.NoValue; + unresolved.Add(new UnresolvedReference(reference.Path, reference.TargetId, reference.Field, reason)); + } + } + + using var document = JsonDocument.Parse(fields); + return new ResolvedEntry(version.EntryId, version.ContentType, version.Version, ResolvedFields.Replace(document.RootElement, values), unresolved); + }).ToList(); + } + + // Each entity's published fields, read in one query: none when it is deleted or has no published version. + private static async Task> PublishedFieldsAsync( + FluentCmsDbContext context, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + var rows = await ( + from entity in context.Entries.AsNoTracking() + where ids.Contains(entity.Id) && entity.SiteId == SiteId + from version in context.EntryVersions.AsNoTracking() + .Where(version => version.EntryId == entity.Id && version.Version == entity.PublishedVersion) + .DefaultIfEmpty() + 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(row.Fields))); + } + + // An entity stores each field as a fact; the view shows the fact's value (SCH-03). + private static JsonElement? FactValue(JsonElement entity, string field) + { + return entity.TryGetProperty(field, out var fact) + && fact.ValueKind == JsonValueKind.Object + && fact.TryGetProperty(FactMetadata.Value, out var value) + && value.ValueKind != JsonValueKind.Null + ? value + : null; + } +} + +internal sealed record VersionToResolve(Guid EntryId, string ContentType, int Version); diff --git a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs index ed05fa9ef..f762663f9 100644 --- a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs +++ b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs @@ -1,8 +1,6 @@ -using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Results; -using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -35,76 +33,14 @@ join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals typ } var number = version ?? entry.LatestVersion; - var fields = await context.EntryVersions - .AsNoTracking() - .Where(row => row.EntryId == entryId && row.Version == number) - .Select(row => row.Fields) - .SingleOrDefaultAsync(cancellationToken); - if (fields is null) + if (!await context.EntryVersions.AnyAsync(row => row.EntryId == entryId && row.Version == number, cancellationToken)) { return Result.Failure( ContentErrorCodes.VersionNotFound, $"Entry {entryId} has no version {number}; its latest version is {entry.LatestVersion}."); } - var references = await context.EntryReferences - .AsNoTracking() - .Where(row => row.EntryId == entryId && row.Version == number && row.TargetKind == ReferenceKind.Entry && row.TargetField != null) - .Select(row => new { row.Path, row.TargetId, Field = row.TargetField! }) - .ToListAsync(cancellationToken); - - var published = await PublishedFieldsAsync(references.Select(reference => reference.TargetId).Distinct().ToList(), cancellationToken); - var values = new Dictionary(StringComparer.Ordinal); - var unresolved = new List(); - - foreach (var reference in references.OrderBy(reference => reference.Path, StringComparer.Ordinal)) - { - var (deleted, entity) = published.GetValueOrDefault(reference.TargetId); - var value = entity is { } fieldsOfEntity ? FactValue(fieldsOfEntity, reference.Field) : null; - values[reference.Path] = value; - - if (value is null) - { - var reason = deleted ? UnresolvedReason.Deleted : entity is null ? UnresolvedReason.NotPublished : UnresolvedReason.NoValue; - unresolved.Add(new UnresolvedReference(reference.Path, reference.TargetId, reference.Field, reason)); - } - } - - using var document = JsonDocument.Parse(fields); - return Result.Success( - new ResolvedEntry(entryId, entry.Slug, number, ResolvedFields.Replace(document.RootElement, values), unresolved)); - } - - // Each entity's published fields, read in one query: none when it is deleted or has no published version. - private async Task> PublishedFieldsAsync(List ids, CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - var rows = await ( - from entity in context.Entries.AsNoTracking() - where ids.Contains(entity.Id) && entity.SiteId == SiteId - from version in context.EntryVersions.AsNoTracking() - .Where(version => version.EntryId == entity.Id && version.Version == entity.PublishedVersion) - .DefaultIfEmpty() - 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(row.Fields))); - } - - // An entity stores each field as a fact; the view shows the fact's value (SCH-03). - private static JsonElement? FactValue(JsonElement entity, string field) - { - return entity.TryGetProperty(field, out var fact) - && fact.ValueKind == JsonValueKind.Object - && fact.TryGetProperty(FactMetadata.Value, out var value) - && value.ValueKind != JsonValueKind.Null - ? value - : null; + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(entryId, entry.Slug, number)], cancellationToken); + return Result.Success(resolved[0]); } } diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs new file mode 100644 index 000000000..43bc58ad3 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -0,0 +1,106 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Delivery; + +// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. +internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions) : IPublishedContent +{ + // Until multi-site (MST-01), every entry belongs to the one site (SCH-07). + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (query.Limit is < 1 or > PublishedQuery.MaxLimit) + { + return Result.Failure( + DeliveryErrorCodes.LimitInvalid, + $"The limit is {query.Limit}; it must be from 1 to {PublishedQuery.MaxLimit}."); + } + + var allowed = await permissions.CheckContentAsync(query.ContentType, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var typeId = await context.ContentTypes + .AsNoTracking() + .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) + .Select(row => (Guid?)row.Id) + .SingleOrDefaultAsync(cancellationToken); + if (typeId is null) + { + return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); + } + + var entries = context.Entries + .AsNoTracking() + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + + // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. + if (query.After is { } after) + { + entries = entries.Where(entry => entry.Id > after); + } + + if (query.References is { } target) + { + entries = entries.Where(entry => context.EntryReferences + .Any(reference => reference.EntryId == entry.Id && reference.Version == entry.PublishedVersion && reference.TargetId == target)); + } + + var rows = await entries + .OrderBy(entry => entry.Id) + .Take(query.Limit + 1) + .Select(entry => new { entry.Id, Version = entry.PublishedVersion!.Value }) + .ToListAsync(cancellationToken); + + var page = rows.Take(query.Limit).ToList(); + var resolved = await EntryResolution.ResolveAsync( + context, + [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + cancellationToken); + + return Result.Success(new PublishedPage( + [.. resolved.Select(Item)], + rows.Count > query.Limit ? page[^1].Id : null)); + } + + public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var entry = await ( + from row in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id + where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null + select new { type.Slug, Version = row.PublishedVersion!.Value }) + .SingleOrDefaultAsync(cancellationToken); + if (entry is null) + { + return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); + } + + var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0])); + } + + private static PublishedItem Item(ResolvedEntry entry) + { + return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index d9dd9264e..c55faddeb 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; using FluentCMS.Core.Entities; using FluentCMS.Core.Extensions; using FluentCMS.Core.Identity; @@ -16,6 +17,7 @@ using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Checks; using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Delivery; using FluentCMS.Infrastructure.Entities; using FluentCMS.Infrastructure.Identity; using FluentCMS.Infrastructure.Jobs; @@ -115,6 +117,7 @@ private static void AddContent(IServiceCollection services, IConfiguration confi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs new file mode 100644 index 000000000..14aedeb01 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Api; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class DeliveryEndpointsTests +{ + private static readonly Guid Page = Guid.CreateVersion7(); + private static readonly Guid Product = Guid.CreateVersion7(); + + [Fact] + public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_cursor() + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetFromJsonAsync($"/api/v1/content/product-page?limit=1&after={Product}&references={Product}"); + + Assert.Equal(new PublishedQuery("product-page", 1, Product, Product), content.Query); + var item = Assert.Single(response!["items"]!.AsArray())!; + Assert.Equal((Page.ToString(), "product-page", 3), ((string?)item["id"], (string?)item["type"], (int)item["version"]!)); + Assert.Equal(99, (int)item["fields"]!["price"]!); + var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; + Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal(Page.ToString(), (string?)response["next"]); + } + + [Fact] + public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var found = await api.Client.GetFromJsonAsync($"/api/v1/entries/{Page}"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}"); + var unknownType = await api.Client.GetAsync("/api/v1/content/news"); + + Assert.Equal(Page.ToString(), (string?)found!["id"]); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + } + + [Theory] + [InlineData("limit=ten", DeliveryErrorCodes.LimitInvalid, "'limit'")] + [InlineData("limit=-1", DeliveryErrorCodes.LimitInvalid, "'limit'")] + [InlineData("after=page-2", DeliveryErrorCodes.ParameterInvalid, "'after'")] + [InlineData("references=drill", DeliveryErrorCodes.ParameterInvalid, "'references'")] + public async Task A_parameter_that_does_not_parse_is_a_problem_with_400_that_names_it(string query, string code, string name) + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetAsync($"/api/v1/content/product-page?{query}"); + + var problem = await AssertProblemAsync(response, HttpStatusCode.BadRequest, code); + Assert.Contains(name, (string?)problem["detail"]); + Assert.Null(content.Query); + } + + [Fact] + public async Task The_host_serves_the_api_to_anonymous_readers_from_the_database() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + + var unknownType = await host.Client.GetAsync("/api/v1/content/product-page"); + var unknownEntry = await host.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}"); + var badLimit = await host.Client.GetAsync("/api/v1/content/product-page?limit=0"); + + await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + await AssertProblemAsync(unknownEntry, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + await AssertProblemAsync(badLimit, HttpStatusCode.BadRequest, DeliveryErrorCodes.LimitInvalid); + } + + private static async Task AssertProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) + { + Assert.Equal(status, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + var problem = (await response.Content.ReadFromJsonAsync())!; + Assert.Equal(code, (string?)problem["errors"]![0]!["code"]); + return problem; + } + + // Answers like the reader would for one published page, and records the query it was asked. + private sealed class StubContent : IPublishedContent + { + public PublishedQuery? Query { get; private set; } + + public Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) + { + Query = query; + return Task.FromResult(query.ContentType == "product-page" + ? Result.Success(new PublishedPage([Item()], Page)) + : Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site.")); + } + + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + return Task.FromResult(id == Page + ? Result.Success(Item()) + : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); + } + + private static PublishedItem Item() + { + return new PublishedItem( + Page, + "product-page", + 3, + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); + } + } + + // The endpoints alone on a free port, over the content given. + private sealed class StubApi(WebApplication app) : IAsyncDisposable + { + public HttpClient Client { get; } = new() { BaseAddress = new Uri(app.Urls.Single()) }; + + public static async Task StartAsync(IPublishedContent content) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(content); + var app = builder.Build(); + app.MapFluentCmsApi(); + await app.StartAsync(); + return new StubApi(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.StopAsync(); + await app.DisposeAsync(); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs new file mode 100644 index 000000000..c174c5269 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -0,0 +1,174 @@ +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class PublishedContentTests +{ + // The F-API acceptance: a frontend reads a product page with the product's price as published, whatever its drafts say. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_page_reads_as_published_with_its_price_resolved_to_the_published_value(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + await PublishAsync(scenario, product, 1); + await scenario.UpdateAsync(product, 1, ProductFields("109")); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Draft headline"}"""); + + var read = (await ContentAsync(scenario, content => content.GetAsync(page))).Value; + var entity = (await ContentAsync(scenario, content => content.GetAsync(product))).Value; + + Assert.Equal((page, "product-page", 1), (read.Id, read.ContentType, read.Version)); + AssertSameFields($$"""{"product": "{{product}}", "price": 99, "headline": "A sturdy drill"}""", read.Fields); + Assert.Empty(read.Unresolved); + Assert.Equal(("product", 1), (entity.ContentType, entity.Version)); + Assert.Equal(99, entity.Fields.GetProperty("price").GetProperty("value").GetInt32()); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Drafts_unpublished_and_deleted_entries_are_neither_listed_nor_read(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var live = await PageAsync(scenario, product, "Live"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + await PublishAsync(scenario, live, 1); + await PublishAsync(scenario, deleted, 1); + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + + var listed = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page")))).Value; + + Assert.Equal([live], listed.Items.Select(item => item.Id)); + Assert.Null(listed.Next); + Assert.Equal("/price", Assert.Single(listed.Items[0].Unresolved).Path); + foreach (var hidden in new[] { draft, deleted, product, Guid.CreateVersion7() }) + { + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(await ContentAsync(scenario, content => content.GetAsync(hidden)))); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Pages_follow_the_order_of_creation_and_end_without_a_next_cursor(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var pages = new List(); + for (var number = 1; number <= 5; number++) + { + pages.Add(await PageAsync(scenario, product, $"Page {number}")); + await PageAsync(scenario, product, $"Draft {number}"); + await PublishAsync(scenario, pages[^1], 1); + } + + var read = new List(); + var cursors = new List(); + Guid? after = null; + do + { + var page = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 2, after)))).Value; + read.AddRange(page.Items.Select(item => item.Id)); + cursors.Add(page.Next); + after = page.Next; + } + while (after is not null); + + Assert.Equal(pages, read); + Assert.Equal([pages[1], pages[3], null], cursors); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task References_keeps_the_entries_whose_published_version_points_at_the_target(string provider) + { + await using var scenario = await CreateAsync(provider); + var (drill, saw) = (await ProductAsync(scenario, "99"), await ProductAsync(scenario, "49")); + var drillPage = await PageAsync(scenario, drill, "Drill"); + var sawPage = await PageAsync(scenario, saw, "Saw"); + var movedPage = await PageAsync(scenario, saw, "Was a saw"); + await PublishAsync(scenario, drillPage, 1); + await PublishAsync(scenario, sawPage, 1); + await PublishAsync(scenario, movedPage, 1); + await scenario.UpdateAsync(movedPage, 1, $$"""{"product": "{{drill}}", "price": "{{drill}}", "headline": "Now a drill"}"""); + + var drillPages = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", References: drill)))).Value; + var sawPages = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", References: saw)))).Value; + + Assert.Equal([drillPage], drillPages.Items.Select(item => item.Id)); + Assert.Equal([sawPage, movedPage], sawPages.Items.Select(item => item.Id)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_limit_out_of_range_or_an_unknown_type_fails(string provider) + { + await using var scenario = await CreateAsync(provider); + + Assert.Equal(DeliveryErrorCodes.LimitInvalid, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 0))))); + Assert.Equal(DeliveryErrorCodes.LimitInvalid, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 101))))); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("news"))))); + Assert.Empty((await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 100)))).Value.Items); + } + + private static async Task CreateAsync(string provider) + { + var scenario = await EntryScenario.CreateAsync(provider); + await scenario.Schema.CreateReferenceModelAsync(); + return scenario; + } + + private static string ProductFields(string price) + { + return $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact(price)}}}"""; + } + + private static async Task ProductAsync(EntryScenario scenario, string price) + { + var created = await scenario.CreateAsync("product", ProductFields(price)); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } + + private static async Task PageAsync(EntryScenario scenario, Guid product, string headline) + { + var created = await scenario.CreateAsync("product-page", $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "{{headline}}"}"""); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } + + // Publish moves the pointer through change sets (#2519); these tests only need published versions. + private static Task PublishAsync(EntryScenario scenario, Guid entry, int version) + { + return scenario.Schema.QueryAsync(context => context.Entries + .Where(row => row.Id == entry) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, version))); + } + + private static string Code(Result result) + { + return Assert.Single(result.Errors).Code; + } + + private static async Task ContentAsync(EntryScenario scenario, Func> read) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } +} From 8e5bff32ca3e3f21514b726860073814a16c4837 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 23:57:56 -0400 Subject: [PATCH 02/15] Serve each published entry as Markdown and add the site index --- docs/api/delivery.md | 18 +++ src/FluentCMS.Api/DeliveryEndpoints.cs | 67 +++++++- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 151 ++++++++++++++++++ .../Delivery/IPublishedContent.cs | 17 +- .../Delivery/PublishedContentReader.cs | 95 ++++++++--- .../Delivery/DeliveryEndpointsTests.cs | 52 +++++- .../Delivery/PublishedContentTests.cs | 34 ++++ .../Delivery/EntryMarkdownTests.cs | 148 +++++++++++++++++ 8 files changed, 560 insertions(+), 22 deletions(-) create mode 100644 src/FluentCMS.Core/Delivery/EntryMarkdown.cs create mode 100644 tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 5b8122d04..ec20b5a26 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -20,6 +20,9 @@ Entities are content too, so their fields hold facts as stored: each value with | --- | --- | | `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | | `GET /api/v1/entries/{id}` | One published entry. | +| `GET /api/v1/entries/{id}.md` | The same entry as Markdown. | +| `GET /api/v1/index` | Every published entry with its type, title and URLs, as JSON. | +| `GET /api/v1/index.md` and `GET /llms.txt` | The same index as Markdown, grouped by content type. | `GET /api/v1/content/{type}` takes these query parameters: @@ -39,6 +42,7 @@ A list looks like this: "type": "product-page", "version": 3, "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "markdown": "/api/v1/entries/0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e.md", "unresolved": [] } ], @@ -48,6 +52,20 @@ A list looks like this: `next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. +## Markdown and the site index + +Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version. + +- Front matter gives the id, type, version and URL. +- The heading is the entry's title: its first text field that holds text. +- Each other field follows under its name, in the order its content type defines them: + - rich text as written, since it is stored as Markdown; + - lists as bullets, and JSON in a fenced block; + - references to whole entries as links to their Markdown. +- On an entity, each fact's value is followed by its source. + +The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. + ## 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`: diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index e9d2fb30c..63e1d337d 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; @@ -16,14 +17,32 @@ public static class DeliveryEndpoints { public const string Prefix = "/api/v1"; + private const string Markdown = EntryMarkdown.MediaType + "; charset=utf-8"; + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) { var api = endpoints.MapGroup(Prefix).AllowAnonymous(); api.MapGet("/content/{type}", ListAsync); api.MapGet("/entries/{id:guid}", GetAsync); + api.MapGet("/entries/{id:guid}.md", GetMarkdownAsync); + api.MapGet("/index", IndexAsync); + api.MapGet("/index.md", IndexMarkdownAsync); + + // Where agents look for a site's index first, by the llms.txt convention. + endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous(); return api; } + public static string EntryUrl(Guid id) + { + return $"{Prefix}/entries/{id}"; + } + + public static string MarkdownUrl(Guid id) + { + return $"{Prefix}/entries/{id}.md"; + } + private static async Task ListAsync( string type, string? limit, @@ -58,6 +77,47 @@ private static async Task GetAsync(Guid id, IPublishedContent content, return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); } + // The URL is the entry's id, so it stays the same across versions and always shows the published one (API-03). + private static async Task GetMarkdownAsync(Guid id, IPublishedContent content, CancellationToken cancellationToken) + { + var item = await content.GetAsync(id, cancellationToken); + return item.IsSuccess + ? Results.Text(EntryMarkdown.Render(item.Value, MarkdownUrl(id), MarkdownUrl), Markdown) + : Problem(item.Errors); + } + + private static async Task IndexAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + return Results.Json(new IndexResponse([.. index.Select(entry => new IndexEntryResponse( + entry.Id, + entry.ContentType, + entry.Version, + entry.Title, + EntryUrl(entry.Id), + MarkdownUrl(entry.Id)))])); + } + + private static async Task IndexMarkdownAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + var markdown = new StringBuilder() + .Append("# Site index\n\n") + .Append(CultureInfo.InvariantCulture, $"Every published entry of this site, by content type, linked to its Markdown. The same index in JSON is at {Prefix}/index.\n"); + + foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName))) + { + markdown.Append(CultureInfo.InvariantCulture, $"\n## {type.Key.ContentTypeName}\n\n"); + foreach (var entry in type) + { + var title = (entry.Title ?? $"{entry.ContentTypeName} {entry.Id}").Replace("[", @"\[", StringComparison.Ordinal).Replace("]", @"\]", StringComparison.Ordinal); + markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n"); + } + } + + return Results.Text(markdown.ToString(), Markdown); + } + private static Guid? Id(string? value, string name, string form, List errors) { if (value is null) @@ -81,6 +141,7 @@ private static ItemResponse Item(PublishedItem item) item.ContentType, item.Version, item.Fields, + MarkdownUrl(item.Id), [.. item.Unresolved.Select(reference => new UnresolvedResponse( reference.Path, reference.EntityId, @@ -109,7 +170,11 @@ private static IResult Problem(IReadOnlyList errors) private sealed record PageResponse(IReadOnlyList Items, Guid? Next); - private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList Unresolved); + + private sealed record IndexResponse(IReadOnlyList Items); + + private sealed record IndexEntryResponse(Guid Id, string Type, int Version, string? Title, string Url, string Markdown); private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); } diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs new file mode 100644 index 000000000..521f2833c --- /dev/null +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using FluentCMS.Core.Schema; + +namespace FluentCMS.Core.Delivery; + +// The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written; +// every other value is written out by its field's type, in the order the content type defines its fields. +public static class EntryMarkdown +{ + public const string MediaType = "text/markdown"; + + // Markdown lines end in \n whatever the platform, JSON blocks included. + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, NewLine = "\n" }; + + // The first text field that holds text, read from its fact on an entity; null when there is none. + public static string? Title(JsonElement fields, ContentTypeDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + return TitleField(fields, definition)?.Text; + } + + // EntryUrl gives the Markdown URL of another entry, for references to whole entries. + public static string Render(PublishedItem item, string url, Func entryUrl) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(entryUrl); + + var definition = item.Definition; + var title = TitleField(item.Fields, definition); + var markdown = new StringBuilder() + .Append("---\n") + .Append(CultureInfo.InvariantCulture, $"id: {item.Id}\n") + .Append(CultureInfo.InvariantCulture, $"type: {item.ContentType}\n") + .Append(CultureInfo.InvariantCulture, $"version: {item.Version}\n") + .Append(CultureInfo.InvariantCulture, $"url: {url}\n") + .Append("---\n\n") + .Append(CultureInfo.InvariantCulture, $"# {title?.Text ?? $"{item.ContentTypeName} {item.Id}"}\n"); + + // The heading shows the title, so its field is not repeated below it. + foreach (var field in definition.Fields.Where(field => field.Name != title?.Field)) + { + if (Value(item.Fields, field.Name, definition.IsEntity) is not { } value) + { + continue; + } + + markdown.Append(CultureInfo.InvariantCulture, $"\n## {field.Name}\n\n").Append(Block(field.Type, field.Settings, value, entryUrl)).Append('\n'); + + if (definition.IsEntity && Source(item.Fields, field.Name) is { } source) + { + markdown.Append(CultureInfo.InvariantCulture, $"\nSource: {source}\n"); + } + } + + return markdown.ToString(); + } + + private static (string Field, string Text)? TitleField(JsonElement fields, ContentTypeDefinition definition) + { + foreach (var field in definition.Fields.Where(field => field.Type == "text")) + { + if (Value(fields, field.Name, definition.IsEntity) is { ValueKind: JsonValueKind.String } value + && !string.IsNullOrWhiteSpace(value.GetString())) + { + return (field.Name, Line(value.GetString()!)); + } + } + + return null; + } + + // The field's value: on an entity, the value inside its fact. Null, blank and missing values are left out. + private static JsonElement? Value(JsonElement fields, string name, bool isEntity) + { + if (fields.ValueKind != JsonValueKind.Object || !fields.TryGetProperty(name, out var value)) + { + return null; + } + + if (isEntity && value.ValueKind == JsonValueKind.Object && !value.TryGetProperty(FactMetadata.Value, out value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.Null || (value.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(value.GetString())) + ? null + : value; + } + + private static string? Source(JsonElement fields, string name) + { + return fields.TryGetProperty(name, out var fact) + && fact.ValueKind == JsonValueKind.Object + && fact.TryGetProperty(FactMetadata.Source, out var source) + && source.ValueKind == JsonValueKind.Object + && source.TryGetProperty(FactMetadata.SourceReference, out var reference) + && reference.ValueKind == JsonValueKind.String + ? source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String + ? $"{reference.GetString()} ({kind.GetString()})" + : reference.GetString() + : null; + } + + private static string Block(string type, JsonElement settings, JsonElement value, Func entryUrl) + { + if (type == "list" && value.ValueKind == JsonValueKind.Array) + { + var itemType = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemType", out var item) ? item.GetString() ?? "" : ""; + var itemSettings = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemSettings", out var nested) ? nested : FieldDefinition.EmptySettings; + + return string.Join('\n', value.EnumerateArray() + .Where(element => element.ValueKind != JsonValueKind.Null) + .Select(element => $"- {Line(Inline(itemType, itemSettings, element, entryUrl))}")); + } + + return type switch + { + "richText" when value.ValueKind == JsonValueKind.String => value.GetString()!.Trim(), + "json" => Fenced(value), + _ when value.ValueKind is JsonValueKind.Object or JsonValueKind.Array => Fenced(value), + _ => Inline(type, settings, value, entryUrl), + }; + } + + private static string Inline(string type, JsonElement settings, JsonElement value, Func entryUrl) + { + // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. + var wholeEntry = type == "reference" && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out _)); + + return value.ValueKind switch + { + JsonValueKind.String when wholeEntry && value.TryGetGuid(out var id) => $"[{id}]({entryUrl(id)})", + JsonValueKind.String when type == "media" => $"Media asset `{value.GetString()}`", + JsonValueKind.String => value.GetString()!, + _ => value.GetRawText(), + }; + } + + private static string Fenced(JsonElement value) + { + return $"```json\n{JsonSerializer.Serialize(value, Indented)}\n```"; + } + + private static string Line(string text) + { + return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 7f9650374..750fc279e 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -1,6 +1,7 @@ using System.Text.Json; using FluentCMS.Core.Content; using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; namespace FluentCMS.Core.Delivery; @@ -13,6 +14,9 @@ public interface IPublishedContent Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); Task> GetAsync(Guid id, CancellationToken cancellationToken = default); + + // Every published entry of every type the caller may read, by type and then by id, for the site index (API-03). + Task> IndexAsync(CancellationToken cancellationToken = default); } // After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or @@ -27,7 +31,18 @@ public sealed record PublishedQuery(string ContentType, int Limit = PublishedQue // Next is null on the last page. public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); -public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); +// Title is EntryMarkdown.Title of the published version, null when it has no text. +public sealed record PublishedSummary(Guid Id, string ContentType, string ContentTypeName, int Version, string? Title); + +// The content type's name and current definition come along, since the published version is read against them. +public sealed record PublishedItem( + Guid Id, + string ContentType, + string ContentTypeName, + ContentTypeDefinition Definition, + int Version, + JsonElement Fields, + IReadOnlyList Unresolved); public static class DeliveryErrorCodes { diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index 43bc58ad3..bcd9616c7 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; @@ -33,19 +34,14 @@ public async Task> ListAsync(PublishedQuery query, Cancell return Result.Failure(allowed.Errors); } - var typeId = await context.ContentTypes - .AsNoTracking() - .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) - .Select(row => (Guid?)row.Id) - .SingleOrDefaultAsync(cancellationToken); - if (typeId is null) + if ((await TypesAsync(query.ContentType, null, cancellationToken)).Values.SingleOrDefault() is not { } type) { return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); } var entries = context.Entries .AsNoTracking() - .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == type.Id && entry.PublishedVersion != null && entry.DeletedAt == null); // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. if (query.After is { } after) @@ -68,39 +64,100 @@ public async Task> ListAsync(PublishedQuery query, Cancell var page = rows.Take(query.Limit).ToList(); var resolved = await EntryResolution.ResolveAsync( context, - [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], cancellationToken); return Result.Success(new PublishedPage( - [.. resolved.Select(Item)], + [.. resolved.Select(entry => Item(entry, type))], rows.Count > query.Limit ? page[^1].Id : null)); } public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await ( - from row in context.Entries.AsNoTracking() - join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id - where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null - select new { type.Slug, Version = row.PublishedVersion!.Value }) + var entry = await context.Entries + .AsNoTracking() + .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) + .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) .SingleOrDefaultAsync(cancellationToken); if (entry is null) { return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); } - var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; + var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0])); + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0], type)); + } + + public async Task> IndexAsync(CancellationToken cancellationToken = default) + { + var types = await TypesAsync(null, null, cancellationToken); + + // The stored documents, not resolved views: a title is a text field of the entry itself, never a resolved reference. + var rows = await ( + from entry in context.Entries.AsNoTracking() + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + where entry.SiteId == SiteId && entry.PublishedVersion != null && entry.DeletedAt == null + select new { entry.Id, entry.ContentTypeId, version.Version, version.Fields }) + .ToListAsync(cancellationToken); + + var readable = await permissions.ReadableTypesAsync(cancellationToken); + + return rows + .Select(row => (Row: row, Type: types[row.ContentTypeId])) + .Where(item => readable(item.Type.Slug)) + .Select(item => + { + using var fields = JsonDocument.Parse(item.Row.Fields); + return new PublishedSummary( + item.Row.Id, + item.Type.Slug, + item.Type.Name, + item.Row.Version, + EntryMarkdown.Title(fields.RootElement, item.Type.Definition)); + }) + .OrderBy(summary => summary.ContentType, StringComparer.Ordinal) + .ThenBy(summary => summary.Id) + .ToList(); + } + + // The site's content types with their current definitions, which a published version is read against: every type, the one + // with the slug, or the one with the id. + private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) + { + var rows = await ( + from type in context.ContentTypes.AsNoTracking() + join version in context.ContentTypeVersions.AsNoTracking() + on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } + where type.SiteId == SiteId && (slug == null || type.Slug == slug) && (id == null || type.Id == id) + select new { type.Id, type.Slug, type.Name, version.Definition }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(row => row.Id, row => new PublishedType(row.Id, row.Slug, row.Name, ReadDefinition(row.Slug, row.Definition))); } - private static PublishedItem Item(ResolvedEntry entry) + private static ContentTypeDefinition ReadDefinition(string slug, string json) { - return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + var definition = ContentTypeDefinition.Parse(json); + + return definition.IsSuccess + ? definition.Value + : throw new InvalidOperationException( + $"The current definition of content type '{slug}' is stored in a form that cannot be read: " + + string.Join("; ", definition.Errors.Select(error => error.Message))); } + + private static PublishedItem Item(ResolvedEntry entry, PublishedType type) + { + return new PublishedItem(entry.EntryId, type.Slug, type.Name, type.Definition, entry.Version, entry.Fields, entry.Unresolved); + } + + private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 14aedeb01..fc7cbe55e 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -32,9 +32,43 @@ public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_c Assert.Equal(99, (int)item["fields"]!["price"]!); var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal($"/api/v1/entries/{Page}.md", (string?)item["markdown"]); Assert.Equal(Page.ToString(), (string?)response["next"]); } + [Fact] + public async Task An_entry_reads_as_markdown_at_its_id() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var response = await api.Client.GetAsync($"/api/v1/entries/{Page}.md"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}.md"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + var markdown = await response.Content.ReadAsStringAsync(); + Assert.StartsWith($"---\nid: {Page}\ntype: product-page\nversion: 3\nurl: /api/v1/entries/{Page}.md\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); + Assert.Contains($"[{Product}](/api/v1/entries/{Product}.md)", markdown, StringComparison.Ordinal); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + } + + [Fact] + public async Task The_site_index_lists_every_entry_in_json_markdown_and_llms_txt() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var json = await api.Client.GetFromJsonAsync("/api/v1/index"); + var markdown = await api.Client.GetStringAsync("/api/v1/index.md"); + var llms = await api.Client.GetStringAsync("/llms.txt"); + + var entries = json!["items"]!.AsArray(); + Assert.Equal([Product.ToString(), Page.ToString()], entries.Select(entry => (string?)entry!["id"])); + Assert.Equal(("Drill [cordless]", $"/api/v1/entries/{Product}", $"/api/v1/entries/{Product}.md"), ((string?)entries[0]!["title"], (string?)entries[0]!["url"], (string?)entries[0]!["markdown"])); + Assert.Contains($@"## Product{"\n\n"}- [Drill \[cordless\]](/api/v1/entries/{Product}.md){"\n"}", markdown, StringComparison.Ordinal); + Assert.Contains($"## Product page\n\n- [Product page {Page}](/api/v1/entries/{Page}.md)\n", markdown, StringComparison.Ordinal); + Assert.Equal(markdown, llms); + } + [Fact] public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() { @@ -110,13 +144,29 @@ public Task> GetAsync(Guid id, CancellationToken cancellat : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); } + public Task> IndexAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>( + [ + new PublishedSummary(Product, "product", "Product", 1, "Drill [cordless]"), + new PublishedSummary(Page, "product-page", "Product page", 3, null), + ]); + } + private static PublishedItem Item() { return new PublishedItem( Page, "product-page", + "Product page", + new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", settings: JsonSerializer.Deserialize("""{"target": "product"}""")), + new FieldDefinition("price", "number"), + new FieldDefinition("headline", "text"), + ]), 3, - JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null, "headline": "A sturdy drill"}"""), [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); } } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index c174c5269..020d5af71 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -127,6 +127,40 @@ public async Task A_limit_out_of_range_or_an_unknown_type_fails(string provider) Assert.Empty((await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 100)))).Value.Items); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_index_lists_each_published_entry_once_with_the_title_of_its_published_version(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var page = await PageAsync(scenario, product, "First headline"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + var post = (await scenario.CreateAsync("blog-post", """{"title": "Launch"}""")).Value.Id; + foreach (var entry in new[] { product, page, deleted, post }) + { + await PublishAsync(scenario, entry, 1); + } + + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Second headline"}"""); + + var before = await ContentAsync(scenario, content => content.IndexAsync()); + await PublishAsync(scenario, page, 2); + var after = await ContentAsync(scenario, content => content.IndexAsync()); + + Assert.Equal( + [ + new PublishedSummary(post, "blog-post", "Blog Post", 1, "Launch"), + new PublishedSummary(product, "product", "Product", 1, "Drill"), + new PublishedSummary(page, "product-page", "Product Page", 1, "First headline"), + ], + before); + Assert.Equal((2, "Second headline"), (after[^1].Version, after[^1].Title)); + Assert.DoesNotContain(after, summary => summary.Id == draft || summary.Id == deleted); + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs new file mode 100644 index 000000000..492d3e3d2 --- /dev/null +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Schema; + +namespace FluentCMS.Tests.Delivery; + +public sealed class EntryMarkdownTests +{ + private static readonly Guid Page = Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e"); + private static readonly Guid Product = Guid.Parse("0192f3a1-0000-7000-8000-000000000001"); + private static readonly Guid Hero = Guid.Parse("0192f3a1-0000-7000-8000-000000000002"); + + private static readonly ContentTypeDefinition PageType = new( + [ + Field("product", "reference", """{"target": "product"}"""), + Field("price", "reference", """{"target": "product", "field": "price"}"""), + Field("headline", "text"), + Field("body", "richText"), + Field("tags", "list", """{"itemType": "text"}"""), + Field("related", "list", """{"itemType": "reference", "itemSettings": {"target": "product"}}"""), + Field("hero", "media"), + Field("featured", "boolean"), + Field("extra", "json"), + Field("note", "text"), + ]); + + private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number")], isEntity: true); + + [Fact] + public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() + { + var item = Item(PageType, "product-page", $$""" + { + "product": "{{Product}}", + "price": 99.5, + "headline": "A sturdy\ndrill", + "body": "Built for **daily** use.\n\n- Two speeds\n", + "tags": ["tools", "drills"], + "related": ["{{Product}}"], + "hero": "{{Hero}}", + "featured": true, + "extra": {"warranty": 2}, + "note": null + } + """); + + var markdown = EntryMarkdown.Render(item, $"/api/v1/entries/{Page}.md", id => $"/api/v1/entries/{id}.md"); + + Assert.Equal( + $$""" + --- + id: {{Page}} + type: product-page + version: 3 + url: /api/v1/entries/{{Page}}.md + --- + + # A sturdy drill + + ## product + + [{{Product}}](/api/v1/entries/{{Product}}.md) + + ## price + + 99.5 + + ## body + + Built for **daily** use. + + - Two speeds + + ## tags + + - tools + - drills + + ## related + + - [{{Product}}](/api/v1/entries/{{Product}}.md) + + ## hero + + Media asset `{{Hero}}` + + ## featured + + true + + ## extra + + ```json + { + "warranty": 2 + } + ``` + + """.Replace("\r\n", "\n", StringComparison.Ordinal), + markdown); + } + + [Fact] + public void An_entity_shows_each_facts_value_with_its_source() + { + var item = Item(ProductType, "product", """ + { + "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + } + """); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains("# Drill\n", markdown, StringComparison.Ordinal); + Assert.EndsWith("## price\n\n99\n\nSource: https://example.com/prices (url)\n", markdown, StringComparison.Ordinal); + Assert.Equal("Drill", EntryMarkdown.Title(item.Fields, ProductType)); + } + + [Fact] + public void Without_text_the_heading_is_the_type_name_and_the_id() + { + var item = Item(PageType, "product-page", $$"""{"product": "{{Product}}", "headline": " "}"""); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains($"# Product page {Page}\n", markdown, StringComparison.Ordinal); + Assert.Null(EntryMarkdown.Title(item.Fields, PageType)); + Assert.DoesNotContain("## headline", markdown, StringComparison.Ordinal); + } + + private static PublishedItem Item(ContentTypeDefinition definition, string type, string fields) + { + return new PublishedItem( + Page, + type, + type == "product" ? "Product" : "Product page", + definition, + 3, + JsonSerializer.Deserialize(fields), + []); + } + + private static FieldDefinition Field(string name, string type, string settings = "{}") + { + return new FieldDefinition(name, type, settings: JsonSerializer.Deserialize(settings)); + } +} From b2794f287f5f341894032b0168dcd9a44c41d087 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:22:21 -0400 Subject: [PATCH 03/15] Preview change sets with tokens --- CONTRIBUTING.md | 2 +- docs/api/delivery.md | 19 + src/FluentCMS.Api/DeliveryEndpoints.cs | 29 + src/FluentCMS.Core/Delivery/IPreviewTokens.cs | 77 + .../Delivery/IPublishedContent.cs | 4 + .../Content/EntryResolution.cs | 39 +- .../Content/ResolvedEntries.cs | 4 +- .../Delivery/DeliveryOptions.cs | 22 + .../Delivery/PreviewTokenConfiguration.cs | 23 + .../Delivery/PreviewTokenDirectory.cs | 135 ++ .../Delivery/PreviewTokenRow.cs | 19 + .../Delivery/PublishedContentReader.cs | 134 +- ...frastructureServiceCollectionExtensions.cs | 7 +- .../Persistence/FluentCmsDbContext.cs | 4 + .../20261019040617_PreviewTokens.Designer.cs | 1240 +++++++++++++++++ .../20261019040617_PreviewTokens.cs | 67 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 51 + .../20261019040601_PreviewTokens.Designer.cs | 1230 ++++++++++++++++ .../Sqlite/20261019040601_PreviewTokens.cs | 67 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 51 + .../Delivery/DeliveryEndpointsTests.cs | 59 + .../Delivery/PreviewTests.cs | 177 +++ 22 files changed, 3434 insertions(+), 26 deletions(-) create mode 100644 src/FluentCMS.Core/Delivery/IPreviewTokens.cs create mode 100644 src/FluentCMS.Infrastructure/Delivery/DeliveryOptions.cs create mode 100644 src/FluentCMS.Infrastructure/Delivery/PreviewTokenConfiguration.cs create mode 100644 src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs create mode 100644 src/FluentCMS.Infrastructure/Delivery/PreviewTokenRow.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dc66c45e..0eb997c16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ The admin stylesheet is compiled by the Tailwind CSS standalone CLI (ADR-0011). Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped. -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) and `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root 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) and `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root 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. diff --git a/docs/api/delivery.md b/docs/api/delivery.md index ec20b5a26..7f1b62766 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -66,6 +66,24 @@ 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`: @@ -73,4 +91,5 @@ Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (` | Status | Codes | | --- | --- | | 400 | `limit_invalid`, `parameter_invalid` | +| 401 | `preview_token_invalid` | | 404 | `entry_not_found`, `content_type_not_found` | diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 63e1d337d..347f01716 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -27,6 +27,8 @@ public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpo api.MapGet("/entries/{id:guid}.md", GetMarkdownAsync); api.MapGet("/index", IndexAsync); api.MapGet("/index.md", IndexMarkdownAsync); + api.MapGet("/preview/entries/{id:guid}", PreviewEntryAsync); + api.MapGet("/preview/changes", PreviewChangesAsync); // Where agents look for a site's index first, by the llms.txt convention. endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous(); @@ -71,6 +73,30 @@ private static async Task ListAsync( : Problem(page.Errors); } + // Unpublished content is never cached on the way, so it disappears with the token (API-04). + private static async Task PreviewEntryAsync(Guid id, HttpContext http, IPreviewContent preview, CancellationToken cancellationToken) + { + http.Response.Headers.CacheControl = "no-store"; + var item = await preview.GetAsync(BearerToken(http), id, cancellationToken); + return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); + } + + private static async Task 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)])) + : Problem(changes.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 GetAsync(Guid id, IPublishedContent content, CancellationToken cancellationToken) { var item = await content.GetAsync(id, cancellationToken); @@ -156,6 +182,7 @@ private static IResult Problem(IReadOnlyList errors) { ContentErrorCodes.EntryNotFound or SchemaErrorCodes.ContentTypeNotFound => StatusCodes.Status404NotFound, AgentPermissionErrorCodes.ScopeDenied => StatusCodes.Status403Forbidden, + DeliveryErrorCodes.PreviewTokenInvalid => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status400BadRequest, }; @@ -170,6 +197,8 @@ private static IResult Problem(IReadOnlyList errors) private sealed record PageResponse(IReadOnlyList Items, Guid? Next); + private sealed record ChangesResponse(Guid ChangeSet, IReadOnlyList Items); + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList Unresolved); private sealed record IndexResponse(IReadOnlyList Items); diff --git a/src/FluentCMS.Core/Delivery/IPreviewTokens.cs b/src/FluentCMS.Core/Delivery/IPreviewTokens.cs new file mode 100644 index 000000000..9616c0710 --- /dev/null +++ b/src/FluentCMS.Core/Delivery/IPreviewTokens.cs @@ -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> IssueAsync(Guid changeSetId, CancellationToken cancellationToken = default); + + Task RevokeAsync(Guid tokenId, CancellationToken cancellationToken = default); + + // Newest first, without their secrets. + Task>> 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> GetAsync(string token, Guid entryId, CancellationToken cancellationToken = default); + + // Every entry the set creates or updates, in the order of its operations. + Task> 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 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 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 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)); + } +} diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 750fc279e..687d302fd 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -48,4 +48,8 @@ public static class DeliveryErrorCodes { public const string LimitInvalid = "limit_invalid"; public const string ParameterInvalid = "parameter_invalid"; + 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 ChangeSetNotPreviewable = "change_set_not_previewable"; } diff --git a/src/FluentCMS.Infrastructure/Content/EntryResolution.cs b/src/FluentCMS.Infrastructure/Content/EntryResolution.cs index f1bd9cc8d..6f6f3e650 100644 --- a/src/FluentCMS.Infrastructure/Content/EntryResolution.cs +++ b/src/FluentCMS.Infrastructure/Content/EntryResolution.cs @@ -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> ResolveAsync( FluentCmsDbContext context, IReadOnlyList versions, + IReadOnlyDictionary entityVersions, CancellationToken cancellationToken) { if (versions.Count == 0) @@ -49,7 +51,11 @@ public static async Task> 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 => { @@ -76,10 +82,11 @@ public static async Task> 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> PublishedFieldsAsync( FluentCmsDbContext context, List ids, + IReadOnlyDictionary entityVersions, CancellationToken cancellationToken) { if (ids.Count == 0) @@ -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(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().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(pair.Value.Fields))); } // An entity stores each field as a fact; the view shows the fact's value (SCH-03). diff --git a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs index f762663f9..68a821b1d 100644 --- a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs +++ b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs @@ -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 NoProposals = []; + public async Task> ResolveAsync(Guid entryId, int? version = null, CancellationToken cancellationToken = default) { var entry = await ( @@ -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.Success(resolved[0]); } } diff --git a/src/FluentCMS.Infrastructure/Delivery/DeliveryOptions.cs b/src/FluentCMS.Infrastructure/Delivery/DeliveryOptions.cs new file mode 100644 index 000000000..39822af8f --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/DeliveryOptions.cs @@ -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 +{ + 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."); + } +} diff --git a/src/FluentCMS.Infrastructure/Delivery/PreviewTokenConfiguration.cs b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenConfiguration.cs new file mode 100644 index 000000000..ef5d00975 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenConfiguration.cs @@ -0,0 +1,23 @@ +using FluentCMS.Infrastructure.ChangeSets; +using FluentCMS.Infrastructure.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Delivery; + +internal sealed class PreviewTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("PreviewTokens"); + row.HasKey(token => token.Id); + row.Property(token => token.Id).ValueGeneratedNever(); + row.Property(token => token.TokenHash).IsRequired(); + + row.HasOne().WithMany().HasForeignKey(token => token.SiteId).OnDelete(DeleteBehavior.Restrict); + row.HasOne().WithMany().HasForeignKey(token => token.ChangeSetId).OnDelete(DeleteBehavior.Restrict); + + // A presented token is found by its hash alone. + row.HasIndex(token => token.TokenHash).IsUnique(); + } +} diff --git a/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs new file mode 100644 index 000000000..2166ca4bf --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs @@ -0,0 +1,135 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Infrastructure.Delivery; + +// Expected failures are found before the unit of work begins, as in the other directories. +internal sealed class PreviewTokenDirectory( + FluentCmsDbContext context, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + ICurrentInitiator currentInitiator, + IOptions options, + TimeProvider clock) : IPreviewTokens +{ + private const string SubjectType = "preview_token"; + + // Until multi-site (MST-01), every change set belongs to the one site (SCH-07). + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> IssueAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + if (RefuseAllButPeople() is { } refused) + { + return Result.Failure(refused.Errors); + } + + var state = await StateAsync(changeSetId, cancellationToken); + if (state is null) + { + return Result.Failure(ChangeSetErrorCodes.ChangeSetNotFound, $"There is no change set {changeSetId} in this site."); + } + + if (!PreviewTokenKeys.Previewable.Contains(state.Value)) + { + return Result.Failure( + DeliveryErrorCodes.ChangeSetNotPreviewable, + $"Change set {changeSetId} is {state}, so there is nothing left to preview."); + } + + var now = clock.GetUtcNow(); + var token = PreviewTokenKeys.Generate(); + var row = new PreviewTokenRow + { + Id = Guid.CreateVersion7(now), + SiteId = SiteId, + ChangeSetId = changeSetId, + TokenHash = PreviewTokenKeys.Hash(token), + CreatedAt = now, + ExpiresAt = now + options.Value.PreviewTokenLifetime, + }; + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + context.PreviewTokens.Add(row); + await auditLog.AppendAsync( + new NewAuditEvent("preview_token.issued") { SubjectType = SubjectType, SubjectId = row.Id, ChangeSetId = changeSetId }, + cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(new IssuedPreviewToken(row.Id, changeSetId, token, row.ExpiresAt)); + } + + public async Task RevokeAsync(Guid tokenId, CancellationToken cancellationToken = default) + { + if (RefuseAllButPeople() is { } refused) + { + return refused; + } + + var row = await context.PreviewTokens.AsNoTracking().SingleOrDefaultAsync(token => token.Id == tokenId && token.SiteId == SiteId, cancellationToken); + if (row is null) + { + return Result.Failure(DeliveryErrorCodes.PreviewTokenNotFound, $"There is no preview token {tokenId} in this site."); + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + // Only a token nobody revoked in the meantime is updated, so of two concurrent revokes the second changes nothing. + var revoked = await context.PreviewTokens + .Where(token => token.Id == tokenId && token.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters.SetProperty(token => token.RevokedAt, clock.GetUtcNow()), cancellationToken); + if (revoked == 0) + { + return Result.Failure(DeliveryErrorCodes.PreviewTokenNotFound, $"Preview token {tokenId} is already revoked."); + } + + await auditLog.AppendAsync( + new NewAuditEvent("preview_token.revoked") { SubjectType = SubjectType, SubjectId = tokenId, ChangeSetId = row.ChangeSetId }, + cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(); + } + + public async Task>> ListAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + if (await StateAsync(changeSetId, cancellationToken) is null) + { + return Result>.Failure(ChangeSetErrorCodes.ChangeSetNotFound, $"There is no change set {changeSetId} in this site."); + } + + var rows = await context.PreviewTokens + .AsNoTracking() + .Where(token => token.ChangeSetId == changeSetId) + .OrderByDescending(token => token.Id) + .ToListAsync(cancellationToken); + + return Result>.Success( + [.. rows.Select(row => new PreviewToken(row.Id, row.ChangeSetId, row.CreatedAt, row.ExpiresAt, row.RevokedAt))]); + } + + // A token shows unapproved content to whoever holds it, so only a person hands one out (API-04). + private Result? RefuseAllButPeople() + { + return currentInitiator.Current is UserInitiator + ? null + : Result.Failure(DeliveryErrorCodes.PreviewByPeopleOnly, "Only a person can issue or revoke a preview token; an agent cannot."); + } + + private Task StateAsync(Guid changeSetId, CancellationToken cancellationToken) + { + return context.ChangeSets + .AsNoTracking() + .Where(set => set.Id == changeSetId && set.SiteId == SiteId) + .Select(set => (ChangeSetState?)set.State) + .SingleOrDefaultAsync(cancellationToken); + } +} diff --git a/src/FluentCMS.Infrastructure/Delivery/PreviewTokenRow.cs b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenRow.cs new file mode 100644 index 000000000..9198df8db --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenRow.cs @@ -0,0 +1,19 @@ +namespace FluentCMS.Infrastructure.Delivery; + +// A preview token of a change set (API-04). Only the token's SHA-256 is stored, so a stolen table reveals no token. +public sealed class PreviewTokenRow +{ + public Guid Id { get; set; } + + public Guid SiteId { get; set; } + + public Guid ChangeSetId { get; set; } + + public byte[] TokenHash { get; set; } = []; + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index bcd9616c7..cab769af4 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; using FluentCMS.Core.Results; @@ -12,11 +13,14 @@ namespace FluentCMS.Infrastructure.Delivery; // An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. -internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions) : IPublishedContent +internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions, TimeProvider clock) + : IPublishedContent, IPreviewContent { // 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 NoProposals = []; + public async Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); @@ -65,6 +69,7 @@ public async Task> ListAsync(PublishedQuery query, Cancell var resolved = await EntryResolution.ResolveAsync( context, [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], + NoProposals, cancellationToken); return Result.Success(new PublishedPage( @@ -72,27 +77,48 @@ [.. resolved.Select(entry => Item(entry, type))], rows.Count > query.Limit ? page[^1].Id : null)); } - public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await context.Entries - .AsNoTracking() - .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) - .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) - .SingleOrDefaultAsync(cancellationToken); - if (entry is null) - { - return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); - } + return ReadAsync(id, proposals: null, cancellationToken); + } - var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; - var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); - if (!allowed.IsSuccess) + public async Task> GetAsync(string token, Guid entryId, CancellationToken cancellationToken = default) + { + var proposals = await ProposalsAsync(token, cancellationToken); + return proposals is null ? PreviewRefused() : await ReadAsync(entryId, proposals, cancellationToken); + } + + public async Task> ChangesAsync(string token, CancellationToken cancellationToken = default) + { + var proposals = await ProposalsAsync(token, cancellationToken); + if (proposals is null) { - return Result.Failure(allowed.Errors); + return PreviewRefused(); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0], type)); + var changed = proposals.Items.Where(id => proposals.Versions[id] is not null).ToList(); + var entries = await context.Entries + .AsNoTracking() + .Where(row => changed.Contains(row.Id) && row.SiteId == SiteId && row.DeletedAt == null) + .Select(row => new { row.Id, row.ContentTypeId }) + .ToListAsync(cancellationToken); + + var types = await TypesAsync(null, null, cancellationToken); + var readable = await permissions.ReadableTypesAsync(cancellationToken); + var shown = changed + .Join(entries, id => id, entry => entry.Id, (id, entry) => (Id: id, Type: types[entry.ContentTypeId])) + .Where(entry => readable(entry.Type.Slug)) + .ToList(); + + var resolved = await EntryResolution.ResolveAsync( + context, + [.. shown.Select(entry => new VersionToResolve(entry.Id, entry.Type.Slug, proposals.Versions[entry.Id]!.Value))], + proposals.Versions, + cancellationToken); + + return Result.Success(new PreviewChanges( + proposals.ChangeSetId, + [.. resolved.Select((entry, index) => Item(entry, shown[index].Type))])); } public async Task> IndexAsync(CancellationToken cancellationToken = default) @@ -128,6 +154,77 @@ join version in context.EntryVersions.AsNoTracking() .ToList(); } + // A set's proposal wins over the published version, and a set that unpublishes the entry hides it, as publishing would. + private async Task> ReadAsync(Guid id, Proposals? proposals, CancellationToken cancellationToken) + { + var entry = await context.Entries + .AsNoTracking() + .Where(row => row.Id == id && row.SiteId == SiteId && row.DeletedAt == null) + .Select(row => new { row.ContentTypeId, row.PublishedVersion }) + .SingleOrDefaultAsync(cancellationToken); + var version = proposals is not null && proposals.Versions.TryGetValue(id, out var proposed) ? proposed : entry?.PublishedVersion; + if (entry is null || version is null) + { + return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); + } + + var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; + var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var resolved = await EntryResolution.ResolveAsync( + context, + [new VersionToResolve(id, type.Slug, version.Value)], + proposals?.Versions ?? NoProposals, + cancellationToken); + return Result.Success(Item(resolved[0], type)); + } + + // The entry and entity versions the token's set proposes, or null when the token does not work (API-04). + private async Task ProposalsAsync(string token, CancellationToken cancellationToken) + { + if (!PreviewTokenKeys.IsWellFormed(token)) + { + return null; + } + + var hash = PreviewTokenKeys.Hash(token); + var found = await ( + from preview in context.PreviewTokens.AsNoTracking() + join set in context.ChangeSets.AsNoTracking() on preview.ChangeSetId equals set.Id + where preview.TokenHash == hash && preview.SiteId == SiteId + select new { preview.ExpiresAt, preview.RevokedAt, set.Id, set.State }) + .SingleOrDefaultAsync(cancellationToken); + + // Compared here, because SQLite cannot compare stored offsets in a query. + if (found is null || found.RevokedAt is not null || found.ExpiresAt <= clock.GetUtcNow() || !PreviewTokenKeys.Previewable.Contains(found.State)) + { + return null; + } + + var operations = await context.ChangeSetOperations + .AsNoTracking() + .Where(operation => operation.ChangeSetId == found.Id && (operation.ItemKind == ItemKind.Entry || operation.ItemKind == ItemKind.Entity)) + .OrderBy(operation => operation.Position) + .Select(operation => new { operation.ItemId, operation.ProposedVersion }) + .ToListAsync(cancellationToken); + + return new Proposals( + found.Id, + [.. operations.Select(operation => operation.ItemId)], + operations.ToDictionary(operation => operation.ItemId, operation => operation.ProposedVersion)); + } + + private static Result PreviewRefused() + { + return Result.Failure( + DeliveryErrorCodes.PreviewTokenInvalid, + "The preview token does not work: it is unknown, expired or revoked, or its change set was published, rejected or reverted."); + } + // The site's content types with their current definitions, which a published version is read against: every type, the one // with the slug, or the one with the id. private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) @@ -160,4 +257,7 @@ private static PublishedItem Item(ResolvedEntry entry, PublishedType type) } private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); + + // Items are in the order of the set's operations; a null version is an unpublish. + private sealed record Proposals(Guid ChangeSetId, IReadOnlyList Items, IReadOnlyDictionary Versions); } diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index c55faddeb..6a2933366 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -117,7 +117,12 @@ private static void AddContent(IServiceCollection services, IConfiguration confi services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddOptions().Bind(configuration.GetSection(DeliveryOptions.SectionName)).ValidateOnStart(); + services.AddSingleton, DeliveryOptionsValidation>(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index d5c436710..6ac74b024 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -3,6 +3,7 @@ using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Checks; using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Delivery; using FluentCMS.Infrastructure.Entities; using FluentCMS.Infrastructure.Jobs; using FluentCMS.Infrastructure.Models; @@ -49,6 +50,8 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet ModelCalls => Set(); + public DbSet PreviewTokens => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -69,6 +72,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new CheckVersionConfiguration()); builder.ApplyConfiguration(new JobConfiguration()); builder.ApplyConfiguration(new ModelCallConfiguration()); + builder.ApplyConfiguration(new PreviewTokenConfiguration()); builder.Entity(user => { diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.Designer.cs new file mode 100644 index 000000000..66e671bf6 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.Designer.cs @@ -0,0 +1,1240 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019040617_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.cs new file mode 100644 index 000000000..7dca2ca5e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019040617_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 550af8e75..ba36080ee 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -486,6 +486,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1095,6 +1131,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.Designer.cs new file mode 100644 index 000000000..e3b1b6838 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.Designer.cs @@ -0,0 +1,1230 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019040601_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.cs new file mode 100644 index 000000000..c2b75798b --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019040601_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SiteId = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "BLOB", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 02a71e309..c204e791b 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -480,6 +480,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1085,6 +1121,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index fc7cbe55e..32fce8ae3 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -115,6 +115,39 @@ public async Task The_host_serves_the_api_to_anonymous_readers_from_the_database await AssertProblemAsync(badLimit, HttpStatusCode.BadRequest, DeliveryErrorCodes.LimitInvalid); } + [Fact] + public async Task A_preview_reads_with_the_bearer_token_and_is_never_cached() + { + await using var api = await StubApi.StartAsync(new StubContent()); + using var entry = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/preview/entries/{Page}"); + entry.Headers.Authorization = new("Bearer", StubPreview.Token); + using var changes = new HttpRequestMessage(HttpMethod.Get, "/api/v1/preview/changes"); + changes.Headers.Authorization = new("Bearer", StubPreview.Token); + + var entryResponse = await api.Client.SendAsync(entry); + var changesResponse = await api.Client.SendAsync(changes); + + Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode); + Assert.True(entryResponse.Headers.CacheControl?.NoStore); + Assert.Equal(Page.ToString(), (string?)(await entryResponse.Content.ReadFromJsonAsync())!["id"]); + var body = (await changesResponse.Content.ReadFromJsonAsync())!; + Assert.Equal((StubPreview.ChangeSet.ToString(), 1), ((string?)body["changeSet"], body["items"]!.AsArray().Count)); + } + + [Fact] + public async Task A_preview_without_a_working_token_is_a_problem_with_401() + { + await using var api = await StubApi.StartAsync(new StubContent()); + using var wrong = new HttpRequestMessage(HttpMethod.Get, "/api/v1/preview/changes"); + wrong.Headers.Authorization = new("Bearer", "fcmsp_wrong"); + + var missing = await api.Client.GetAsync($"/api/v1/preview/entries/{Page}"); + var refused = await api.Client.SendAsync(wrong); + + await AssertProblemAsync(missing, HttpStatusCode.Unauthorized, DeliveryErrorCodes.PreviewTokenInvalid); + await AssertProblemAsync(refused, HttpStatusCode.Unauthorized, DeliveryErrorCodes.PreviewTokenInvalid); + } + private static async Task AssertProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) { Assert.Equal(status, response.StatusCode); @@ -171,6 +204,31 @@ private static PublishedItem Item() } } + // Works for one token only, as a preview of a set that changes the page. + private sealed class StubPreview : IPreviewContent + { + public const string Token = "fcmsp_works"; + + public static readonly Guid ChangeSet = Guid.CreateVersion7(); + + public async Task> GetAsync(string token, Guid entryId, CancellationToken cancellationToken = default) + { + return token == Token ? await new StubContent().GetAsync(entryId, cancellationToken) : Refused(); + } + + public async Task> ChangesAsync(string token, CancellationToken cancellationToken = default) + { + return token == Token + ? Result.Success(new PreviewChanges(ChangeSet, [(await new StubContent().GetAsync(Page, cancellationToken)).Value])) + : Refused(); + } + + private static Result Refused() + { + return Result.Failure(DeliveryErrorCodes.PreviewTokenInvalid, "The preview token does not work."); + } + } + // The endpoints alone on a free port, over the content given. private sealed class StubApi(WebApplication app) : IAsyncDisposable { @@ -181,6 +239,7 @@ public static async Task StartAsync(IPublishedContent content) var builder = WebApplication.CreateSlimBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddSingleton(content); + builder.Services.AddSingleton(new StubPreview()); var app = builder.Build(); app.MapFluentCmsApi(); await app.StartAsync(); diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs new file mode 100644 index 000000000..bfb77c2b0 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs @@ -0,0 +1,177 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.ChangeSets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; +using static FluentCMS.IntegrationTests.ChangeSets.EntryChangeSetTests; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class PreviewTests +{ + // S1 with API-04: a frontend previews the new price on the page before anyone approves the set, and the token ends with it. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_token_shows_the_set_as_it_would_publish_until_the_set_publishes(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (page, product) = await LiveCatalogAsync(scenario); + var set = await scenario.OpenAsync( + Operation(OperationKind.Update, ItemKind.Entity, product, 1, 2), + Operation(OperationKind.Update, ItemKind.Entry, page, 1, 2)); + var token = (await TokensAsync(scenario, tokens => tokens.IssueAsync(set.Id))).Value; + + var previewed = (await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, page))).Value; + var changes = (await PreviewAsync(scenario, preview => preview.ChangesAsync(token.Token))).Value; + var published = (await PublishedAsync(scenario, content => content.GetAsync(page))).Value; + + Assert.Equal(2, previewed.Version); + AssertSameFields("""{"title": "Widget, now cheaper", "price": 24.99}""", previewed.Fields); + Assert.Equal(set.Id, changes.ChangeSetId); + Assert.Equal([(product, 2), (page, 2)], changes.Items.Select(item => (item.Id, item.Version))); + AssertSameFields("""{"title": "Widget", "price": 19.99}""", published.Fields); + + await scenario.PublishAsync(set.Id); + + Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, page)))); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_token_stops_working_when_revoked_when_its_set_is_rejected_and_when_it_expires(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (page, _) = await LiveCatalogAsync(scenario); + var set = await scenario.OpenAsync(Operation(OperationKind.Update, ItemKind.Entry, page, 1, 2)); + var revoked = (await TokensAsync(scenario, tokens => tokens.IssueAsync(set.Id))).Value; + var expiring = (await TokensAsync(scenario, tokens => tokens.IssueAsync(set.Id))).Value; + var unused = (await TokensAsync(scenario, tokens => tokens.IssueAsync(set.Id))).Value; + + Assert.True((await TokensAsync(scenario, tokens => tokens.RevokeAsync(revoked.Id))).IsSuccess); + Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.GetAsync(revoked.Token, page)))); + Assert.True((await PreviewAsync(scenario, preview => preview.GetAsync(expiring.Token, page))).IsSuccess); + Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.GetAsync(PreviewTokenKeys.Generate(), page)))); + + scenario.AdvanceClock(TimeSpan.FromDays(7)); + Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.GetAsync(expiring.Token, page)))); + + var second = await scenario.OpenAsync(Operation(OperationKind.Update, ItemKind.Entry, page, 1, 2)); + var onRejected = (await TokensAsync(scenario, tokens => tokens.IssueAsync(second.Id))).Value; + Assert.True((await PreviewAsync(scenario, preview => preview.ChangesAsync(onRejected.Token))).IsSuccess); + await scenario.RunAsync(directory => directory.TransitionAsync(second.Id, ChangeSetState.ChecksRunning)); + await scenario.AsPersonAsync(directory => directory.TransitionAsync(second.Id, ChangeSetState.AwaitingReview)); + Assert.True((await scenario.AsPersonAsync(directory => directory.RejectAsync(second.Id, "The headline is off."))).IsSuccess); + + Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.ChangesAsync(onRejected.Token)))); + Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.ChangesAsync("not a token")))); + + var listed = (await TokensAsync(scenario, tokens => tokens.ListAsync(set.Id))).Value; + Assert.Equal([unused.Id, expiring.Id, revoked.Id], listed.Select(token => token.Id)); + Assert.NotNull(listed[^1].RevokedAt); + var events = await scenario.QueryAsync(context => context.AuditEvents.Where(row => row.SubjectType == "preview_token").ToListAsync()); + Assert.Equal(4, events.Count(row => row.Operation == "preview_token.issued")); + var revokedEvent = Assert.Single(events, row => row.Operation == "preview_token.revoked"); + Assert.Equal((revoked.Id, set.Id), (revokedEvent.SubjectId!.Value, revokedEvent.ChangeSetId!.Value)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Only_a_person_issues_a_token_and_only_for_a_set_that_can_still_publish(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (page, _) = await LiveCatalogAsync(scenario); + var published = await scenario.PublishAsync(Operation(OperationKind.Update, ItemKind.Entry, page, 1, 2)); + var open = await scenario.OpenAsync(); + var token = (await TokensAsync(scenario, tokens => tokens.IssueAsync(open.Id))).Value; + + var ended = await TokensAsync(scenario, tokens => tokens.IssueAsync(published.Id)); + var unknown = await TokensAsync(scenario, tokens => tokens.IssueAsync(Guid.CreateVersion7())); + var unknownRevoke = await TokensAsync(scenario, tokens => tokens.RevokeAsync(Guid.CreateVersion7())); + scenario.Initiator = Agent; + var byAgent = await TokensAsync(scenario, tokens => tokens.IssueAsync(open.Id)); + var revokeByAgent = await TokensAsync(scenario, tokens => tokens.RevokeAsync(token.Id)); + + Assert.Equal(DeliveryErrorCodes.ChangeSetNotPreviewable, Code(ended)); + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Code(unknown)); + Assert.Equal(DeliveryErrorCodes.PreviewTokenNotFound, Assert.Single(unknownRevoke.Errors).Code); + Assert.Equal(DeliveryErrorCodes.PreviewByPeopleOnly, Code(byAgent)); + Assert.Equal(DeliveryErrorCodes.PreviewByPeopleOnly, Assert.Single(revokeByAgent.Errors).Code); + Assert.StartsWith(PreviewTokenKeys.Prefix, token.Token, StringComparison.Ordinal); + Assert.Equal(1, await scenario.QueryAsync(context => context.PreviewTokens.CountAsync())); + Assert.Equal( + PreviewTokenKeys.Hash(token.Token), + await scenario.QueryAsync(context => context.PreviewTokens.Select(row => row.TokenHash).SingleAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task What_the_set_unpublishes_is_gone_and_what_it_leaves_reads_as_published(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (page, product) = await LiveCatalogAsync(scenario); + var draft = (await EntriesAsync(scenario, entries => entries.CreateAsync(new NewEntry("page", Json("""{"title": "Draft"}"""))))).Value.Id; + var set = await scenario.OpenAsync(Operation(OperationKind.Delete, ItemKind.Entity, product, 1, null)); + var token = (await TokensAsync(scenario, tokens => tokens.IssueAsync(set.Id))).Value; + + var shown = (await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, page))).Value; + var unpublished = await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, product)); + var hidden = await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, draft)); + var changes = (await PreviewAsync(scenario, preview => preview.ChangesAsync(token.Token))).Value; + + Assert.Equal(1, shown.Version); + AssertSameFields("""{"title": "Widget", "price": null}""", shown.Fields); + Assert.Equal(UnresolvedReason.NotPublished, Assert.Single(shown.Unresolved).Reason); + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(unpublished)); + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(hidden)); + Assert.Empty(changes.Items); + } + + // The catalog published at version 1, with version 2 of both stored: the product at 24.99 and the page with a new title. + private static async Task<(Guid Page, Guid Product)> LiveCatalogAsync(ChangeSetScenario scenario) + { + var (page, product) = await CatalogAsync(scenario); + await scenario.PublishAsync( + Operation(OperationKind.Create, ItemKind.Entity, product, null, 1), + Operation(OperationKind.Create, ItemKind.Entry, page, null, 1)); + Assert.True((await EntriesAsync(scenario, entries => entries.UpdateAsync(product, 1, Json(ProductFields("24.99"))))).IsSuccess); + await UpdatePageAsync(scenario, page, 1, "Widget, now cheaper"); + return (page, product); + } + + private static ChangeOperation Operation(OperationKind kind, ItemKind itemKind, Guid item, int? baseVersion, int? proposedVersion) + { + return ChangeOperation.Create(kind, itemKind, item, baseVersion, proposedVersion).Value; + } + + private static string Code(Result result) + { + return Assert.Single(result.Errors).Code; + } + + private static async Task TokensAsync(ChangeSetScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + private static async Task PreviewAsync(ChangeSetScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + private static async Task PublishedAsync(ChangeSetScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } +} From ec34d2fe70b25f9ffeae3acfec0817bb036112a6 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:20:33 -0400 Subject: [PATCH 04/15] Rate limit the delivery API and check the read performance target --- CONTRIBUTING.md | 2 +- docs/api/delivery.md | 5 + .../ApiServiceCollectionExtensions.cs | 12 +- src/FluentCMS.Api/DeliveryEndpoints.cs | 10 +- src/FluentCMS.Api/DeliveryRateLimit.cs | 55 ++++++++ src/FluentCMS.Host/FluentCmsHost.cs | 2 +- .../Delivery/DeliveryHardeningTests.cs | 90 +++++++++++++ .../Delivery/DeliveryPerformanceTests.cs | 120 ++++++++++++++++++ 8 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 src/FluentCMS.Api/DeliveryRateLimit.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0eb997c16..8b5123069 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ The admin stylesheet is compiled by the Tailwind CSS standalone CLI (ADR-0011). Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped. -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) and `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root 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), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `Media__Storage` (the storage new uploads go to, `local` by default) and `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root 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. diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 7f1b62766..70c8f7443 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -84,6 +84,10 @@ References to entity fields resolve to the values the set proposes, so a page pr 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`. +## Limits + +Each client address may make 600 requests a minute to the delivery API, `/llms.txt` included (`Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window`). A request beyond that gets a 429 problem with `Retry-After`. + ## 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`: @@ -92,4 +96,5 @@ Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (` | --- | --- | | 400 | `limit_invalid`, `parameter_invalid` | | 401 | `preview_token_invalid` | +| 429 | `rate_limited` | | 404 | `entry_not_found`, `content_type_not_found` | diff --git a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs index 1e8c53a30..19cbd2e72 100644 --- a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs +++ b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs @@ -1,12 +1,20 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.Api; public static class ApiServiceCollectionExtensions { - // The delivery API reads through Core services that Infrastructure registers, so it adds none of its own yet. - public static IServiceCollection AddFluentCmsApi(this IServiceCollection services) + // The delivery API reads through Core services that Infrastructure registers; it adds its rate limit policy (SEC-07). + public static IServiceCollection AddFluentCmsApi(this IServiceCollection services, IConfiguration configuration) { + services.AddOptions() + .Bind(configuration.GetSection(DeliveryRateLimitOptions.SectionName)) + .Validate(options => options.PermitLimit >= 1 && options.Window > TimeSpan.Zero, "Delivery__RateLimit__PermitLimit must be at least 1 and Delivery__RateLimit__Window longer than zero.") + .ValidateOnStart(); + services.AddRateLimiter(options => options.AddPolicy(DeliveryRateLimit.PolicyName)); return services; } } diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 347f01716..b4e19ff58 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -21,7 +21,7 @@ public static class DeliveryEndpoints public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) { - var api = endpoints.MapGroup(Prefix).AllowAnonymous(); + var api = endpoints.MapGroup(Prefix).AllowAnonymous().RequireRateLimiting(DeliveryRateLimit.PolicyName); api.MapGet("/content/{type}", ListAsync); api.MapGet("/entries/{id:guid}", GetAsync); api.MapGet("/entries/{id:guid}.md", GetMarkdownAsync); @@ -31,7 +31,7 @@ public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpo api.MapGet("/preview/changes", PreviewChangesAsync); // Where agents look for a site's index first, by the llms.txt convention. - endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous(); + endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous().RequireRateLimiting(DeliveryRateLimit.PolicyName); return api; } @@ -53,6 +53,12 @@ private static async Task ListAsync( IPublishedContent content, CancellationToken cancellationToken) { + // Not a slug, so no content type has it, and the database is not asked. + if (!SchemaNames.IsSlug(type)) + { + return Problem([new Error(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{type}' in this site.")]); + } + var errors = new List(); var size = PublishedQuery.DefaultLimit; if (limit is not null && !int.TryParse(limit, NumberStyles.None, CultureInfo.InvariantCulture, out size)) diff --git a/src/FluentCMS.Api/DeliveryRateLimit.cs b/src/FluentCMS.Api/DeliveryRateLimit.cs new file mode 100644 index 000000000..b3193fdd4 --- /dev/null +++ b/src/FluentCMS.Api/DeliveryRateLimit.cs @@ -0,0 +1,55 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Api; + +public sealed class DeliveryRateLimitOptions +{ + public const string SectionName = "Delivery:RateLimit"; + + public int PermitLimit { get; set; } = 600; + + public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1); +} + +// Limits the delivery API per client address (SEC-07). Its own rejection answers with a problem, so a throttled client reads the +// same shape as every other API error, not the plain text the sign-in forms get. +public sealed class DeliveryRateLimit : IRateLimiterPolicy +{ + public const string PolicyName = "delivery"; + + public const string RateLimited = "rate_limited"; + + public Func? OnRejected { get; } = RejectAsync; + + public RateLimitPartition GetPartition(HttpContext httpContext) + { + var options = httpContext.RequestServices.GetRequiredService>().Value; + return RateLimitPartition.GetFixedWindowLimiter( + httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = options.PermitLimit, Window = options.Window, QueueLimit = 0 }); + } + + private static async ValueTask RejectAsync(OnRejectedContext context, CancellationToken cancellationToken) + { + var http = context.HttpContext; + http.Response.StatusCode = StatusCodes.Status429TooManyRequests; + var retry = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter) ? (int)Math.Ceiling(retryAfter.TotalSeconds) : (int?)null; + if (retry is { } seconds) + { + http.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture); + } + + await Results.Problem( + statusCode: StatusCodes.Status429TooManyRequests, + detail: retry is { } wait ? $"Too many requests from this address; try again in {wait} seconds." : "Too many requests from this address; try again shortly.", + extensions: new Dictionary + { + ["errors"] = new[] { new { Code = RateLimited, Message = "Too many requests from this address." } }, + }).ExecuteAsync(http); + } +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index f3d39ee5c..2447c887e 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -191,7 +191,7 @@ private static void AddFluentCms(IServiceCollection services, IConfiguration con { services.AddFluentCmsCore(); services.AddFluentCmsInfrastructure(configuration); - services.AddFluentCmsApi(); + services.AddFluentCmsApi(configuration); services.AddFluentCmsAdmin(); // Both modes share one key ring, so whatever the web host protects the stdio host can read. diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs new file mode 100644 index 000000000..ff3c2ca42 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using FluentCMS.Api; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class DeliveryHardeningTests +{ + [Fact] + public async Task Requests_beyond_the_limit_get_a_problem_with_retry_after_while_the_admin_stays_reachable() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, settings: new Dictionary + { + ["Delivery:RateLimit:PermitLimit"] = "3", + ["Delivery:RateLimit:Window"] = "00:01:00", + }); + + for (var request = 0; request < 3; request++) + { + Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}")).StatusCode); + } + + var throttled = await host.Client.GetAsync("/api/v1/index"); + var llms = await host.Client.GetAsync("/llms.txt"); + + Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode); + Assert.NotNull(throttled.Headers.RetryAfter); + Assert.Equal("application/problem+json", throttled.Content.Headers.ContentType?.MediaType); + Assert.Equal(DeliveryRateLimit.RateLimited, (string?)(await throttled.Content.ReadFromJsonAsync())!["errors"]![0]!["code"]); + Assert.Equal(HttpStatusCode.TooManyRequests, llms.StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.Client.GetAsync("/health")).StatusCode); + } + + [Fact] + public void Each_client_address_has_its_own_limit() + { + var services = new ServiceCollection() + .AddSingleton(Options.Create(new DeliveryRateLimitOptions { PermitLimit = 1 })) + .BuildServiceProvider(); + var policy = new DeliveryRateLimit(); + + HttpContext From(string address) + { + var context = new DefaultHttpContext { RequestServices = services }; + context.Connection.RemoteIpAddress = IPAddress.Parse(address); + return context; + } + + var first = policy.GetPartition(From("203.0.113.7")); + var again = policy.GetPartition(From("203.0.113.7")); + var other = policy.GetPartition(From("198.51.100.2")); + + Assert.Equal(first.PartitionKey, again.PartitionKey); + Assert.NotEqual(first.PartitionKey, other.PartitionKey); + } + + [Theory] + [InlineData("Product-Page")] + [InlineData("product_page")] + [InlineData("a%20b")] + public async Task A_type_that_cannot_be_a_slug_is_not_found_and_responses_are_not_sniffed(string type) + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + + var response = await host.Client.GetAsync($"/api/v1/content/{type}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, (string?)(await response.Content.ReadFromJsonAsync())!["errors"]![0]!["code"]); + Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single()); + Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single()); + } + + [Fact] + public async Task A_limit_no_request_can_meet_stops_the_host() + { + using var install = new TemporaryInstall(); + + var exception = await Assert.ThrowsAsync( + () => AdminHost.StartAsync(install, settings: new Dictionary { ["Delivery:RateLimit:PermitLimit"] = "0" })); + + Assert.Contains("Delivery__RateLimit__PermitLimit", exception.Message); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs new file mode 100644 index 000000000..d2d098489 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class DeliveryPerformanceTests +{ + private const int Products = 50; + + private const int Entries = 10_000; + + private const int Reads = 200; + + // PERF-03: one resolved entry, a product page whose price comes from its product, reads under 100 ms at p95 with 10,000 entries + // loaded. The pages are written straight to the tables, as the directory would store them, so loading them takes seconds. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task One_resolved_entry_reads_under_100_ms_at_p95_with_10000_entries_loaded(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + await scenario.Schema.CreateReferenceModelAsync(); + var products = new List(); + for (var number = 0; number < Products; number++) + { + var product = (await scenario.CreateAsync("product", $$"""{"name": {{Fact($"\"Product {number}\"")}}, "price": {{Fact($"{number + 10}")}}}""")).Value.Id; + products.Add(product); + } + + var pages = await LoadPagesAsync(scenario, products, Entries - Products); + await scenario.Schema.QueryAsync(context => context.Entries + .Where(row => products.Contains(row.Id)) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, 1))); + + var random = new Random(2026); + var timings = new List(); + for (var read = 0; read < Reads + 10; read++) + { + var page = pages[random.Next(pages.Count)]; + await using var scope = scenario.Services.CreateAsyncScope(); + var content = scope.ServiceProvider.GetRequiredService(); + + var watch = Stopwatch.StartNew(); + var item = await content.GetAsync(page); + watch.Stop(); + + Assert.True(item.IsSuccess); + Assert.Empty(item.Value.Unresolved); + + // The first reads warm the connection pool and the query caches, which every running host has done long before. + if (read >= 10) + { + timings.Add(watch.Elapsed.TotalMilliseconds); + } + } + + Assert.Equal(Entries, await scenario.Schema.QueryAsync(context => context.Entries.CountAsync())); + var p95 = timings.Order().ElementAt((int)Math.Ceiling(timings.Count * 0.95) - 1); + Assert.True(p95 < 100, $"p95 of one resolved entry read was {p95:F1} ms on {provider}."); + } + + private static async Task> LoadPagesAsync(EntryScenario scenario, List products, int count) + { + var type = await scenario.Schema.QueryAsync(context => context.ContentTypes.SingleAsync(row => row.Slug == "product-page")); + var pages = new List(); + var now = DateTimeOffset.UtcNow; + + for (var batch = 0; batch < count; batch += 1000) + { + await scenario.Schema.QueryAsync(async context => + { + for (var number = batch; number < Math.Min(batch + 1000, count); number++) + { + var id = Guid.CreateVersion7(); + var product = products[number % products.Count]; + pages.Add(id); + context.Entries.Add(new EntryRow + { + Id = id, + SiteId = Site.Default.Id, + ContentTypeId = type.Id, + LatestVersion = 1, + PublishedVersion = 1, + State = EntryState.Published, + }); + context.EntryVersions.Add(new EntryVersionRow + { + Id = Guid.CreateVersion7(), + EntryId = id, + Version = 1, + ContentTypeVersion = type.Version, + Fields = $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Page {{number}}"}""", + CreatedAt = now, + }); + context.EntryReferences.Add(new EntryReferenceRow + { + Id = Guid.CreateVersion7(), EntryId = id, Version = 1, Path = "/product", TargetKind = ReferenceKind.Entry, TargetId = product, + }); + context.EntryReferences.Add(new EntryReferenceRow + { + Id = Guid.CreateVersion7(), EntryId = id, Version = 1, Path = "/price", TargetKind = ReferenceKind.Entry, TargetId = product, TargetField = "price", + }); + } + + return await context.SaveChangesAsync(); + }); + } + + return pages; + } +} From fc739d9012f1202ef0585f28ae54d01cdd6c9108 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:51:20 -0400 Subject: [PATCH 05/15] Serve each published entry as Markdown and add the site index, on the reviewed read endpoints --- docs/api/delivery.md | 18 ++++ src/FluentCMS.Api/DeliveryEndpoints.cs | 73 +++++++++++++- .../Delivery/IPublishedContent.cs | 17 +++- .../Delivery/PublishedContentReader.cs | 95 +++++++++++++++---- .../Delivery/DeliveryEndpointsTests.cs | 53 ++++++++++- .../Delivery/PublishedContentTests.cs | 34 +++++++ 6 files changed, 268 insertions(+), 22 deletions(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 5b8122d04..ec20b5a26 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -20,6 +20,9 @@ Entities are content too, so their fields hold facts as stored: each value with | --- | --- | | `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | | `GET /api/v1/entries/{id}` | One published entry. | +| `GET /api/v1/entries/{id}.md` | The same entry as Markdown. | +| `GET /api/v1/index` | Every published entry with its type, title and URLs, as JSON. | +| `GET /api/v1/index.md` and `GET /llms.txt` | The same index as Markdown, grouped by content type. | `GET /api/v1/content/{type}` takes these query parameters: @@ -39,6 +42,7 @@ A list looks like this: "type": "product-page", "version": 3, "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "markdown": "/api/v1/entries/0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e.md", "unresolved": [] } ], @@ -48,6 +52,20 @@ A list looks like this: `next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. +## Markdown and the site index + +Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version. + +- Front matter gives the id, type, version and URL. +- The heading is the entry's title: its first text field that holds text. +- Each other field follows under its name, in the order its content type defines them: + - rich text as written, since it is stored as Markdown; + - lists as bullets, and JSON in a fenced block; + - references to whole entries as links to their Markdown. +- On an entity, each fact's value is followed by its source. + +The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. + ## 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`: diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 230f668d9..6e42d0a31 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; @@ -16,19 +17,37 @@ public static class DeliveryEndpoints { public const string Prefix = "/api/v1"; + private const string Markdown = EntryMarkdown.MediaType + "; charset=utf-8"; + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) { var api = endpoints.MapGroup(Prefix).AllowAnonymous(); api.MapGet("/content/{type}", ListAsync); api.MapGet("/entries/{id}", GetAsync); + api.MapGet("/entries/{id}.md", GetMarkdownAsync); + api.MapGet("/index", IndexAsync); + api.MapGet("/index.md", IndexMarkdownAsync); // 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. api.MapGet("/{**path}", (string? path) => Problem( [new Error(DeliveryErrorCodes.EndpointNotFound, $"There is no delivery endpoint at {Prefix}/{path}; see docs/api/delivery.md.")])); + + // Where agents look for a site's index first, by the llms.txt convention. + endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous(); return api; } + public static string EntryUrl(Guid id) + { + return $"{Prefix}/entries/{id}"; + } + + public static string MarkdownUrl(Guid id) + { + return $"{Prefix}/entries/{id}.md"; + } + private static async Task ListAsync( string type, string? limit, @@ -69,6 +88,53 @@ private static async Task GetAsync(string id, IPublishedContent content return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); } + // The URL is the entry's id, so it stays the same across versions and always shows the published one (API-03). + private static async Task GetMarkdownAsync(string id, IPublishedContent content, CancellationToken cancellationToken) + { + var errors = new List(); + if (Id(id, "id", "the id of an entry", errors) is not { } entryId) + { + return Problem(errors); + } + + var item = await content.GetAsync(entryId, cancellationToken); + return item.IsSuccess + ? Results.Text(EntryMarkdown.Render(item.Value, MarkdownUrl(entryId), MarkdownUrl), Markdown) + : Problem(item.Errors); + } + + private static async Task IndexAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + return Results.Json(new IndexResponse([.. index.Select(entry => new IndexEntryResponse( + entry.Id, + entry.ContentType, + entry.Version, + entry.Title, + EntryUrl(entry.Id), + MarkdownUrl(entry.Id)))])); + } + + private static async Task IndexMarkdownAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + var markdown = new StringBuilder() + .Append("# Site index\n\n") + .Append(CultureInfo.InvariantCulture, $"Every published entry of this site, by content type, linked to its Markdown. The same index in JSON is at {Prefix}/index.\n"); + + foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName))) + { + markdown.Append(CultureInfo.InvariantCulture, $"\n## {type.Key.ContentTypeName}\n\n"); + foreach (var entry in type) + { + var title = (entry.Title ?? $"{entry.ContentTypeName} {entry.Id}").Replace("[", @"\[", StringComparison.Ordinal).Replace("]", @"\]", StringComparison.Ordinal); + markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n"); + } + } + + return Results.Text(markdown.ToString(), Markdown); + } + private static Guid? Id(string? value, string name, string form, List errors) { if (value is null) @@ -92,6 +158,7 @@ private static ItemResponse Item(PublishedItem item) item.ContentType, item.Version, item.Fields, + MarkdownUrl(item.Id), [.. item.Unresolved.Select(reference => new UnresolvedResponse( reference.Path, reference.EntityId, @@ -120,7 +187,11 @@ private static IResult Problem(IReadOnlyList errors) private sealed record PageResponse(IReadOnlyList Items, Guid? Next); - private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList Unresolved); + + private sealed record IndexResponse(IReadOnlyList Items); + + private sealed record IndexEntryResponse(Guid Id, string Type, int Version, string? Title, string Url, string Markdown); private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); } diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 8ebe61661..2fe67d547 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -1,6 +1,7 @@ using System.Text.Json; using FluentCMS.Core.Content; using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; namespace FluentCMS.Core.Delivery; @@ -13,6 +14,9 @@ public interface IPublishedContent Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); Task> GetAsync(Guid id, CancellationToken cancellationToken = default); + + // Every published entry of every type the caller may read, by type and then by id, for the site index (API-03). + Task> IndexAsync(CancellationToken cancellationToken = default); } // After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or @@ -27,7 +31,18 @@ public sealed record PublishedQuery(string ContentType, int Limit = PublishedQue // Next is null on the last page. public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); -public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); +// Title is EntryMarkdown.Title of the published version, null when it has no text. +public sealed record PublishedSummary(Guid Id, string ContentType, string ContentTypeName, int Version, string? Title); + +// The content type's name and current definition come along, since the published version is read against them. +public sealed record PublishedItem( + Guid Id, + string ContentType, + string ContentTypeName, + ContentTypeDefinition Definition, + int Version, + JsonElement Fields, + IReadOnlyList Unresolved); public static class DeliveryErrorCodes { diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index 43bc58ad3..bcd9616c7 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; @@ -33,19 +34,14 @@ public async Task> ListAsync(PublishedQuery query, Cancell return Result.Failure(allowed.Errors); } - var typeId = await context.ContentTypes - .AsNoTracking() - .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) - .Select(row => (Guid?)row.Id) - .SingleOrDefaultAsync(cancellationToken); - if (typeId is null) + if ((await TypesAsync(query.ContentType, null, cancellationToken)).Values.SingleOrDefault() is not { } type) { return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); } var entries = context.Entries .AsNoTracking() - .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == type.Id && entry.PublishedVersion != null && entry.DeletedAt == null); // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. if (query.After is { } after) @@ -68,39 +64,100 @@ public async Task> ListAsync(PublishedQuery query, Cancell var page = rows.Take(query.Limit).ToList(); var resolved = await EntryResolution.ResolveAsync( context, - [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], cancellationToken); return Result.Success(new PublishedPage( - [.. resolved.Select(Item)], + [.. resolved.Select(entry => Item(entry, type))], rows.Count > query.Limit ? page[^1].Id : null)); } public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await ( - from row in context.Entries.AsNoTracking() - join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id - where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null - select new { type.Slug, Version = row.PublishedVersion!.Value }) + var entry = await context.Entries + .AsNoTracking() + .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) + .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) .SingleOrDefaultAsync(cancellationToken); if (entry is null) { return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); } - var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; + var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0])); + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0], type)); + } + + public async Task> IndexAsync(CancellationToken cancellationToken = default) + { + var types = await TypesAsync(null, null, cancellationToken); + + // The stored documents, not resolved views: a title is a text field of the entry itself, never a resolved reference. + var rows = await ( + from entry in context.Entries.AsNoTracking() + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + where entry.SiteId == SiteId && entry.PublishedVersion != null && entry.DeletedAt == null + select new { entry.Id, entry.ContentTypeId, version.Version, version.Fields }) + .ToListAsync(cancellationToken); + + var readable = await permissions.ReadableTypesAsync(cancellationToken); + + return rows + .Select(row => (Row: row, Type: types[row.ContentTypeId])) + .Where(item => readable(item.Type.Slug)) + .Select(item => + { + using var fields = JsonDocument.Parse(item.Row.Fields); + return new PublishedSummary( + item.Row.Id, + item.Type.Slug, + item.Type.Name, + item.Row.Version, + EntryMarkdown.Title(fields.RootElement, item.Type.Definition)); + }) + .OrderBy(summary => summary.ContentType, StringComparer.Ordinal) + .ThenBy(summary => summary.Id) + .ToList(); + } + + // The site's content types with their current definitions, which a published version is read against: every type, the one + // with the slug, or the one with the id. + private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) + { + var rows = await ( + from type in context.ContentTypes.AsNoTracking() + join version in context.ContentTypeVersions.AsNoTracking() + on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } + where type.SiteId == SiteId && (slug == null || type.Slug == slug) && (id == null || type.Id == id) + select new { type.Id, type.Slug, type.Name, version.Definition }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(row => row.Id, row => new PublishedType(row.Id, row.Slug, row.Name, ReadDefinition(row.Slug, row.Definition))); } - private static PublishedItem Item(ResolvedEntry entry) + private static ContentTypeDefinition ReadDefinition(string slug, string json) { - return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + var definition = ContentTypeDefinition.Parse(json); + + return definition.IsSuccess + ? definition.Value + : throw new InvalidOperationException( + $"The current definition of content type '{slug}' is stored in a form that cannot be read: " + + string.Join("; ", definition.Errors.Select(error => error.Message))); } + + private static PublishedItem Item(ResolvedEntry entry, PublishedType type) + { + return new PublishedItem(entry.EntryId, type.Slug, type.Name, type.Definition, entry.Version, entry.Fields, entry.Unresolved); + } + + private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 6fb0bf28b..97bd7f26c 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -32,9 +32,43 @@ public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_c Assert.Equal(99, (int)item["fields"]!["price"]!); var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal($"/api/v1/entries/{Page}.md", (string?)item["markdown"]); Assert.Equal(Page.ToString(), (string?)response["next"]); } + [Fact] + public async Task An_entry_reads_as_markdown_at_its_id() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var response = await api.Client.GetAsync($"/api/v1/entries/{Page}.md"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}.md"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + var markdown = await response.Content.ReadAsStringAsync(); + Assert.StartsWith($"---\nid: {Page}\ntype: product-page\nversion: 3\nurl: /api/v1/entries/{Page}.md\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); + Assert.Contains($"[{Product}](/api/v1/entries/{Product}.md)", markdown, StringComparison.Ordinal); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + } + + [Fact] + public async Task The_site_index_lists_every_entry_in_json_markdown_and_llms_txt() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var json = await api.Client.GetFromJsonAsync("/api/v1/index"); + var markdown = await api.Client.GetStringAsync("/api/v1/index.md"); + var llms = await api.Client.GetStringAsync("/llms.txt"); + + var entries = json!["items"]!.AsArray(); + Assert.Equal([Product.ToString(), Page.ToString()], entries.Select(entry => (string?)entry!["id"])); + Assert.Equal(("Drill [cordless]", $"/api/v1/entries/{Product}", $"/api/v1/entries/{Product}.md"), ((string?)entries[0]!["title"], (string?)entries[0]!["url"], (string?)entries[0]!["markdown"])); + Assert.Contains($@"## Product{"\n\n"}- [Drill \[cordless\]](/api/v1/entries/{Product}.md){"\n"}", markdown, StringComparison.Ordinal); + Assert.Contains($"## Product page\n\n- [Product page {Page}](/api/v1/entries/{Page}.md)\n", markdown, StringComparison.Ordinal); + Assert.Equal(markdown, llms); + } + [Fact] public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() { @@ -84,6 +118,7 @@ public async Task The_host_serves_the_api_to_anonymous_readers_from_the_database // The admin's not-found page answers every other path of the host, so the API answers each of its own. [Theory] [InlineData("/api/v1/entries/not-a-guid", HttpStatusCode.BadRequest, DeliveryErrorCodes.ParameterInvalid)] + [InlineData("/api/v1/entries/not-a-guid.md", HttpStatusCode.BadRequest, DeliveryErrorCodes.ParameterInvalid)] [InlineData("/api/v1/entries/", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] [InlineData("/api/v1/content/", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] [InlineData("/api/v1/nothing/here", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] @@ -124,13 +159,29 @@ public Task> GetAsync(Guid id, CancellationToken cancellat : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); } + public Task> IndexAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>( + [ + new PublishedSummary(Product, "product", "Product", 1, "Drill [cordless]"), + new PublishedSummary(Page, "product-page", "Product page", 3, null), + ]); + } + private static PublishedItem Item() { return new PublishedItem( Page, "product-page", + "Product page", + new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", settings: JsonSerializer.Deserialize("""{"target": "product"}""")), + new FieldDefinition("price", "number"), + new FieldDefinition("headline", "text"), + ]), 3, - JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null, "headline": "A sturdy drill"}"""), [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); } } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index b49951b77..f2f77cce9 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -159,6 +159,40 @@ await scenario.PublishAsync( Assert.Equal([product], (await content.ListAsync(new PublishedQuery("product"))).Value.Items.Select(item => item.Id)); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_index_lists_each_published_entry_once_with_the_title_of_its_published_version(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var page = await PageAsync(scenario, product, "First headline"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + var post = (await scenario.CreateAsync("blog-post", """{"title": "Launch"}""")).Value.Id; + foreach (var entry in new[] { product, page, deleted, post }) + { + await PublishAsync(scenario, entry, 1); + } + + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Second headline"}"""); + + var before = await ContentAsync(scenario, content => content.IndexAsync()); + await PublishAsync(scenario, page, 2); + var after = await ContentAsync(scenario, content => content.IndexAsync()); + + Assert.Equal( + [ + new PublishedSummary(post, "blog-post", "Blog Post", 1, "Launch"), + new PublishedSummary(product, "product", "Product", 1, "Drill"), + new PublishedSummary(page, "product-page", "Product Page", 1, "First headline"), + ], + before); + Assert.Equal((2, "Second headline"), (after[^1].Version, after[^1].Title)); + Assert.DoesNotContain(after, summary => summary.Id == draft || summary.Id == deleted); + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); From 34717a8f77d11afbe5e0080d4647256988b6eb6d Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:53:47 -0400 Subject: [PATCH 06/15] Preview change sets with tokens, on the reviewed Markdown endpoints --- CONTRIBUTING.md | 2 +- src/FluentCMS.Api/DeliveryEndpoints.cs | 35 + .../Delivery/IPublishedContent.cs | 4 + .../20261019055338_PreviewTokens.Designer.cs | 1360 +++++++++++++++++ .../20261019055338_PreviewTokens.cs | 67 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 51 + .../20261019055323_PreviewTokens.Designer.cs | 1350 ++++++++++++++++ .../Sqlite/20261019055323_PreviewTokens.cs | 67 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 51 + .../Delivery/DeliveryEndpointsTests.cs | 59 + 10 files changed, 3045 insertions(+), 1 deletion(-) create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e6cb5805..4e7f0bf09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 6e42d0a31..8536d84e7 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -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. @@ -76,6 +78,36 @@ private static async Task ListAsync( : Problem(page.Errors); } + // Unpublished content is never cached on the way, so it disappears with the token (API-04). + private static async Task PreviewEntryAsync(string id, HttpContext http, IPreviewContent preview, CancellationToken cancellationToken) + { + http.Response.Headers.CacheControl = "no-store"; + var errors = new List(); + 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)) : Problem(item.Errors); + } + + private static async Task 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)])) + : Problem(changes.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 GetAsync(string id, IPublishedContent content, CancellationToken cancellationToken) { var errors = new List(); @@ -173,6 +205,7 @@ private static IResult Problem(IReadOnlyList errors) { ContentErrorCodes.EntryNotFound or SchemaErrorCodes.ContentTypeNotFound or DeliveryErrorCodes.EndpointNotFound => StatusCodes.Status404NotFound, AgentPermissionErrorCodes.ScopeDenied => StatusCodes.Status403Forbidden, + DeliveryErrorCodes.PreviewTokenInvalid => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status400BadRequest, }; @@ -187,6 +220,8 @@ private static IResult Problem(IReadOnlyList errors) private sealed record PageResponse(IReadOnlyList Items, Guid? Next); + private sealed record ChangesResponse(Guid ChangeSet, IReadOnlyList Items); + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList Unresolved); private sealed record IndexResponse(IReadOnlyList Items); diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 2fe67d547..01f42d27a 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -49,4 +49,8 @@ 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 ChangeSetNotPreviewable = "change_set_not_previewable"; } diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.Designer.cs new file mode 100644 index 000000000..425ea9d77 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.Designer.cs @@ -0,0 +1,1360 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019055338_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.cs new file mode 100644 index 000000000..7dca2ca5e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055338_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index cacc463ac..5cc65880c 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -486,6 +486,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1197,6 +1233,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.Designer.cs new file mode 100644 index 000000000..4ea174873 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.Designer.cs @@ -0,0 +1,1350 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019055323_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.cs new file mode 100644 index 000000000..c2b75798b --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055323_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SiteId = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "BLOB", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 2b4cc51e2..fa04012e1 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -480,6 +480,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1187,6 +1223,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 97bd7f26c..b6e4dc67a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -130,6 +130,39 @@ public async Task A_path_or_id_the_api_does_not_know_is_a_problem_never_the_admi await AssertProblemAsync(await host.Client.GetAsync(path), status, code); } + [Fact] + public async Task A_preview_reads_with_the_bearer_token_and_is_never_cached() + { + await using var api = await StubApi.StartAsync(new StubContent()); + using var entry = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/preview/entries/{Page}"); + entry.Headers.Authorization = new("Bearer", StubPreview.Token); + using var changes = new HttpRequestMessage(HttpMethod.Get, "/api/v1/preview/changes"); + changes.Headers.Authorization = new("Bearer", StubPreview.Token); + + var entryResponse = await api.Client.SendAsync(entry); + var changesResponse = await api.Client.SendAsync(changes); + + Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode); + Assert.True(entryResponse.Headers.CacheControl?.NoStore); + Assert.Equal(Page.ToString(), (string?)(await entryResponse.Content.ReadFromJsonAsync())!["id"]); + var body = (await changesResponse.Content.ReadFromJsonAsync())!; + Assert.Equal((StubPreview.ChangeSet.ToString(), 1), ((string?)body["changeSet"], body["items"]!.AsArray().Count)); + } + + [Fact] + public async Task A_preview_without_a_working_token_is_a_problem_with_401() + { + await using var api = await StubApi.StartAsync(new StubContent()); + using var wrong = new HttpRequestMessage(HttpMethod.Get, "/api/v1/preview/changes"); + wrong.Headers.Authorization = new("Bearer", "fcmsp_wrong"); + + var missing = await api.Client.GetAsync($"/api/v1/preview/entries/{Page}"); + var refused = await api.Client.SendAsync(wrong); + + await AssertProblemAsync(missing, HttpStatusCode.Unauthorized, DeliveryErrorCodes.PreviewTokenInvalid); + await AssertProblemAsync(refused, HttpStatusCode.Unauthorized, DeliveryErrorCodes.PreviewTokenInvalid); + } + private static async Task AssertProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) { Assert.Equal(status, response.StatusCode); @@ -186,6 +219,31 @@ private static PublishedItem Item() } } + // Works for one token only, as a preview of a set that changes the page. + private sealed class StubPreview : IPreviewContent + { + public const string Token = "fcmsp_works"; + + public static readonly Guid ChangeSet = Guid.CreateVersion7(); + + public async Task> GetAsync(string token, Guid entryId, CancellationToken cancellationToken = default) + { + return token == Token ? await new StubContent().GetAsync(entryId, cancellationToken) : Refused(); + } + + public async Task> ChangesAsync(string token, CancellationToken cancellationToken = default) + { + return token == Token + ? Result.Success(new PreviewChanges(ChangeSet, [(await new StubContent().GetAsync(Page, cancellationToken)).Value])) + : Refused(); + } + + private static Result Refused() + { + return Result.Failure(DeliveryErrorCodes.PreviewTokenInvalid, "The preview token does not work."); + } + } + // The endpoints alone on a free port, over the content given. private sealed class StubApi(WebApplication app) : IAsyncDisposable { @@ -196,6 +254,7 @@ public static async Task StartAsync(IPublishedContent content) var builder = WebApplication.CreateSlimBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddSingleton(content); + builder.Services.AddSingleton(new StubPreview()); var app = builder.Build(); app.MapFluentCmsApi(); await app.StartAsync(); From 08477f0581898056ddeb1f8e2b54eff4b4a6fc1a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:35:58 -0400 Subject: [PATCH 07/15] Escape plain text in Markdown and read each version against its own definition --- docs/api/delivery.md | 10 +- src/FluentCMS.Api/DeliveryEndpoints.cs | 4 +- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 135 +++++++++++++----- .../Delivery/PublishedContentReader.cs | 103 +++++++------ .../Delivery/DeliveryEndpointsTests.cs | 2 +- .../Delivery/PublishedContentTests.cs | 50 +++++++ .../Delivery/EntryMarkdownTests.cs | 99 +++++++++++-- 7 files changed, 307 insertions(+), 96 deletions(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index ec20b5a26..990eb2623 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -56,13 +56,15 @@ A list looks like this: Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version. -- Front matter gives the id, type, version and URL. +- Front matter gives the id, type, version and URL, as quoted strings apart from the version number. - The heading is the entry's title: its first text field that holds text. -- Each other field follows under its name, in the order its content type defines them: +- Each other field follows under its name, in the order of the content type definition that the published version was written against: - rich text as written, since it is stored as Markdown; - - lists as bullets, and JSON in a fenced block; + - other text escaped so it reads as written, which means a value can never pose as a heading, a field, a link or HTML; + - lists as bullets, each item keeping its own paragraphs, and JSON in a fenced block; - references to whole entries as links to their Markdown. -- On an entity, each fact's value is followed by its source. +- On an entity, each fact's value is followed by a `**Source:**` item, which also says when the fact is unsourced. +- References that could not be resolved are listed under "Unresolved references", each with its reason. The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 6e42d0a31..55bebd23c 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -124,10 +124,10 @@ private static async Task IndexMarkdownAsync(IPublishedContent content, foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName))) { - markdown.Append(CultureInfo.InvariantCulture, $"\n## {type.Key.ContentTypeName}\n\n"); + markdown.Append(CultureInfo.InvariantCulture, $"\n## {EntryMarkdown.InlineText(type.Key.ContentTypeName)}\n\n"); foreach (var entry in type) { - var title = (entry.Title ?? $"{entry.ContentTypeName} {entry.Id}").Replace("[", @"\[", StringComparison.Ordinal).Replace("]", @"\]", StringComparison.Ordinal); + var title = EntryMarkdown.InlineText(entry.Title ?? $"{entry.ContentTypeName} {entry.Id}"); markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n"); } } diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs index 521f2833c..12c3e82b5 100644 --- a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -1,12 +1,14 @@ using System.Globalization; using System.Text; using System.Text.Json; +using FluentCMS.Core.Content; using FluentCMS.Core.Schema; namespace FluentCMS.Core.Delivery; // The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written; -// every other value is written out by its field's type, in the order the content type defines its fields. +// every other value is written as text that reads as written, so no value can pose as a heading, a field, a source or a link. +// Fields follow in the order of the definition the published version was written against. public static class EntryMarkdown { public const string MediaType = "text/markdown"; @@ -14,7 +16,7 @@ public static class EntryMarkdown // Markdown lines end in \n whatever the platform, JSON blocks included. private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, NewLine = "\n" }; - // The first text field that holds text, read from its fact on an entity; null when there is none. + // The first text field that holds text, read from its fact on an entity, on one line; null when there is none. public static string? Title(JsonElement fields, ContentTypeDefinition definition) { ArgumentNullException.ThrowIfNull(definition); @@ -22,6 +24,49 @@ public static class EntryMarkdown return TitleField(fields, definition)?.Text; } + // Plain text as Markdown that reads as written (CommonMark lets a backslash escape any punctuation). The characters that open + // emphasis, code, links, HTML, entities, headings and tables are escaped wherever they are, and those that open a list, a quote + // or a heading underline where a line starts. Lines lose their indentation, so none becomes a code block; breaks stay. + public static string Text(string text) + { + ArgumentNullException.ThrowIfNull(text); + + var escaped = new StringBuilder(text.Length + 16); + var lines = text.ReplaceLineEndings("\n").Split('\n'); + for (var index = 0; index < lines.Length; index++) + { + if (index > 0) + { + escaped.Append('\n'); + } + + var line = lines[index].TrimStart(); + var digits = line.TakeWhile(char.IsAsciiDigit).Count(); + for (var position = 0; position < line.Length; position++) + { + var character = line[position]; + var opensLine = position == 0 && character is '-' or '+' or '=' or '>'; + var endsListNumber = position == digits && digits > 0 && character is '.' or ')'; + if (character is '\\' or '`' or '*' or '_' or '[' or ']' or '<' or '>' or '&' or '#' or '|' or '~' || opensLine || endsListNumber) + { + escaped.Append('\\'); + } + + escaped.Append(character); + } + } + + return escaped.ToString(); + } + + // Text for one line, such as a heading or a link's text: its whitespace collapsed, then escaped as Text is. + public static string InlineText(string text) + { + ArgumentNullException.ThrowIfNull(text); + + return Text(Line(text)); + } + // EntryUrl gives the Markdown URL of another entry, for references to whole entries. public static string Render(PublishedItem item, string url, Func entryUrl) { @@ -32,12 +77,12 @@ public static string Render(PublishedItem item, string url, Func e var title = TitleField(item.Fields, definition); var markdown = new StringBuilder() .Append("---\n") - .Append(CultureInfo.InvariantCulture, $"id: {item.Id}\n") - .Append(CultureInfo.InvariantCulture, $"type: {item.ContentType}\n") + .Append(CultureInfo.InvariantCulture, $"id: \"{item.Id}\"\n") + .Append(CultureInfo.InvariantCulture, $"type: \"{item.ContentType}\"\n") .Append(CultureInfo.InvariantCulture, $"version: {item.Version}\n") - .Append(CultureInfo.InvariantCulture, $"url: {url}\n") + .Append(CultureInfo.InvariantCulture, $"url: \"{url}\"\n") .Append("---\n\n") - .Append(CultureInfo.InvariantCulture, $"# {title?.Text ?? $"{item.ContentTypeName} {item.Id}"}\n"); + .Append(CultureInfo.InvariantCulture, $"# {InlineText(title?.Text ?? $"{item.ContentTypeName} {item.Id}")}\n"); // The heading shows the title, so its field is not repeated below it. foreach (var field in definition.Fields.Where(field => field.Name != title?.Field)) @@ -49,9 +94,19 @@ public static string Render(PublishedItem item, string url, Func e markdown.Append(CultureInfo.InvariantCulture, $"\n## {field.Name}\n\n").Append(Block(field.Type, field.Settings, value, entryUrl)).Append('\n'); - if (definition.IsEntity && Source(item.Fields, field.Name) is { } source) + // A list item with a strong label, which an escaped value cannot form, so a value cannot pass for a source. + if (definition.IsEntity) { - markdown.Append(CultureInfo.InvariantCulture, $"\nSource: {source}\n"); + markdown.Append(CultureInfo.InvariantCulture, $"\n- **Source:** {Source(item.Fields, field.Name) ?? "none, so this fact is unsourced"}\n"); + } + } + + if (item.Unresolved.Count > 0) + { + markdown.Append("\n## Unresolved references\n\n"); + foreach (var reference in item.Unresolved) + { + markdown.Append(CultureInfo.InvariantCulture, $"- `{reference.Path}`: {reference.Field} of entry {reference.EntityId} {Reason(reference.Reason)}\n"); } } @@ -62,8 +117,7 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte { foreach (var field in definition.Fields.Where(field => field.Type == "text")) { - if (Value(fields, field.Name, definition.IsEntity) is { ValueKind: JsonValueKind.String } value - && !string.IsNullOrWhiteSpace(value.GetString())) + if (Value(fields, field.Name, definition.IsEntity) is { ValueKind: JsonValueKind.String } value) { return (field.Name, Line(value.GetString()!)); } @@ -92,53 +146,64 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte private static string? Source(JsonElement fields, string name) { - return fields.TryGetProperty(name, out var fact) - && fact.ValueKind == JsonValueKind.Object - && fact.TryGetProperty(FactMetadata.Source, out var source) - && source.ValueKind == JsonValueKind.Object - && source.TryGetProperty(FactMetadata.SourceReference, out var reference) - && reference.ValueKind == JsonValueKind.String - ? source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String - ? $"{reference.GetString()} ({kind.GetString()})" - : reference.GetString() - : null; + if (!fields.TryGetProperty(name, out var fact) + || fact.ValueKind != JsonValueKind.Object + || !fact.TryGetProperty(FactMetadata.Source, out var source) + || source.ValueKind != JsonValueKind.Object + || !source.TryGetProperty(FactMetadata.SourceReference, out var reference) + || reference.ValueKind != JsonValueKind.String) + { + return null; + } + + var text = InlineText(reference.GetString()!); + return source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String + ? $"{text} \\({InlineText(kind.GetString()!)}\\)" + : text; + } + + private static string Reason(UnresolvedReason reason) + { + return reason switch + { + UnresolvedReason.NotPublished => "is not published", + UnresolvedReason.NoValue => "has no value", + UnresolvedReason.Deleted => "is deleted", + _ => "cannot be resolved", + }; } private static string Block(string type, JsonElement settings, JsonElement value, Func entryUrl) { if (type == "list" && value.ValueKind == JsonValueKind.Array) { - var itemType = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemType", out var item) ? item.GetString() ?? "" : ""; + var itemType = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemType", out var item) && item.ValueKind == JsonValueKind.String + ? item.GetString()! + : ""; var itemSettings = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemSettings", out var nested) ? nested : FieldDefinition.EmptySettings; + // Each item keeps its own block, indented under its bullet so paragraphs and fences stay inside the item. return string.Join('\n', value.EnumerateArray() .Where(element => element.ValueKind != JsonValueKind.Null) - .Select(element => $"- {Line(Inline(itemType, itemSettings, element, entryUrl))}")); + .Select(element => "- " + Block(itemType, itemSettings, element, entryUrl).Replace("\n", "\n ", StringComparison.Ordinal).Replace("\n \n", "\n\n", StringComparison.Ordinal))); } - return type switch - { - "richText" when value.ValueKind == JsonValueKind.String => value.GetString()!.Trim(), - "json" => Fenced(value), - _ when value.ValueKind is JsonValueKind.Object or JsonValueKind.Array => Fenced(value), - _ => Inline(type, settings, value, entryUrl), - }; - } - - private static string Inline(string type, JsonElement settings, JsonElement value, Func entryUrl) - { // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. var wholeEntry = type == "reference" && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out _)); return value.ValueKind switch { + JsonValueKind.String when type == "richText" => value.GetString()!.Trim(), JsonValueKind.String when wholeEntry && value.TryGetGuid(out var id) => $"[{id}]({entryUrl(id)})", - JsonValueKind.String when type == "media" => $"Media asset `{value.GetString()}`", - JsonValueKind.String => value.GetString()!, + JsonValueKind.String when type == "media" && value.TryGetGuid(out var asset) => $"Media asset `{asset}`", + JsonValueKind.String => Text(value.GetString()!), + JsonValueKind.Object or JsonValueKind.Array => Fenced(value), + _ when type == "json" => Fenced(value), _ => value.GetRawText(), }; } + // The serializer writes a backtick in a string as `, so no value inside can close the fence. private static string Fenced(JsonElement value) { return $"```json\n{JsonSerializer.Serialize(value, Indented)}\n```"; diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index bcd9616c7..0e8ffa7b1 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -11,7 +11,8 @@ namespace FluentCMS.Infrastructure.Delivery; -// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. +// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. Each item +// carries the definition its version was written against, so a revision of the type waiting for review changes nothing it shows. internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions) : IPublishedContent { // Until multi-site (MST-01), every entry belongs to the one site (SCH-07). @@ -34,7 +35,12 @@ public async Task> ListAsync(PublishedQuery query, Cancell return Result.Failure(allowed.Errors); } - if ((await TypesAsync(query.ContentType, null, cancellationToken)).Values.SingleOrDefault() is not { } type) + var type = await context.ContentTypes + .AsNoTracking() + .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) + .Select(row => new { row.Id, row.Name }) + .SingleOrDefaultAsync(cancellationToken); + if (type is null) { return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); } @@ -55,109 +61,118 @@ public async Task> ListAsync(PublishedQuery query, Cancell .Any(reference => reference.EntryId == entry.Id && reference.Version == entry.PublishedVersion && reference.TargetId == target)); } - var rows = await entries - .OrderBy(entry => entry.Id) + var rows = await ( + from entry in entries + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + orderby entry.Id + select new { entry.Id, version.Version, version.ContentTypeVersion }) .Take(query.Limit + 1) - .Select(entry => new { entry.Id, Version = entry.PublishedVersion!.Value }) .ToListAsync(cancellationToken); var page = rows.Take(query.Limit).ToList(); + var definitions = await DefinitionsAsync([.. page.Select(row => (type.Id, row.ContentTypeVersion))], cancellationToken); var resolved = await EntryResolution.ResolveAsync( context, - [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], + [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], cancellationToken); return Result.Success(new PublishedPage( - [.. resolved.Select(entry => Item(entry, type))], + [.. resolved.Select((entry, index) => Item(entry, type.Name, definitions[(type.Id, page[index].ContentTypeVersion)]))], rows.Count > query.Limit ? page[^1].Id : null)); } public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await context.Entries - .AsNoTracking() - .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) - .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) + var entry = await ( + from row in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = row.Id, Version = row.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null + select new { row.ContentTypeId, type.Slug, type.Name, version.Version, version.ContentTypeVersion }) .SingleOrDefaultAsync(cancellationToken); if (entry is null) { return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); } - var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; - var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0], type)); + var definitions = await DefinitionsAsync([(entry.ContentTypeId, entry.ContentTypeVersion)], cancellationToken); + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0], entry.Name, definitions[(entry.ContentTypeId, entry.ContentTypeVersion)])); } public async Task> IndexAsync(CancellationToken cancellationToken = default) { - var types = await TypesAsync(null, null, cancellationToken); - // The stored documents, not resolved views: a title is a text field of the entry itself, never a resolved reference. var rows = await ( from entry in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on entry.ContentTypeId equals type.Id join version in context.EntryVersions.AsNoTracking() on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } where entry.SiteId == SiteId && entry.PublishedVersion != null && entry.DeletedAt == null - select new { entry.Id, entry.ContentTypeId, version.Version, version.Fields }) + select new { entry.Id, entry.ContentTypeId, type.Slug, type.Name, version.Version, version.ContentTypeVersion, version.Fields }) .ToListAsync(cancellationToken); var readable = await permissions.ReadableTypesAsync(cancellationToken); + var shown = rows.Where(row => readable(row.Slug)).ToList(); + var definitions = await DefinitionsAsync([.. shown.Select(row => (row.ContentTypeId, row.ContentTypeVersion))], cancellationToken); - return rows - .Select(row => (Row: row, Type: types[row.ContentTypeId])) - .Where(item => readable(item.Type.Slug)) - .Select(item => + return shown + .Select(row => { - using var fields = JsonDocument.Parse(item.Row.Fields); + using var fields = JsonDocument.Parse(row.Fields); return new PublishedSummary( - item.Row.Id, - item.Type.Slug, - item.Type.Name, - item.Row.Version, - EntryMarkdown.Title(fields.RootElement, item.Type.Definition)); + row.Id, + row.Slug, + row.Name, + row.Version, + EntryMarkdown.Title(fields.RootElement, definitions[(row.ContentTypeId, row.ContentTypeVersion)])); }) .OrderBy(summary => summary.ContentType, StringComparer.Ordinal) .ThenBy(summary => summary.Id) .ToList(); } - // The site's content types with their current definitions, which a published version is read against: every type, the one - // with the slug, or the one with the id. - private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) + // The definitions of the type versions given, read in one query; every pair must be stored. + private async Task> DefinitionsAsync( + IReadOnlyCollection<(Guid TypeId, int Version)> wanted, + CancellationToken cancellationToken) { - var rows = await ( - from type in context.ContentTypes.AsNoTracking() - join version in context.ContentTypeVersions.AsNoTracking() - on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } - where type.SiteId == SiteId && (slug == null || type.Slug == slug) && (id == null || type.Id == id) - select new { type.Id, type.Slug, type.Name, version.Definition }) + var pairs = wanted.ToHashSet(); + var typeIds = pairs.Select(pair => pair.TypeId).Distinct().ToList(); + var versions = pairs.Select(pair => pair.Version).Distinct().ToList(); + + var rows = await context.ContentTypeVersions + .AsNoTracking() + .Where(row => typeIds.Contains(row.ContentTypeId) && versions.Contains(row.Version)) + .Select(row => new { row.ContentTypeId, row.Version, row.Definition }) .ToListAsync(cancellationToken); - return rows.ToDictionary(row => row.Id, row => new PublishedType(row.Id, row.Slug, row.Name, ReadDefinition(row.Slug, row.Definition))); + return rows + .Where(row => pairs.Contains((row.ContentTypeId, row.Version))) + .ToDictionary(row => (row.ContentTypeId, row.Version), row => ReadDefinition(row.ContentTypeId, row.Version, row.Definition)); } - private static ContentTypeDefinition ReadDefinition(string slug, string json) + private static ContentTypeDefinition ReadDefinition(Guid typeId, int version, string json) { var definition = ContentTypeDefinition.Parse(json); return definition.IsSuccess ? definition.Value : throw new InvalidOperationException( - $"The current definition of content type '{slug}' is stored in a form that cannot be read: " + $"Version {version} of content type {typeId} is stored in a form that cannot be read: " + string.Join("; ", definition.Errors.Select(error => error.Message))); } - private static PublishedItem Item(ResolvedEntry entry, PublishedType type) + private static PublishedItem Item(ResolvedEntry entry, string typeName, ContentTypeDefinition definition) { - return new PublishedItem(entry.EntryId, type.Slug, type.Name, type.Definition, entry.Version, entry.Fields, entry.Unresolved); + return new PublishedItem(entry.EntryId, entry.ContentType, typeName, definition, entry.Version, entry.Fields, entry.Unresolved); } - - private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 97bd7f26c..e29b0294a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -47,7 +47,7 @@ public async Task An_entry_reads_as_markdown_at_its_id() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); var markdown = await response.Content.ReadAsStringAsync(); - Assert.StartsWith($"---\nid: {Page}\ntype: product-page\nversion: 3\nurl: /api/v1/entries/{Page}.md\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); + Assert.StartsWith($"---\nid: \"{Page}\"\ntype: \"product-page\"\nversion: 3\nurl: \"/api/v1/entries/{Page}.md\"\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); Assert.Contains($"[{Product}](/api/v1/entries/{Product}.md)", markdown, StringComparison.Ordinal); await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index f2f77cce9..e0986c67a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -7,6 +7,7 @@ using FluentCMS.Infrastructure.Persistence; using FluentCMS.IntegrationTests.ChangeSets; using FluentCMS.IntegrationTests.Content; +using FluentCMS.IntegrationTests.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using static FluentCMS.IntegrationTests.Content.EntryScenario; @@ -193,6 +194,55 @@ public async Task The_index_lists_each_published_entry_once_with_the_title_of_it Assert.DoesNotContain(after, summary => summary.Id == draft || summary.Id == deleted); } + // API-03: the Markdown URL is the entry's id, so it stays the same across versions and always shows the published one. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_markdown_of_a_published_page_follows_its_published_version(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + await PublishAsync(scenario, product, 1); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Now cheaper"}"""); + + async Task MarkdownAsync() => + EntryMarkdown.Render((await ContentAsync(scenario, content => content.GetAsync(page))).Value, $"/api/v1/entries/{page}.md", id => $"/api/v1/entries/{id}.md"); + + var draft = await MarkdownAsync(); + await PublishAsync(scenario, page, 2); + var published = await MarkdownAsync(); + + Assert.Contains("# A sturdy drill\n", draft, StringComparison.Ordinal); + Assert.Contains("## price\n\n99\n", draft, StringComparison.Ordinal); + Assert.Contains($"## product\n\n[{product}](/api/v1/entries/{product}.md)\n", draft, StringComparison.Ordinal); + Assert.Contains("# Now cheaper\n", published, StringComparison.Ordinal); + Assert.Contains($"url: \"/api/v1/entries/{page}.md\"\n", published, StringComparison.Ordinal); + Assert.Contains("version: 2\n", published, StringComparison.Ordinal); + } + + // A revision of the type waiting for review changes nothing a reader sees: each version is read against the definition it was + // written against. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_published_version_is_read_against_the_definition_it_was_written_against(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + var revised = new ContentTypeDefinition([SchemaScenario.Reference("product", "product"), SchemaScenario.Field("body", "richText")]); + Assert.True((await scenario.Schema.ReviseAsync("product-page", 1, revised)).IsSuccess); + + var read = (await ContentAsync(scenario, content => content.GetAsync(page))).Value; + var index = await ContentAsync(scenario, content => content.IndexAsync()); + + Assert.Equal(["product", "price", "headline", "body"], read.Definition.Fields.Select(field => field.Name)); + Assert.Equal("A sturdy drill", Assert.Single(index).Title); + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs index 492d3e3d2..ee6aca3ff 100644 --- a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; using FluentCMS.Core.Schema; @@ -20,11 +21,14 @@ public sealed class EntryMarkdownTests Field("related", "list", """{"itemType": "reference", "itemSettings": {"target": "product"}}"""), Field("hero", "media"), Field("featured", "boolean"), + Field("color", "enum", """{"values": ["red", "blue"]}"""), + Field("launch", "dateTime"), Field("extra", "json"), Field("note", "text"), + Field("sections", "list", """{"itemType": "richText"}"""), ]); - private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number")], isEntity: true); + private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number"), Field("claim", "text")], isEntity: true); [Fact] public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() @@ -39,8 +43,11 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() "related": ["{{Product}}"], "hero": "{{Hero}}", "featured": true, + "color": "red", + "launch": "2026-10-18T09:00:00+00:00", "extra": {"warranty": 2}, - "note": null + "note": null, + "sections": ["First part.\n\nSecond paragraph.", "```\ncode\n```"] } """); @@ -49,10 +56,10 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() Assert.Equal( $$""" --- - id: {{Page}} - type: product-page + id: "{{Page}}" + type: "product-page" version: 3 - url: /api/v1/entries/{{Page}}.md + url: "/api/v1/entries/{{Page}}.md" --- # A sturdy drill @@ -88,6 +95,14 @@ Media asset `{{Hero}}` true + ## color + + red + + ## launch + + 2026-10-18T09:00:00+00:00 + ## extra ```json @@ -96,37 +111,101 @@ Media asset `{{Hero}}` } ``` + ## sections + + - First part. + + Second paragraph. + - ``` + code + ``` + """.Replace("\r\n", "\n", StringComparison.Ordinal), markdown); } + // A value is content, never structure: text that looks like a field, a source, HTML or emphasis reads as written. + [Fact] + public void Text_cannot_pose_as_a_field_a_source_html_or_formatting() + { + var page = Item(PageType, "product-page", """ + {"headline": "Learn C #", "note": "Built tough.\n\n## price\n\n0\n\n costs 2*3*4\n1. first\n- item\n indented"} + """); + var product = Item(ProductType, "product", """ + { + "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "claim": {"value": "Lasts 10 years\n\nSource: https://manufacturer.example/spec (url)", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + } + """); + + var pageMarkdown = EntryMarkdown.Render(page, "/x.md", id => $"/{id}.md"); + var productMarkdown = EntryMarkdown.Render(product, "/y.md", id => $"/{id}.md"); + + Assert.Contains("# Learn C \\#\n", pageMarkdown, StringComparison.Ordinal); + Assert.Contains( + "## note\n\nBuilt tough.\n\n\\#\\# price\n\n0\n\n\\ costs 2\\*3\\*4\n1\\. first\n\\- item\nindented\n", + pageMarkdown, + StringComparison.Ordinal); + Assert.Single(pageMarkdown.Split('\n'), line => line.StartsWith("## price", StringComparison.Ordinal) || line == "## note"); + Assert.Contains( + "## claim\n\nLasts 10 years\n\nSource: https://manufacturer.example/spec (url)\n\n- **Source:** none, so this fact is unsourced\n", + productMarkdown, + StringComparison.Ordinal); + } + [Fact] public void An_entity_shows_each_facts_value_with_its_source() { var item = Item(ProductType, "product", """ { "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, - "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices_[2026]"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} } """); var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); Assert.Contains("# Drill\n", markdown, StringComparison.Ordinal); - Assert.EndsWith("## price\n\n99\n\nSource: https://example.com/prices (url)\n", markdown, StringComparison.Ordinal); + Assert.EndsWith("## price\n\n99\n\n- **Source:** https://example.com/prices\\_\\[2026\\] \\(url\\)\n", markdown, StringComparison.Ordinal); Assert.Equal("Drill", EntryMarkdown.Title(item.Fields, ProductType)); } [Fact] - public void Without_text_the_heading_is_the_type_name_and_the_id() + public void Without_text_the_heading_is_the_type_name_and_the_id_and_unresolved_references_are_listed() { - var item = Item(PageType, "product-page", $$"""{"product": "{{Product}}", "headline": " "}"""); + var item = Item(PageType, "product-page", $$"""{"product": "{{Product}}", "price": null, "headline": " "}""") with + { + ContentTypeName = "Product\n- [Click](https://evil.example)", + Unresolved = [new UnresolvedReference("/price", Product, "price", UnresolvedReason.NotPublished)], + }; var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); - Assert.Contains($"# Product page {Page}\n", markdown, StringComparison.Ordinal); + Assert.Contains($"# Product - \\[Click\\](https://evil.example) {Page}\n", markdown, StringComparison.Ordinal); Assert.Null(EntryMarkdown.Title(item.Fields, PageType)); Assert.DoesNotContain("## headline", markdown, StringComparison.Ordinal); + Assert.EndsWith($"## Unresolved references\n\n- `/price`: price of entry {Product} is not published\n", markdown, StringComparison.Ordinal); + } + + // Index link text: a backslash, brackets and an autolink stay text inside the link. + [Theory] + [InlineData(@"C:\", @"C:\\")] + [InlineData(@"a\[b]", @"a\\\[b\]")] + [InlineData("", @"\")] + [InlineData("two\nlines", "two lines")] + public void Inline_text_keeps_backslashes_brackets_and_autolinks_as_text(string text, string expected) + { + Assert.Equal(expected, EntryMarkdown.InlineText(text)); + } + + [Fact] + public void A_backtick_in_json_cannot_close_its_fence() + { + var item = Item(PageType, "product-page", """{"extra": {"snippet": "```` four"}}"""); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains("```json\n{\n \"snippet\": \"\\u0060\\u0060\\u0060\\u0060 four\"\n}\n```\n", markdown, StringComparison.Ordinal); } private static PublishedItem Item(ContentTypeDefinition definition, string type, string fields) From 52c364dc7cff5025da9377e44335d2033772e6a8 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:53:43 -0400 Subject: [PATCH 08/15] List endpoint_not_found among the delivery errors --- docs/api/delivery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 93deeded8..5d48b750b 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -94,4 +94,4 @@ Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (` | --- | --- | | 400 | `limit_invalid`, `parameter_invalid` | | 401 | `preview_token_invalid` | -| 404 | `entry_not_found`, `content_type_not_found` | +| 404 | `entry_not_found`, `content_type_not_found`, `endpoint_not_found` | From 887b325a9d58a6c90102af3b2d27db3cb3112a22 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:19:08 -0400 Subject: [PATCH 09/15] Quote rich text in Markdown, give every fact its source, and keep URLs exact --- docs/api/delivery.md | 6 +- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 92 +++++++++++++++---- .../Delivery/PublishedContentTests.cs | 1 + .../Delivery/EntryMarkdownTests.cs | 85 +++++++++++------ 4 files changed, 135 insertions(+), 49 deletions(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 990eb2623..aac8eda73 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -59,11 +59,11 @@ Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` - Front matter gives the id, type, version and URL, as quoted strings apart from the version number. - The heading is the entry's title: its first text field that holds text. - Each other field follows under its name, in the order of the content type definition that the published version was written against: - - rich text as written, since it is stored as Markdown; - - other text escaped so it reads as written, which means a value can never pose as a heading, a field, a link or HTML; + - rich text as written, since it is stored as Markdown, inside a block quote so that whatever it opens ends with its field; + - other text escaped where it could open structure, which means a value can never pose as a heading, a field, a link or HTML, while words and URLs keep their characters; - lists as bullets, each item keeping its own paragraphs, and JSON in a fenced block; - references to whole entries as links to their Markdown. -- On an entity, each fact's value is followed by a `**Source:**` item, which also says when the fact is unsourced. +- On an entity, every fact, the title's included, is followed by a `**Source:**` paragraph, which also says when the fact is unsourced. A source that is a web address is a link. - References that could not be resolved are listed under "Unresolved references", each with its reason. The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs index 12c3e82b5..f3d7cf64d 100644 --- a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -6,9 +6,10 @@ namespace FluentCMS.Core.Delivery; -// The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written; -// every other value is written as text that reads as written, so no value can pose as a heading, a field, a source or a link. -// Fields follow in the order of the definition the published version was written against. +// The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written, but +// quoted, so whatever it opens ends with its field. Every other value is written as text that reads as written. No value can pose +// as a heading, a field, a source or a link outside its own field. Fields follow in the order of the definition the published +// version was written against. public static class EntryMarkdown { public const string MediaType = "text/markdown"; @@ -24,9 +25,10 @@ public static class EntryMarkdown return TitleField(fields, definition)?.Text; } - // Plain text as Markdown that reads as written (CommonMark lets a backslash escape any punctuation). The characters that open - // emphasis, code, links, HTML, entities, headings and tables are escaped wherever they are, and those that open a list, a quote - // or a heading underline where a line starts. Lines lose their indentation, so none becomes a code block; breaks stay. + // Plain text as Markdown that reads as written; CommonMark lets a backslash escape any punctuation. Escaped wherever they are: + // what opens code, emphasis, links, autolinks, HTML and strikethrough, an underscore that is not inside a word, and an ampersand + // that starts an entity. Escaped where a line starts: what opens a heading, a list, a quote, a table or a heading underline. + // Lines lose their indentation, so none becomes a code block; breaks stay. URLs and words keep their characters. public static string Text(string text) { ArgumentNullException.ThrowIfNull(text); @@ -45,9 +47,7 @@ public static string Text(string text) for (var position = 0; position < line.Length; position++) { var character = line[position]; - var opensLine = position == 0 && character is '-' or '+' or '=' or '>'; - var endsListNumber = position == digits && digits > 0 && character is '.' or ')'; - if (character is '\\' or '`' or '*' or '_' or '[' or ']' or '<' or '>' or '&' or '#' or '|' or '~' || opensLine || endsListNumber) + if (NeedsEscape(line, position, digits)) { escaped.Append('\\'); } @@ -84,8 +84,8 @@ public static string Render(PublishedItem item, string url, Func e .Append("---\n\n") .Append(CultureInfo.InvariantCulture, $"# {InlineText(title?.Text ?? $"{item.ContentTypeName} {item.Id}")}\n"); - // The heading shows the title, so its field is not repeated below it. - foreach (var field in definition.Fields.Where(field => field.Name != title?.Field)) + // The heading shows the title, so its field is not repeated below it, unless it is a fact, whose source belongs with it. + foreach (var field in definition.Fields.Where(field => definition.IsEntity || field.Name != title?.Field)) { if (Value(item.Fields, field.Name, definition.IsEntity) is not { } value) { @@ -94,10 +94,10 @@ public static string Render(PublishedItem item, string url, Func e markdown.Append(CultureInfo.InvariantCulture, $"\n## {field.Name}\n\n").Append(Block(field.Type, field.Settings, value, entryUrl)).Append('\n'); - // A list item with a strong label, which an escaped value cannot form, so a value cannot pass for a source. + // A paragraph with a strong label after the value, which escaped text cannot form and quoted rich text cannot reach. if (definition.IsEntity) { - markdown.Append(CultureInfo.InvariantCulture, $"\n- **Source:** {Source(item.Fields, field.Name) ?? "none, so this fact is unsourced"}\n"); + markdown.Append(CultureInfo.InvariantCulture, $"\n**Source:** {Source(item.Fields, field.Name) ?? "none, so this fact is unsourced"}\n"); } } @@ -113,6 +113,53 @@ public static string Render(PublishedItem item, string url, Func e return markdown.ToString(); } + private static bool NeedsEscape(string line, int position, int digits) + { + var character = line[position]; + if (character is '\\' or '`' or '*' or '[' or ']' or '<' or '>' or '~') + { + return true; + } + + if (position == 0 && character is '#' or '-' or '+' or '=' or '|' or ':') + { + return true; + } + + if (position == digits && digits > 0 && character is '.' or ')') + { + return true; + } + + // A run of hashes that ends a line after a space would close a heading and be dropped from it. + if (character == '#' && char.IsWhiteSpace(line[position - 1]) && line[position..].TrimEnd().All(each => each == '#')) + { + return true; + } + + // An underscore inside a word opens no emphasis, as in a URL or an identifier. + if (character == '_') + { + return position == 0 || position == line.Length - 1 || !char.IsLetterOrDigit(line[position - 1]) || !char.IsLetterOrDigit(line[position + 1]); + } + + // Only an ampersand that starts a named or numeric entity reads as anything but itself. + if (character == '&') + { + var rest = line.AsSpan(position + 1); + var name = rest.StartsWith("#") ? rest[1..] : rest; + var length = 0; + while (length < name.Length && char.IsAsciiLetterOrDigit(name[length])) + { + length++; + } + + return length > 0 && length < name.Length && name[length] == ';'; + } + + return false; + } + private static (string Field, string Text)? TitleField(JsonElement fields, ContentTypeDefinition definition) { foreach (var field in definition.Fields.Where(field => field.Type == "text")) @@ -144,6 +191,7 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte : value; } + // A source that is an absolute web address is written as an autolink, which keeps it exact; anything else is escaped text. private static string? Source(JsonElement fields, string name) { if (!fields.TryGetProperty(name, out var fact) @@ -156,7 +204,12 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte return null; } - var text = InlineText(reference.GetString()!); + var address = reference.GetString()!.Trim(); + var text = Uri.TryCreate(address, UriKind.Absolute, out var uri) + && uri.Scheme is "http" or "https" + && !address.Any(character => char.IsWhiteSpace(character) || char.IsControl(character) || character is '<' or '>') + ? $"<{address}>" + : InlineText(address); return source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String ? $"{text} \\({InlineText(kind.GetString()!)}\\)" : text; @@ -189,11 +242,12 @@ private static string Block(string type, JsonElement settings, JsonElement value } // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. - var wholeEntry = type == "reference" && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out _)); + var wholeEntry = type == "reference" + && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out var field) && field.ValueKind == JsonValueKind.String); return value.ValueKind switch { - JsonValueKind.String when type == "richText" => value.GetString()!.Trim(), + JsonValueKind.String when type == "richText" => Quoted(value.GetString()!.Trim()), JsonValueKind.String when wholeEntry && value.TryGetGuid(out var id) => $"[{id}]({entryUrl(id)})", JsonValueKind.String when type == "media" && value.TryGetGuid(out var asset) => $"Media asset `{asset}`", JsonValueKind.String => Text(value.GetString()!), @@ -203,6 +257,12 @@ private static string Block(string type, JsonElement settings, JsonElement value }; } + // A block quote is a container, so a fence, an HTML block or a list the rich text leaves open closes where the quote ends. + private static string Quoted(string markdown) + { + return string.Join('\n', markdown.ReplaceLineEndings("\n").Split('\n').Select(line => line.Length == 0 ? ">" : $"> {line}")); + } + // The serializer writes a backtick in a string as `, so no value inside can close the fence. private static string Fenced(JsonElement value) { diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index e0986c67a..32bc6aece 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -158,6 +158,7 @@ await scenario.PublishAsync( Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(await content.GetAsync(page))); Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(await content.ListAsync(new PublishedQuery("page")))); Assert.Equal([product], (await content.ListAsync(new PublishedQuery("product"))).Value.Items.Select(item => item.Id)); + Assert.Equal([product], (await content.IndexAsync()).Select(summary => summary.Id)); } [Theory] diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs index ee6aca3ff..447fa363e 100644 --- a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -26,9 +26,12 @@ public sealed class EntryMarkdownTests Field("extra", "json"), Field("note", "text"), Field("sections", "list", """{"itemType": "richText"}"""), + Field("maker", "reference", """{"target": "product", "field": null}"""), ]); - private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number"), Field("claim", "text")], isEntity: true); + private static readonly ContentTypeDefinition ProductType = new( + [Field("name", "text"), Field("price", "number"), Field("claim", "richText"), Field("colors", "list", """{"itemType": "text"}""")], + isEntity: true); [Fact] public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() @@ -47,7 +50,8 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() "launch": "2026-10-18T09:00:00+00:00", "extra": {"warranty": 2}, "note": null, - "sections": ["First part.\n\nSecond paragraph.", "```\ncode\n```"] + "sections": ["First part.\n\nSecond paragraph.", "```\ncode\n```"], + "maker": "{{Product}}" } """); @@ -74,9 +78,9 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() ## body - Built for **daily** use. - - - Two speeds + > Built for **daily** use. + > + > - Two speeds ## tags @@ -113,12 +117,16 @@ Media asset `{{Hero}}` ## sections - - First part. + - > First part. + > + > Second paragraph. + - > ``` + > code + > ``` + + ## maker - Second paragraph. - - ``` - code - ``` + [{{Product}}](/api/v1/entries/{{Product}}.md) """.Replace("\r\n", "\n", StringComparison.Ordinal), markdown); @@ -129,44 +137,56 @@ Second paragraph. public void Text_cannot_pose_as_a_field_a_source_html_or_formatting() { var page = Item(PageType, "product-page", """ - {"headline": "Learn C #", "note": "Built tough.\n\n## price\n\n0\n\n costs 2*3*4\n1. first\n- item\n indented"} + {"headline": "Learn C #", "note": "Built tough.\n\n## price\n\n0\n\n costs 2*3*4 & more\n1. first\n- item\n indented\n---"} """); - var product = Item(ProductType, "product", """ + + var markdown = EntryMarkdown.Render(page, "/x.md", id => $"/{id}.md"); + + Assert.Contains("# Learn C \\#\n", markdown, StringComparison.Ordinal); + Assert.Contains( + "## note\n\nBuilt tough.\n\n\\## price\n\n0\n\n\\ costs 2\\*3\\*4 \\& more\n1\\. first\n\\- item\nindented\n\\---\n", + markdown, + StringComparison.Ordinal); + } + + // Rich text stays rich, but quoted: a fence or a fake source it holds ends with its field. + [Fact] + public void Rich_text_cannot_reach_the_fields_and_sources_after_it() + { + var claimFirst = new ContentTypeDefinition([Field("name", "text"), Field("claim", "richText"), Field("price", "number")], isEntity: true); + var product = Item(claimFirst, "product", """ { - "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, - "claim": {"value": "Lasts 10 years\n\nSource: https://manufacturer.example/spec (url)", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + "name": {"value": "Drill", "source": {"kind": "person", "reference": "Amir"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "claim": {"value": "Lasts 10 years\n\n**Source:** \n\n```\nopen fence", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "price": {"value": 99, "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} } """); - var pageMarkdown = EntryMarkdown.Render(page, "/x.md", id => $"/{id}.md"); - var productMarkdown = EntryMarkdown.Render(product, "/y.md", id => $"/{id}.md"); + var markdown = EntryMarkdown.Render(product, "/y.md", id => $"/{id}.md"); - Assert.Contains("# Learn C \\#\n", pageMarkdown, StringComparison.Ordinal); - Assert.Contains( - "## note\n\nBuilt tough.\n\n\\#\\# price\n\n0\n\n\\ costs 2\\*3\\*4\n1\\. first\n\\- item\nindented\n", - pageMarkdown, - StringComparison.Ordinal); - Assert.Single(pageMarkdown.Split('\n'), line => line.StartsWith("## price", StringComparison.Ordinal) || line == "## note"); Assert.Contains( - "## claim\n\nLasts 10 years\n\nSource: https://manufacturer.example/spec (url)\n\n- **Source:** none, so this fact is unsourced\n", - productMarkdown, + "## claim\n\n> Lasts 10 years\n>\n> **Source:** \n>\n> ```\n> open fence\n\n**Source:** none, so this fact is unsourced\n", + markdown, StringComparison.Ordinal); + Assert.EndsWith("## price\n\n99\n\n**Source:** none, so this fact is unsourced\n", markdown, StringComparison.Ordinal); } [Fact] - public void An_entity_shows_each_facts_value_with_its_source() + public void An_entity_shows_each_facts_value_with_its_source_the_title_fact_included() { var item = Item(ProductType, "product", """ { "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, - "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices_[2026]"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices_[2026]?a=1&b=2"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "colors": {"value": ["red", "blue"], "source": {"kind": "file", "reference": "exports/catalog_2026.csv"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} } """); var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); - Assert.Contains("# Drill\n", markdown, StringComparison.Ordinal); - Assert.EndsWith("## price\n\n99\n\n- **Source:** https://example.com/prices\\_\\[2026\\] \\(url\\)\n", markdown, StringComparison.Ordinal); + Assert.Contains("# Drill\n\n## name\n\nDrill\n\n**Source:** none, so this fact is unsourced\n", markdown, StringComparison.Ordinal); + Assert.Contains("## price\n\n99\n\n**Source:** \\(url\\)\n", markdown, StringComparison.Ordinal); + Assert.EndsWith("## colors\n\n- red\n- blue\n\n**Source:** exports/catalog_2026.csv \\(file\\)\n", markdown, StringComparison.Ordinal); Assert.Equal("Drill", EntryMarkdown.Title(item.Fields, ProductType)); } @@ -187,13 +207,18 @@ public void Without_text_the_heading_is_the_type_name_and_the_id_and_unresolved_ Assert.EndsWith($"## Unresolved references\n\n- `/price`: price of entry {Product} is not published\n", markdown, StringComparison.Ordinal); } - // Index link text: a backslash, brackets and an autolink stay text inside the link. + // Link text and plain text: what could open structure is escaped, and words and URLs keep their characters. [Theory] [InlineData(@"C:\", @"C:\\")] [InlineData(@"a\[b]", @"a\\\[b\]")] [InlineData("", @"\")] [InlineData("two\nlines", "two lines")] - public void Inline_text_keeps_backslashes_brackets_and_autolinks_as_text(string text, string expected) + [InlineData("https://en.wikipedia.org/wiki/Power_drill?a=1&b=2#history", "https://en.wikipedia.org/wiki/Power_drill?a=1&b=2#history")] + [InlineData("_emphasis_ and snake_case", @"\_emphasis\_ and snake_case")] + [InlineData("C# and F#", "C# and F#")] + [InlineData("Title ##", @"Title \##")] + [InlineData("© and © and AT&T", @"\© and \© and AT&T")] + public void Inline_text_escapes_only_what_could_open_structure(string text, string expected) { Assert.Equal(expected, EntryMarkdown.InlineText(text)); } From 7f024ee8555d2933134dad6642e6bb436b8c30ce Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:06:40 -0400 Subject: [PATCH 10/15] Regenerate the preview token migrations on top of v2 --- .../20261019080632_PreviewTokens.Designer.cs | 1520 +++++++++++++++++ .../20261019080632_PreviewTokens.cs | 67 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 51 + .../20261019080553_PreviewTokens.Designer.cs | 1510 ++++++++++++++++ .../Sqlite/20261019080553_PreviewTokens.cs | 67 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 51 + 6 files changed, 3266 insertions(+) create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.cs diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.Designer.cs new file mode 100644 index 000000000..d58130dc5 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.Designer.cs @@ -0,0 +1,1520 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019080632_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.cs new file mode 100644 index 000000000..7dca2ca5e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019080632_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index fa298c4fd..13b9add91 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -628,6 +628,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1357,6 +1393,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.Designer.cs new file mode 100644 index 000000000..2724b54f3 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.Designer.cs @@ -0,0 +1,1510 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019080553_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.cs new file mode 100644 index 000000000..c2b75798b --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019080553_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SiteId = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "BLOB", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 455bfb118..4022ede5c 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -622,6 +622,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1347,6 +1383,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) From 418fd23acd4a692f4f32fb09ff4c65132b453309 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:46:31 -0400 Subject: [PATCH 11/15] Keep list items in their bullets and escape extension block openers --- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 7 ++++--- src/FluentCMS.Core/Delivery/IPublishedContent.cs | 2 +- .../FluentCMS.Tests/Delivery/EntryMarkdownTests.cs | 13 +++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs index f3d7cf64d..67a9083d3 100644 --- a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -27,7 +27,8 @@ public static class EntryMarkdown // Plain text as Markdown that reads as written; CommonMark lets a backslash escape any punctuation. Escaped wherever they are: // what opens code, emphasis, links, autolinks, HTML and strikethrough, an underscore that is not inside a word, and an ampersand - // that starts an entity. Escaped where a line starts: what opens a heading, a list, a quote, a table or a heading underline. + // that starts an entity. Escaped where a line starts: what opens a heading, a list, a quote, a table, a heading underline, or the + // math and figure blocks of common extensions. // Lines lose their indentation, so none becomes a code block; breaks stay. URLs and words keep their characters. public static string Text(string text) { @@ -121,7 +122,7 @@ private static bool NeedsEscape(string line, int position, int digits) return true; } - if (position == 0 && character is '#' or '-' or '+' or '=' or '|' or ':') + if (position == 0 && character is '#' or '-' or '+' or '=' or '|' or ':' or '$' or '^') { return true; } @@ -238,7 +239,7 @@ private static string Block(string type, JsonElement settings, JsonElement value // Each item keeps its own block, indented under its bullet so paragraphs and fences stay inside the item. return string.Join('\n', value.EnumerateArray() .Where(element => element.ValueKind != JsonValueKind.Null) - .Select(element => "- " + Block(itemType, itemSettings, element, entryUrl).Replace("\n", "\n ", StringComparison.Ordinal).Replace("\n \n", "\n\n", StringComparison.Ordinal))); + .Select(element => "- " + Block(itemType, itemSettings, element, entryUrl).TrimStart().Replace("\n", "\n ", StringComparison.Ordinal).Replace("\n \n", "\n\n", StringComparison.Ordinal))); } // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 2fe67d547..f4a8123a1 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -34,7 +34,7 @@ public sealed record PublishedPage(IReadOnlyList Items, Guid? Nex // Title is EntryMarkdown.Title of the published version, null when it has no text. public sealed record PublishedSummary(Guid Id, string ContentType, string ContentTypeName, int Version, string? Title); -// The content type's name and current definition come along, since the published version is read against them. +// The content type's name and the definition the version was written against come along, since the version is read against it. public sealed record PublishedItem( Guid Id, string ContentType, diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs index 447fa363e..070a797db 100644 --- a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -218,11 +218,24 @@ public void Without_text_the_heading_is_the_type_name_and_the_id_and_unresolved_ [InlineData("C# and F#", "C# and F#")] [InlineData("Title ##", @"Title \##")] [InlineData("© and © and AT&T", @"\© and \© and AT&T")] + [InlineData("$$", @"\$$")] + [InlineData("^^^ figure", @"\^^^ figure")] public void Inline_text_escapes_only_what_could_open_structure(string text, string expected) { Assert.Equal(expected, EntryMarkdown.InlineText(text)); } + // CommonMark lets an item start with one blank line at most, so leading blank lines would push the text out of its bullet. + [Fact] + public void A_list_item_that_starts_with_blank_lines_stays_in_its_bullet() + { + var item = Item(PageType, "product-page", """{"tags": ["one", "\n\nsecond item", "third"]}"""); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains("## tags\n\n- one\n- second item\n- third\n", markdown, StringComparison.Ordinal); + } + [Fact] public void A_backtick_in_json_cannot_close_its_fence() { From 617c95ceaaff6b1924f046e6540be819bb26590f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:00:01 -0400 Subject: [PATCH 12/15] Regenerate the preview token migrations on top of v2 --- .../20261019085959_PreviewTokens.Designer.cs | 1557 +++++++++++++++++ .../20261019085959_PreviewTokens.cs | 67 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 51 + .../20261019085908_PreviewTokens.Designer.cs | 1547 ++++++++++++++++ .../Sqlite/20261019085908_PreviewTokens.cs | 67 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 51 + 6 files changed, 3340 insertions(+) create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.cs diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.Designer.cs new file mode 100644 index 000000000..d33129a9f --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.Designer.cs @@ -0,0 +1,1557 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019085959_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddedById") + .HasColumnType("uuid"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Events") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.cs new file mode 100644 index 000000000..7dca2ca5e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019085959_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 43f11153d..d40af02d0 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -628,6 +628,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1394,6 +1430,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.Designer.cs new file mode 100644 index 000000000..2ee547baf --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.Designer.cs @@ -0,0 +1,1547 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019085908_PreviewTokens")] + partial class PreviewTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.Property("AddedById") + .HasColumnType("TEXT"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DisabledAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.cs new file mode 100644 index 000000000..c2b75798b --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019085908_PreviewTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class PreviewTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviewTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SiteId = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "BLOB", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviewTokens", x => x.Id); + table.ForeignKey( + name: "FK_PreviewTokens_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PreviewTokens_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_ChangeSetId", + table: "PreviewTokens", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_SiteId", + table: "PreviewTokens", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_PreviewTokens_TokenHash", + table: "PreviewTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviewTokens"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index f660d42a6..63a920233 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -622,6 +622,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.Property("Id") @@ -1384,6 +1420,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => { b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) From ebf2770af629e96f9c02a21e606b983511c9a800 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:48:43 -0400 Subject: [PATCH 13/15] Refuse a second revoke before the unit of work and name the bearer scheme on 401 --- src/FluentCMS.Api/DeliveryEndpoints.cs | 15 ++++++- .../Delivery/IPublishedContent.cs | 1 + .../Delivery/PreviewTokenDirectory.cs | 12 +++++- .../Delivery/DeliveryEndpointsTests.cs | 2 + .../Delivery/PreviewTests.cs | 39 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 5a15d3613..4cf897e76 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -89,7 +89,7 @@ private static async Task PreviewEntryAsync(string id, HttpContext http } var item = await preview.GetAsync(BearerToken(http), entryId, cancellationToken); - return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); + return item.IsSuccess ? Results.Json(Item(item.Value)) : PreviewProblem(http, item.Errors); } private static async Task PreviewChangesAsync(HttpContext http, IPreviewContent preview, CancellationToken cancellationToken) @@ -98,7 +98,18 @@ private static async Task PreviewChangesAsync(HttpContext http, IPrevie var changes = await preview.ChangesAsync(BearerToken(http), cancellationToken); return changes.IsSuccess ? Results.Json(new ChangesResponse(changes.Value.ChangeSetId, [.. changes.Value.Items.Select(Item)])) - : Problem(changes.Errors); + : PreviewProblem(http, changes.Errors); + } + + // A 401 names the scheme it expects (RFC 9110). + private static IResult PreviewProblem(HttpContext http, IReadOnlyList 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. diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index b1580f10b..979252ca3 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -52,5 +52,6 @@ public static class DeliveryErrorCodes 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"; } diff --git a/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs index 2166ca4bf..18d4a7a48 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs @@ -80,6 +80,11 @@ public async Task RevokeAsync(Guid tokenId, CancellationToken cancellati return Result.Failure(DeliveryErrorCodes.PreviewTokenNotFound, $"There is no preview token {tokenId} in this site."); } + if (row.RevokedAt is { } revokedAt) + { + return Result.Failure([AlreadyRevoked(tokenId, revokedAt)]); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); // Only a token nobody revoked in the meantime is updated, so of two concurrent revokes the second changes nothing. @@ -88,7 +93,7 @@ public async Task RevokeAsync(Guid tokenId, CancellationToken cancellati .ExecuteUpdateAsync(setters => setters.SetProperty(token => token.RevokedAt, clock.GetUtcNow()), cancellationToken); if (revoked == 0) { - return Result.Failure(DeliveryErrorCodes.PreviewTokenNotFound, $"Preview token {tokenId} is already revoked."); + return Result.Failure(DeliveryErrorCodes.PreviewTokenAlreadyRevoked, $"Preview token {tokenId} was revoked meanwhile."); } await auditLog.AppendAsync( @@ -116,6 +121,11 @@ public async Task>> ListAsync(Guid changeSetI [.. rows.Select(row => new PreviewToken(row.Id, row.ChangeSetId, row.CreatedAt, row.ExpiresAt, row.RevokedAt))]); } + private static Error AlreadyRevoked(Guid tokenId, DateTimeOffset revokedAt) + { + return new Error(DeliveryErrorCodes.PreviewTokenAlreadyRevoked, $"Preview token {tokenId} was already revoked at {revokedAt:O}."); + } + // A token shows unapproved content to whoever holds it, so only a person hands one out (API-04). private Result? RefuseAllButPeople() { diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index c48f5e215..0d9e8ac2b 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -161,6 +161,8 @@ public async Task A_preview_without_a_working_token_is_a_problem_with_401() await AssertProblemAsync(missing, HttpStatusCode.Unauthorized, DeliveryErrorCodes.PreviewTokenInvalid); await AssertProblemAsync(refused, HttpStatusCode.Unauthorized, DeliveryErrorCodes.PreviewTokenInvalid); + Assert.Equal("Bearer", Assert.Single(missing.Headers.WwwAuthenticate).Scheme); + Assert.Equal("Bearer", Assert.Single(refused.Headers.WwwAuthenticate).Scheme); } private static async Task AssertProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs index bfb77c2b0..d2a95663a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs @@ -1,11 +1,14 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; +using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Delivery; using FluentCMS.Infrastructure.Persistence; using FluentCMS.IntegrationTests.ChangeSets; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; using static FluentCMS.IntegrationTests.ChangeSets.EntryChangeSetTests; using static FluentCMS.IntegrationTests.Content.EntryScenario; @@ -56,6 +59,18 @@ public async Task A_token_stops_working_when_revoked_when_its_set_is_rejected_an Assert.True((await TokensAsync(scenario, tokens => tokens.RevokeAsync(revoked.Id))).IsSuccess); Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.GetAsync(revoked.Token, page)))); + + // A second revoke is refused before the unit of work, so a caller's own unit of work still commits. + await using (var outer = scenario.Services.CreateAsyncScope()) + { + var unitOfWork = outer.ServiceProvider.GetRequiredService(); + await using var callerScope = await unitOfWork.BeginAsync(); + var again = await outer.ServiceProvider.GetRequiredService().RevokeAsync(revoked.Id); + + Assert.Equal(DeliveryErrorCodes.PreviewTokenAlreadyRevoked, Assert.Single(again.Errors).Code); + await callerScope.CommitAsync(); + } + Assert.True((await PreviewAsync(scenario, preview => preview.GetAsync(expiring.Token, page))).IsSuccess); Assert.Equal(DeliveryErrorCodes.PreviewTokenInvalid, Code(await PreviewAsync(scenario, preview => preview.GetAsync(PreviewTokenKeys.Generate(), page)))); @@ -119,6 +134,11 @@ public async Task What_the_set_unpublishes_is_gone_and_what_it_leaves_reads_as_p await using var scenario = await ScenarioAsync(provider); var (page, product) = await LiveCatalogAsync(scenario); var draft = (await EntriesAsync(scenario, entries => entries.CreateAsync(new NewEntry("page", Json("""{"title": "Draft"}"""))))).Value.Id; + var deleted = (await EntriesAsync(scenario, entries => entries.CreateAsync(new NewEntry("page", Json("""{"title": "Deleted"}"""))))).Value.Id; + var archived = (await EntriesAsync(scenario, entries => entries.CreateAsync(new NewEntry("page", Json("""{"title": "Archived"}"""))))).Value.Id; + await scenario.PublishAsync(Operation(OperationKind.Create, ItemKind.Entry, deleted, null, 1), Operation(OperationKind.Create, ItemKind.Entry, archived, null, 1)); + await scenario.PublishAsync(Operation(OperationKind.Delete, ItemKind.Entry, archived, 1, null)); + Assert.True((await EntriesAsync(scenario, entries => entries.DeleteAsync(deleted, 1))).IsSuccess); var set = await scenario.OpenAsync(Operation(OperationKind.Delete, ItemKind.Entity, product, 1, null)); var token = (await TokensAsync(scenario, tokens => tokens.IssueAsync(set.Id))).Value; @@ -132,9 +152,28 @@ public async Task What_the_set_unpublishes_is_gone_and_what_it_leaves_reads_as_p Assert.Equal(UnresolvedReason.NotPublished, Assert.Single(shown.Unresolved).Reason); Assert.Equal(ContentErrorCodes.EntryNotFound, Code(unpublished)); Assert.Equal(ContentErrorCodes.EntryNotFound, Code(hidden)); + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, deleted)))); + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(await PreviewAsync(scenario, preview => preview.GetAsync(token.Token, archived)))); Assert.Empty(changes.Items); } + [Theory] + [InlineData("00:00:00")] + [InlineData("-1.00:00:00")] + [InlineData("366.00:00:00")] + public void A_token_lifetime_that_is_not_positive_or_over_a_year_is_refused(string lifetime) + { + using var services = TestServices.Build(new Dictionary + { + ["Database:ConnectionString"] = "Data Source=unused.db", + ["Delivery:PreviewTokenLifetime"] = lifetime, + }); + + var exception = Assert.Throws(() => services.GetRequiredService>().Value); + + Assert.Contains("Delivery__PreviewTokenLifetime", exception.Message); + } + // The catalog published at version 1, with version 2 of both stored: the product at 24.99 and the page with a new title. private static async Task<(Guid Page, Guid Product)> LiveCatalogAsync(ChangeSetScenario scenario) { From 0c2cb6ff7168c61ef59c2b6f5207d55352d4d058 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 06:51:00 -0400 Subject: [PATCH 14/15] Count clients by the address a trusted proxy forwards and by their IPv6 /64 --- CONTRIBUTING.md | 2 +- docs/api/delivery.md | 2 + src/FluentCMS.Api/DeliveryRateLimit.cs | 28 +++++++- src/FluentCMS.Host/FluentCmsHost.cs | 2 + src/FluentCMS.Host/Security/ReverseProxy.cs | 71 +++++++++++++++++++ .../Delivery/DeliveryEndpointsTests.cs | 14 ++++ .../Delivery/DeliveryHardeningTests.cs | 71 +++++++++++++++++++ .../Delivery/DeliveryPerformanceTests.cs | 5 ++ 8 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/FluentCMS.Host/Security/ReverseProxy.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 592c7b328..3e368e343 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute 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), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count; with none, the header is ignored), `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. diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 3b8e22f01..d14b4f38d 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -90,6 +90,8 @@ A token stops working when it is revoked or expires (`Delivery__PreviewTokenLife Each client address may make 600 requests a minute to the delivery API, `/llms.txt` included (`Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window`). A request beyond that gets a 429 problem with `Retry-After`. +An IPv6 client is counted by its /64 prefix. Behind a reverse proxy, list the proxy in `ReverseProxy__KnownProxies__0` or its network in `ReverseProxy__KnownNetworks__0`, so each client is counted by the address the proxy forwards. Without that, every client shares the proxy's address. `X-Forwarded-For` from anyone else is ignored. + ## 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`: diff --git a/src/FluentCMS.Api/DeliveryRateLimit.cs b/src/FluentCMS.Api/DeliveryRateLimit.cs index b3193fdd4..28f5f34d4 100644 --- a/src/FluentCMS.Api/DeliveryRateLimit.cs +++ b/src/FluentCMS.Api/DeliveryRateLimit.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Net; +using System.Net.Sockets; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; @@ -30,10 +32,34 @@ public RateLimitPartition GetPartition(HttpContext httpContext) { var options = httpContext.RequestServices.GetRequiredService>().Value; return RateLimitPartition.GetFixedWindowLimiter( - httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + ClientKey(httpContext.Connection.RemoteIpAddress), _ => new FixedWindowRateLimiterOptions { PermitLimit = options.PermitLimit, Window = options.Window, QueueLimit = 0 }); } + // An IPv6 client usually holds a whole /64, so it is counted by that prefix; by its full address it could take a fresh one per + // request. Behind a trusted proxy the address is the client's, as the host's forwarded headers set it. + public static string ClientKey(IPAddress? address) + { + if (address is null) + { + return "unknown"; + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily != AddressFamily.InterNetworkV6) + { + return address.ToString(); + } + + var prefix = address.GetAddressBytes(); + Array.Clear(prefix, 8, 8); + return $"{new IPAddress(prefix)}/64"; + } + private static async ValueTask RejectAsync(OnRejectedContext context, CancellationToken cancellationToken) { var http = context.HttpContext; diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index 4a2c09903..6113ea334 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -53,12 +53,14 @@ public static WebApplication BuildWebApplication(string[] args, Action KnownProxies { get; set; } = []; + + // Networks in CIDR notation such as 10.0.0.0/8. + public List KnownNetworks { get; set; } = []; + + public bool IsConfigured => KnownProxies.Count > 0 || KnownNetworks.Count > 0; +} + +internal sealed class ReverseProxyOptionsValidation : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ReverseProxyOptions options) + { + var failures = options.KnownProxies + .Where(proxy => !IPAddress.TryParse(proxy, out _)) + .Select(proxy => $"ReverseProxy__KnownProxies has '{proxy}', which is not an IP address such as 10.0.0.5.") + .Concat(options.KnownNetworks + .Where(network => !System.Net.IPNetwork.TryParse(network, out _)) + .Select(network => $"ReverseProxy__KnownNetworks has '{network}', which is not a network such as 10.0.0.0/8.")) + .ToList(); + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } +} + +internal static class ReverseProxy +{ + public static IServiceCollection AddReverseProxy(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions().Bind(configuration.GetSection(ReverseProxyOptions.SectionName)).ValidateOnStart(); + services.AddSingleton, ReverseProxyOptionsValidation>(); + return services; + } + + // Before the rate limiters, so they count the client the trusted proxy names. Only the nearest proxy's entry is taken. + public static IApplicationBuilder UseReverseProxy(this IApplicationBuilder app) + { + var proxy = app.ApplicationServices.GetRequiredService>().Value; + if (!proxy.IsConfigured) + { + return app; + } + + var forwarded = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }; + forwarded.KnownProxies.Clear(); + forwarded.KnownIPNetworks.Clear(); + foreach (var address in proxy.KnownProxies) + { + forwarded.KnownProxies.Add(IPAddress.Parse(address)); + } + + foreach (var network in proxy.KnownNetworks) + { + forwarded.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network)); + } + + return app.UseForwardedHeaders(forwarded); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 0d9e8ac2b..749faa3da 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -83,6 +83,20 @@ public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); } + [Theory] + [InlineData("Product-Page")] + [InlineData("product_page")] + public async Task A_type_that_cannot_be_a_slug_is_not_found_without_asking_the_reader(string type) + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetAsync($"/api/v1/content/{type}"); + + await AssertProblemAsync(response, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + Assert.Null(content.Query); + } + [Theory] [InlineData("limit=ten", DeliveryErrorCodes.LimitInvalid, "'limit'")] [InlineData("limit=-1", DeliveryErrorCodes.LimitInvalid, "'limit'")] diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs index ff3c2ca42..73840263a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryHardeningTests.cs @@ -37,6 +37,77 @@ public async Task Requests_beyond_the_limit_get_a_problem_with_retry_after_while Assert.Equal(HttpStatusCode.OK, (await host.Client.GetAsync("/health")).StatusCode); } + // Behind a trusted proxy each client has its own limit; without one, X-Forwarded-For is ignored and cannot pick a fresh bucket. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Only_a_trusted_proxy_names_the_client_a_limit_counts(bool trusted) + { + using var install = new TemporaryInstall(); + var settings = new Dictionary + { + ["Delivery:RateLimit:PermitLimit"] = "3", + ["Delivery:RateLimit:Window"] = "00:01:00", + }; + if (trusted) + { + settings["ReverseProxy:KnownProxies:0"] = "127.0.0.1"; + } + + await using var host = await AdminHost.StartAsync(install, settings: settings); + + async Task FromAsync(string client) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/index"); + request.Headers.Add("X-Forwarded-For", client); + return (await host.Client.SendAsync(request)).StatusCode; + } + + var sixClients = new List(); + for (var client = 1; client <= 6; client++) + { + sixClients.Add(await FromAsync($"203.0.113.{client}")); + } + + if (trusted) + { + Assert.All(sixClients, status => Assert.Equal(HttpStatusCode.OK, status)); + Assert.Equal(HttpStatusCode.OK, await FromAsync("203.0.113.1")); + Assert.Equal(HttpStatusCode.OK, await FromAsync("203.0.113.1")); + Assert.Equal(HttpStatusCode.TooManyRequests, await FromAsync("203.0.113.1")); + } + else + { + Assert.Equal([HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK], sixClients.Take(3)); + Assert.All(sixClients.Skip(3), status => Assert.Equal(HttpStatusCode.TooManyRequests, status)); + } + } + + [Fact] + public async Task A_proxy_that_is_not_an_address_stops_the_host() + { + using var install = new TemporaryInstall(); + + var exception = await Assert.ThrowsAsync(() => AdminHost.StartAsync(install, settings: new Dictionary + { + ["ReverseProxy:KnownProxies:0"] = "proxy.example", + ["ReverseProxy:KnownNetworks:0"] = "10.0.0.0/33", + })); + + Assert.Contains("ReverseProxy__KnownProxies has 'proxy.example'", exception.Message); + Assert.Contains("ReverseProxy__KnownNetworks has '10.0.0.0/33'", exception.Message); + } + + [Theory] + [InlineData("2001:db8:1:2::5", "2001:db8:1:2:ffff::9", true)] + [InlineData("2001:db8:1:2::5", "2001:db8:1:3::5", false)] + [InlineData("::ffff:203.0.113.7", "203.0.113.7", true)] + [InlineData("203.0.113.7", "203.0.113.8", false)] + public void An_ipv6_client_is_counted_by_its_64_prefix(string first, string second, bool same) + { + Assert.Equal(same, DeliveryRateLimit.ClientKey(IPAddress.Parse(first)) == DeliveryRateLimit.ClientKey(IPAddress.Parse(second))); + } + [Fact] public void Each_client_address_has_its_own_limit() { diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs index d2d098489..876152dda 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryPerformanceTests.cs @@ -12,6 +12,11 @@ namespace FluentCMS.IntegrationTests.Delivery; +// Measured alone: the collection runs after the parallel ones, so the rest of the suite does not load the machine meanwhile. +[CollectionDefinition(nameof(DeliveryPerformanceTests), DisableParallelization = true)] +public sealed class DeliveryPerformanceCollection; + +[Collection(nameof(DeliveryPerformanceTests))] public sealed class DeliveryPerformanceTests { private const int Products = 50; From a549df655a444f9825506763fbf26cb63cfd0c73 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 07:37:53 -0400 Subject: [PATCH 15/15] Say that one forwarded hop is read --- CONTRIBUTING.md | 2 +- docs/api/delivery.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e368e343..a6eeca73a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count; with none, the header is ignored), `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), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count, read one hop deep; with none, the header is ignored), `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. diff --git a/docs/api/delivery.md b/docs/api/delivery.md index d14b4f38d..6265b91f3 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -90,7 +90,7 @@ A token stops working when it is revoked or expires (`Delivery__PreviewTokenLife Each client address may make 600 requests a minute to the delivery API, `/llms.txt` included (`Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window`). A request beyond that gets a 429 problem with `Retry-After`. -An IPv6 client is counted by its /64 prefix. Behind a reverse proxy, list the proxy in `ReverseProxy__KnownProxies__0` or its network in `ReverseProxy__KnownNetworks__0`, so each client is counted by the address the proxy forwards. Without that, every client shares the proxy's address. `X-Forwarded-For` from anyone else is ignored. +An IPv6 client is counted by its /64 prefix. Behind a reverse proxy, list the proxy in `ReverseProxy__KnownProxies__0` or its network in `ReverseProxy__KnownNetworks__0`, so each client is counted by the address the proxy forwards. Without that, every client shares the proxy's address. `X-Forwarded-For` from anyone else is ignored. Only the address the nearest listed proxy forwards is read, so with a chain such as a CDN in front of nginx, have nginx pass the real client address in its own `X-Forwarded-For`. ## Errors