Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ public sealed class AIChatSession : ExtensibleEntity, IModifiedUtcAwareModel
/// </summary>
public string ClientId { get; set; }

/// <summary>
/// Gets or sets the captured remote-address value for this session when plain-text or encrypted storage is enabled.
/// </summary>
public string RemoteAddress { get; set; }

/// <summary>
/// Gets or sets the hashed remote-address signal captured for this session when enabled.
/// </summary>
public string RemoteAddressHash { get; set; }

/// <summary>
/// Gets or sets the collection of document references attached to this session.
/// Documents are uploaded by users and used for RAG (Retrieval-Augmented Generation).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ public sealed class AIChatSessionEvent : ExtensibleEntity, IModifiedUtcAwareMode
public string ProfileId { get; set; }

/// <summary>
/// Gets or sets the persistent anonymous visitor identifier.
/// Generated on the client side and stored in localStorage for cross-session tracking.
/// Gets or sets the persistent visitor identifier resolved by the configured visitor-identity strategy.
/// </summary>
public string VisitorId { get; set; }

Expand All @@ -32,6 +31,16 @@ public sealed class AIChatSessionEvent : ExtensibleEntity, IModifiedUtcAwareMode
/// </summary>
public bool IsAuthenticated { get; set; }

/// <summary>
/// Gets or sets the captured remote-address value for this session when plain-text or encrypted storage is enabled.
/// </summary>
public string RemoteAddress { get; set; }

/// <summary>
/// Gets or sets the hashed remote-address signal captured for this session when enabled.
/// </summary>
public string RemoteAddressHash { get; set; }

/// <summary>
/// Gets or sets the UTC timestamp when the session started.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Configures how AI chat rate-limit keys are partitioned.
/// </summary>
public sealed class AIChatRateLimitingOptions
{
/// <summary>
/// Gets or sets the key partitions used for authenticated chat-message throttling.
/// </summary>
public ChatRateLimitPartition AuthenticatedMessagePartitions { get; set; } = ChatRateLimitPartition.AuthenticatedUser;

/// <summary>
/// Gets or sets the key partitions used for anonymous chat-message throttling.
/// </summary>
public ChatRateLimitPartition AnonymousMessagePartitions { get; set; } =
ChatRateLimitPartition.Visitor |
ChatRateLimitPartition.NetworkAddress |
ChatRateLimitPartition.Session |
ChatRateLimitPartition.Connection;

/// <summary>
/// Gets or sets the key partitions used for anonymous session-start throttling.
/// </summary>
public ChatRateLimitPartition AnonymousSessionStartPartitions { get; set; } =
ChatRateLimitPartition.Visitor |
ChatRateLimitPartition.NetworkAddress;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Represents the resolved visitor identity for the current AI chat request.
/// </summary>
public sealed class AIVisitorIdentity
{
/// <summary>
/// Gets or sets the stable visitor identifier.
/// For authenticated users this is the user identifier; for anonymous users this is a long-lived visitor token.
/// </summary>
public string VisitorId { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the visitor is authenticated.
/// </summary>
public bool IsAuthenticated { get; set; }

/// <summary>
/// Gets or sets the hashed remote-address signal used only for abuse controls.
/// </summary>
public string RemoteAddressHash { get; set; }

/// <summary>
/// Gets or sets the captured remote-address value when plain-text or encrypted storage is enabled.
/// </summary>
public string RemoteAddress { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Configures anonymous visitor tracking for AI chat widgets and sessions.
/// </summary>
public sealed class AIVisitorIdentityOptions
{
/// <summary>
/// Gets or sets the cookie name used to persist the anonymous visitor identifier.
/// </summary>
public string CookieName { get; set; } = "crestapps-ai-visitor";

/// <summary>
/// Gets or sets how long the anonymous visitor cookie remains valid.
/// </summary>
public TimeSpan CookieLifetime { get; set; } = TimeSpan.FromDays(180);

/// <summary>
/// Gets or sets how the remote address should be captured for abuse controls and optional auditing.
/// </summary>
public AIVisitorRemoteAddressMode RemoteAddressMode { get; set; } = AIVisitorRemoteAddressMode.Hashed;

/// <summary>
/// Gets or sets an application-specific salt used when hashing remote addresses.
/// This value is used when <see cref="RemoteAddressMode"/> is set to <see cref="AIVisitorRemoteAddressMode.Hashed"/>
/// or <see cref="AIVisitorRemoteAddressMode.Encrypted"/>.
/// </summary>
public string RemoteAddressHashSalt { get; set; } = "CrestApps.Core.AI.VisitorIdentity";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Determines how the framework captures remote-address data for AI chat requests.
/// </summary>
public enum AIVisitorRemoteAddressMode
{
/// <summary>
/// Do not capture or persist remote-address data.
/// </summary>
Disabled = 0,

/// <summary>
/// Capture only a salted hash of the remote address for privacy-first abuse controls.
/// </summary>
Hashed = 1,

/// <summary>
/// Capture the remote address in plain text for operational controls such as blocklists.
/// </summary>
PlainText = 2,

/// <summary>
/// Capture the remote address in encrypted form for at-rest protection while still allowing hashed abuse partitioning.
/// </summary>
Encrypted = 3,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Identifies which request attributes participate in AI chat rate-limit partitioning.
/// </summary>
[Flags]
public enum ChatRateLimitPartition
{
/// <summary>
/// No partitioning is applied.
/// </summary>
None = 0,

/// <summary>
/// Partition by authenticated user identifier.
/// </summary>
AuthenticatedUser = 1 << 0,

/// <summary>
/// Partition by the stable visitor identifier.
/// </summary>
Visitor = 1 << 1,

/// <summary>
/// Partition by the configured remote-address representation.
/// </summary>
NetworkAddress = 1 << 2,

/// <summary>
/// Partition by chat session identifier.
/// </summary>
Session = 1 << 3,

/// <summary>
/// Partition by connection identifier.
/// </summary>
Connection = 1 << 4,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Resolves the current visitor identity for AI chat requests.
/// </summary>
public interface IAIVisitorIdentityResolver
{
/// <summary>
/// Resolves the current visitor identity for the active request context.
/// </summary>
/// <returns>The resolved visitor identity.</returns>
AIVisitorIdentity Resolve();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace CrestApps.Core.AI.Security;

/// <summary>
/// Provides rate limiting for creating anonymous AI chat sessions.
/// </summary>
public interface IChatSessionStartRateLimiter
{
/// <summary>
/// Determines whether the current session-start request should be rate-limited.
/// </summary>
/// <param name="context">The prompt security context identifying the visitor and request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// A <see cref="RateLimitResult"/> indicating whether the request is allowed or throttled.
/// </returns>
ValueTask<RateLimitResult> EvaluateAsync(PromptSecurityContext context, CancellationToken cancellationToken = default);

/// <summary>
/// Resets the rate-limit tracking state for the provided key.
/// </summary>
/// <param name="key">The rate-limit key to clear.</param>
void Reset(string key);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,19 @@ public sealed class PromptSecurityContext
/// Gets or sets the connection identifier for the current connection.
/// </summary>
public string ConnectionId { get; set; }

/// <summary>
/// Gets or sets the resolved visitor identifier for the current request.
/// </summary>
public string VisitorId { get; set; }

/// <summary>
/// Gets or sets the hashed remote-address signal used only for abuse controls.
/// </summary>
public string RemoteAddressHash { get; set; }

/// <summary>
/// Gets or sets the captured remote-address value when plain-text or encrypted storage is enabled.
/// </summary>
public string RemoteAddress { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,21 @@ public sealed class PromptSecurityOptions
/// Gets or sets the maximum number of messages per session that can be sent
/// within the rate limit window. Set to zero to disable rate limiting.
/// </summary>
public int MaxMessagesPerWindow { get; set; }
public int MaxMessagesPerWindow { get; set; } = 20;

/// <summary>
/// Gets or sets the rate limit window duration.
/// </summary>
public TimeSpan RateLimitWindow { get; set; } = TimeSpan.FromMinutes(1);

/// <summary>
/// Gets or sets the maximum number of anonymous chat sessions that can be started
/// within the anonymous session rate-limit window. Set to zero to disable this limit.
/// </summary>
public int MaxAnonymousSessionsPerWindow { get; set; } = 5;

/// <summary>
/// Gets or sets the anonymous session-start rate-limit window duration.
/// </summary>
public TimeSpan AnonymousSessionRateLimitWindow { get; set; } = TimeSpan.FromMinutes(10);
}
2 changes: 2 additions & 0 deletions src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ description: Initial standalone release notes for the CrestApps.Core repository.
## Highlights

- ships the shared abstractions, infrastructure, AI runtime, provider integrations, and protocol packages under the `CrestApps.Core` name
- assigns stable first-party anonymous visitor IDs to AI chat sessions, uses those visitor IDs for unique-visitor analytics, makes remote-address capture configurable with privacy-first hashed defaults plus optional plain-text or encrypted-at-rest storage, protects anonymous chat session starts with shared ASP.NET Core plus hub-level rate limiting, and documents how hosts can tune thresholds or replace the default endpoint policy with their own `Microsoft.AspNetCore.RateLimiting` policy
- defers AI Profile initial-prompt persistence until the first real user prompt arrives and stops the sample chat widgets from auto-creating empty sessions on initial page load
- includes a reference MVC host and an Aspire host for local composition and testing
- includes a dedicated `CrestApps.Core.Tests` project for framework validation
- publishes a framework-focused documentation site at [core.crestapps.com](https://core.crestapps.com)
Expand Down
24 changes: 22 additions & 2 deletions src/CrestApps.Core.Docs/docs/core/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps
.AddAISuite(ai => ai
.AddOpenAI()
.AddChatInteractions(chatInteractions => chatInteractions
.ConfigureVisitorIdentity(options =>
{
options.RemoteAddressMode = AIVisitorRemoteAddressMode.Hashed;
})
.ConfigureChatRateLimiting(options =>
{
options.AnonymousSessionStartPartitions =
ChatRateLimitPartition.Visitor |
ChatRateLimitPartition.NetworkAddress;
})
.AddEntityCoreStores()
)
)
Expand All @@ -31,6 +41,16 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps

By default, connections are discovered from `CrestApps:AI:Connections` and deployments are discovered from `CrestApps:AI:Deployments`. Connection-based deployments can reference a shared `ConnectionName`, while contained-connection deployments can embed provider-specific settings directly in the deployment entry.

The chat builder also exposes options-pattern hooks for visitor identity and rate-limit partitioning. Use `ConfigureVisitorIdentity(...)` to keep the default privacy-first hashed remote-address behavior, opt into plain-text remote-address storage when your host needs operator-managed IP controls, or encrypt remote addresses at rest with ASP.NET Core Data Protection while still using a hash for throttling. Use `ConfigureChatRateLimiting(...)` to adjust which keys participate in message and session-start throttling without replacing the built-in limiter.

By default, the framework reduces anonymous widget spam in three ways before you add a CAPTCHA or challenge provider:

1. It issues a stable first-party visitor cookie so anonymous usage can be tracked across multiple sessions instead of treating every session as a new visitor.
2. It rate-limits prompt traffic per visitor, with optional network-address participation and session/connection fallbacks.
3. It rate-limits anonymous session starts separately, so robots cannot bypass prompt limits by creating fresh sessions repeatedly.

For AI Profile-based chat, `AIProfileMetadata.InitialPrompt` is now persisted lazily. The framework does not save the initial assistant prompt or create a widget session on page load by default. Instead, the session and the initial prompt are committed when the first real user prompt arrives, which avoids inflating analytics with empty sessions.

When you are ready to turn an ad hoc interaction into a reusable runtime contract, move that setup into an [AI Profile](./ai-profiles.md).

### Registering Chat Interaction Stores
Expand Down Expand Up @@ -196,7 +216,7 @@ NewAsync() SaveAsync() (inactivity / explicit close)

| Stage | What Happens |
|-------|-------------|
| **Creation** | `IAIChatSessionManager.NewAsync()` allocates a new `AIChatSession`, assigns a `SessionId`, sets `Status = Active`, records `CreatedUtc`, and associates it with the profile and user. |
| **Creation** | `IAIChatSessionManager.NewAsync()` allocates a new `AIChatSession`, assigns a `SessionId`, sets `Status = Active`, records `CreatedUtc`, and associates it with the profile and user. Anonymous browser sessions now receive a stable first-party visitor-backed `ClientId` so analytics and throttling can span multiple sessions from the same visitor. |
| **Active Use** | Every user message updates `LastActivityUtc`. Prompts are appended via `IAIChatSessionPromptStore`. Documents may be attached to `session.Documents`. |
| **Interaction Transfer** | If a response handler transfers the conversation (e.g., AI → live agent), a new `ChatInteraction` is created while the session continues. The session's `ResponseHandlerName` updates to the new handler. |
| **Closure** | The session status changes to `Closed` and `ClosedAtUtc` is recorded. The shared post-close processor updates extraction state, post-session task results, resolution analysis, and conversion-goal evaluation so hosts reuse the same runtime behavior. |
Expand All @@ -214,7 +234,7 @@ NewAsync() SaveAsync() (inactivity / explicit close)
| `ProfileId` | `string` | Associated AI profile |
| `Title` | `string` | Human-readable title (often AI-generated after the first exchange) |
| `UserId` | `string` | Authenticated user who owns the session |
| `ClientId` | `string` | Anonymous client identifier (used when `UserId` is null) |
| `ClientId` | `string` | Stable anonymous visitor identifier (used when `UserId` is null) |
| `Status` | `ChatSessionStatus` | `Active`, `Closed`, etc. |
| `ResponseHandlerName` | `string` | Which `IChatResponseHandler` processes messages |
| `Documents` | `List<ChatDocumentInfo>` | Uploaded files for RAG processing |
Expand Down
Loading
Loading