Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 63 additions & 2 deletions docs/adapters/aspnetcore.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,66 @@
# Compendium.Adapters.AspNetCore

> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184).
ASP.NET Core integration: tenant validation middleware, security headers, problem-details mapping, and authentication helpers.

ASP.NET Core integration: tenant validation middleware, problem details, auth helpers.
## Install

```bash
dotnet add package Compendium.Adapters.AspNetCore
```

## Configuration

The adapter exposes several middleware and helper registrations. Most projects wire the tenant middleware first.

```csharp
using Compendium.Adapters.AspNetCore.Security;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<TenantValidationMiddlewareOptions>(
builder.Configuration.GetSection("Compendium:Tenant"));

var app = builder.Build();

app.UseMiddleware<TenantValidationMiddleware>();
// ...
app.Run();
```

`TenantValidationMiddlewareOptions` (see [`TenantValidationMiddleware.cs`](https://github.com/sassy-solutions/compendium/blob/main/src/Adapters/Compendium.Adapters.AspNetCore/Security/TenantValidationMiddleware.cs)):

| Option | Default | Description |
|---|---|---|
| `TenantHeaderName` | `X-Tenant-ID` | HTTP header to read the tenant from |
| `EnableSubdomainResolution` | `true` | Whether to fall back to the host's subdomain |
| `IgnoredSubdomains` | `www, api, admin, app, dashboard, console, portal, staging, dev, test` | Subdomains *not* treated as tenant identifiers |
| `TenantClaimTypes` | `tenant_id, tid, org_id, organization_id, urn:zitadel:iam:org:id` | JWT claim names checked, in order |
| `ExcludedPaths` | `/health, /healthz, /ready, /live, /metrics, /.well-known, /swagger, /api-docs` | Paths skipped by tenant validation |

## Usage

The middleware extracts the tenant from header / subdomain / JWT, validates that all sources agree, looks up the tenant via `ITenantStore`, and sets `TenantContext` for the rest of the pipeline. See [the multi-tenancy concept page](../concepts/multi-tenancy.md) for the full data flow.

Downstream handlers receive the tenant via DI:

```csharp
public sealed class GetOrdersHandler(TenantContext tenant, IOrderRepository repo)
: IQueryHandler<GetOrdersQuery, IReadOnlyList<Order>>
{
public Task<IReadOnlyList<Order>> Handle(GetOrdersQuery q, CancellationToken ct)
=> repo.ListAsync(tenant.Current.Id, ct);
}
```

## Gotchas

- **Order matters.** Place the tenant middleware *after* authentication (so the JWT claim is available) but *before* authorization (so policies can read `TenantContext`).
- **`ExcludedPaths` are prefix matches.** `/health` matches `/health` and `/healthz` and `/health-check`. Pick paths intentionally.
- **CRLF in `Path`.** The middleware sanitizes user-controlled paths before logging (POM-175). If you mirror the pattern in your own middleware, do the same.
- **Subdomain resolution requires at least 3 host parts.** `acme.example.com` works; `localhost`, IPs, and bare-domain (`example.com`) don't.

## See also

- [API Reference: Compendium.Adapters.AspNetCore.Security](../api/Compendium.Adapters.AspNetCore.Security.html)
- [Multi-tenancy concept](../concepts/multi-tenancy.md)
- [ADR 0004 — Multi-tenancy strategy](../adr/0004-multi-tenancy-strategy.md)
66 changes: 64 additions & 2 deletions docs/adapters/lemonsqueezy.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,67 @@
# Compendium.Adapters.LemonSqueezy

> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184).
[LemonSqueezy](https://www.lemonsqueezy.com/) billing adapter — alternative to Stripe, focused on merchant-of-record billing and license-key delivery for software products. Implements the same `Compendium.Abstractions.Billing` port as the Stripe adapter, so swapping between them is a DI change.

LemonSqueezy billing adapter.
## Install

```bash
dotnet add package Compendium.Adapters.LemonSqueezy
```

## Configuration

```json
{
"LemonSqueezy": {
"ApiKey": "eyJ0eXAi...",
"StoreId": "12345",
"WebhookSigningSecret": "your-webhook-secret",
"BaseUrl": "https://api.lemonsqueezy.com/v1/",
"TimeoutSeconds": 30
}
}
```

Options (`LemonSqueezyOptions`):

| Option | Default | Description |
|---|---|---|
| `ApiKey` | _required_ | LemonSqueezy API key (Bearer token) |
| `StoreId` | _required_ | Your store ID |
| `WebhookSigningSecret` | _required for webhooks_ | HMAC-SHA256 secret used by the webhook endpoint |
| `BaseUrl` | `https://api.lemonsqueezy.com/v1/` | API base URL |
| `TimeoutSeconds` | `30` | HTTP timeout |
| `MaxRetries` | `3` | Retry attempts on transient failures |
| `TestMode` | `false` | Whether to flag operations as test |

## Usage

The adapter exposes:

- `IBillingService` — customers, subscriptions, checkouts (same shape as Stripe)
- `ILicenseService` — license key validation, activation, deactivation (LS-specific feature)

```csharp
public sealed class ValidateLicenseHandler(ILicenseService licenses)
: IQueryHandler<ValidateLicenseQuery, LicenseValidationResult>
{
public Task<Result<LicenseValidationResult>> Handle(
ValidateLicenseQuery q, CancellationToken ct)
=> licenses.ValidateLicenseAsync(q.LicenseKey, q.InstanceId, ct);
}
```

The license API is used for software activation flows — when you want to ship a downloadable app and gate it behind a key.

## Gotchas

- **JSON:API for resources, plain JSON for license endpoints.** LemonSqueezy uses JSON:API for customers/subscriptions/checkouts, but its license endpoints (`/licenses/validate`, `/licenses/activate`, `/licenses/deactivate`) return plain JSON. The adapter handles both internally; don't be surprised if you debug the wire format and see two shapes.
- **License keys are secrets — `licenseKeyId` is not.** The `licenseKeyId` returned by the API identifies a license record (like a customer ID) and is fine to log. The `licenseKey` itself is sensitive; Compendium masks it via `GetKeyShort` in service-layer logs and never includes it in URL paths.
- **Activation limits.** A license key can only be activated on N machines (configured per product variant). Hitting the limit returns a specific error code; your UX should distinguish "invalid key" from "limit reached."
- **Webhook signature.** Set `WebhookSigningSecret` and verify HMAC in your webhook endpoint. Without it, anyone can POST events to your service.

## See also

- [API Reference: Compendium.Adapters.LemonSqueezy.Configuration](../api/Compendium.Adapters.LemonSqueezy.Configuration.html)
- [LemonSqueezy docs](https://docs.lemonsqueezy.com/)
- [Stripe adapter](stripe.md) — same port, different provider
73 changes: 71 additions & 2 deletions docs/adapters/listmonk.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,74 @@
# Compendium.Adapters.Listmonk

> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184).
[Listmonk](https://listmonk.app/) is a self-hosted newsletter and mailing list manager. This adapter implements the email-provider and newsletter-management ports from `Compendium.Abstractions.Email` against a Listmonk instance.

Listmonk newsletter adapter.
## Install

```bash
dotnet add package Compendium.Adapters.Listmonk
```

You need a Listmonk instance reachable from your service.

## Configuration

```json
{
"Listmonk": {
"BaseUrl": "https://listmonk.example.com",
"Username": "api-user",
"Password": "<long-random-secret>",
"DefaultFromEmail": "noreply@example.com",
"DefaultFromName": "Acme",
"DefaultListId": 1
}
}
```

Options (`ListmonkOptions`):

| Option | Default | Description |
|---|---|---|
| `BaseUrl` | _required_ | URL of the Listmonk instance |
| `Username` | _required_ | Basic-auth username |
| `Password` | _required_ | Basic-auth password (Listmonk does not expose API tokens — use a strong password) |
| `DefaultFromEmail` | _empty_ | Sender address for transactional emails |
| `DefaultFromName` | _empty_ | Sender display name |
| `DefaultListId` | `null` | Default list new subscribers are added to |
| `TimeoutSeconds` | `30` | HTTP timeout |
| `MaxRetries` | `3` | Retry attempts on transient failures |
| `SkipSslValidation` | `false` | Dev-only — never enable in production |

## Usage

```csharp
public sealed class SubscribeHandler(INewsletterService newsletter)
: ICommandHandler<SubscribeCommand>
{
public async Task<Result> Handle(SubscribeCommand cmd, CancellationToken ct)
{
var subResult = await newsletter.SubscribeAsync(
email: cmd.Email,
listId: cmd.ListId.ToString(),
attributes: new Dictionary<string, object> { ["source"] = "checkout" },
Comment on lines +51 to +53
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INewsletterService.SubscribeAsync takes a SubscribeRequest object; there is no overload that accepts email, listId, and attributes as separate parameters. This example should construct new SubscribeRequest { Email = ..., ListIds = [...], Attributes = ... } and pass that into SubscribeAsync.

Suggested change
email: cmd.Email,
listId: cmd.ListId.ToString(),
attributes: new Dictionary<string, object> { ["source"] = "checkout" },
new SubscribeRequest
{
Email = cmd.Email,
ListIds = [cmd.ListId.ToString()],
Attributes = new Dictionary<string, object> { ["source"] = "checkout" }
},

Copilot uses AI. Check for mistakes.
ct);

return subResult.IsSuccess ? Result.Success() : subResult.Error;
}
}
```

The adapter also exposes `IEmailService` for transactional sends, list management (`ListMailingListsAsync`, etc.), and bulk operations.

## Gotchas

- **PII in logs.** Subscriber emails are sent to Listmonk (intended), but Compendium does not log raw emails server-side after [POM-178](https://github.com/sassy-solutions/compendium/pull/3). When extending this adapter, mirror the pattern: log `subscriber_id` post-lookup, never the raw email.
- **List IDs are integers.** Listmonk uses numeric list IDs; the adapter accepts `string` for portability and parses internally. Pass numeric strings.
- **Basic auth over HTTPS only.** Listmonk credentials over plain HTTP leak the password on every request. If `BaseUrl` starts with `http://`, fail fast in production.
- **Bulk operations are paginated.** Listing more than ~100 subscribers requires pagination — use the `page`/`per_page` parameters in the underlying client.

## See also

- [API Reference: Compendium.Adapters.Listmonk.Configuration](../api/Compendium.Adapters.Listmonk.Configuration.html)
- [Listmonk docs](https://listmonk.app/docs/)
- [`Compendium.Abstractions.Email`](../api/Compendium.Abstractions.Email.html) — port contracts
78 changes: 76 additions & 2 deletions docs/adapters/openrouter.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,79 @@
# Compendium.Adapters.OpenRouter

> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184).
[OpenRouter](https://openrouter.ai/) is a unified API that proxies many LLM providers (Anthropic, OpenAI, Google, Mistral, Meta, …) behind a single OpenAI-compatible interface. This adapter implements the AI-provider port from `Compendium.Abstractions.AI` against OpenRouter.

OpenRouter AI provider adapter.
## Install

```bash
dotnet add package Compendium.Adapters.OpenRouter
```

You need an OpenRouter API key.

## Configuration

```json
{
"OpenRouter": {
"ApiKey": "sk-or-v1-...",
"DefaultModel": "anthropic/claude-3.5-sonnet",
"SiteUrl": "https://example.com",
"SiteName": "Acme"
}
}
```

Options (`OpenRouterOptions`):

| Option | Default | Description |
|---|---|---|
| `ApiKey` | _required_ | OpenRouter API key |
| `BaseUrl` | `https://openrouter.ai/api/v1` | API base URL |
| `DefaultModel` | `anthropic/claude-3.5-sonnet` | Default model when not specified per call |
| `DefaultTemperature` | `0.7` | Default temperature |
| `DefaultMaxTokens` | `4096` | Default max tokens |
| `TimeoutSeconds` | `120` | HTTP timeout (LLM calls can be slow) |
| `RetryAttempts` | `3` | Retries on transient failures |
| `SiteUrl` | `null` | Sent in `HTTP-Referer` for OpenRouter rankings |
| `SiteName` | `null` | Sent in `X-Title` for OpenRouter rankings |
| `EnableLogging` | `false` | Log full request/response (do not enable in prod) |
| `Models` | `{}` | Per-model overrides (max tokens, temperature, custom params) |

## Usage

```csharp
public sealed class SummarizeHandler(IAIProvider ai)
: IQueryHandler<SummarizeQuery, string>
{
public async Task<Result<string>> Handle(SummarizeQuery q, CancellationToken ct)
{
var result = await ai.CompleteAsync(new CompletionRequest
{
Model = "anthropic/claude-3.5-sonnet",
Messages = [new("user", q.Text)],
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompletionRequest.Messages is a list of Message (with Role: MessageRole + Content) and there’s no new("user", ...) constructor. The sample should use Message.User(q.Text) (or new Message { Role = MessageRole.User, Content = ... }) so it compiles against Compendium.Abstractions.AI.

Suggested change
Messages = [new("user", q.Text)],
Messages = [Message.User(q.Text)],

Copilot uses AI. Check for mistakes.
MaxTokens = 500,
}, ct);

return result.IsSuccess
? result.Value.Content
: result.Error;
}
}
```

The adapter supports streaming (`CompletionChunk`), embeddings (`EmbeddingRequest`/`EmbeddingResponse`), and per-model parameter overrides via `OpenRouterOptions.Models`.

## Gotchas

- **`EnableLogging` logs full prompts.** Useful for development, terrible for compliance: prompts often contain user PII and sensitive context. Keep this off outside dev.
- **Model strings are provider-prefixed.** Use `anthropic/claude-3.5-sonnet`, not `claude-3.5-sonnet`. OpenRouter routes by the prefix.
- **Pricing varies wildly per model.** Compendium does not enforce a budget — use OpenRouter's dashboard or your own metering on top. A runaway loop on `anthropic/claude-opus` is much more expensive than the same loop on `meta-llama/llama-3.1-8b`.
- **`DefaultMaxTokens: 4096` is conservative.** Larger context windows are available (200k for Claude 3.5 Sonnet, 1M+ for some models); raise `DefaultMaxTokens` if you need them.
- **Latency tail.** LLM responses occasionally time out at 60s+ even on fast models. The default `TimeoutSeconds: 120` is intentional; do not lower it without testing.

## See also

- [API Reference: Compendium.Adapters.OpenRouter.Configuration](../api/Compendium.Adapters.OpenRouter.Configuration.html)
- [OpenRouter docs](https://openrouter.ai/docs)
- [`samples/03-AI-WithOpenRouter`](https://github.com/sassy-solutions/compendium/tree/main/samples/03-AI-WithOpenRouter)
- [`Compendium.Abstractions.AI`](../api/Compendium.Abstractions.AI.html) — port contracts
86 changes: 84 additions & 2 deletions docs/adapters/postgresql.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,87 @@
# Compendium.Adapters.PostgreSQL

> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184).
PostgreSQL-backed event store and projection store for Compendium. Designed for high-throughput SaaS workloads with proper connection pooling and bulk-insert support.

PostgreSQL event store and projection store.
## Install

```bash
dotnet add package Compendium.Adapters.PostgreSQL
```

You also need a PostgreSQL 14+ instance reachable from your service.

## Configuration

```json
{
"Compendium": {
"EventStore": {
"ConnectionString": "Host=localhost;Database=compendium;Username=app;Password=secret",
"AutoCreateSchema": false,
"BatchSize": 1000
}
}
}
```

```csharp
builder.Services.Configure<PostgreSqlOptions>(
builder.Configuration.GetSection(PostgreSqlOptions.SectionName));
```

Options (`PostgreSqlOptions`):

| Option | Default | Description |
|---|---|---|
| `ConnectionString` | _required_ | Standard Npgsql connection string |
| `MaxPoolSize` | `200` | Application-level concurrency cap (SemaphoreSlim) |
| `CommandTimeout` | `60s` | Single-query timeout |
| `TableName` | `event_store` | Event store table |
| `AutoCreateSchema` | `false` | Whether to auto-create tables on startup (dev only) |
| `BatchSize` | `1000` | Bulk-write batch size |
| `MinimumPoolSize` | `50` | Npgsql min pool — prewarmed connections |
| `MaximumPoolSize` | `200` | Npgsql max pool (must be ≤ Postgres `max_connections`) |
| `ConnectionIdleLifetime` | `900s` | Idle connection close timeout |
| `ConnectionLifetime` | `3600s` | Hard connection recycle |
| `ConnectionTimeout` | `30s` | Pool wait timeout |
| `Keepalive` | `30s` | TCP keepalive (`0` to disable) |
| `EnablePooling` | `true` | Disable only for debugging |

## Usage

Once registered, the event store is available via the `IEventStore` port (from `Compendium.Abstractions`):

```csharp
public sealed class PlaceOrderHandler(IEventStore eventStore)
: ICommandHandler<PlaceOrderCommand>
{
public async Task<Result> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var orderResult = OrderAggregate.Create(cmd.CustomerId, cmd.Amount);
if (orderResult.IsFailure) return orderResult.Error;

await eventStore.AppendAsync(
orderResult.Value.Id.ToString(),
orderResult.Value.DomainEvents,
expectedVersion: 0,
ct);
Comment on lines +63 to +67
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IEventStore does not have an AppendAsync method. The abstraction defines AppendEventsAsync(string aggregateId, IEnumerable<IDomainEvent> events, long expectedVersion, CancellationToken ct = default), so this sample won’t compile as written.

Copilot uses AI. Check for mistakes.

return Result.Success();
}
}
```

For schema bootstrapping in a non-dev environment, run the migrations under `tests/Integration/.../Database/` as a reference, or write your own migrations.
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refers to migrations under tests/Integration/.../Database/, but there’s no such directory in the repo (tests use AutoCreateSchema / in-code CREATE TABLE IF NOT EXISTS instead). Please update the reference to point at a real schema bootstrap source (or remove it).

Suggested change
For schema bootstrapping in a non-dev environment, run the migrations under `tests/Integration/.../Database/` as a reference, or write your own migrations.
For schema bootstrapping in a non-dev environment, use the adapter's `AutoCreateSchema` / in-code `CREATE TABLE IF NOT EXISTS` behavior as a reference for the required tables, then create and manage your own version-controlled migrations.

Copilot uses AI. Check for mistakes.

## Gotchas

- **`AutoCreateSchema` is false by default — on purpose.** In production you want migrations under version control, not implicit DDL on startup.
- **`MaximumPoolSize` must be smaller than the server's `max_connections`.** A microservice deployed in 10 replicas with `MaximumPoolSize: 200` is asking for 2000 connections; many Postgres deployments cap at 100–200.
- **`BatchSize: 1000` is tuned for Postgres on SSD.** On constrained hardware (small RDS), drop it to 200–500.
- **Connection idle lifetime under proxies.** If you front Postgres with PgBouncer in transaction mode, keep `ConnectionIdleLifetime` shorter than the proxy's idle timeout to avoid stale-handle errors.

## See also

- [API Reference: Compendium.Adapters.PostgreSQL.Configuration](../api/Compendium.Adapters.PostgreSQL.Configuration.html)
- [Event Sourcing concept](../concepts/event-sourcing.md)
- [`samples/02-MultiTenant-WithPostgres`](https://github.com/sassy-solutions/compendium/tree/main/samples/02-MultiTenant-WithPostgres)
Loading
Loading