From 5f6ef90d62356fe104c288032a7a8be93953c71c Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:45:13 -0400 Subject: [PATCH 1/2] Add webhook subscriptions with allow-listed URLs, a secret shown once and the admin page --- CONTRIBUTING.md | 10 + .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Webhooks.razor | 197 +++ .../Components/Webhooks/NewWebhookForm.cs | 75 + .../Security/ISecretProtector.cs | 10 + .../Webhooks/IWebhookDirectory.cs | 20 + .../Webhooks/WebhookErrorCodes.cs | 15 + src/FluentCMS.Core/Webhooks/WebhookEvents.cs | 10 + src/FluentCMS.Core/Webhooks/WebhookSecrets.cs | 19 + .../Webhooks/WebhookSubscription.cs | 134 ++ src/FluentCMS.Host/FluentCmsHost.cs | 2 + .../Security/DataProtectionSecretProtector.cs | 25 + src/FluentCMS.Host/Security/KeyRingOptions.cs | 2 +- ...frastructureServiceCollectionExtensions.cs | 9 + .../Persistence/FluentCmsDbContext.cs | 4 + ...019053029_WebhookSubscriptions.Designer.cs | 1346 +++++++++++++++++ .../20261019053029_WebhookSubscriptions.cs | 41 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 37 + ...019052937_WebhookSubscriptions.Designer.cs | 1336 ++++++++++++++++ .../20261019052937_WebhookSubscriptions.cs | 40 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 37 + .../Webhooks/WebhookDirectory.cs | 144 ++ .../WebhookSubscriptionConfiguration.cs | 20 + .../Webhooks/WebhookSubscriptionRow.cs | 21 + .../Agents/AgentCredentialScenario.cs | 88 +- .../TemporaryDatabase.cs | 49 + .../FluentCMS.IntegrationTests/TestDoubles.cs | 38 + .../Webhooks/WebhookDirectoryTests.cs | 163 ++ .../Webhooks/WebhookScenario.cs | 134 ++ .../Webhooks/WebhookScreenTests.cs | 138 ++ 30 files changed, 4079 insertions(+), 86 deletions(-) create mode 100644 src/FluentCMS.Admin/Components/Pages/Webhooks.razor create mode 100644 src/FluentCMS.Admin/Components/Webhooks/NewWebhookForm.cs create mode 100644 src/FluentCMS.Core/Security/ISecretProtector.cs create mode 100644 src/FluentCMS.Core/Webhooks/IWebhookDirectory.cs create mode 100644 src/FluentCMS.Core/Webhooks/WebhookErrorCodes.cs create mode 100644 src/FluentCMS.Core/Webhooks/WebhookEvents.cs create mode 100644 src/FluentCMS.Core/Webhooks/WebhookSecrets.cs create mode 100644 src/FluentCMS.Core/Webhooks/WebhookSubscription.cs create mode 100644 src/FluentCMS.Host/Security/DataProtectionSecretProtector.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.cs create mode 100644 src/FluentCMS.Infrastructure/Webhooks/WebhookDirectory.cs create mode 100644 src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionConfiguration.cs create mode 100644 src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionRow.cs create mode 100644 tests/FluentCMS.IntegrationTests/Webhooks/WebhookDirectoryTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/Webhooks/WebhookScenario.cs create mode 100644 tests/FluentCMS.IntegrationTests/Webhooks/WebhookScreenTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71f48f431..3ded9786e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,6 +120,16 @@ Every model call goes through `IModelGateway` in `FluentCMS.Core.Models` (ADR-00 - **Records.** Every call is kept in `ModelCalls` with its provider, model and version, verbatim prompt and response, tokens, cost and outcome (MDL-04, CHK-05). Audit events carry its prompt hash, and `IModelCallLog.FindByPromptHashAsync` finds the prompt again. A provider's error stays in the record; the caller gets the record's id. A call is made outside a unit of work, so the database is not held while a provider answers, and making one inside one throws. - **Retention.** Prompts and responses are kept for `Models__PromptRetention`, 90 days by default. A person runs `IModelCallLog.PurgeAsync`, which clears the prompt, response and error of older calls, keeps the rest of each record, and appends a `model_call.purged` event for each call (AUD-04, AUD-N2). +## Webhooks + +An administrator adds a webhook on `/webhooks` with the URL of an endpoint and the events it is told about, `change_set.published` and `change_set.reverted` (API-05). Only a person lists, adds or disables one; an agent is refused (SEC-02). + +- **URL.** It is https, or http to an endpoint on the same machine, and its host is on `Outbound__AllowedHosts`, the allow-list the model gateway uses. Nothing can be added until an administrator names a host (SEC-05). A URL with a user name or password in it is refused. A URL is never edited: disable the webhook and add a new one. +- **Secret.** Each webhook gets a signing secret, 32 random bytes written as `whsec_` and base64, shown once in the response that adds it. It is stored encrypted with the Data Protection key ring, so losing `DataProtection__KeysDirectory` loses the secrets too. It never appears in a list, a log or the audit log (SEC-07). +- **Audit.** Adding and disabling append `webhook.added` and `webhook.disabled`, which name the subscription. + +Deliveries, their signature and how a receiver checks it come with the next slice. + ## Extension points Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass. diff --git a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor index ae30f29f7..cd1e057da 100644 --- a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor +++ b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor @@ -12,6 +12,7 @@ @L["Audit log"] @L["Users"] @L["Agents"] + @L["Webhooks"] @* A plain form, so it posts to the sign-out page's handler from any page, static or interactive. *@
diff --git a/src/FluentCMS.Admin/Components/Pages/Webhooks.razor b/src/FluentCMS.Admin/Components/Pages/Webhooks.razor new file mode 100644 index 000000000..123084f5d --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/Webhooks.razor @@ -0,0 +1,197 @@ +@page "/webhooks" +@using System.Globalization +@using FluentCMS.Admin.Components.Webhooks +@using FluentCMS.Core.Webhooks +@inject IWebhookDirectory Directory +@inject IStringLocalizer L + +@L["Webhooks"] + +
+

@L["Webhooks"]

+

@L["Each endpoint is told when a change set is published or reverted. Its host must be on the outbound allow-list."]

+
+ +@if (_message is not null) +{ +

@_message

+} + +@* Shown in the response to the post that added it and nowhere else, so the secret never sits in a URL, a redirect or the + browser's history, and no later page can show it again. *@ +@if (_added is { } added) +{ + + +

@L["Signing secret for {0}", added.Subscription.Url]

+ @L["Copy the secret now and store it with the endpoint, which checks each delivery's signature with it. It is stored encrypted and cannot be shown again."] +
+ +
+ + + +
+
+
+} + + + +

@L["Add webhook"]

+
+ + + + @if (_errors.Form is not null) + { + + } + + + +
+ @L["Events"] +
+ + +
+ @if (_errors.For(NewWebhookErrors.Events) is { } eventsError) + { +

@eventsError

+ } +
+
+ +
+ +
+
+ +
+ + + + @L["URL"] + @L["Events"] + @L["Added"] + @L["Status"] + @L["Actions"] + + + + @if (_subscriptions.Count == 0) + { + + @L["No webhook is set up yet."] + + } + @foreach (var subscription in _subscriptions) + { + + @subscription.Url + @string.Join(", ", subscription.Events.Select(name => L[name].Value)) + + + @L["by {0}", subscription.AddedBy.DisplayName] + + + @if (subscription.Enabled) + { + @L["Enabled"] + } + else + { + @L["Disabled"] + } + + + @if (subscription.Enabled) + { + @* Disabling cannot be undone, so the posted form sits in a dialog that asks first. The form is named after the + subscription, so the action reaches the subscription it was rendered for. *@ + + @L["Disable"] @subscription.Url + + + @L["Disable the webhook to {0}?", subscription.Url] + @L["The endpoint is told about nothing from now on. This cannot be undone; add the webhook again to start over, with a new secret."] + +
+ + + @L["Cancel"] + + + +
+
+ } +
+
+ } +
+
+
+ +@code { + private readonly NewWebhookErrors _errors = new(); + private IReadOnlyList _subscriptions = []; + private AddedWebhookSubscription? _added; + private string? _message; + + [SupplyParameterFromForm(FormName = "add-webhook")] + private NewWebhookForm Form { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Form ??= NewWebhookForm.Defaults(); + await LoadAsync(); + } + + private async Task AddAsync() + { + var added = await Directory.AddAsync(Form.ToSubscription()); + if (!added.IsSuccess) + { + _errors.Report(added.Errors, L); + return; + } + + _added = added.Value; + Form = NewWebhookForm.Defaults(); + await LoadAsync(); + } + + private async Task DisableAsync(WebhookSubscription subscription) + { + var disabled = await Directory.DisableAsync(subscription.Id); + _message = disabled.IsSuccess + ? L["The webhook to {0} is disabled. It is told about nothing from now on.", subscription.Url] + : string.Join(" ", disabled.Errors.Select(Describe)); + await LoadAsync(); + } + + private async Task LoadAsync() + { + var listed = await Directory.ListAsync(); + _subscriptions = listed.IsSuccess ? listed.Value : []; + if (!listed.IsSuccess) + { + _message = string.Join(" ", listed.Errors.Select(Describe)); + } + } + + // The error code is the resource key; the directory's own message is the text until a resource exists. + private string Describe(FluentCMS.Core.Results.Error error) + { + var localized = L[error.Code]; + return localized.ResourceNotFound ? error.Message : localized.Value; + } +} diff --git a/src/FluentCMS.Admin/Components/Webhooks/NewWebhookForm.cs b/src/FluentCMS.Admin/Components/Webhooks/NewWebhookForm.cs new file mode 100644 index 000000000..879b45653 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Webhooks/NewWebhookForm.cs @@ -0,0 +1,75 @@ +using FluentCMS.Core.Results; +using FluentCMS.Core.Webhooks; +using Microsoft.Extensions.Localization; + +namespace FluentCMS.Admin.Components.Webhooks; + +// A new subscription as the screen asks for it, bound from the posted form. +public sealed class NewWebhookForm +{ + public string? Url { get; set; } + + public bool Published { get; set; } + + public bool Reverted { get; set; } + + // Only for the first render: a posted form starts from nothing, so a box left unticked stays unticked. + public static NewWebhookForm Defaults() => new() { Published = true, Reverted = true }; + + public NewWebhookSubscription ToSubscription() + { + var events = new List(); + if (Published) + { + events.Add(WebhookEvents.ChangeSetPublished); + } + + if (Reverted) + { + events.Add(WebhookEvents.ChangeSetReverted); + } + + return new NewWebhookSubscription(Url ?? string.Empty, events); + } +} + +// What is wrong with a new subscription, by field, plus anything that belongs to no field. +public sealed class NewWebhookErrors +{ + public const string Events = "Events"; + + private readonly Dictionary _fields = []; + + public string? Form { get; private set; } + + public string? For(string field) => _fields.GetValueOrDefault(field); + + // Core reports problems by code: the code is the resource key, and Core's own message the text until a resource exists. + public void Report(IEnumerable errors, IStringLocalizer localizer) + { + foreach (var error in errors) + { + var localized = localizer[error.Code]; + var message = localized.ResourceNotFound ? error.Message : localized.Value; + var field = error.Code switch + { + WebhookErrorCodes.UrlInvalid + or WebhookErrorCodes.UrlTooLong + or WebhookErrorCodes.UrlHasCredentials + or WebhookErrorCodes.UrlNotHttps + or WebhookErrorCodes.HostNotAllowed => nameof(NewWebhookForm.Url), + WebhookErrorCodes.EventsMissing or WebhookErrorCodes.EventUnknown => Events, + _ => null, + }; + + if (field is null) + { + Form = Form is null ? message : Form + " " + message; + } + else + { + _fields[field] = _fields.TryGetValue(field, out var existing) ? existing + " " + message : message; + } + } + } +} diff --git a/src/FluentCMS.Core/Security/ISecretProtector.cs b/src/FluentCMS.Core/Security/ISecretProtector.cs new file mode 100644 index 000000000..797c9a055 --- /dev/null +++ b/src/FluentCMS.Core/Security/ISecretProtector.cs @@ -0,0 +1,10 @@ +namespace FluentCMS.Core.Security; + +// Encrypts a secret the product has to read back, such as a webhook's signing secret, so the database never holds it in plain text +// (SEC-07). The host implements it with its Data Protection key ring; the purpose keeps one kind of secret from opening another. +public interface ISecretProtector +{ + string Protect(string purpose, string secret); + + string Unprotect(string purpose, string protectedSecret); +} diff --git a/src/FluentCMS.Core/Webhooks/IWebhookDirectory.cs b/src/FluentCMS.Core/Webhooks/IWebhookDirectory.cs new file mode 100644 index 000000000..7a34e275d --- /dev/null +++ b/src/FluentCMS.Core/Webhooks/IWebhookDirectory.cs @@ -0,0 +1,20 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Webhooks; + +// The endpoints told when a change set is published or reverted (API-05). Adding and disabling each append their audit event in +// the same unit of work (AUD-N1), and only a person may list, add or disable one: a subscription sends content out of the product, +// which no agent or engine decides (SEC-02). +public interface IWebhookDirectory +{ + Task>> ListAsync(CancellationToken cancellationToken = default); + + // The signing secret is in the result and nowhere else readable; it is stored encrypted and never listed. + Task> AddAsync(NewWebhookSubscription subscription, CancellationToken cancellationToken = default); + + Task> DisableAsync(Guid id, CancellationToken cancellationToken = default); +} + +public sealed record NewWebhookSubscription(string Url, IReadOnlyList Events); + +public sealed record AddedWebhookSubscription(WebhookSubscription Subscription, string Secret); diff --git a/src/FluentCMS.Core/Webhooks/WebhookErrorCodes.cs b/src/FluentCMS.Core/Webhooks/WebhookErrorCodes.cs new file mode 100644 index 000000000..12853ad01 --- /dev/null +++ b/src/FluentCMS.Core/Webhooks/WebhookErrorCodes.cs @@ -0,0 +1,15 @@ +namespace FluentCMS.Core.Webhooks; + +public static class WebhookErrorCodes +{ + public const string UrlInvalid = "webhook_url_invalid"; + public const string UrlTooLong = "webhook_url_too_long"; + public const string UrlHasCredentials = "webhook_url_has_credentials"; + public const string UrlNotHttps = "webhook_url_not_https"; + public const string HostNotAllowed = "webhook_host_not_allowed"; + public const string EventsMissing = "webhook_events_missing"; + public const string EventUnknown = "webhook_event_unknown"; + public const string NotFound = "webhook_not_found"; + public const string AlreadyDisabled = "webhook_already_disabled"; + public const string PeopleOnly = "webhook_people_only"; +} diff --git a/src/FluentCMS.Core/Webhooks/WebhookEvents.cs b/src/FluentCMS.Core/Webhooks/WebhookEvents.cs new file mode 100644 index 000000000..1679e46c9 --- /dev/null +++ b/src/FluentCMS.Core/Webhooks/WebhookEvents.cs @@ -0,0 +1,10 @@ +namespace FluentCMS.Core.Webhooks; + +// The events a subscription can ask for in M1 (API-05); others come with M2. +public static class WebhookEvents +{ + public const string ChangeSetPublished = "change_set.published"; + public const string ChangeSetReverted = "change_set.reverted"; + + public static IReadOnlyList All { get; } = [ChangeSetPublished, ChangeSetReverted]; +} diff --git a/src/FluentCMS.Core/Webhooks/WebhookSecrets.cs b/src/FluentCMS.Core/Webhooks/WebhookSecrets.cs new file mode 100644 index 000000000..21f09e13b --- /dev/null +++ b/src/FluentCMS.Core/Webhooks/WebhookSecrets.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; + +namespace FluentCMS.Core.Webhooks; + +// A signing secret is 32 random bytes in the Standard Webhooks form, whsec_ and base64, so a receiver's existing verifier reads it. +// It is protected under its own purpose, so no other stored secret opens it. +public static class WebhookSecrets +{ + public const string Prefix = "whsec_"; + + public const string ProtectionPurpose = "FluentCMS.Webhooks.SigningSecret"; + + private const int SecretBytes = 32; + + public static string Generate() + { + return Prefix + Convert.ToBase64String(RandomNumberGenerator.GetBytes(SecretBytes)); + } +} diff --git a/src/FluentCMS.Core/Webhooks/WebhookSubscription.cs b/src/FluentCMS.Core/Webhooks/WebhookSubscription.cs new file mode 100644 index 000000000..fded782ce --- /dev/null +++ b/src/FluentCMS.Core/Webhooks/WebhookSubscription.cs @@ -0,0 +1,134 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Models; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Webhooks; + +// One endpoint and the events it is told about (API-05). Its URL is checked when it is added: HTTPS on a host the administrator +// allow-listed (SEC-05). A subscription is never edited; it is disabled and added again, so its secret and URL never drift apart. +public sealed class WebhookSubscription +{ + public const int MaxUrlLength = 2048; + + private WebhookSubscription(Guid id, Uri url, IReadOnlyList events, DateTimeOffset addedAt, UserInitiator addedBy, DateTimeOffset? disabledAt) + { + Id = id; + Url = url; + Events = events; + AddedAt = addedAt; + AddedBy = addedBy; + DisabledAt = disabledAt; + } + + public Guid Id { get; } + + public Uri Url { get; } + + public IReadOnlyList Events { get; } + + public DateTimeOffset AddedAt { get; } + + public UserInitiator AddedBy { get; } + + public DateTimeOffset? DisabledAt { get; private set; } + + public bool Enabled => DisabledAt is null; + + public static Result Add(NewWebhookSubscription subscription, IOutboundHosts outbound, UserInitiator addedBy, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(subscription); + ArgumentNullException.ThrowIfNull(outbound); + ArgumentNullException.ThrowIfNull(addedBy); + + var errors = new List(); + var url = CheckUrl(subscription.Url, outbound, errors); + var events = CheckEvents(subscription.Events, errors); + + return errors.Count == 0 + ? Result.Success(new WebhookSubscription(Guid.CreateVersion7(now), url!, events, now.ToUniversalTime(), addedBy, disabledAt: null)) + : Result.Failure(errors); + } + + // Rebuilds a stored subscription without checking it again, so it stays readable if the allow-list or the rules change. + public static WebhookSubscription Restore(Guid id, Uri url, IReadOnlyList events, DateTimeOffset addedAt, UserInitiator addedBy, DateTimeOffset? disabledAt) + { + ArgumentNullException.ThrowIfNull(url); + ArgumentNullException.ThrowIfNull(events); + ArgumentNullException.ThrowIfNull(addedBy); + + return new WebhookSubscription(id, url, events, addedAt, addedBy, disabledAt); + } + + public Result Disable(DateTimeOffset now) + { + if (DisabledAt is { } disabledAt) + { + return Result.Failure(WebhookErrorCodes.AlreadyDisabled, $"The webhook to {Url} was already disabled at {disabledAt:O}."); + } + + DisabledAt = now.ToUniversalTime(); + return Result.Success(); + } + + private static Uri? CheckUrl(string? text, IOutboundHosts outbound, List errors) + { + var value = text?.Trim() ?? string.Empty; + if (value.Length > MaxUrlLength) + { + errors.Add(new Error(WebhookErrorCodes.UrlTooLong, $"A webhook URL is at most {MaxUrlLength} characters, and this one has {value.Length}.")); + return null; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out var url) || url.Scheme is not ("https" or "http") || url.Host.Length == 0) + { + errors.Add(new Error( + WebhookErrorCodes.UrlInvalid, + $"'{value}' is not a web address; give the full URL the endpoint listens on, such as https://hooks.example.com/fluentcms.")); + return null; + } + + var failed = errors.Count; + if (url.UserInfo.Length > 0) + { + errors.Add(new Error( + WebhookErrorCodes.UrlHasCredentials, + "A webhook URL cannot carry a user name or password, which would be stored and listed with it; the endpoint checks each delivery's signature instead.")); + } + + // Plain HTTP stays on this machine, for an endpoint under development. + if (url.Scheme != Uri.UriSchemeHttps && !url.IsLoopback) + { + errors.Add(new Error( + WebhookErrorCodes.UrlNotHttps, + $"Deliveries carry content, so the URL uses https; plain http is accepted only for a local endpoint such as http://localhost:5005, and {url.Host} is not one.")); + } + + if (!outbound.IsAllowed(url)) + { + errors.Add(new Error( + WebhookErrorCodes.HostNotAllowed, + $"The host {url.Host} is not on the outbound allow-list; an administrator adds it to Outbound__AllowedHosts, then the webhook can be added.")); + } + + return errors.Count == failed ? url : null; + } + + // Kept in the order WebhookEvents lists them, so the same choice is always stored the same way. + private static IReadOnlyList CheckEvents(IReadOnlyList? events, List errors) + { + var names = (events ?? []).Select(name => name?.Trim() ?? string.Empty).Distinct(StringComparer.Ordinal).ToList(); + var known = string.Join(", ", WebhookEvents.All); + + if (names.Count == 0) + { + errors.Add(new Error(WebhookErrorCodes.EventsMissing, $"A webhook needs at least one event to be told about: {known}.")); + } + + foreach (var unknown in names.Where(name => !WebhookEvents.All.Contains(name, StringComparer.Ordinal))) + { + errors.Add(new Error(WebhookErrorCodes.EventUnknown, $"'{unknown}' is not a webhook event; use {known}.")); + } + + return [.. WebhookEvents.All.Where(name => names.Contains(name, StringComparer.Ordinal))]; + } +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index fb11e874a..dacc75768 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -3,6 +3,7 @@ using FluentCMS.Api; using FluentCMS.Core; using FluentCMS.Core.Identity; +using FluentCMS.Core.Security; using FluentCMS.Host.Audit; using FluentCMS.Host.Identity; using FluentCMS.Host.Jobs; @@ -198,6 +199,7 @@ private static void AddFluentCms(IServiceCollection services, IConfiguration con services.AddDataProtection() .SetApplicationName("FluentCMS") .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(environment.ContentRootPath, keyRing.KeysDirectory))); + services.AddSingleton(); // Media on local disk lives under the content root unless an absolute path is given, as the key ring does (MED-04). services.PostConfigure(disk => disk.Root = Path.Combine(environment.ContentRootPath, disk.Root)); diff --git a/src/FluentCMS.Host/Security/DataProtectionSecretProtector.cs b/src/FluentCMS.Host/Security/DataProtectionSecretProtector.cs new file mode 100644 index 000000000..04bfddf89 --- /dev/null +++ b/src/FluentCMS.Host/Security/DataProtectionSecretProtector.cs @@ -0,0 +1,25 @@ +using FluentCMS.Core.Security; +using Microsoft.AspNetCore.DataProtection; + +namespace FluentCMS.Host.Security; + +// Stored secrets use the key ring that protects cookies, so the web and stdio hosts read each other's, and losing the key ring +// loses them with the sign-ins (SEC-07). +internal sealed class DataProtectionSecretProtector(IDataProtectionProvider provider) : ISecretProtector +{ + public string Protect(string purpose, string secret) + { + ArgumentException.ThrowIfNullOrWhiteSpace(purpose); + ArgumentNullException.ThrowIfNull(secret); + + return provider.CreateProtector(purpose).Protect(secret); + } + + public string Unprotect(string purpose, string protectedSecret) + { + ArgumentException.ThrowIfNullOrWhiteSpace(purpose); + ArgumentNullException.ThrowIfNull(protectedSecret); + + return provider.CreateProtector(purpose).Unprotect(protectedSecret); + } +} diff --git a/src/FluentCMS.Host/Security/KeyRingOptions.cs b/src/FluentCMS.Host/Security/KeyRingOptions.cs index d65ce684c..ba858f530 100644 --- a/src/FluentCMS.Host/Security/KeyRingOptions.cs +++ b/src/FluentCMS.Host/Security/KeyRingOptions.cs @@ -1,6 +1,6 @@ namespace FluentCMS.Host.Security; -// Where Data Protection keeps the keys that protect cookies, anti-forgery tokens and, later, stored secrets (SEC-07). +// Where Data Protection keeps the keys that protect cookies, anti-forgery tokens and stored secrets such as webhook secrets (SEC-07). public sealed class KeyRingOptions { public const string SectionName = "DataProtection"; diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 3ca85ac8a..6372b6f12 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using FluentCMS.Core.Models; using FluentCMS.Core.Persistence; using FluentCMS.Core.Schema; +using FluentCMS.Core.Webhooks; using FluentCMS.Infrastructure.Agents; using FluentCMS.Infrastructure.Audit; using FluentCMS.Infrastructure.ChangeSets; @@ -23,6 +24,7 @@ using FluentCMS.Infrastructure.Models; using FluentCMS.Infrastructure.Persistence; using FluentCMS.Infrastructure.Schema; +using FluentCMS.Infrastructure.Webhooks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -49,6 +51,7 @@ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollect AddChecks(services); AddJobs(services); AddModels(services, configuration); + AddWebhooks(services); services.AddHostedService(); @@ -135,6 +138,12 @@ private static void AddJobs(IServiceCollection services) services.AddScoped(); } + // Subscriptions share the outbound allow-list with the model gateway (SEC-05); the host provides the secret protector (SEC-07). + private static void AddWebhooks(IServiceCollection services) + { + services.AddScoped(); + } + // The provider wiring behind the gateway in Core (ADR-0008). A profile the host cannot use stops it at startup. private static void AddModels(IServiceCollection services, IConfiguration configuration) { diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index 145b7e19c..b6a5d53e7 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -9,6 +9,7 @@ using FluentCMS.Infrastructure.Models; using FluentCMS.Infrastructure.Schema; using FluentCMS.Infrastructure.Sites; +using FluentCMS.Infrastructure.Webhooks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -54,6 +55,8 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet ModelCalls => Set(); + public DbSet WebhookSubscriptions => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -76,6 +79,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new CheckVersionConfiguration()); builder.ApplyConfiguration(new JobConfiguration()); builder.ApplyConfiguration(new ModelCallConfiguration()); + builder.ApplyConfiguration(new WebhookSubscriptionConfiguration()); builder.Entity(user => { diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs new file mode 100644 index 000000000..4dd61134e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs @@ -0,0 +1,1346 @@ +// +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("20261019053029_WebhookSubscriptions")] + partial class WebhookSubscriptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.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.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.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/20261019053029_WebhookSubscriptions.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.cs new file mode 100644 index 000000000..6eaee0320 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class WebhookSubscriptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WebhookSubscriptions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + Events = table.Column>(type: "text[]", nullable: false), + ProtectedSecret = table.Column(type: "text", nullable: false), + AddedAt = table.Column(type: "timestamp with time zone", nullable: false), + AddedById = table.Column(type: "uuid", nullable: false), + AddedByName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + DisabledAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookSubscriptions", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WebhookSubscriptions"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index cacc463ac..4e6c9e565 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -1011,6 +1011,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs new file mode 100644 index 000000000..b9026ee1e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs @@ -0,0 +1,1336 @@ +// +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("20261019052937_WebhookSubscriptions")] + partial class WebhookSubscriptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.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.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.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/20261019052937_WebhookSubscriptions.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.cs new file mode 100644 index 000000000..635cd8e8c --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class WebhookSubscriptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WebhookSubscriptions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + Events = table.Column(type: "TEXT", nullable: false), + ProtectedSecret = table.Column(type: "TEXT", nullable: false), + AddedAt = table.Column(type: "TEXT", nullable: false), + AddedById = table.Column(type: "TEXT", nullable: false), + AddedByName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + DisabledAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookSubscriptions", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WebhookSubscriptions"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 2b4cc51e2..62d454081 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -1005,6 +1005,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") diff --git a/src/FluentCMS.Infrastructure/Webhooks/WebhookDirectory.cs b/src/FluentCMS.Infrastructure/Webhooks/WebhookDirectory.cs new file mode 100644 index 000000000..4b9283e03 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Webhooks/WebhookDirectory.cs @@ -0,0 +1,144 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Models; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Security; +using FluentCMS.Core.Webhooks; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Webhooks; + +// Expected failures are found before the unit of work begins, so they leave a caller's outer unit of work able to commit. The +// audit events name the subscription and never its secret. +internal sealed class WebhookDirectory( + FluentCmsDbContext context, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + ICurrentInitiator currentInitiator, + IOutboundHosts outbound, + ISecretProtector protector, + TimeProvider clock) : IWebhookDirectory +{ + private const string SubjectType = "webhook_subscription"; + + private static readonly Result PeopleOnly = Result.Failure( + WebhookErrorCodes.PeopleOnly, + "Only a signed-in person can list, add or disable webhooks; an agent or an engine cannot."); + + public async Task>> ListAsync(CancellationToken cancellationToken = default) + { + var person = Person(); + if (!person.IsSuccess) + { + return Result>.Failure(person.Errors); + } + + var rows = await context.WebhookSubscriptions + .AsNoTracking() + .OrderBy(row => row.Id) + .ToListAsync(cancellationToken); + + return Result>.Success([.. rows.Select(ToSubscription)]); + } + + public async Task> AddAsync(NewWebhookSubscription subscription, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subscription); + + var person = Person(); + if (!person.IsSuccess) + { + return Result.Failure(person.Errors); + } + + var added = WebhookSubscription.Add(subscription, outbound, person.Value, clock.GetUtcNow()); + if (!added.IsSuccess) + { + return Result.Failure(added.Errors); + } + + var secret = WebhookSecrets.Generate(); + var row = ToRow(added.Value, protector.Protect(WebhookSecrets.ProtectionPurpose, secret)); + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + context.WebhookSubscriptions.Add(row); + await auditLog.AppendAsync(new NewAuditEvent("webhook.added") { SubjectType = SubjectType, SubjectId = row.Id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(new AddedWebhookSubscription(added.Value, secret)); + } + + public async Task> DisableAsync(Guid id, CancellationToken cancellationToken = default) + { + var person = Person(); + if (!person.IsSuccess) + { + return Result.Failure(person.Errors); + } + + var row = await context.WebhookSubscriptions.AsNoTracking().SingleOrDefaultAsync(stored => stored.Id == id, cancellationToken); + if (row is null) + { + return Result.Failure(WebhookErrorCodes.NotFound, $"There is no webhook {id}; list the webhooks to find its id."); + } + + var subscription = ToSubscription(row); + var disabled = subscription.Disable(clock.GetUtcNow()); + if (!disabled.IsSuccess) + { + return Result.Failure(disabled.Errors); + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + // Only a subscription nobody disabled in the meantime is updated, so of two concurrent disables the second changes nothing. + var updated = await context.WebhookSubscriptions + .Where(stored => stored.Id == id && stored.DisabledAt == null) + .ExecuteUpdateAsync(setters => setters.SetProperty(stored => stored.DisabledAt, subscription.DisabledAt), cancellationToken); + if (updated == 0) + { + return Result.Failure( + WebhookErrorCodes.AlreadyDisabled, + $"The webhook to {subscription.Url} was disabled by another request at the same time."); + } + + await auditLog.AppendAsync(new NewAuditEvent("webhook.disabled") { SubjectType = SubjectType, SubjectId = id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(subscription); + } + + // A subscription sends content out of the product, which only a person decides: no agent, whatever its scope, and no engine. + private Result Person() + { + return currentInitiator.Current is UserInitiator person ? Result.Success(person) : PeopleOnly; + } + + private static WebhookSubscriptionRow ToRow(WebhookSubscription subscription, string protectedSecret) + { + return new WebhookSubscriptionRow + { + Id = subscription.Id, + Url = subscription.Url.OriginalString, + Events = [.. subscription.Events], + ProtectedSecret = protectedSecret, + AddedAt = subscription.AddedAt, + AddedById = subscription.AddedBy.UserId, + AddedByName = subscription.AddedBy.DisplayName, + DisabledAt = subscription.DisabledAt, + }; + } + + private static WebhookSubscription ToSubscription(WebhookSubscriptionRow row) + { + return WebhookSubscription.Restore( + row.Id, + new Uri(row.Url, UriKind.Absolute), + row.Events, + row.AddedAt, + new UserInitiator(row.AddedById, row.AddedByName), + row.DisabledAt); + } +} diff --git a/src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionConfiguration.cs b/src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionConfiguration.cs new file mode 100644 index 000000000..5562bf91d --- /dev/null +++ b/src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionConfiguration.cs @@ -0,0 +1,20 @@ +using FluentCMS.Core.Webhooks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Webhooks; + +internal sealed class WebhookSubscriptionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("WebhookSubscriptions"); + + row.HasKey(subscription => subscription.Id); + row.Property(subscription => subscription.Id).ValueGeneratedNever(); + row.Property(subscription => subscription.Url).HasMaxLength(WebhookSubscription.MaxUrlLength).IsRequired(); + row.Property(subscription => subscription.ProtectedSecret).IsRequired(); + row.Property(subscription => subscription.AddedByName).HasMaxLength(256).IsRequired(); + row.PrimitiveCollection(subscription => subscription.Events); + } +} diff --git a/src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionRow.cs b/src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionRow.cs new file mode 100644 index 000000000..cc2e42396 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Webhooks/WebhookSubscriptionRow.cs @@ -0,0 +1,21 @@ +namespace FluentCMS.Infrastructure.Webhooks; + +// A WebhookSubscription with its signing secret, which is stored encrypted and never in plain text (SEC-07). +public sealed class WebhookSubscriptionRow +{ + public Guid Id { get; set; } + + public string Url { get; set; } = string.Empty; + + public List Events { get; set; } = []; + + public string ProtectedSecret { get; set; } = string.Empty; + + public DateTimeOffset AddedAt { get; set; } + + public Guid AddedById { get; set; } + + public string AddedByName { get; set; } = string.Empty; + + public DateTimeOffset? DisabledAt { get; set; } +} diff --git a/tests/FluentCMS.IntegrationTests/Agents/AgentCredentialScenario.cs b/tests/FluentCMS.IntegrationTests/Agents/AgentCredentialScenario.cs index 8b18a5357..d562037d2 100644 --- a/tests/FluentCMS.IntegrationTests/Agents/AgentCredentialScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Agents/AgentCredentialScenario.cs @@ -1,12 +1,9 @@ -using System.Collections.Concurrent; -using System.Text; using FluentCMS.Core.Agents; using FluentCMS.Core.Identity; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Agents; using FluentCMS.Infrastructure.Audit; using FluentCMS.Infrastructure.Persistence; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -23,20 +20,17 @@ internal sealed class AgentCredentialScenario : IAsyncDisposable public static readonly DateTimeOffset Start = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); - private readonly string _provider; private readonly TemporaryDatabase _database; private readonly SwitchableInitiator _initiator; private readonly CapturedLogs _logs; private AgentCredentialScenario( - string provider, TemporaryDatabase database, ServiceProvider services, SwitchableInitiator initiator, ManualClock clock, CapturedLogs logs) { - _provider = provider; _database = database; Services = services; _initiator = initiator; @@ -70,7 +64,7 @@ public static async Task CreateAsync(string provider) .AddSingleton(clock)); await TestServices.MigrateAsync(services); - return new AgentCredentialScenario(provider, database, services, initiator, clock, logs); + return new AgentCredentialScenario(database, services, initiator, clock, logs); } public static NewAgentCredential CatalogSync(DateTimeOffset? expiresAt = null) @@ -110,50 +104,9 @@ public async Task QueryAsync(Func> query) return await query(scope.ServiceProvider.GetRequiredService()); } - // Whether any value stored anywhere holds the text: the bytes of the SQLite file, or every row of every table on PostgreSQL. - public async Task StoresTextAsync(string text) + public Task StoresTextAsync(string text) { - if (_provider == DatabaseProviders.Sqlite) - { - SqliteConnection.ClearAllPools(); - var path = new SqliteConnectionStringBuilder(_database.Configuration["Database:ConnectionString"]).DataSource; - var bytes = await File.ReadAllBytesAsync(path); - return bytes.AsSpan().IndexOf(Encoding.UTF8.GetBytes(text)) >= 0; - } - - return await QueryAsync(async context => - { - var connection = context.Database.GetDbConnection(); - await connection.OpenAsync(); - - var tables = new List(); - await using (var list = connection.CreateCommand()) - { - list.CommandText = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'"; - await using var reader = await list.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - tables.Add(reader.GetString(0)); - } - } - - foreach (var table in tables) - { - await using var search = connection.CreateCommand(); - search.CommandText = $"SELECT EXISTS (SELECT 1 FROM \"{table}\" AS stored WHERE strpos(stored::text, @text) > 0)"; - var parameter = search.CreateParameter(); - parameter.ParameterName = "text"; - parameter.Value = text; - search.Parameters.Add(parameter); - - if ((bool)(await search.ExecuteScalarAsync())!) - { - return true; - } - } - - return false; - }); + return _database.StoresTextAsync(Services, text); } public async ValueTask DisposeAsync() @@ -166,39 +119,4 @@ private sealed class SwitchableInitiator : ICurrentInitiator { public Initiator? Current { get; set; } } - - private sealed class CapturedLogs : ILoggerProvider - { - private readonly ConcurrentQueue _messages = new(); - - public IReadOnlyCollection Messages => _messages; - - public ILogger CreateLogger(string categoryName) - { - return new Logger(this); - } - - public void Dispose() - { - } - - private sealed class Logger(CapturedLogs owner) : ILogger - { - public IDisposable? BeginScope(TState state) - where TState : notnull - { - return null; - } - - public bool IsEnabled(LogLevel logLevel) - { - return true; - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - owner._messages.Enqueue(formatter(state, exception) + exception); - } - } - } } diff --git a/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs b/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs index 8658e9fb3..5466ee6ed 100644 --- a/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs +++ b/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs @@ -1,4 +1,8 @@ +using System.Text; using FluentCMS.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Npgsql; namespace FluentCMS.IntegrationTests; @@ -54,6 +58,51 @@ public async ValueTask DisposeAsync() } } + // Whether any value stored anywhere holds the text: the bytes of the SQLite file, or every row of every table on PostgreSQL. + public async Task StoresTextAsync(IServiceProvider services, string text) + { + if (_sqlite is not null) + { + SqliteConnection.ClearAllPools(); + var path = new SqliteConnectionStringBuilder(Configuration["Database:ConnectionString"]).DataSource; + var bytes = await File.ReadAllBytesAsync(path); + return bytes.AsSpan().IndexOf(Encoding.UTF8.GetBytes(text)) >= 0; + } + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var connection = context.Database.GetDbConnection(); + await connection.OpenAsync(); + + var tables = new List(); + await using (var list = connection.CreateCommand()) + { + list.CommandText = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'"; + await using var reader = await list.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tables.Add(reader.GetString(0)); + } + } + + foreach (var table in tables) + { + await using var search = connection.CreateCommand(); + search.CommandText = $"SELECT EXISTS (SELECT 1 FROM \"{table}\" AS stored WHERE strpos(stored::text, @text) > 0)"; + var parameter = search.CreateParameter(); + parameter.ParameterName = "text"; + parameter.Value = text; + search.Parameters.Add(parameter); + + if ((bool)(await search.ExecuteScalarAsync())!) + { + return true; + } + } + + return false; + } + private static async Task ExecuteAsync(NpgsqlConnectionStringBuilder server, string statement) { await using var connection = new NpgsqlConnection(server.ConnectionString); diff --git a/tests/FluentCMS.IntegrationTests/TestDoubles.cs b/tests/FluentCMS.IntegrationTests/TestDoubles.cs index 3b157f689..50c16e1c8 100644 --- a/tests/FluentCMS.IntegrationTests/TestDoubles.cs +++ b/tests/FluentCMS.IntegrationTests/TestDoubles.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using FluentCMS.Core.Identity; +using Microsoft.Extensions.Logging; namespace FluentCMS.IntegrationTests; @@ -25,3 +27,39 @@ public override DateTimeOffset GetUtcNow() return Now; } } + +// Everything logged at any level, so a test can show a secret never reaches a log. +internal sealed class CapturedLogs : ILoggerProvider +{ + private readonly ConcurrentQueue _messages = new(); + + public IReadOnlyCollection Messages => _messages; + + public ILogger CreateLogger(string categoryName) + { + return new Logger(this); + } + + public void Dispose() + { + } + + private sealed class Logger(CapturedLogs owner) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + owner._messages.Enqueue(formatter(state, exception) + exception); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Webhooks/WebhookDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookDirectoryTests.cs new file mode 100644 index 000000000..6a5012fde --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookDirectoryTests.cs @@ -0,0 +1,163 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Webhooks; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Webhooks.WebhookScenario; + +namespace FluentCMS.IntegrationTests.Webhooks; + +public sealed class WebhookDirectoryTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_person_adds_a_subscription_whose_secret_is_returned_once_and_stored_encrypted(string provider) + { + await using var scenario = await WebhookScenario.CreateAsync(provider); + + var added = (await scenario.RunAsync(directory => directory.AddAsync(Frontend()))).Value; + var secret = added.Secret[WebhookSecrets.Prefix.Length..]; + + Assert.StartsWith(WebhookSecrets.Prefix, added.Secret, StringComparison.Ordinal); + Assert.Equal(32, Convert.FromBase64String(secret).Length); + + var listed = Assert.Single((await scenario.RunAsync(directory => directory.ListAsync())).Value); + Assert.Equal(added.Subscription.Id, listed.Id); + Assert.Equal(new Uri("https://hooks.example.com/fluentcms"), listed.Url); + Assert.Equal([WebhookEvents.ChangeSetPublished, WebhookEvents.ChangeSetReverted], listed.Events); + Assert.True(listed.Enabled); + Assert.Equal((Start, Amir), (listed.AddedAt, listed.AddedBy)); + + var row = Assert.Single(await scenario.RowsAsync()); + Assert.DoesNotContain(secret, row.ProtectedSecret, StringComparison.Ordinal); + Assert.Equal(added.Secret, scenario.Unprotect(row.ProtectedSecret)); + Assert.False(await scenario.StoresTextAsync(secret)); + + var addition = Assert.Single(await scenario.EventsAsync()); + Assert.Equal(("webhook.added", added.Subscription.Id, Amir.UserId), (addition.Operation, addition.SubjectId!.Value, addition.InitiatorId)); + + Assert.Contains(scenario.Logs, line => line.Contains("\"WebhookSubscriptions\"", StringComparison.Ordinal)); + Assert.DoesNotContain(scenario.Logs, line => line.Contains(secret, StringComparison.Ordinal)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_allow_listed_host_its_subdomains_and_an_allow_listed_local_endpoint_are_accepted(string provider) + { + await using var scenario = await WebhookScenario.CreateAsync(provider); + + foreach (var url in new[] { "https://example.com/hooks", "https://deep.hooks.example.com/hooks", "http://localhost:5005/hooks" }) + { + var added = await scenario.RunAsync(directory => directory.AddAsync(Frontend(url))); + Assert.True(added.IsSuccess, string.Join("; ", added.Errors.Select(error => error.Message))); + } + + Assert.Equal(3, (await scenario.RowsAsync()).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_url_or_events_that_break_a_rule_are_refused_with_a_reason_and_nothing_is_stored(string provider) + { + await using var scenario = await WebhookScenario.CreateAsync(provider); + var cases = new (NewWebhookSubscription Subscription, string[] Codes)[] + { + (Frontend("https://example.com.evil.test/hooks"), [WebhookErrorCodes.HostNotAllowed]), + (Frontend("https://hooks.other.test/hooks"), [WebhookErrorCodes.HostNotAllowed]), + (Frontend("http://hooks.example.com/hooks"), [WebhookErrorCodes.UrlNotHttps]), + (Frontend("http://localhost:5006/hooks"), [WebhookErrorCodes.HostNotAllowed]), + (Frontend("https://user:secret@hooks.example.com/hooks"), [WebhookErrorCodes.UrlHasCredentials]), + (Frontend("hooks.example.com/hooks"), [WebhookErrorCodes.UrlInvalid]), + (Frontend("ftp://hooks.example.com/hooks"), [WebhookErrorCodes.UrlInvalid]), + (Frontend("https://hooks.example.com/" + new string('a', WebhookSubscription.MaxUrlLength)), [WebhookErrorCodes.UrlTooLong]), + (new NewWebhookSubscription("https://hooks.example.com/hooks", []), [WebhookErrorCodes.EventsMissing]), + (new NewWebhookSubscription("https://hooks.example.com/hooks", ["change_set.created"]), [WebhookErrorCodes.EventUnknown]), + (new NewWebhookSubscription("http://hooks.other.test/hooks", []), [WebhookErrorCodes.UrlNotHttps, WebhookErrorCodes.HostNotAllowed, WebhookErrorCodes.EventsMissing]), + }; + + foreach (var (subscription, codes) in cases) + { + var refused = await scenario.RunAsync(directory => directory.AddAsync(subscription)); + + Assert.Equal(codes, refused.Errors.Select(error => error.Code)); + } + + var hostRefusal = await scenario.RunAsync(directory => directory.AddAsync(Frontend("https://hooks.other.test/hooks"))); + Assert.Contains("Outbound__AllowedHosts", Assert.Single(hostRefusal.Errors).Message, StringComparison.Ordinal); + Assert.Empty(await scenario.RowsAsync()); + Assert.Empty(await scenario.EventsAsync()); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Disabling_marks_the_subscription_disabled_once_and_appends_its_event(string provider) + { + await using var scenario = await WebhookScenario.CreateAsync(provider); + var added = (await scenario.RunAsync(directory => directory.AddAsync(Frontend()))).Value.Subscription; + scenario.Clock.Now = Start.AddHours(1); + + var disabled = await scenario.RunAsync(directory => directory.DisableAsync(added.Id)); + var again = await scenario.RunAsync(directory => directory.DisableAsync(added.Id)); + var unknown = await scenario.RunAsync(directory => directory.DisableAsync(Guid.NewGuid())); + + Assert.Equal(Start.AddHours(1), disabled.Value.DisabledAt); + Assert.False(Assert.Single((await scenario.RunAsync(directory => directory.ListAsync())).Value).Enabled); + Assert.Equal(WebhookErrorCodes.AlreadyDisabled, Assert.Single(again.Errors).Code); + Assert.Equal(WebhookErrorCodes.NotFound, Assert.Single(unknown.Errors).Code); + Assert.Equal(["webhook.added", "webhook.disabled"], (await scenario.EventsAsync()).OrderBy(row => row.Timestamp).Select(row => row.Operation)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Only_a_person_lists_adds_or_disables_a_subscription(string provider) + { + await using var scenario = await WebhookScenario.CreateAsync(provider); + var added = (await scenario.RunAsync(directory => directory.AddAsync(Frontend()))).Value.Subscription; + + foreach (var initiator in new Initiator?[] { new AgentInitiator(Guid.NewGuid(), "example-agent"), SystemInitiator.Checks, null }) + { + scenario.Initiator = initiator; + + var list = await scenario.RunAsync(directory => directory.ListAsync()); + var add = await scenario.RunAsync(directory => directory.AddAsync(Frontend("https://example.com/other"))); + var disable = await scenario.RunAsync(directory => directory.DisableAsync(added.Id)); + + Assert.Equal(WebhookErrorCodes.PeopleOnly, Assert.Single(list.Errors).Code); + Assert.Equal(WebhookErrorCodes.PeopleOnly, Assert.Single(add.Errors).Code); + Assert.Equal(WebhookErrorCodes.PeopleOnly, Assert.Single(disable.Errors).Code); + } + + var row = Assert.Single(await scenario.RowsAsync()); + Assert.Null(row.DisabledAt); + Assert.Single(await scenario.EventsAsync()); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_migration_down_drops_the_table_and_up_adds_it_again(string provider) + { + await using var scenario = await WebhookScenario.CreateAsync(provider); + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var database = scope.ServiceProvider.GetRequiredService().Database; + var migrations = database.GetMigrations().ToList(); + var index = migrations.FindIndex(name => name.EndsWith("_WebhookSubscriptions", StringComparison.Ordinal)); + await database.GetService().MigrateAsync(migrations[index - 1]); + } + + await Assert.ThrowsAnyAsync(() => scenario.QueryAsync(context => context.WebhookSubscriptions.AnyAsync())); + + await TestServices.MigrateAsync(scenario.Services); + + Assert.True((await scenario.RunAsync(directory => directory.AddAsync(Frontend()))).IsSuccess); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Webhooks/WebhookScenario.cs b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookScenario.cs new file mode 100644 index 000000000..7485699dc --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookScenario.cs @@ -0,0 +1,134 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Security; +using FluentCMS.Core.Webhooks; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.Infrastructure.Webhooks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.IntegrationTests.Webhooks; + +// A migrated database on one provider with the webhook directory acting as whoever Initiator names. example.com and localhost:5005 +// are on the outbound allow-list, secrets are protected with a real key ring, and everything logged is captured. +internal sealed class WebhookScenario : IAsyncDisposable +{ + public static readonly UserInitiator Amir = new(Guid.NewGuid(), "Amir"); + + public static readonly DateTimeOffset Start = new(2026, 10, 19, 12, 0, 0, TimeSpan.Zero); + + private readonly TemporaryDatabase _database; + private readonly SwitchableInitiator _initiator; + private readonly CapturedLogs _logs; + + private WebhookScenario(TemporaryDatabase database, ServiceProvider services, SwitchableInitiator initiator, ManualClock clock, CapturedLogs logs) + { + _database = database; + Services = services; + _initiator = initiator; + Clock = clock; + _logs = logs; + } + + public ServiceProvider Services { get; } + + public ManualClock Clock { get; } + + public IReadOnlyCollection Logs => _logs.Messages; + + public Initiator? Initiator + { + get => _initiator.Current; + set => _initiator.Current = value; + } + + public static async Task CreateAsync(string provider) + { + var database = await TemporaryDatabase.CreateAsync(provider); + var initiator = new SwitchableInitiator { Current = Amir }; + var clock = new ManualClock(Start); + var logs = new CapturedLogs(); + var settings = new Dictionary(database.Configuration) + { + ["Outbound:AllowedHosts:0"] = "example.com", + ["Outbound:AllowedHosts:1"] = "localhost:5005", + }; + var services = TestServices.Build( + settings, + collection => collection + .AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs)) + .AddSingleton(initiator) + .AddSingleton(clock) + .AddSingleton()); + + await TestServices.MigrateAsync(services); + return new WebhookScenario(database, services, initiator, clock, logs); + } + + public static NewWebhookSubscription Frontend(string url = "https://hooks.example.com/fluentcms") + { + return new NewWebhookSubscription(url, [WebhookEvents.ChangeSetReverted, WebhookEvents.ChangeSetPublished]); + } + + // Each call gets its own scope and DbContext, as separate requests would. + public async Task RunAsync(Func> action) + { + await using var scope = Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + public Task> RowsAsync() + { + return QueryAsync(context => context.WebhookSubscriptions.AsNoTracking().ToListAsync()); + } + + public Task> EventsAsync() + { + return QueryAsync(context => context.AuditEvents.AsNoTracking().Where(row => row.SubjectType == "webhook_subscription").ToListAsync()); + } + + public async Task QueryAsync(Func> query) + { + await using var scope = Services.CreateAsyncScope(); + return await query(scope.ServiceProvider.GetRequiredService()); + } + + public string Unprotect(string protectedSecret) + { + return Services.GetRequiredService().Unprotect(WebhookSecrets.ProtectionPurpose, protectedSecret); + } + + public Task StoresTextAsync(string text) + { + return _database.StoresTextAsync(Services, text); + } + + public async ValueTask DisposeAsync() + { + await Services.DisposeAsync(); + await _database.DisposeAsync(); + } + + private sealed class SwitchableInitiator : ICurrentInitiator + { + public Initiator? Current { get; set; } + } + + // The host's protector on a key ring that lives as long as the test, so what is stored is real ciphertext. + private sealed class EphemeralSecretProtector : ISecretProtector + { + private readonly EphemeralDataProtectionProvider _provider = new(); + + public string Protect(string purpose, string secret) + { + return _provider.CreateProtector(purpose).Protect(secret); + } + + public string Unprotect(string purpose, string protectedSecret) + { + return _provider.CreateProtector(purpose).Unprotect(protectedSecret); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Webhooks/WebhookScreenTests.cs b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookScreenTests.cs new file mode 100644 index 000000000..638cd5683 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookScreenTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentCMS.Core.Security; +using FluentCMS.Core.Webhooks; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Webhooks; + +public sealed partial class WebhookScreenTests +{ + private static readonly Dictionary AllowExampleCom = new() { ["Outbound:AllowedHosts:0"] = "example.com" }; + + [Fact] + public async Task An_anonymous_request_is_sent_to_sign_in() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var response = await host.Client.GetAsync("/webhooks"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/sign-in?ReturnUrl=%2Fwebhooks", host.LocationOf(response)); + } + + [Fact] + public async Task An_administrator_adds_a_webhook_whose_secret_is_shown_once_and_stored_encrypted() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, settings: AllowExampleCom); + var ownerId = await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var form = await host.Client.GetStringAsync("/webhooks"); + Assert.Contains("href=\"webhooks\"", form); + Assert.Matches("name=\"Form.Published\"[^>]*checked", form); + Assert.Contains("No webhook is set up yet.", form); + + var added = await host.SubmitAsync("/webhooks", "add-webhook", Frontend()); + var html = await added.Content.ReadAsStringAsync(); + + // The secret is in the response to the post, not in a redirect, and no page shows it again. + Assert.Equal(HttpStatusCode.OK, added.StatusCode); + var secret = SecretIn(html); + Assert.StartsWith(WebhookSecrets.Prefix, secret, StringComparison.Ordinal); + + var list = await host.Client.GetStringAsync("/webhooks"); + Assert.Contains("https://hooks.example.com/fluentcms", list); + Assert.Contains("data-status=\"enabled\"", list); + Assert.DoesNotContain(secret, list); + Assert.DoesNotContain("data-testid=\"webhook-secret\"", list); + + await using var scope = host.App.Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var row = Assert.Single(await context.WebhookSubscriptions.AsNoTracking().ToListAsync()); + Assert.Equal([WebhookEvents.ChangeSetPublished], row.Events); + Assert.DoesNotContain(secret[WebhookSecrets.Prefix.Length..], row.ProtectedSecret, StringComparison.Ordinal); + Assert.Equal(secret, scope.ServiceProvider.GetRequiredService().Unprotect(WebhookSecrets.ProtectionPurpose, row.ProtectedSecret)); + + var addition = Assert.Single(await context.AuditEvents.AsNoTracking().Where(row => row.Operation == "webhook.added").ToListAsync()); + Assert.Equal((ownerId, row.Id), (addition.InitiatorId, addition.SubjectId!.Value)); + } + + [Fact] + public async Task A_url_off_the_allow_list_is_reported_next_to_its_field_and_nothing_is_added() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, settings: AllowExampleCom); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var fields = Frontend(); + fields["Form.Url"] = "https://hooks.other.test/fluentcms"; + fields.Remove("Form.Published"); + + var refused = await host.SubmitAsync("/webhooks", "add-webhook", fields); + var html = await refused.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, refused.StatusCode); + Assert.Contains("is not on the outbound allow-list", html); + Assert.Contains("id=\"events-error\"", html); + Assert.Contains("aria-invalid=\"true\"", html); + Assert.DoesNotContain("data-testid=\"webhook-secret\"", html); + + await using var scope = host.App.Services.CreateAsyncScope(); + Assert.Empty(await scope.ServiceProvider.GetRequiredService().WebhookSubscriptions.ToListAsync()); + } + + [Fact] + public async Task Disabling_from_the_list_marks_the_webhook_disabled() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, settings: AllowExampleCom); + var ownerId = await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + await host.SubmitAsync("/webhooks", "add-webhook", Frontend()); + Guid id; + await using (var scope = host.App.Services.CreateAsyncScope()) + { + id = (await scope.ServiceProvider.GetRequiredService().WebhookSubscriptions.SingleAsync()).Id; + } + + var disabled = await host.SubmitAsync("/webhooks", $"disable-{id:N}", new Dictionary()); + var html = await disabled.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, disabled.StatusCode); + Assert.Contains("The webhook to https://hooks.example.com/fluentcms is disabled.", html); + Assert.Contains("data-status=\"disabled\"", html); + Assert.DoesNotContain($"disable-{id:N}", html); + + await using var check = host.App.Services.CreateAsyncScope(); + var context = check.ServiceProvider.GetRequiredService(); + Assert.NotNull((await context.WebhookSubscriptions.AsNoTracking().SingleAsync()).DisabledAt); + var disabling = Assert.Single(await context.AuditEvents.AsNoTracking().Where(row => row.Operation == "webhook.disabled").ToListAsync()); + Assert.Equal((ownerId, id), (disabling.InitiatorId, disabling.SubjectId!.Value)); + } + + private static Dictionary Frontend() + { + return new Dictionary + { + ["Form.Url"] = "https://hooks.example.com/fluentcms", + ["Form.Published"] = "true", + }; + } + + private static string SecretIn(string html) + { + var match = SecretField().Match(html); + Assert.True(match.Success, "The response shows no secret."); + return WebUtility.HtmlDecode(match.Groups[1].Value); + } + + [GeneratedRegex("data-testid=\"webhook-secret\".*?value=\"([^\"]+)\"", RegexOptions.Singleline)] + private static partial Regex SecretField(); +} From a3df7339245be3f0df539bed7a1cff4290d762ce Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:10:50 -0400 Subject: [PATCH 2/2] Merge origin/v2 and re-scaffold the webhook migrations on it --- ...igner.cs => 20261019070912_WebhookSubscriptions.Designer.cs} | 2 +- ...kSubscriptions.cs => 20261019070912_WebhookSubscriptions.cs} | 0 ...igner.cs => 20261019070824_WebhookSubscriptions.Designer.cs} | 2 +- ...kSubscriptions.cs => 20261019070824_WebhookSubscriptions.cs} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019053029_WebhookSubscriptions.Designer.cs => 20261019070912_WebhookSubscriptions.Designer.cs} (99%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019053029_WebhookSubscriptions.cs => 20261019070912_WebhookSubscriptions.cs} (100%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019052937_WebhookSubscriptions.Designer.cs => 20261019070824_WebhookSubscriptions.Designer.cs} (99%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019052937_WebhookSubscriptions.cs => 20261019070824_WebhookSubscriptions.cs} (100%) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070912_WebhookSubscriptions.Designer.cs similarity index 99% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070912_WebhookSubscriptions.Designer.cs index 4dd61134e..deb036e68 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070912_WebhookSubscriptions.Designer.cs @@ -13,7 +13,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlFluentCmsDbContext))] - [Migration("20261019053029_WebhookSubscriptions")] + [Migration("20261019070912_WebhookSubscriptions")] partial class WebhookSubscriptions { /// diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070912_WebhookSubscriptions.cs similarity index 100% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019053029_WebhookSubscriptions.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070912_WebhookSubscriptions.cs diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070824_WebhookSubscriptions.Designer.cs similarity index 99% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070824_WebhookSubscriptions.Designer.cs index b9026ee1e..d73ea26dc 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070824_WebhookSubscriptions.Designer.cs @@ -11,7 +11,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite { [DbContext(typeof(SqliteFluentCmsDbContext))] - [Migration("20261019052937_WebhookSubscriptions")] + [Migration("20261019070824_WebhookSubscriptions")] partial class WebhookSubscriptions { /// diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070824_WebhookSubscriptions.cs similarity index 100% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019052937_WebhookSubscriptions.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070824_WebhookSubscriptions.cs