diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
index d8cd42fb7..5febbf4b7 100644
--- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
+++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
@@ -28,7 +28,7 @@
-
+
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
index 68ca51d23..a7754d8df 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
@@ -826,7 +826,7 @@ namespace Squidex.Domain.Apps.Core {
}
///
- /// Looks up a localized string similar to Optional number of contents to skip..
+ /// Looks up a localized string similar to Optional number of items to skip..
///
public static string QuerySkip {
get {
@@ -835,7 +835,7 @@ namespace Squidex.Domain.Apps.Core {
}
///
- /// Looks up a localized string similar to Optional number of contents to take..
+ /// Looks up a localized string similar to Optional number of items to take..
///
public static string QueryTop {
get {
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
index 25ca0e595..b8a267523 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
@@ -373,10 +373,10 @@
Optional OData full text search.
- Optional number of contents to skip.
+ Optional number of items to skip.
- Optional number of contents to take.
+ Optional number of items to take.
The optional version of the content to retrieve an older instance (not cached).
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
index 02651950e..b8f65e7e4 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
@@ -28,6 +28,7 @@
+
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs
new file mode 100644
index 000000000..74476816a
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs
@@ -0,0 +1,22 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Security;
+using Squidex.Messaging.Subscriptions;
+
+namespace Squidex.Domain.Apps.Core.Subscriptions
+{
+ public abstract class AppSubscription : ISubscription
+ {
+ public DomainId AppId { get; set; }
+
+ public PermissionSet Permissions { get; set; }
+
+ public abstract ValueTask ShouldHandle(object message);
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs
new file mode 100644
index 000000000..d13e7c0ac
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs
@@ -0,0 +1,75 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
+using Squidex.Domain.Apps.Events.Assets;
+using Squidex.Shared;
+
+namespace Squidex.Domain.Apps.Core.Subscriptions
+{
+ public sealed class AssetSubscription : AppSubscription
+ {
+ public EnrichedAssetEventType? Type { get; set; }
+
+ public override ValueTask ShouldHandle(object message)
+ {
+ return new ValueTask(ShouldHandleCore(message));
+ }
+
+ private bool ShouldHandleCore(object message)
+ {
+ switch (message)
+ {
+ case EnrichedAssetEvent enrichedAssetEvent:
+ return ShouldHandle(enrichedAssetEvent);
+ case AssetEvent assetEvent:
+ return ShouldHandle(assetEvent);
+ default:
+ return false;
+ }
+ }
+
+ private bool ShouldHandle(EnrichedAssetEvent @event)
+ {
+ return CheckType(@event) && CheckPermission(@event.AppId.Name);
+ }
+
+ private bool ShouldHandle(AssetEvent @event)
+ {
+ return CheckType(@event) && CheckPermission(@event.AppId.Name);
+ }
+
+ private bool CheckType(EnrichedAssetEvent @event)
+ {
+ return Type == null || Type.Value == @event.Type;
+ }
+
+ private bool CheckType(AssetEvent @event)
+ {
+ switch (Type)
+ {
+ case EnrichedAssetEventType.Created:
+ return @event is AssetCreated;
+ case EnrichedAssetEventType.Deleted:
+ return @event is AssetDeleted;
+ case EnrichedAssetEventType.Annotated:
+ return @event is AssetAnnotated;
+ case EnrichedAssetEventType.Updated:
+ return @event is AssetUpdated;
+ default:
+ return true;
+ }
+ }
+
+ private bool CheckPermission(string appName)
+ {
+ var permission = PermissionIds.ForApp(PermissionIds.AppAssetsRead, appName);
+
+ return Permissions.Includes(permission);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs
new file mode 100644
index 000000000..0eef9fb21
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs
@@ -0,0 +1,90 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
+using Squidex.Domain.Apps.Events.Contents;
+using Squidex.Shared;
+
+namespace Squidex.Domain.Apps.Core.Subscriptions
+{
+ public sealed class ContentSubscription : AppSubscription
+ {
+ public string? SchemaName { get; set; }
+
+ public EnrichedContentEventType? Type { get; set; }
+
+ public override ValueTask ShouldHandle(object message)
+ {
+ return new ValueTask(ShouldHandleCore(message));
+ }
+
+ private bool ShouldHandleCore(object message)
+ {
+ switch (message)
+ {
+ case EnrichedContentEvent enrichedContentEvent:
+ return ShouldHandle(enrichedContentEvent);
+ case ContentEvent contentEvent:
+ return ShouldHandle(contentEvent);
+ default:
+ return false;
+ }
+ }
+
+ private bool ShouldHandle(EnrichedContentEvent @event)
+ {
+ var schemaName = @event.SchemaId.Name;
+
+ return CheckSchema(schemaName) && CheckType(@event) && CheckPermission(@event.AppId.Name, schemaName);
+ }
+
+ private bool ShouldHandle(ContentEvent @event)
+ {
+ var schemaName = @event.SchemaId.Name;
+
+ return CheckSchema(schemaName) && CheckType(@event) && CheckPermission(@event.AppId.Name, schemaName);
+ }
+
+ private bool CheckSchema(string schemaName)
+ {
+ return string.IsNullOrWhiteSpace(SchemaName) || schemaName == SchemaName;
+ }
+
+ private bool CheckType(EnrichedContentEvent @event)
+ {
+ return Type == null || Type.Value == @event.Type;
+ }
+
+ private bool CheckType(ContentEvent @event)
+ {
+ switch (Type)
+ {
+ case EnrichedContentEventType.Created:
+ return @event is ContentCreated;
+ case EnrichedContentEventType.Deleted:
+ return @event is ContentDeleted;
+ case EnrichedContentEventType.Published:
+ return @event is ContentStatusChanged { Change: Contents.StatusChange.Published };
+ case EnrichedContentEventType.Unpublished:
+ return @event is ContentStatusChanged { Change: Contents.StatusChange.Unpublished };
+ case EnrichedContentEventType.StatusChanged:
+ return @event is ContentStatusChanged { Change: Contents.StatusChange.Change };
+ case EnrichedContentEventType.Updated:
+ return @event is ContentUpdated;
+ default:
+ return true;
+ }
+ }
+
+ private bool CheckPermission(string appName, string schemaName)
+ {
+ var permission = PermissionIds.ForApp(PermissionIds.AppContentsRead, appName, schemaName);
+
+ return Permissions.Allows(permission);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs
new file mode 100644
index 000000000..b9efecf77
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs
@@ -0,0 +1,87 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Events;
+using Squidex.Infrastructure;
+using Squidex.Messaging.Subscriptions;
+
+namespace Squidex.Domain.Apps.Core.Subscriptions
+{
+ public sealed class EventMessageEvaluator : IMessageEvaluator
+ {
+ private readonly Dictionary> subscriptions = new Dictionary>();
+ private readonly ReaderWriterLockSlim readerWriterLock = new ReaderWriterLockSlim();
+
+ public async ValueTask> GetSubscriptionsAsync(object message)
+ {
+ if (message is not AppEvent appEvent)
+ {
+ return Enumerable.Empty();
+ }
+
+ readerWriterLock.EnterReadLock();
+ try
+ {
+ List? result = null;
+
+ if (subscriptions.TryGetValue(appEvent.AppId.Id, out var appSubscriptions))
+ {
+ foreach (var (id, subscription) in appSubscriptions)
+ {
+ if (await subscription.ShouldHandle(appEvent))
+ {
+ result ??= new List();
+ result.Add(id);
+ }
+ }
+ }
+
+ return result ?? Enumerable.Empty();
+ }
+ finally
+ {
+ readerWriterLock.ExitReadLock();
+ }
+ }
+
+ public void SubscriptionAdded(Guid id, ISubscription subscription)
+ {
+ if (subscription is not AppSubscription appSubscription)
+ {
+ return;
+ }
+
+ readerWriterLock.EnterWriteLock();
+ try
+ {
+ subscriptions.GetOrAddNew(appSubscription.AppId)[id] = appSubscription;
+ }
+ finally
+ {
+ readerWriterLock.ExitWriteLock();
+ }
+ }
+
+ public void SubscriptionRemoved(Guid id, ISubscription subscription)
+ {
+ if (subscription is not AppSubscription appSubscription)
+ {
+ return;
+ }
+
+ readerWriterLock.EnterWriteLock();
+ try
+ {
+ subscriptions.GetOrAddDefault(appSubscription.AppId)?.Remove(id);
+ }
+ finally
+ {
+ readerWriterLock.ExitWriteLock();
+ }
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
new file mode 100644
index 000000000..65264fd14
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
@@ -0,0 +1,50 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
+using Squidex.Domain.Apps.Events;
+using Squidex.Infrastructure.EventSourcing;
+using Squidex.Messaging.Subscriptions;
+
+namespace Squidex.Domain.Apps.Core.Subscriptions
+{
+ public sealed class EventMessageWrapper : IPayloadWrapper
+ {
+ private readonly IEnumerable subscriptionEventCreators;
+
+ public Envelope Event { get; }
+
+ object IPayloadWrapper.Message => Event.Payload;
+
+ public EventMessageWrapper(Envelope @event, IEnumerable subscriptionEventCreators)
+ {
+ Event = @event;
+
+ this.subscriptionEventCreators = subscriptionEventCreators;
+ }
+
+ public async ValueTask