diff --git a/docs/adapters/aspnetcore.md b/docs/adapters/aspnetcore.md index f56a356..56f4358 100644 --- a/docs/adapters/aspnetcore.md +++ b/docs/adapters/aspnetcore.md @@ -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( + builder.Configuration.GetSection("Compendium:Tenant")); + +var app = builder.Build(); + +app.UseMiddleware(); +// ... +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> +{ + public Task> 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) diff --git a/docs/adapters/lemonsqueezy.md b/docs/adapters/lemonsqueezy.md index 7f97782..9df9f74 100644 --- a/docs/adapters/lemonsqueezy.md +++ b/docs/adapters/lemonsqueezy.md @@ -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 +{ + public Task> 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 diff --git a/docs/adapters/listmonk.md b/docs/adapters/listmonk.md index fe3e62b..7575db0 100644 --- a/docs/adapters/listmonk.md +++ b/docs/adapters/listmonk.md @@ -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": "", + "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 +{ + public async Task Handle(SubscribeCommand cmd, CancellationToken ct) + { + var subResult = await newsletter.SubscribeAsync( + email: cmd.Email, + listId: cmd.ListId.ToString(), + attributes: new Dictionary { ["source"] = "checkout" }, + 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 diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index 04f1411..c189503 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -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 +{ + public async Task> Handle(SummarizeQuery q, CancellationToken ct) + { + var result = await ai.CompleteAsync(new CompletionRequest + { + Model = "anthropic/claude-3.5-sonnet", + Messages = [new("user", q.Text)], + 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 diff --git a/docs/adapters/postgresql.md b/docs/adapters/postgresql.md index bafa037..8e39079 100644 --- a/docs/adapters/postgresql.md +++ b/docs/adapters/postgresql.md @@ -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( + 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 +{ + public async Task 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); + + 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. + +## 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) diff --git a/docs/adapters/redis.md b/docs/adapters/redis.md index 29bff4e..df15518 100644 --- a/docs/adapters/redis.md +++ b/docs/adapters/redis.md @@ -1,5 +1,77 @@ # Compendium.Adapters.Redis -> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184). +Redis adapter for caching and (where useful) cross-instance coordination. Built on `StackExchange.Redis`. -Redis cache adapter. +## Install + +```bash +dotnet add package Compendium.Adapters.Redis +``` + +You need a Redis 6+ instance. + +## Configuration + +```json +{ + "Compendium": { + "Redis": { + "ConnectionString": "localhost:6379", + "KeyPrefix": "compendium", + "DefaultDatabase": 0 + } + } +} +``` + +```csharp +builder.Services.Configure( + builder.Configuration.GetSection(RedisOptions.SectionName)); +``` + +Options (`RedisOptions`): + +| Option | Default | Description | +|---|---|---| +| `ConnectionString` | `localhost:6379` | StackExchange.Redis connection string | +| `DefaultDatabase` | `0` | Redis logical DB number | +| `ConnectTimeout` | `5000ms` | Initial connect timeout | +| `CommandTimeout` | `5000ms` | Per-command timeout | +| `RetryCount` | `3` | Retries on transient failures | +| `RetryDelayMs` | `1000ms` | Delay between retries | +| `KeyPrefix` | `compendium` | Prefix added to every key | +| `ValidateConnectionString` | `true` | Validate string at startup | + +## Usage + +```csharp +public sealed class CachedOrderRepository(ICacheStore cache, IOrderRepository inner) + : IOrderRepository +{ + public async Task FindAsync(OrderId id, CancellationToken ct) + { + var key = $"order:{id}"; + var cached = await cache.GetAsync(key, ct); + if (cached is not null) return cached; + + var order = await inner.FindAsync(id, ct); + if (order is not null) + await cache.SetAsync(key, order, TimeSpan.FromMinutes(5), ct); + + return order; + } +} +``` + +## Gotchas + +- **Multi-tenancy in Redis is your responsibility.** A flat key namespace shared across tenants is a leak waiting to happen. Always include the tenant ID in the cache key (e.g. `order:{tenantId}:{orderId}`). +- **`KeyPrefix` is per-application, not per-tenant.** Use it for environment isolation (`compendium-prod`, `compendium-staging`) — not for tenancy. +- **Don't store secrets in Redis without TLS.** The default `ConnectionString` does not enable TLS; in production, use `rediss://` or set `ssl=true`. +- **`SkipSslValidation` style flags do not exist here on purpose.** If your cluster uses a self-signed cert, terminate it at a sidecar/proxy or import the CA properly. +- **AOF vs RDB.** If you use Redis as a durable store (rare for cache), set the persistence mode in the Redis config — Compendium can't influence what happens after the data leaves your service. + +## See also + +- [API Reference: Compendium.Adapters.Redis.Configuration](../api/Compendium.Adapters.Redis.Configuration.html) +- [Multi-tenancy concept](../concepts/multi-tenancy.md) diff --git a/docs/adapters/stripe.md b/docs/adapters/stripe.md index 1ccbe99..159e8df 100644 --- a/docs/adapters/stripe.md +++ b/docs/adapters/stripe.md @@ -1,5 +1,71 @@ # Compendium.Adapters.Stripe -> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184). +[Stripe](https://stripe.com/) billing adapter. Implements the billing-provider port from `Compendium.Abstractions.Billing`: customers, subscriptions, checkout sessions, and webhook handling with HMAC validation. -Stripe billing adapter. +## Install + +```bash +dotnet add package Compendium.Adapters.Stripe +``` + +You need a Stripe account and a secret API key. + +## Configuration + +```json +{ + "Stripe": { + "SecretKey": "sk_test_...", + "PublishableKey": "pk_test_...", + "WebhookSigningSecret": "whsec_...", + "ApiVersion": "2024-06-20" + } +} +``` + +Options (`StripeOptions`): + +| Option | Default | Description | +|---|---|---| +| `SecretKey` | _required_ | Stripe secret key (`sk_live_…` or `sk_test_…`) | +| `PublishableKey` | `null` | Frontend-safe key; surfaced for clients | +| `WebhookSigningSecret` | _strongly recommended_ | `whsec_…` — when empty, signature validation is skipped (dev only) | +| `ApiVersion` | `null` | Pin a specific API version (e.g. `2024-06-20`) | +| `TestMode` | `false` | Convenience flag; the actual mode is determined by the secret-key prefix | + +## Usage + +```csharp +public sealed class StartSubscriptionHandler(IBillingService billing) + : ICommandHandler +{ + public async Task Handle(StartSubscriptionCommand cmd, CancellationToken ct) + { + var customerResult = await billing.EnsureCustomerAsync( + email: cmd.Email, + tenantId: cmd.TenantId, + ct); + if (customerResult.IsFailure) return customerResult.Error; + + var subResult = await billing.StartSubscriptionAsync( + customerResult.Value.Id, cmd.PriceId, ct); + return subResult.IsSuccess ? Result.Success() : subResult.Error; + } +} +``` + +For webhook handling, Compendium provides a webhook handler that validates the signature using `WebhookSigningSecret` and emits domain integration events (`SubscriptionCreatedEvent`, `PaymentSucceededEvent`, `InvoicePaidEvent`, etc.) — listed in [`src/Core/Compendium.Core/Domain/Events/Integration/`](https://github.com/sassy-solutions/compendium/tree/main/src/Core/Compendium.Core/Domain/Events/Integration). + +## Gotchas + +- **Empty `WebhookSigningSecret` skips validation.** That is fine for local development with the Stripe CLI but a security hole in any other environment. The library warns at startup; do not ignore it. +- **Pin `ApiVersion`.** Stripe occasionally ships breaking changes to the default version. Pinning insulates you from surprise behavior changes; bump deliberately. +- **PII in logs.** Customer emails are sent to Stripe (the whole point of the call), but they should *not* be logged on your side. Compendium's logging pattern uses `customer_id` post-creation and `MaskEmail()` from `Compendium.Adapters.Shared` for pre-creation logs. See [POM-178](https://github.com/sassy-solutions/compendium/pull/3) for the GDPR rationale. +- **Idempotency keys.** Stripe supports them; pass through `Stripe-Idempotency-Key` (via `IdempotencyOptions` in your call) on writes that you might retry. +- **Test vs live mode.** A `sk_test_…` key cannot read `sk_live_…` data — fully separate environments. The `TestMode` flag is informational only; the SDK derives the actual mode from the key. + +## See also + +- [API Reference: Compendium.Adapters.Stripe.Configuration](../api/Compendium.Adapters.Stripe.Configuration.html) +- [Stripe docs](https://stripe.com/docs) +- [`Compendium.Abstractions.Billing`](../api/Compendium.Abstractions.Billing.html) — port contracts diff --git a/docs/adapters/zitadel.md b/docs/adapters/zitadel.md index 65acb9e..07beff6 100644 --- a/docs/adapters/zitadel.md +++ b/docs/adapters/zitadel.md @@ -1,5 +1,82 @@ # Compendium.Adapters.Zitadel -> Coming soon — tracked in [POM-184](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-184). +[Zitadel](https://zitadel.com/) OIDC identity adapter. Implements the identity provider port from `Compendium.Abstractions.Identity`, including org provisioning, user management, and token introspection. -Zitadel OIDC identity adapter. +## Install + +```bash +dotnet add package Compendium.Adapters.Zitadel +``` + +You need a Zitadel instance and either a service-account JSON key or a Personal Access Token (PAT). + +## Configuration + +```json +{ + "Zitadel": { + "Authority": "https://zitadel.example.com", + "ServiceAccountKeyPath": "/etc/zitadel/sa.json", + "ProjectId": "239820934820", + "RedirectUriTemplate": "https://{organization}.admin.example.com/api/auth/callback/zitadel", + "PostLogoutUriTemplate": "https://{organization}.admin.example.com" + } +} +``` + +```csharp +builder.Services.Configure( + builder.Configuration.GetSection("Zitadel")); +``` + +Options (`ZitadelOptions`): + +| Option | Default | Description | +|---|---|---| +| `Authority` | _required_ | Base URL of the Zitadel instance | +| `ServiceAccountKeyJson` | `null` | Service-account key JSON inline (use for K8s secrets) | +| `ServiceAccountKeyPath` | `null` | Path to service-account JSON (alternative to inline) | +| `ClientId` / `ClientSecret` | `null` | OAuth2 client credentials flow (alternative to SA key) | +| `PersonalAccessToken` | `null` | PAT used directly as Bearer (simplest auth path) | +| `ProjectId` | `null` | Required for some management operations | +| `DefaultOrganizationId` | `null` | Default org for operations not bound to a tenant | +| `TimeoutSeconds` | `30` | HTTP timeout | +| `MaxRetries` | `3` | Retry attempts on transient failures | +| `InternalBaseUrl` | `null` | Cluster-local URL to bypass hairpin NAT | +| `SkipSslValidation` | `false` | Dev-only — never enable in production | +| `RedirectUriTemplate` | `null` | OIDC redirect URI template; must contain `{organization}` | +| `PostLogoutUriTemplate` | `null` | OIDC post-logout URI template | + +The `{organization}` placeholder in the URI templates is substituted with the org name at provision time. Constants are exposed via `ZitadelOptions.OrganizationPlaceholder`. + +## Usage + +```csharp +public sealed class CreateTenantHandler(IIdentityProvider identity) + : ICommandHandler +{ + public async Task> Handle(CreateTenantCommand cmd, CancellationToken ct) + { + var orgResult = await identity.CreateOrganizationAsync(cmd.Name, ct); + if (orgResult.IsFailure) return orgResult.Error; + + return new TenantId(orgResult.Value.Id); + } +} +``` + +The adapter exposes endpoints for organizations, users, projects, OIDC apps, and token introspection — see [`src/Adapters/Compendium.Adapters.Zitadel/Services/`](https://github.com/sassy-solutions/compendium/tree/main/src/Adapters/Compendium.Adapters.Zitadel/Services). + +## Gotchas + +- **Authentication priority.** When both `PersonalAccessToken` and `ServiceAccountKey*` are set, the PAT wins. Avoid setting both in the same environment. +- **PATs vs service-account keys.** PATs are single-credential and rotate manually; SA keys can be issued and revoked through the Zitadel admin UI. Prefer SA keys for production. +- **`SkipSslValidation` is poison in prod.** If your Zitadel uses a private CA, mount it via a CA bundle in the container instead. +- **`InternalBaseUrl` for in-cluster traffic.** When the gateway routes Zitadel via a public host, internal calls hit the same hostname and may hairpin through your ingress. Setting `InternalBaseUrl` to the cluster-local service skips that loop while keeping the `Host` header correct for routing. +- **Redirect URIs and tenant placeholders.** If you forget `{organization}` in `RedirectUriTemplate`, provisioning fails — Zitadel rejects identical redirect URIs across orgs. + +## See also + +- [API Reference: Compendium.Adapters.Zitadel.Configuration](../api/Compendium.Adapters.Zitadel.Configuration.html) +- [Zitadel docs](https://zitadel.com/docs) +- [ADR 0004 — Multi-tenancy strategy](../adr/0004-multi-tenancy-strategy.md)