diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 289b2bf16..73c92309d 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. 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 aac8eda73..612e23487 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -68,6 +68,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`: @@ -75,4 +93,5 @@ Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (` | Status | Codes | | --- | --- | | 400 | `limit_invalid`, `parameter_invalid` | -| 404 | `entry_not_found`, `content_type_not_found` | +| 401 | `preview_token_invalid` | +| 404 | `entry_not_found`, `content_type_not_found`, `endpoint_not_found` | diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 55bebd23c..4cf897e76 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,47 @@ 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)) : PreviewProblem(http, 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)])) + : 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. + 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 +216,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 +231,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 f4a8123a1..979252ca3 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -49,4 +49,9 @@ public static class DeliveryErrorCodes public const string LimitInvalid = "limit_invalid"; public const string ParameterInvalid = "parameter_invalid"; public const string EndpointNotFound = "endpoint_not_found"; + public const string PreviewTokenInvalid = "preview_token_invalid"; + public const string PreviewByPeopleOnly = "preview_by_people_only"; + public const string PreviewTokenNotFound = "preview_token_not_found"; + public const string PreviewTokenAlreadyRevoked = "preview_token_already_revoked"; + public const string ChangeSetNotPreviewable = "change_set_not_previewable"; } 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..18d4a7a48 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PreviewTokenDirectory.cs @@ -0,0 +1,145 @@ +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."); + } + + 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. + 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.PreviewTokenAlreadyRevoked, $"Preview token {tokenId} was revoked meanwhile."); + } + + 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))]); + } + + 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() + { + 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 0e8ffa7b1..76bb81bb4 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; @@ -13,11 +14,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. 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 +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); @@ -75,6 +79,7 @@ orderby entry.Id var resolved = await EntryResolution.ResolveAsync( context, [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + NoProposals, cancellationToken); return Result.Success(new PublishedPage( @@ -82,30 +87,49 @@ [.. resolved.Select((entry, index) => Item(entry, type.Name, definitions[(type.I 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 ( - 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."); - } + return ReadAsync(id, proposals: null, cancellationToken); + } - var allowed = await permissions.CheckContentAsync(entry.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 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)])); + var changed = proposals.Items.Where(id => proposals.Versions[id] is not null).ToList(); + var numbers = changed.Select(id => proposals.Versions[id]!.Value).Distinct().ToList(); + 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 entry.Id equals version.EntryId + where changed.Contains(entry.Id) && numbers.Contains(version.Version) && entry.SiteId == SiteId && entry.DeletedAt == null + select new { entry.Id, entry.ContentTypeId, type.Slug, type.Name, version.Version, version.ContentTypeVersion }) + .ToListAsync(cancellationToken)) + .Where(row => proposals.Versions[row.Id] == row.Version) + .ToDictionary(row => row.Id); + + var readable = await permissions.ReadableTypesAsync(cancellationToken); + var shown = changed.Where(id => rows.TryGetValue(id, out var row) && readable(row.Slug)).Select(id => rows[id]).ToList(); + var definitions = await DefinitionsAsync([.. shown.Select(row => (row.ContentTypeId, row.ContentTypeVersion))], cancellationToken); + var resolved = await EntryResolution.ResolveAsync( + context, + [.. shown.Select(row => new VersionToResolve(row.Id, row.Slug, row.Version))], + proposals.Versions, + cancellationToken); + + return Result.Success(new PreviewChanges( + proposals.ChangeSetId, + [.. resolved.Select((entry, index) => Item(entry, shown[index].Name, definitions[(shown[index].ContentTypeId, shown[index].ContentTypeVersion)]))])); } public async Task> IndexAsync(CancellationToken cancellationToken = default) @@ -140,6 +164,83 @@ 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 ( + 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.DeletedAt == null + select new { row.ContentTypeId, type.Slug, type.Name, row.PublishedVersion }) + .SingleOrDefaultAsync(cancellationToken); + var number = proposals is not null && proposals.Versions.TryGetValue(id, out var proposed) ? proposed : entry?.PublishedVersion; + if (entry is null || number is not { } version) + { + 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 typeVersion = await context.EntryVersions + .AsNoTracking() + .Where(row => row.EntryId == id && row.Version == version) + .Select(row => row.ContentTypeVersion) + .SingleAsync(cancellationToken); + var definitions = await DefinitionsAsync([(entry.ContentTypeId, typeVersion)], cancellationToken); + var resolved = await EntryResolution.ResolveAsync( + context, + [new VersionToResolve(id, entry.Slug, version)], + proposals?.Versions ?? NoProposals, + cancellationToken); + return Result.Success(Item(resolved[0], entry.Name, definitions[(entry.ContentTypeId, typeVersion)])); + } + + // 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 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, @@ -175,4 +276,7 @@ private static PublishedItem Item(ResolvedEntry entry, string typeName, ContentT { return new PublishedItem(entry.EntryId, entry.ContentType, typeName, definition, entry.Version, entry.Fields, entry.Unresolved); } + + // 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 5ae7d314f..17a794e37 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -145,7 +145,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 67d7fbcff..3f1fe490d 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.Media; using FluentCMS.Infrastructure.Jobs; @@ -61,6 +62,8 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet WebhookSubscriptions => Set(); + public DbSet PreviewTokens => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -86,6 +89,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new CheckRunConfiguration()); builder.ApplyConfiguration(new CheckResultConfiguration()); builder.ApplyConfiguration(new WebhookSubscriptionConfiguration()); + builder.ApplyConfiguration(new PreviewTokenConfiguration()); builder.Entity(user => { 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) diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index e29b0294a..0d9e8ac2b 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -130,6 +130,41 @@ 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); + 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) { Assert.Equal(status, response.StatusCode); @@ -186,6 +221,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 +256,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..d2a95663a --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/PreviewTests.cs @@ -0,0 +1,216 @@ +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; + +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)))); + + // 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)))); + + 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 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; + + 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.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) + { + 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()); + } +}