diff --git a/README.md b/README.md index e2c5aaf..df2ed77 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,12 @@ Waymark is a schema-driven journey orchestration engine for onboarding and compl ## Documentation map - [`getting-started.md`](./getting-started.md) — prerequisites, local setup, and contribution workflow -- [`user-guide.md`](./user-guide.md) — persona model, session lifecycle, and webhook integration +- [`user-guide.md`](./user-guide.md) — persona model, credentials, session lifecycle, rate limits, and webhook integration - [`docs/journey-json.md`](./docs/journey-json.md) — journey JSON notation, structure, frontend linkage, and visualization -- [`docs/issues/`](./docs/issues/) — known gaps tracked as GitHub issues +- [`docs/runbook.md`](./docs/runbook.md) — configuration reference, credentials, rate limits, scaling, and operations +- [`docs/troubleshooting.md`](./docs/troubleshooting.md) — symptoms, causes and fixes for common failures +- [`docs/architecture/`](./docs/architecture/) — C4 context, container and component diagrams +- [`docs/issues/`](./docs/issues/) — staging area for backlog items the CI workflow turns into GitHub issues. Currently empty; open work lives on the [issue tracker](https://github.com/MaximumTrainer/Waymark/issues). ## Repository structure diff --git a/docs/architecture/components.md b/docs/architecture/components.md index 7e02de6..962a5ae 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -8,12 +8,12 @@ C4Component Container_Boundary(api, "ASP.NET Core API (.NET 10)") { - Component(workflow_ctrl, "WorkflowController", "ASP.NET Controller", "POST /sessions/start, POST /sessions/{id}/steps/{nodeId}/submit, GET /sessions/{id}/next, GET /sessions/{id}/submissions, GET /sessions/{id}/stream (SSE)") + Component(workflow_ctrl, "WorkflowController", "ASP.NET Controller", "POST /sessions/start, POST /sessions/{id}/steps/{nodeId}/submit, GET /sessions/{id}/next, GET /sessions/{id}/submissions, GET /sessions/{id}/events (SSE)") Component(flows_ctrl, "FlowsController", "ASP.NET Controller", "CRUD endpoints for Flows, Nodes, Connections. Operator-only.") Component(customers_ctrl, "CustomersController", "ASP.NET Controller", "CRUD for CustomerProfiles. GET by externalId.") Component(webhooks_ctrl, "WebhooksController", "ASP.NET Controller", "Register/list/delete webhooks. Inspect delivery history.") - Component(analytics_ctrl, "AnalyticsController", "ASP.NET Controller", "GET /analytics/flows/{id} — session counts, completion rate, avg duration, abandonment node.") - Component(auth_ctrl, "AuthController", "ASP.NET Controller", "GET /auth/me — returns authenticated caller identity.") + Component(analytics_ctrl, "AnalyticsController", "ASP.NET Controller", "POST /analytics/events — ingests browser-raised events. GET /analytics/sessions/{id}/events — one session trail in order. GET /analytics/flows/{id} — session counts, completion rate, avg duration, abandonment node.") + Component(auth_ctrl, "AuthController", "ASP.NET Controller", "GET /api/auth/me — returns authenticated caller identity.") Component(workflow_svc, "WorkflowService", "Application Service", "Orchestrates session lifecycle: start, submit, advance, abandon. Evaluates compliance rules. Executes logic nodes. Emits SSE events. Dispatches webhooks.") Component(flow_svc, "FlowService", "Application Service", "CRUD for flows, nodes, connections. Validates node types and connection structure.") diff --git a/docs/architecture/containers.md b/docs/architecture/containers.md index 41772ea..02e9e60 100644 --- a/docs/architecture/containers.md +++ b/docs/architecture/containers.md @@ -10,11 +10,12 @@ C4Container Person(applicant, "Applicant", "Completes onboarding journey") System_Boundary(waymark, "Waymark") { - Container(spa, "React SPA", "React 18 + TypeScript + Vite", "Flow builder UI, step renderer, analytics dashboard, session history. Communicates with API via REST and SSE.") + Container(spa, "React SPA", "React 18 + TypeScript + Vite", "Applicant journey on the public route; flow builder, analytics and session history behind the operator SSO check. Communicates with API via REST and SSE.") Container(api, "ASP.NET Core API", ".NET 10, C#", "Hosts all REST endpoints. Handles auth, rate limiting, compliance evaluation, webhook dispatch, file uploads, SSE streaming.") Container(db, "PostgreSQL 16", "Relational database", "Stores flows, nodes, sessions, submissions, customer profiles, webhooks, webhook delivery logs.") Container(file_store, "File Store", "Local filesystem (dev) / Object Storage (prod)", "Stores uploaded documents. Files referenced by fileId in submissions.") - Container(bg_service, "Session Timeout Service", ".NET IHostedService", "Background service: abandons sessions inactive beyond configured timeout (default: 60 minutes). Runs inside the API process.") + Container(bg_service, "Session Timeout Service", ".NET IHostedService", "Background service: abandons sessions inactive beyond configured timeout (default: 1440 minutes). Runs inside the API process.") + Container(broker, "RabbitMQ", "Message broker", "Optional, required above one API replica. Fans session events out to every instance so SSE streams stay live, and carries domain events.") } System_Ext(idp, "Identity Provider", "OIDC/JWT issuer (Auth0, Keycloak, etc.)") @@ -30,15 +31,20 @@ C4Container Rel(api, webhook_consumer, "Dispatches events", "HTTPS POST") Rel(api, virus_scanner, "Scans uploaded documents", "HTTP API") Rel(bg_service, db, "Reads active sessions, writes abandoned status", "PostgreSQL") + Rel(api, broker, "Publishes and consumes session events", "AMQP") ``` ## Container Responsibilities ### React SPA (`src/frontend/`) -- Flow builder: drag-and-drop node/connection editor + +Applicant route (`/`, public): - Step renderer: schema-driven form engine (renders fields from `jsonContent`) + +Operator routes (`/admin`, behind SSO — no operator surface renders on the public route): +- Flow builder: drag-and-drop node/connection editor - Analytics dashboard: flow performance metrics -- Session history: paginated list + detail view +- Session history: paginated list + detail view, including the per-session analytics event trail - Webhook delivery inspector: delivery logs + manual retry - Version history: snapshot diffs + rollback @@ -51,12 +57,23 @@ C4Container ### PostgreSQL Database - Schema managed via **EF Core migrations** (`OpenOnboarding.Infrastructure/Migrations/`) -- Tables: `Flows`, `Nodes`, `Connections`, `Sessions`, `Submissions`, `CustomerProfiles`, `Webhooks`, `WebhookDeliveries` +- Tables: `Flows`, `Nodes`, `Connections`, `Sessions`, `Submissions`, `CustomerProfiles`, `Webhooks`, `WebhookDeliveries`, `AnalyticsEvents` + +### RabbitMQ (optional) +- Not required for a single replica; the API falls back to an in-process emitter and logs a startup + warning outside Development. +- **Session event fan-out** — an SSE stream is pinned to the instance that accepted it, so events + raised on another instance must travel between them. Enabled with `SessionEvents__Transport=rabbitmq`. +- Each instance binds its own exclusive, auto-delete queue to a shared fanout exchange. Messages are + transient: an event is only useful to a stream that is open right now. +- Connection and topology recovery are enabled explicitly, so a dropped connection re-declares the + queue and re-attaches the consumer rather than leaving a stream silently dead. ### Session Timeout Service - Polls every minute for sessions where `UpdatedAt < now - SessionTimeoutMinutes` - Marks timed-out sessions as `Abandoned` -- Configured via `SessionTimeoutMinutes` (default: 60) +- Configured via `SessionTimeoutMinutes` (default: 1440). Set it to `0` or less to disable the + sweep entirely; the service logs that it is disabled and stops. ## Technology Stack diff --git a/docs/issues/01-saml-placeholder-wire-format.md b/docs/issues/01-saml-placeholder-wire-format.md deleted file mode 100644 index 7e1e618..0000000 --- a/docs/issues/01-saml-placeholder-wire-format.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: "Replace placeholder SAML wire format with standard SAML XML" -labels: ["bug", "security", "authentication"] ---- - -## Summary - -The current SAML authentication controller (`SamlAuthController`) uses a **temporary placeholder wire format** that serialises the authentication request and assertion as Base64-encoded JSON instead of the standard SAML 2.0 XML envelope. This means real SAML identity providers (Azure AD, Okta, Auth0, etc.) cannot be connected without replacing this implementation. - -The placeholder is gated behind `Authentication:Saml:EnablePlaceholderProvider` and all three endpoints (`GET /auth/saml/metadata`, `GET /auth/saml/login`, `POST /auth/saml/callback`) return `404 Not Found` when the flag is `false`. - -**Affected file:** `src/backend/OpenOnboarding.Api/Controllers/SamlAuthController.cs` - -### Current placeholder behaviour (lines 69–79) - -```csharp -// Placeholder wire format: -// Until full SAML XML tooling is integrated, we encode a compact JSON envelope that -// tests and local mocks can round-trip deterministically. -var samlRequestPayload = new -{ - issuer = configuration["Authentication:Saml:Issuer"] ?? "waymark-service-provider", - assertionConsumerServiceUrl = ResolveAcsUrl() -}; -var samlRequest = Convert.ToBase64String( - Encoding.UTF8.GetBytes(JsonSerializer.Serialize(samlRequestPayload))); -``` - -And on callback (lines 279–293): - -```csharp -// Placeholder wire format: -// IdP assertions are expected to be base64-encoded JSON in this temporary implementation. -var jsonBytes = Convert.FromBase64String(encodedResponse); -return JsonSerializer.Deserialize(jsonBytes, AssertionJsonOptions); -``` - ---- - -## Requirements - -### 1 — AuthnRequest generation (`GET /auth/saml/login`) - -- Produce a standard `` XML document signed with the SP private key. -- Base64-encode and optionally deflate-compress the XML per the **HTTP-Redirect binding** (SAML 2.0 §3.4). -- Include required attributes: `ID`, `Version="2.0"`, `IssueInstant`, `Destination`, `AssertionConsumerServiceURL`, ``. -- Remove the JSON fallback path entirely. - -### 2 — Assertion Consumer Service (`POST /auth/saml/callback`) - -- Parse the `SAMLResponse` form field as a Base64-encoded XML document. -- Validate the XML digital signature of the `` and/or `` using the configured IdP certificate. -- Verify: `InResponseTo` matches the issued `AuthnRequest` ID; `NotBefore`/`NotOnOrAfter` conditions; `Recipient` ACS URL matches. -- Support both signed responses and signed assertions. -- Extract `NameID`, email, and display-name attributes from the assertion. -- Return 400 / redirect to `/login?error=saml_invalid_assertion` on any verification failure (existing error-redirect helper can be reused). - -### 3 — SP Metadata (`GET /auth/saml/metadata`) - -- Expose the SP X.509 signing certificate in the `` element so IdPs can import it automatically. -- Make the entity ID (`Authentication:Saml:Issuer`) and ACS URL consistent with the login endpoint. - -### 4 — Configuration - -| Key | Required | Notes | -|-----|----------|-------| -| `Authentication:Saml:Issuer` | Yes | SP entity ID | -| `Authentication:Saml:IdpSsoUrl` | Yes | IdP SSO endpoint | -| `Authentication:Saml:IdpCertificate` | Yes | PEM or Base64 DER of IdP signing cert | -| `Authentication:Saml:SpCertificate` | Yes | PEM of SP signing cert | -| `Authentication:Saml:SpPrivateKey` | Yes | PEM of SP private key (stored as secret) | -| `Authentication:Saml:EnablePlaceholderProvider` | No | Remove flag once real implementation ships | - -### 5 — Dependency - -Prefer a well-maintained .NET SAML library (e.g. `ITfoxtec.Identity.Saml2`) rather than hand-rolling XML signature logic. Validate any chosen library version against the GitHub Advisory Database before adding it. - ---- - -## Acceptance Criteria - -- [ ] `GET /auth/saml/login` redirects to the IdP with a valid, standards-compliant `SAMLRequest` query parameter (Base64-deflated XML, not JSON). -- [ ] `POST /auth/saml/callback` accepts a real IdP `SAMLResponse`, verifies the XML signature, and issues an `AdminSession` cookie on success. -- [ ] An expired, tampered, or missing signature causes a redirect to `/login?error=saml_invalid_assertion`. -- [ ] An `InResponseTo` mismatch causes a redirect to `/login?error=saml_invalid_assertion`. -- [ ] A `NotOnOrAfter` violation causes a redirect to `/login?error=saml_invalid_assertion`. -- [ ] `GET /auth/saml/metadata` returns well-formed SAML metadata XML that includes the SP signing certificate. -- [ ] `Authentication:Saml:EnablePlaceholderProvider` flag and all placeholder code paths are removed. -- [ ] All existing `SamlAuthControllerTests` tests are updated or replaced to cover the real implementation; placeholder-specific tests are removed. -- [ ] A new test exercises the full login → callback → session flow using a self-signed test certificate. diff --git a/docs/issues/02-session-timeout-service-tests.md b/docs/issues/02-session-timeout-service-tests.md deleted file mode 100644 index 02e32ef..0000000 --- a/docs/issues/02-session-timeout-service-tests.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: "Add unit tests for SessionTimeoutService" -labels: ["testing", "reliability"] ---- - -## Summary - -`SessionTimeoutService` is a `BackgroundService` that periodically marks stale sessions as `Abandoned`. It is registered as a hosted service in production but has **zero unit tests**. The core logic (`CheckAndAbandonAsync`) is already `public` and designed for testability (the method signature even says "Exposed as public for testability"), yet no test file exists. - -**Affected file:** `src/backend/OpenOnboarding.Infrastructure/Services/SessionTimeoutService.cs` - -### Current implementation (lines 50–72) - -```csharp -/// -/// Finds all sessions in the Started state whose last update is older than -/// and transitions them to Abandoned. -/// Exposed as public for testability. -/// -public async Task CheckAndAbandonAsync(int timeoutMinutes, CancellationToken cancellationToken = default) -{ - await using var scope = scopeFactory.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var cutoff = DateTimeOffset.UtcNow.AddMinutes(-timeoutMinutes); - - var timedOut = await dbContext.Sessions - .Where(x => x.Status == SessionStatus.Started && x.UpdatedAt < cutoff) - .ToListAsync(cancellationToken); - - foreach (var session in timedOut) - { - session.Status = SessionStatus.Abandoned; - session.UpdatedAt = DateTimeOffset.UtcNow; - } - - if (timedOut.Count > 0) - { - await dbContext.SaveChangesAsync(cancellationToken); - logger.LogInformation("Auto-abandoned {Count} timed-out session(s).", timedOut.Count); - } -} -``` - ---- - -## Requirements - -Add a new test class `SessionTimeoutServiceTests` in `src/backend/OpenOnboarding.Application.Tests/` using the same `WebApplicationFactory`-based pattern as the existing tests (e.g. `WorkflowServiceTests`). - -### Test cases required - -| # | Scenario | Expected outcome | -|---|----------|-----------------| -| 1 | Session updated more than `timeoutMinutes` ago with status `Started` | Status transitions to `Abandoned`, `UpdatedAt` is refreshed | -| 2 | Session updated within `timeoutMinutes` ago with status `Started` | Session is **not** changed | -| 3 | Session with status `Completed` that is older than the timeout | Session is **not** changed | -| 4 | Session with status `Abandoned` that is older than the timeout | Session is **not** changed | -| 5 | Multiple sessions — mix of stale and fresh — in a single call | Only stale `Started` sessions are abandoned | -| 6 | `timeoutMinutes = 0` (service disabled) | `CheckAndAbandonAsync` is never invoked (service exits immediately) — verify via log or mock | -| 7 | `CancellationToken` cancelled mid-run | Method exits without throwing `OperationCanceledException` to the caller | - -### Additional coverage - -- Verify the `ILogger` message `"Auto-abandoned {Count} timed-out session(s)."` is emitted exactly once when sessions are abandoned (use `ILogger` capture or a mock logger). -- Verify **no** save or log call is made when there are no stale sessions (performance guard). - ---- - -## Acceptance Criteria - -- [ ] `SessionTimeoutServiceTests` exists in `OpenOnboarding.Application.Tests`. -- [ ] All 7 scenarios in the table above have a passing test. -- [ ] Logger output is verified for the abandon-success and no-sessions paths. -- [ ] Tests use an in-memory or SQLite test database (same pattern as existing integration tests) — no real Postgres required. -- [ ] `dotnet test` passes with all new tests green. -- [ ] No existing tests are modified or removed. diff --git a/docs/issues/03-null-virus-scan-observability.md b/docs/issues/03-null-virus-scan-observability.md deleted file mode 100644 index 38ba496..0000000 --- a/docs/issues/03-null-virus-scan-observability.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: "NullVirusScanService — add monitoring and observability for bypassed scans" -labels: ["security", "observability"] ---- - -## Summary - -When `VirusScan:Enabled` is `false` (the default), `NullVirusScanService` is registered instead of `ClamAvScanService`. The null implementation silently returns `ScanResult(true, null)` — i.e. "clean" — for every file without emitting any log, metric, or telemetry signal. - -This means: -- Documents uploaded in a production environment with virus scanning disabled are accepted with no audit trail of the bypass. -- Operators have no way to detect misconfiguration (e.g. ClamAV disabled accidentally). - -**Affected file:** `src/backend/OpenOnboarding.Infrastructure/Services/NullVirusScanService.cs` - -### Current implementation - -```csharp -public sealed class NullVirusScanService : IVirusScanService -{ - public Task ScanAsync(Stream stream, CancellationToken ct = default) => - Task.FromResult(new ScanResult(true, null)); -} -``` - ---- - -## Requirements - -### 1 — Logging - -Inject `ILogger` and emit a **warning** on every call: - -``` -Virus scanning is disabled (NullVirusScanService). File scan bypassed. -``` - -The log message must include the stream length (if seekable) so that operators can correlate bypassed scans with file size in log aggregation tooling. - -### 2 — Metrics - -Increment an `IMetricsService` counter for bypassed scans so the Prometheus dashboard can alert on unexpected volume: - -``` -metricsService.IncrementVirusScanBypassed(); // new counter to be added to IMetricsService -``` - -Add `IncrementVirusScanBypassed()` to `IMetricsService` and implement it in `PrometheusMetricsService` (counter name: `waymark_virus_scan_bypassed_total`). - -### 3 — Startup warning - -In `ServiceCollectionExtensions.AddInfrastructure`, after registering `NullVirusScanService`, log a **startup warning** via the host logger: - -``` -VirusScan:Enabled is false — virus scanning is disabled. Do not use this configuration in production. -``` - -### 4 — Tests - -Add test cases in `DocumentUploadAndSseTests` (or a new `NullVirusScanServiceTests` class) to verify: -- The warning log is emitted when `NullVirusScanService.ScanAsync` is called. -- The metrics counter is incremented once per call. - ---- - -## Acceptance Criteria - -- [ ] `NullVirusScanService` logs a `LogLevel.Warning` message on every call. -- [ ] `NullVirusScanService` increments a `waymark_virus_scan_bypassed_total` Prometheus counter on every call. -- [ ] `IMetricsService` and `PrometheusMetricsService` implement `IncrementVirusScanBypassed()`. -- [ ] A startup warning is logged when `NullVirusScanService` is registered. -- [ ] Unit tests verify the log emission and counter increment. -- [ ] Existing virus-scanning tests continue to pass. -- [ ] `dotnet test` passes. diff --git a/docs/issues/04-rabbitmq-eventbus-resilience.md b/docs/issues/04-rabbitmq-eventbus-resilience.md deleted file mode 100644 index 657d808..0000000 --- a/docs/issues/04-rabbitmq-eventbus-resilience.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "RabbitMqEventBus — fix sync-over-async startup registration and add resilience" -labels: ["bug", "reliability", "infrastructure"] ---- - -## Summary - -Two related problems exist in the RabbitMQ event bus integration: - -### Problem 1 — Sync-over-async in DI startup (deadlock risk) - -`ServiceCollectionExtensions.AddInfrastructure` uses `.GetAwaiter().GetResult()` to block on the async factory: - -```csharp -// src/backend/OpenOnboarding.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs:84 -services.AddSingleton(sp => - RabbitMqEventBus.CreateAsync(rabbitUri, exchange, sp.GetRequiredService>()) - .GetAwaiter().GetResult()); // ← sync-over-async antipattern -``` - -This blocks a thread-pool thread during startup and can cause a deadlock on runtimes that synchronise the thread pool (e.g. some ASP.NET Core startup contexts). - -### Problem 2 — No resilience or reconnection - -If RabbitMQ is temporarily unavailable after startup, `PublishAsync` will throw and the event is silently lost (the exception is re-thrown and logged, but the caller — `SubmitStepCommandHandler` — has no retry or fallback). There is no circuit breaker, no reconnection, and no dead-letter handling. - -**Affected files:** -- `src/backend/OpenOnboarding.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs` -- `src/backend/OpenOnboarding.Infrastructure/EventBus/RabbitMqEventBus.cs` - ---- - -## Requirements - -### Fix 1 — Remove sync-over-async in startup - -Replace the blocking singleton factory with an `IHostedService` initializer pattern: - -1. Create `RabbitMqEventBusInitializer : IHostedService` that connects in `StartAsync` (async) and stores the `RabbitMqEventBus` instance in a shared holder. -2. Register a lightweight `IEventBus` proxy that delegates to the holder and throws an `InvalidOperationException` with a clear message if called before initialization completes. -3. Alternatively, use `IHostApplicationBuilder.Services.AddHostedService` + `Lazy>` with `IServiceProvider.GetRequiredService` deferred to first use. - -The chosen approach must not use `.GetAwaiter().GetResult()` or `.Result` anywhere in the startup path. - -### Fix 2 — Resilient publish with Polly - -Add retry-with-backoff around `BasicPublishAsync` using **Polly** (already a transitive dependency): - -- 3 retry attempts with exponential back-off (1 s, 2 s, 4 s). -- On exhausted retries, log the failure at `Error` level and **do not rethrow** (fire-and-forget events should not fail the caller's transaction). -- Add a `bool ThrowOnPublishFailure` option (default `false`) for contexts that require strong delivery guarantees. - -### Fix 3 — Unit tests for RabbitMqEventBus - -Add `RabbitMqEventBusTests` in `OpenOnboarding.Application.Tests` using a mocked `IChannel` / `IConnection`: - -| Test | Scenario | -|------|----------| -| Happy path | `PublishAsync` serialises notification to JSON and calls `BasicPublishAsync` once | -| Transient failure + retry | First call throws; second call succeeds; verify 2 total calls | -| Exhausted retries | All 3 attempts throw; verify `LogError` is called; verify no exception escapes | -| Routing key | Routing key equals `notification.GetType().Name` | - ---- - -## Acceptance Criteria - -- [ ] No `.GetAwaiter().GetResult()` or `.Result` call remains in the RabbitMQ startup path. -- [ ] API starts successfully even when RabbitMQ is temporarily unreachable (connection failure is logged; application continues with degraded event publishing). -- [ ] `PublishAsync` retries up to 3 times on transient channel exceptions before logging at `Error` level. -- [ ] `RabbitMqEventBusTests` exists with the 4 test cases listed above, all green. -- [ ] `dotnet test` passes. -- [ ] Default credentials `amqp://guest:guest@localhost:5672/` are not hard-coded; configuration falls back to a documented default only when `EventBus:RabbitMq:Uri` is absent. diff --git a/docs/issues/05-document-storage-scan-missing-file.md b/docs/issues/05-document-storage-scan-missing-file.md deleted file mode 100644 index e9f7928..0000000 --- a/docs/issues/05-document-storage-scan-missing-file.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: "LocalDocumentStorageService — fix silent clean-result for missing files in ScanAsync" -labels: ["bug", "security"] ---- - -## Summary - -`LocalDocumentStorageService.ScanAsync` contains a logic gap: if the requested file does not exist on disk, it falls through to calling `_virusScanService.ScanAsync(Stream.Null, cancellationToken)` and returns a **clean scan result** for a non-existent file. - -This means: -1. A caller that provides an invalid or tampered `fileId` receives `ScanResult(true, null)` — "file is clean" — even though the file was never read. -2. The `NullVirusScanService` further amplifies this: in environments where virus scanning is disabled, *any* `fileId` value (including one that does not exist) is declared clean. - -**Affected file:** `src/backend/OpenOnboarding.Infrastructure/Services/LocalDocumentStorageService.cs` (lines 61–73) - -### Current code - -```csharp -public async Task ScanAsync(string fileId, CancellationToken cancellationToken = default) -{ - if (fileId.Length >= 2) - { - var filePath = Path.Combine(_basePath, fileId[..2], fileId); - if (File.Exists(filePath)) - { - await using var stream = File.OpenRead(filePath); - return await _virusScanService.ScanAsync(stream, cancellationToken); - } - } - // ← Silent fallthrough: passes Stream.Null when file is not found - return await _virusScanService.ScanAsync(Stream.Null, cancellationToken); -} -``` - ---- - -## Requirements - -### Fix — throw `NotFoundException` when file does not exist - -Replace the silent fallthrough with the same `NotFoundException` that `GetStreamAsync` already throws: - -```csharp -public async Task ScanAsync(string fileId, CancellationToken cancellationToken = default) -{ - if (fileId.Length < 2) - throw new NotFoundException($"File '{fileId}' not found."); - - var filePath = Path.Combine(_basePath, fileId[..2], fileId); - if (!File.Exists(filePath)) - throw new NotFoundException($"File '{fileId}' not found."); - - await using var stream = File.OpenRead(filePath); - return await _virusScanService.ScanAsync(stream, cancellationToken); -} -``` - -### Tests to add - -Add the following test cases in `DocumentUploadAndSseTests` or a new `LocalDocumentStorageServiceTests` class: - -| # | Scenario | Expected outcome | -|---|----------|-----------------| -| 1 | `ScanAsync` called with a valid `fileId` that was previously stored | Returns the scan result from `IVirusScanService` | -| 2 | `ScanAsync` called with a `fileId` that does not exist on disk | Throws `NotFoundException` | -| 3 | `ScanAsync` called with a `fileId` shorter than 2 characters | Throws `NotFoundException` | -| 4 | `ScanAsync` when `IVirusScanService` returns infected | Returns infected `ScanResult` to caller | - ---- - -## Acceptance Criteria - -- [ ] `ScanAsync` throws `NotFoundException` (not `ApplicationException` or similar) when the file does not exist. -- [ ] `ScanAsync` never calls `_virusScanService.ScanAsync(Stream.Null, ...)`. -- [ ] The 4 test cases above are implemented and green. -- [ ] `GetStreamAsync` and `StoreAsync` behaviour is unchanged. -- [ ] `dotnet test` passes. diff --git a/docs/issues/06-session-event-emitter-cleanup.md b/docs/issues/06-session-event-emitter-cleanup.md deleted file mode 100644 index c681cd5..0000000 --- a/docs/issues/06-session-event-emitter-cleanup.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: "InMemorySessionEventEmitter — document channel eviction behaviour and add stale-channel cleanup" -labels: ["reliability", "observability"] ---- - -## Summary - -`InMemorySessionEventEmitter` uses a `ConcurrentDictionary>` to fan events to SSE subscribers. Channels are created on first access and completed (closed) when a `session-completed` or `session-abandoned` event is emitted. However: - -1. **Stale channels are never removed from the dictionary.** A completed channel's entry stays in `_channels` indefinitely. In a long-running process with many sessions this is a slow memory leak. -2. **The channel is bounded to 100 items with `DropOldest`.** If a subscriber is slow, events are silently dropped. There is no log or metric when this occurs. -3. **There is no test** that verifies channel completion, event ordering, or the DropOldest eviction behaviour. - -**Affected file:** `src/backend/OpenOnboarding.Infrastructure/Services/InMemorySessionEventEmitter.cs` - -### Current implementation - -```csharp -public sealed class InMemorySessionEventEmitter : ISessionEventEmitter -{ - private readonly ConcurrentDictionary> _channels = new(); - - public Task EmitAsync(Guid sessionId, string eventType, object payload, ...) - { - var channel = _channels.GetOrAdd(sessionId, _ => CreateChannel()); - // ... - if (eventType is "session-completed" or "session-abandoned") - channel.Writer.TryComplete(); // ← channel closed but entry never removed - - return Task.CompletedTask; - } - - private static Channel CreateChannel() => - Channel.CreateBounded(new BoundedChannelOptions(100) - { - FullMode = BoundedChannelFullMode.DropOldest, // ← silent drop - ... - }); -} -``` - ---- - -## Requirements - -### Fix 1 — Remove completed channel entries - -After calling `TryComplete()`, remove the entry from `_channels`: - -```csharp -channel.Writer.TryComplete(); -_channels.TryRemove(sessionId, out _); -``` - -This bounds dictionary growth to the number of active (open) sessions rather than all sessions ever seen. - -### Fix 2 — Log event drops - -Override the `DropOldest` path by implementing a custom `IChannelWriter` wrapper, or use a `Channel.CreateUnbounded` with a configurable high-watermark soft limit and log a warning when the count exceeds it. - -At minimum, add a single log warning at `Warning` level: - -``` -Session event channel for {SessionId} is full; oldest event dropped. -``` - -This requires a different channel strategy since `System.Threading.Channels` does not natively callback on drop. Consider switching to `BoundedChannelFullMode.Wait` (to apply backpressure) or `DropOldest` with a logged wrapper. - -### Fix 3 — Unit tests - -Add `InMemorySessionEventEmitterTests` in `OpenOnboarding.Application.Tests`: - -| # | Scenario | Expected outcome | -|---|----------|-----------------| -| 1 | Emit and subscribe — events are received in order | All emitted events returned by `SubscribeAsync` | -| 2 | Emit `session-completed` — channel is completed | `SubscribeAsync` async enumerable completes without `CancellationToken` | -| 3 | Emit `session-abandoned` — channel is completed | Same as above | -| 4 | After `session-completed`, entry is removed from internal dictionary | A second call to `EmitAsync` for the same session ID creates a fresh channel | -| 5 | Subscribing before any events — events emitted later are still received | Ordering guarantee | - ---- - -## Acceptance Criteria - -- [ ] `_channels` dictionary no longer retains entries for sessions whose channel has been completed. -- [ ] Event drops (when channel is full) produce a `LogLevel.Warning` log entry containing the session ID. -- [ ] The 5 unit test cases above exist and are green. -- [ ] Existing `DocumentUploadAndSseTests` SSE tests pass without modification. -- [ ] `dotnet test` passes. diff --git a/docs/issues/07-frontend-unit-tests.md b/docs/issues/07-frontend-unit-tests.md deleted file mode 100644 index 6f90646..0000000 --- a/docs/issues/07-frontend-unit-tests.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: "Frontend — add unit tests for StepRenderer, useOnboarding, and App" -labels: ["testing", "frontend"] ---- - -## Summary - -The frontend test suite currently covers analytics helpers, the session event source hook, the API client, Pact consumer contracts, the dev proxy, and the flow authoring panel — a total of **7 test files**. - -The following high-complexity components have **no unit tests at all**: - -| File | LOC | Description | -|------|-----|-------------| -| `src/onboarding/components/StepRenderer.tsx` | ~512 | Renders all node types; owns `FormErrorBoundary`; calls analytics | -| `src/onboarding/hooks/useOnboarding.ts` | ~134 | Manages session lifecycle, step navigation, and SSE subscription | -| `src/App.tsx` | ~392 | Top-level routing, auth state, journey/persona selection | -| `src/onboarding/hooks/useFlow.ts` | — | Fetches flow definition and manages loading/error state | - -These are the critical paths for the entire user-facing onboarding journey, and any regression here will not be caught by automated tests. - -**Affected files:** -- `src/frontend/src/onboarding/components/StepRenderer.tsx` -- `src/frontend/src/onboarding/hooks/useOnboarding.ts` -- `src/frontend/src/App.tsx` -- `src/frontend/src/onboarding/hooks/useFlow.ts` - ---- - -## Requirements - -### 1 — StepRenderer tests (`stepRenderer.test.ts`) - -Use `@testing-library/react` and `vitest`. - -| Test | Description | -|------|-------------| -| Renders `Form` node | Given a node of type `Form` with two fields, all field labels and inputs are present in the DOM | -| Renders `Information` node | Node content text is visible | -| Renders `DocumentUpload` node | File input is rendered | -| Renders `Redirect` node | Redirect message / indicator is shown | -| `FormErrorBoundary` catches render errors | When a child throws, the error boundary message "This step could not be loaded" is shown | -| Analytics: `step_view` fired on mount | `useJourneyAnalytics().track` is called with `{ event: 'step_view', nodeType: 'Form' }` on mount | -| Validation error shown | When the parent passes a compliance error, the error message is visible | - -### 2 — `useOnboarding` tests (`useOnboarding.test.ts`) - -Use `renderHook` from `@testing-library/react`. - -| Test | Description | -|------|-------------| -| Initial state | `currentNode` is null; `isLoading` is false; `sessionId` is null | -| `startSession` — success | After calling `startSession`, `sessionId` is set and `currentNode` is populated | -| `startSession` — API error | `error` state is set; `isLoading` returns to false | -| `submitStep` — success | `currentNode` advances to next node | -| `submitStep` — compliance violation | `complianceErrors` is populated; `currentNode` does not change | -| Session completed | When SSE emits `session-completed`, `isCompleted` becomes true | - -### 3 — `useFlow` tests (`useFlow.test.ts`) - -| Test | Description | -|------|-------------| -| Loading state | `isLoading` is true while fetch is in-flight | -| Success | `flow` is populated after fetch resolves | -| Error | `error` is set when fetch rejects | - -### 4 — App smoke tests (`App.test.tsx`) - -| Test | Description | -|------|-------------| -| Default render | App renders without crashing (smoke test) | -| Journey selector visible | Journey dropdown/list is present | -| Unauthenticated admin route | Dashboard is not shown when admin session cookie is absent | - ---- - -## Acceptance Criteria - -- [ ] `stepRenderer.test.ts` exists with all 7 test cases green. -- [ ] `useOnboarding.test.ts` exists with all 6 test cases green. -- [ ] `useFlow.test.ts` exists with all 3 test cases green. -- [ ] `App.test.tsx` exists with all 3 test cases green. -- [ ] `npm run test` in `src/frontend` passes with all new tests included. -- [ ] No existing tests are modified or removed. -- [ ] No `@ts-ignore` or `any` casts introduced solely to satisfy the test harness. diff --git a/docs/issues/08-frontend-app-error-boundary.md b/docs/issues/08-frontend-app-error-boundary.md deleted file mode 100644 index bc4d24e..0000000 --- a/docs/issues/08-frontend-app-error-boundary.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Frontend — add top-level error boundary for auth/routing failures" -labels: ["reliability", "frontend"] ---- - -## Summary - -`StepRenderer.tsx` correctly wraps its form in a `FormErrorBoundary` that catches rendering errors within a single step. However, `App.tsx` — which controls authentication state, journey selection, persona routing, and admin-dashboard rendering — has **no error boundary** at all. - -An unhandled JavaScript exception in any top-level component (e.g. a failed auth check, a missing flow definition, an unexpected null during persona resolution) will crash the entire application to a blank page with no user-facing feedback. - -**Affected file:** `src/frontend/src/App.tsx` - ---- - -## Requirements - -### 1 — Top-level `AppErrorBoundary` component - -Create a new class component `AppErrorBoundary` (or a file `src/components/AppErrorBoundary.tsx`): - -- Catches any render-phase error originating from `` or its descendants. -- Displays a user-friendly error screen: - ``` - Something went wrong. Please refresh the page or contact support. - [Refresh] button - ``` -- Logs the error to `console.error` in development. -- Exposes the caught error detail only in development (hide stack traces in production). -- Accepts an optional `onReset` prop so that tests can programmatically reset the boundary. - -### 2 — Wire it up in `main.tsx` - -Wrap the root render in `AppErrorBoundary`: - -```tsx -// src/frontend/src/main.tsx -root.render( - - - -) -``` - -### 3 — Tests - -Add `AppErrorBoundary.test.tsx`: - -| Test | Expected outcome | -|------|-----------------| -| Child throws during render | Error boundary message is displayed; child content is not | -| Error message is shown | "Something went wrong" text is visible | -| Refresh button present | A button that calls `window.location.reload` (or `onReset`) is present | -| Error detail hidden in production | Stack trace is not present in the DOM when `import.meta.env.PROD === true` | - ---- - -## Acceptance Criteria - -- [ ] `AppErrorBoundary` component exists and is rendered as the root wrapper in `main.tsx`. -- [ ] An unhandled render error in any child of `` shows the friendly error screen instead of a blank page. -- [ ] Stack trace / error detail is not exposed in production builds. -- [ ] All 4 `AppErrorBoundary.test.tsx` tests pass. -- [ ] `npm run test` passes. -- [ ] `npm run build` produces no TypeScript errors. diff --git a/docs/issues/09-webhook-cancellation-token.md b/docs/issues/09-webhook-cancellation-token.md deleted file mode 100644 index 45f07f6..0000000 --- a/docs/issues/09-webhook-cancellation-token.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: "WebhookService — CancellationToken not propagated through delivery retry loop" -labels: ["bug", "reliability"] ---- - -## Summary - -`WebhookService.DeliverAsync` accepts a `CancellationToken` parameter but does **not** propagate it into the inner delivery loop or the `SaveChangesAsync` calls inside `DeliverToWebhookAsync`. This means: - -1. A host shutdown signal (e.g. `SIGTERM`) cannot cleanly interrupt a delivery attempt. -2. Database save operations during the retry loop cannot be cancelled. -3. The `Task.Delay` inside the retry loop already uses `CancellationToken.None` explicitly, bypassing the caller's token. - -**Affected file:** `src/backend/OpenOnboarding.Infrastructure/Services/WebhookService.cs` - -### Current code (lines 94–133) - -```csharp -private async Task DeliverToWebhookAsync(Webhook webhook, Guid sessionId, string eventType, string payloadJson) -// ↑ No CancellationToken parameter - - // ... - for (var attempt = 0; attempt <= 2; attempt++) - { - if (attempt > 0) - await _delay(delays[attempt - 1], CancellationToken.None); // ← ignores caller token - - var result = await webhookHttpClient.SendAsync(webhook.Url, payloadJson, signature); - // ... - await dbContext.SaveChangesAsync(); // ← no cancellation token - } - - delivery.Status = "failed"; - await dbContext.SaveChangesAsync(); // ← no cancellation token -``` - ---- - -## Requirements - -### Fix 1 — Pass `CancellationToken` through the delivery pipeline - -1. Add a `CancellationToken cancellationToken` parameter to `DeliverToWebhookAsync`. -2. Pass the token to all `SaveChangesAsync` calls: `SaveChangesAsync(cancellationToken)`. -3. Pass the token to `_delay`: `await _delay(delays[attempt - 1], cancellationToken)`. -4. Pass the token to `webhookHttpClient.SendAsync` if the interface supports it (add overload if needed). -5. In `DeliverAsync`, pass `cancellationToken` when calling `DeliverToWebhookAsync` for each webhook. - -### Fix 2 — Handle `OperationCanceledException` gracefully - -When cancellation is requested during a delivery attempt, the partially-recorded `WebhookDelivery` should be saved with `Status = "cancelled"` before the exception propagates, so the delivery log is not left in an indeterminate state. - -```csharp -catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) -{ - delivery.Status = "cancelled"; - await dbContext.SaveChangesAsync(CancellationToken.None); // Use None; host is shutting down - throw; -} -``` - -### Tests to add - -Add to `WebhookRetryTests` (or a new test class): - -| Test | Expected outcome | -|------|-----------------| -| Cancelled before first attempt | `DeliverToWebhookAsync` exits immediately; delivery saved as `"cancelled"` | -| Cancelled during retry delay | Retry loop exits; delivery saved as `"cancelled"` | -| Normal delivery (existing tests) | Pass without modification | - ---- - -## Acceptance Criteria - -- [ ] `DeliverToWebhookAsync` signature includes `CancellationToken cancellationToken`. -- [ ] All `SaveChangesAsync` calls inside the delivery loop pass the token. -- [ ] Retry `Task.Delay` passes the token. -- [ ] On cancellation, the delivery record is persisted with `Status = "cancelled"`. -- [ ] Both new cancellation test cases are green. -- [ ] All existing `WebhookRetryTests` pass without modification. -- [ ] `dotnet test` passes. diff --git a/docs/issues/10-cloud-document-storage.md b/docs/issues/10-cloud-document-storage.md deleted file mode 100644 index 16ddab5..0000000 --- a/docs/issues/10-cloud-document-storage.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: "Add cloud/blob storage adapter for document storage (production readiness)" -labels: ["enhancement", "infrastructure"] ---- - -## Summary - -`LocalDocumentStorageService` stores uploaded documents on the local filesystem under `{webroot}/uploads/`. This is suitable for local development but is not viable in production because: - -- Files are lost when the container/pod restarts. -- Horizontal scaling is impossible without a shared filesystem. -- There is no access-control layer, content-addressable deduplication, or server-side encryption. - -The port (`IDocumentStorageService`) already exists; only a cloud adapter is missing. - -**Affected file:** `src/backend/OpenOnboarding.Infrastructure/Services/LocalDocumentStorageService.cs` -**Port:** `src/backend/OpenOnboarding.Application/Interfaces/IDocumentStorageService.cs` - ---- - -## Requirements - -### 1 — New `BlobDocumentStorageService` adapter - -Create `src/backend/OpenOnboarding.Infrastructure/Services/BlobDocumentStorageService.cs` implementing `IDocumentStorageService` using the Azure Blob Storage SDK (`Azure.Storage.Blobs`) or an S3-compatible SDK (`AWSSDK.S3` / `Minio`). - -The implementation must: - -- Use a configurable container/bucket name (`DocumentStorage:ContainerName`). -- Store metadata (file name, content type, size, uploaded-at) as blob metadata or a sidecar JSON object. -- Return a `StoredFileInfo` consistent with `LocalDocumentStorageService`. -- For `GetStreamAsync`, return a streaming (non-buffered) download so large files do not load fully into memory. -- For `ScanAsync`, stream the blob content to `IVirusScanService` without materialising the full file locally. - -### 2 — Configuration-driven registration - -In `ServiceCollectionExtensions.AddInfrastructure`, select the implementation based on `DocumentStorage:Provider`: - -| Value | Implementation | -|-------|----------------| -| `"local"` (default) | `LocalDocumentStorageService` | -| `"azureblob"` | `BlobDocumentStorageService` (Azure) | -| `"s3"` | `BlobDocumentStorageService` (S3/Minio) | - -Validate required configuration keys at startup (fail fast) using the existing `IStartupFilter` / validator pattern. - -### 3 — File retention / expiry - -Implement a `CleanupExpiredDocumentsService : BackgroundService` that: - -- Runs on a configurable schedule (default: daily). -- Deletes documents older than a configurable retention period (`DocumentStorage:RetentionDays`, default: 90). -- Logs the count of deleted documents. -- Is guarded by a feature flag `DocumentStorage:EnableCleanup` (default `true`). - -### 4 — Tests - -- Unit tests for `BlobDocumentStorageService` using a mocked `BlobContainerClient`. -- Unit tests for `CleanupExpiredDocumentsService` using an in-memory/mock storage service. -- Integration test (optional, skipped in CI without credentials) against Azurite / LocalStack. - ---- - -## Acceptance Criteria - -- [ ] `BlobDocumentStorageService` implements `IDocumentStorageService` and passes the same behavioural tests as `LocalDocumentStorageService`. -- [ ] `DocumentStorage:Provider = "azureblob"` registers `BlobDocumentStorageService`; missing required config causes a startup exception with a descriptive message. -- [ ] `LocalDocumentStorageService` remains the default and all existing tests pass. -- [ ] `CleanupExpiredDocumentsService` deletes only documents older than `RetentionDays` and logs the count. -- [ ] Unit tests for both the storage adapter and cleanup service pass. -- [ ] `dotnet test` passes. -- [ ] No secrets or connection strings are hard-coded; all credentials come from configuration / environment variables. diff --git a/docs/issues/11-browser-api-key-grants-operator.md b/docs/issues/11-browser-api-key-grants-operator.md deleted file mode 100644 index b52711e..0000000 --- a/docs/issues/11-browser-api-key-grants-operator.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "Frontend API key grants full Operator access to every browser visitor" -labels: ["security", "authentication", "frontend"] ---- - -## Summary - -The SPA authenticates to the backend with a **shared API key baked into the browser bundle** (`VITE_API_KEY`), and `ApiKeyAuthenticationHandler` maps any valid API key to a **full `Operator` principal**. Vite inlines `VITE_*` variables at build time, so the key ships as a literal string in the published JavaScript and is readable by anyone who loads the public onboarding page. - -Any visitor can extract the key and call every Operator-scoped endpoint. - -**Affected files:** -- `src/backend/OpenOnboarding.Api/Authentication/ApiKeyAuthenticationHandler.cs` (lines 34–39) -- `src/frontend/.env.example` -- `src/frontend/src/onboarding/api/workflow-api-client.ts` (line 19) -- `src/frontend/src/onboarding/hooks/useOnboarding.ts` (line 14), `useFlow.ts` (line 4) -- `src/frontend/src/App.tsx` (line 107), `builder/AdminJourneyBuilderPage.tsx` (line 6), `builder/FlowAuthoringPanel.tsx` (line 13) -- `src/frontend/src/sessions/SessionList.tsx`, `sessions/SessionDetail.tsx`, `webhooks/WebhookDeliveries.tsx`, `analytics/FlowAnalytics.tsx`, `flows/FlowVersionHistory.tsx` - -### The handler grants Operator unconditionally - -```csharp -var claims = new[] -{ - new Claim(ClaimTypes.NameIdentifier, "api-key-user"), - new Claim(ClaimTypes.Name, "api-key-user"), - new Claim(ClaimTypes.Role, AppRoles.Operator), -}; -``` - -### What the leaked key unlocks - -With the key alone (no SSO, no cookie) a caller can reach, among others: - -| Endpoint | Policy | Exposure | -|---|---|---| -| `GET /api/workflow/sessions` | `OperatorOnly` | Every applicant session across every flow | -| `GET /api/workflow/sessions/{id}/submissions` | `OperatorOnly` | All submitted form data (PII) | -| `GET /api/workflow/flows/{id}/stats` | `OperatorOnly` | Business metrics | -| `POST`/`PUT`/`DELETE /api/flows` | Operator | Create, rewrite or delete journey definitions | -| `POST /api/webhooks` | Operator | Register an attacker-controlled webhook and exfiltrate future events | - -The resource-based `SessionOwnershipHandler` short-circuits on `IsInRole(Operator)`, so per-session ownership checks provide no defence either. - -The `Applicant` role and the `customerProfileId` ownership path already exist in the backend, but the shipped frontend has no way to obtain an applicant credential — it uses the Operator API key for applicant traffic. - ---- - -## Requirements - -1. Stop shipping a privileged credential to the browser. The public onboarding app must authenticate as an **applicant**, not an operator. -2. Introduce a per-session applicant credential issued by the backend at `POST /api/workflow/sessions/start` (e.g. a short-lived JWT carrying `role=Applicant` and `customerProfileId`), and have the SPA send that instead of `X-Api-Key`. -3. Treat `X-Api-Key` as a **server-to-server** credential only: document it as such, and consider binding it to a named integration principal rather than a blanket Operator role. -4. Operator-scoped UI must use the existing SAML `AdminSession` cookie, not an API key. -5. Remove `VITE_API_KEY` from `.env.example` and from all frontend source, or restrict it to a local-development-only escape hatch that is absent from production builds. - ---- - -## Acceptance Criteria - -- [ ] A production frontend build contains no API key string: `grep -r "$VITE_API_KEY" dist/` returns no matches. -- [ ] Starting a session and submitting a step succeed without any `X-Api-Key` header being sent from the browser. -- [ ] An applicant credential issued for session A returns `403 Forbidden` when used against session B's `GET /api/workflow/sessions/{id}` and `.../submit`. -- [ ] An applicant credential returns `403 Forbidden` from `GET /api/workflow/sessions`, `GET /api/workflow/sessions/{id}/submissions` and `GET /api/workflow/flows/{id}/stats`. -- [ ] Operator-scoped frontend views authenticate with the `AdminSession` cookie; removing the cookie makes them return `401`/`403` rather than succeeding via an API key. -- [ ] `src/frontend/.env.example` no longer instructs developers to place an Operator-equivalent API key in the browser bundle. -- [ ] Integration tests cover: applicant credential accepted for its own session; rejected for another session; rejected for every `OperatorOnly` endpoint. -- [ ] `docs/runbook.md` documents the applicant credential lifetime and the server-to-server-only status of `Authentication:ApiKey`. diff --git a/docs/issues/12-operator-ui-on-public-route.md b/docs/issues/12-operator-ui-on-public-route.md deleted file mode 100644 index df8a784..0000000 --- a/docs/issues/12-operator-ui-on-public-route.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: "Operator-only UI is rendered on the unauthenticated applicant route" -labels: ["security", "frontend"] ---- - -## Summary - -`App.tsx` guards **only** `/admin/journey-builder` behind the SSO session check. The root route `/` — the page an applicant loads to complete onboarding — renders the full operator toolset below the step renderer with no authentication gate at all. - -**Affected file:** `src/frontend/src/App.tsx` - -### The guard covers one route - -```tsx -if (isAdminJourneyBuilderRoute && adminAuthState !== 'authorized') { ... } // line 223 -if (isAdminJourneyBuilderRoute && adminAuthState === 'authorized') { ... } // line 231 -``` - -Everything after that falls through to the applicant view, which renders: - -| Component | Line | What it exposes | -|---|---|---| -| `FlowAuthoringPanel` | 320 | Create / edit / publish journey definitions | -| `FlowVersionHistory` | 328 | Version history and rollback controls | -| `FlowAnalytics` | 372 | Aggregate conversion and drop-off metrics | -| `SessionList` / `SessionDetail` | 384 | Every applicant's session, including submitted field data | -| `WebhookDeliveries` | 390 | Webhook endpoints and delivery payloads | - -This is a separate defect from the credential problem in the API-key issue: even after the backend stops trusting a browser-held Operator key, these views would still render and issue operator API calls on the applicant's page. Their requests would then fail with `401`/`403`, leaving broken UI in front of applicants. - -The applicant page also renders a "Journey" and "Persona" picker plus the Visual Journey Builder canvas, which let an applicant switch themselves onto any seeded flow. - ---- - -## Requirements - -1. Move every operator-facing section off the applicant route onto an authenticated route (e.g. extend `/admin` to host flow authoring, version history, analytics, sessions and webhook deliveries). -2. Apply the existing `adminAuthState === 'authorized'` check — which already requires an `Operator` role from `GET /api/auth/me` — to those routes. -3. Reduce the applicant route to the onboarding experience: the step renderer and its progress indication. -4. Replace the ad-hoc `window.location.pathname` routing with a single place that maps route → required role, so a new admin surface cannot be added without a guard. - ---- - -## Acceptance Criteria - -- [ ] Loading `/` with no `AdminSession` cookie renders only the onboarding step renderer — none of `FlowAuthoringPanel`, `FlowVersionHistory`, `FlowAnalytics`, `SessionList`, `SessionDetail` or `WebhookDeliveries` appear in the DOM. -- [ ] Loading `/` issues no request to any `OperatorOnly` endpoint (`/api/workflow/sessions`, `/api/workflow/flows/{id}/stats`, `/api/webhooks`). -- [ ] The journey and persona selectors are not present on the unauthenticated applicant route. -- [ ] Operator sections are reachable on an authenticated admin route and render there for a user whose `/api/auth/me` response includes the `Operator` role. -- [ ] A user without the `Operator` role who navigates directly to an admin route is redirected to `/login` and never sees operator data. -- [ ] Route → required-role mapping lives in one module covered by unit tests. -- [ ] Vitest tests assert the unauthenticated root render excludes each operator component; a Playwright spec covers the redirect for an unauthenticated admin route. diff --git a/docs/issues/13-frontend-unit-tests-not-in-ci.md b/docs/issues/13-frontend-unit-tests-not-in-ci.md deleted file mode 100644 index 1fbd6fa..0000000 --- a/docs/issues/13-frontend-unit-tests-not-in-ci.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "npm test fails on a clean checkout and frontend unit tests never run in CI" -labels: ["bug", "testing", "frontend"] ---- - -## Summary - -Two defects compound each other: - -1. `npm test` (vitest) **fails** on a clean checkout — it collects the Playwright specs in `e2e/` and every one of them errors. -2. The `frontend-ci` job never runs `npm test`, so the failure is invisible and the 108 frontend unit tests added for the frontend-unit-tests issue are **not enforced on any pull request**. - -**Affected files:** -- `src/frontend/vitest.config.ts` -- `.github/workflows/ci.yml` (`frontend-ci` job, lines 93–120) -- `src/frontend/playwright/onboarding-journeys.spec.ts` (orphaned duplicate) - -### Reproduction - -``` -$ cd src/frontend && npm test - Test Files 6 failed | 14 passed (20) - Tests 108 passed (108) -``` - -Each failure is the same: - -``` -Error: Playwright Test did not expect test.describe() to be called here. - ❯ e2e/step-renderer.spec.ts:41:6 -``` - -### Root cause - -`vitest.config.ts` excludes the wrong directory: - -```ts -exclude: [...configDefaults.exclude, 'playwright/**'], -``` - -The Playwright specs live in `e2e/` — `playwright.config.ts` sets `testDir: './e2e'`. The `playwright/` directory still contains `onboarding-journeys.spec.ts`, byte-identical to `e2e/onboarding-journeys.spec.ts`, and is outside `testDir`, so that copy is never executed by Playwright either. Vitest excludes the dead directory and collects the live one. - -### Why CI did not catch it - -`frontend-ci` runs `npm ci` → `npm run lint` → `npm run build`. There is no test step. The other jobs run `npm run test:pact` and `npm run test:e2e`, so Pact and Playwright are covered, but the vitest suite is not. - ---- - -## Requirements - -1. Fix the vitest exclude so it matches the real Playwright directory (`e2e/**`), or scope vitest's `include` to `src/**`. -2. Delete the orphaned `src/frontend/playwright/` directory — it is a stale duplicate that no runner executes. -3. Add a test step to `frontend-ci` so a failing unit test blocks the pull request. -4. Consider uploading vitest coverage as a build artifact, matching the backend job's behaviour. - ---- - -## Acceptance Criteria - -- [ ] `npm test` exits `0` on a clean checkout of `main`. -- [ ] `npm test` reports `20 passed (20)` test files — no Playwright spec is collected by vitest. -- [ ] `src/frontend/playwright/` no longer exists, and `npm run test:e2e` still runs the full `e2e/` suite. -- [ ] `frontend-ci` runs `npm test` and the job fails when a unit test fails (demonstrate with a deliberately broken test on a scratch branch). -- [ ] Introducing a failing vitest assertion causes a red CI check on a pull request. diff --git a/docs/issues/14-sse-single-instance.md b/docs/issues/14-sse-single-instance.md deleted file mode 100644 index 39b69d2..0000000 --- a/docs/issues/14-sse-single-instance.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "SSE session progress events break when the API runs more than one replica" -labels: ["bug", "reliability", "infrastructure"] ---- - -## Summary - -`ISessionEventEmitter` has exactly one implementation, `InMemorySessionEventEmitter`, registered unconditionally as a singleton. It holds a `ConcurrentDictionary>` in **process memory**, so an event written on one API instance can only be read by a `GET /api/workflow/sessions/{id}/events` stream attached to that same instance. - -Both CD workflows deploy to platforms that routinely run more than one replica — Azure Container Apps (`cd-azure.yml`) and Amazon ECS (`cd-aws.yml`). At two or more replicas, an applicant whose SSE stream is held by instance B silently receives nothing for a step submitted through instance A. - -**Affected files:** -- `src/backend/OpenOnboarding.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs` (line 67) -- `src/backend/OpenOnboarding.Infrastructure/Services/InMemorySessionEventEmitter.cs` -- `src/backend/OpenOnboarding.Api/Controllers/WorkflowController.cs` (`GET sessions/{sessionId:guid}/events`, line 269) - -### The registration has no distributed alternative - -```csharp -services.AddSingleton(); -``` - -Unlike `IEventBus` — which already selects `RabbitMqEventBus` when `RabbitMq:Host` is configured and falls back to `InMemoryEventBus` otherwise — there is no configuration-driven swap for the event emitter. - -### Failure mode - -The failure is silent. The stream stays open, the client sees no error, and progress simply never arrives; the applicant appears stuck until they reload and re-fetch state over HTTP. Sticky sessions would mask it only while the assigned instance survives — a scale-in or rolling deploy re-breaks it mid-journey. - -RabbitMQ is already a dependency in `docker-compose.yml` and the infrastructure layer, so a fan-out adapter has a natural home. - ---- - -## Requirements - -1. Add a distributed `ISessionEventEmitter` adapter that fans an event out to every API instance — a RabbitMQ fanout exchange (reusing the existing connection management) or Redis pub/sub. -2. Select it by configuration the same way `IEventBus` is selected, keeping `InMemorySessionEventEmitter` as the single-instance and test default. -3. Preserve the existing semantics: per-session channels, the 100-event capacity bound, the completion/abandonment terminal events, and the stale-channel cleanup. -4. Document the deployment requirement — running more than one replica without a distributed emitter configured must be called out in the runbook. -5. Consider failing startup (or logging a prominent warning) when the in-memory emitter is active in a non-Development environment. - ---- - -## Acceptance Criteria - -- [ ] A distributed `ISessionEventEmitter` implementation exists and is registered when its transport is configured; `InMemorySessionEventEmitter` remains the default otherwise. -- [ ] An integration test starts two API hosts sharing one transport, opens the SSE stream on host B, emits an event through host A, and asserts host B's stream receives it. -- [ ] Terminal events (`session-completed`, `session-abandoned`) close the stream on every instance, not just the emitting one. -- [ ] Channel capacity limits and stale-channel cleanup behave identically for both implementations, covered by tests. -- [ ] A non-Development environment running the in-memory emitter logs a warning naming the scale-out limitation. -- [ ] `docs/runbook.md` documents the configuration keys and states that multi-replica deployments require the distributed emitter. diff --git a/docs/issues/15-saml-assertion-encryption.md b/docs/issues/15-saml-assertion-encryption.md deleted file mode 100644 index 7fb363e..0000000 --- a/docs/issues/15-saml-assertion-encryption.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "SAML metadata advertises assertion encryption the ACS cannot decrypt" -labels: ["bug", "security", "authentication"] ---- - -## Summary - -`GET /auth/saml/metadata` publishes a `` containing the SP certificate, telling every identity provider that Waymark accepts encrypted assertions. The callback never configures a decryption certificate, so an IdP that honours the advertised key will produce `EncryptedAssertion` responses that the ACS cannot read — every login fails with a generic `saml_invalid_assertion` redirect. - -Azure AD, Okta and Auth0 all offer to encrypt assertions when the SP metadata advertises an encryption key, and some tenants enforce it by policy. - -**Affected file:** `src/backend/OpenOnboarding.Api/Controllers/SamlAuthController.cs` - -### Metadata advertises the capability (line 45) - -```xml - - - - {certBase64} - - - -``` - -### `BuildSamlConfiguration` never sets a decryption certificate - -```csharp -var config = new Saml2Configuration -{ - Issuer = issuer, - SingleSignOnDestination = new Uri(idpSsoUrl), - SigningCertificate = spCert, - CertificateValidationMode = X509CertificateValidationMode.None, - RevocationMode = X509RevocationMode.NoCheck -}; -``` - -`Saml2Configuration.DecryptionCertificate` is required for ITfoxtec to unwrap an `EncryptedAssertion`. The SP certificate is already loaded with its private key (`X509Certificate2.CreateFromPem(certPem, keyPem)`), so the material needed is in hand. - -### Related loose end - -`src/frontend/src/App.tsx` defines login error messages for `saml_assertion_not_encrypted` and `saml_certificate_expired`, but the controller emits only `saml_invalid_assertion`, `saml_access_denied` and `saml_csrf_failed`. Those two messages are unreachable, and an encrypted-assertion failure surfaces to the operator as the generic "Invalid SAML assertion" with nothing in the logs distinguishing it. - ---- - -## Requirements - -1. Set `DecryptionCertificate` on the `Saml2Configuration` from the configured SP certificate and private key so `EncryptedAssertion` responses decrypt. -2. Add an optional `Authentication:Saml:RequireEncryptedAssertion` setting. When `true`, reject an unencrypted assertion and redirect with `saml_assertion_not_encrypted`. -3. If encryption will not be supported, remove the `use="encryption"` `KeyDescriptor` from metadata instead — the current state advertises a capability that does not exist and must not stay as-is. -4. Distinguish decryption and signature failures in the logs so an operator can tell them apart from the generic redirect. -5. Reconcile the frontend error-code table with the codes the controller actually emits (including `saml_certificate_expired`, which is currently unreachable). - ---- - -## Acceptance Criteria - -- [ ] A `POST /auth/saml/callback` carrying an `EncryptedAssertion` encrypted to the SP certificate decrypts successfully and issues an `AdminSession` cookie. -- [ ] Both a signed unencrypted assertion and a signed encrypted assertion are accepted when `RequireEncryptedAssertion` is unset or `false`. -- [ ] With `RequireEncryptedAssertion=true`, an unencrypted assertion redirects to `/login?error=saml_assertion_not_encrypted`. -- [ ] An assertion encrypted to the wrong key redirects to `/login?error=saml_invalid_assertion` and logs a decryption-specific message. -- [ ] Tests cover the encrypted happy path, the wrong-key failure, and the `RequireEncryptedAssertion` rejection, using self-signed test certificates in the style of the existing `SamlAuthControllerTests`. -- [ ] Every error code in the frontend `LOGIN_ERROR_MESSAGES` table is reachable from the controller, or is removed. -- [ ] `docs/runbook.md` documents the encryption configuration and the IdP-side setup it implies. diff --git a/docs/issues/16-analytics-no-durable-sink.md b/docs/issues/16-analytics-no-durable-sink.md deleted file mode 100644 index 22cf6d6..0000000 --- a/docs/issues/16-analytics-no-durable-sink.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: "Journey analytics events have no durable sink and browser events never reach the backend" -labels: ["enhancement", "observability"] ---- - -## Summary - -The analytics pipeline is fully built on both sides but terminates in a log statement on the backend and in `console` on the frontend. No journey analytics event is ever persisted or exported, and browser-side events never leave the browser at all — there is no ingestion endpoint to receive them. - -**Affected files:** -- `src/backend/OpenOnboarding.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs` (lines 79–82) -- `src/backend/OpenOnboarding.Infrastructure/Services/ConsoleAnalyticsProvider.cs` -- `src/frontend/src/analytics/consoleAnalyticsSink.ts` -- `src/frontend/src/analytics/JourneyAnalyticsContext.tsx` - -### Backend: one sink, and it writes to the logger - -```csharp -if (configuration.GetValue("Analytics:ConsoleProvider:Enabled", true)) - services.AddSingleton(); - -services.AddSingleton(); -``` - -`TelemetryService` already fans out to every registered `IAnalyticsProvider` in parallel with per-provider error isolation, and `StartSessionCommandHandler` and `SubmitStepCommandHandler` already emit through it. The extension point works — nothing is plugged into it. - -### Frontend: events are dispatched to a console sink only - -`JourneyAnalyticsProvider` supports `registerSink` at runtime and `App.tsx` passes `initialSinks={[consoleAnalyticsSink]}`. Client-side events (step viewed, field interactions, journey abandonment) are therefore observable only in an open devtools console on the applicant's own machine. - -### What still works - -This is not a total absence of analytics: `SessionAnalyticsService` computes aggregate flow statistics directly from persisted sessions and submissions, and `GET /api/workflow/flows/{id}/stats` backs the `FlowAnalytics` view. What is missing is the **event stream** — per-step timing, drop-off points, and any client-side signal. - ---- - -## Requirements - -1. Add at least one durable `IAnalyticsProvider` — persist `AnalyticsEvent` rows to the database, or forward to an OpenTelemetry/OTLP collector — selected by configuration in the same style as `IEventBus` and `IVirusScanService`. -2. Add an ingestion endpoint (e.g. `POST /api/analytics/events`) that accepts batched client events, validates and rate-limits them, and dispatches them through `ITelemetryService`. -3. Ship an HTTP sink for the frontend that batches events and posts them to that endpoint, keeping the console sink for development. -4. Attribute client events to the authenticated session so they cannot be forged for arbitrary sessions. -5. Define a retention policy for stored events, consistent with the existing document-cleanup background service. - ---- - -## Acceptance Criteria - -- [ ] A durable analytics provider is registered by configuration and stores or forwards every event `TelemetryService` dispatches. -- [ ] Completing a journey produces a retrievable ordered event trail for that session, covering session start and each step submission. -- [ ] `POST /api/analytics/events` accepts a batch, rejects events whose session id does not match the caller's credential with `403`, and is rate-limited. -- [ ] The frontend HTTP sink batches events and survives endpoint failure without breaking the onboarding flow (failures are dropped or retried, never surfaced to the applicant). -- [ ] Disabling the durable provider by configuration leaves the application working with the console provider only. -- [ ] A retention policy is implemented and documented; stored events older than the configured window are removed. -- [ ] Unit tests cover provider registration by configuration, ingestion authorisation, and sink batching/failure handling. diff --git a/docs/issues/README.md b/docs/issues/README.md index a437387..82eb325 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -1,6 +1,36 @@ # Waymark — Issue Backlog -This directory contains the detailed requirements for identified functional gaps, TODOs, and stub implementations in the codebase. Each file is formatted as a GitHub issue and can be bulk-created using the helper script below. +This directory is a staging area for backlog items written as markdown, which a GitHub Actions +workflow turns into real GitHub issues. It is **not** a record of open work: once an item becomes a +GitHub issue, the issue is the source of truth and the file has done its job. + +**The backlog is currently empty.** Every item that passed through here has been implemented and its +GitHub issue closed, so the files were removed. Open work lives on the +[issue tracker](https://github.com/MaximumTrainer/Waymark/issues). + +## Adding a backlog item + +Create `docs/issues/NN-short-slug.md` with YAML front-matter carrying the title and labels, followed +by the issue body in markdown: + +```markdown +--- +title: A one-line statement of the defect or gap +labels: bug, backend, security +--- + +## Summary +... + +## Requirements +... + +## Acceptance Criteria + +- [ ] ... +``` + +Number files sequentially from the highest already used. Numbers 01–16 are spent. ## Creating the issues @@ -11,38 +41,26 @@ Issues are created via the **Create Backlog Issues** GitHub Actions workflow. 3. Optionally enable **Dry run** to preview what would be created without touching the issue tracker. 4. Click **Run workflow** to confirm. -The workflow is idempotent: it checks for an existing issue with the same title (open or closed) before creating a new one, so re-running it is safe. - -The workflow also fires automatically whenever a numbered issue markdown file (`docs/issues/[0-9]*.md`) is pushed to `main`. - -## Issue index - -| # | File | Title | Labels | -|---|------|-------|--------| -| 1 | [01-saml-placeholder-wire-format.md](01-saml-placeholder-wire-format.md) | Replace placeholder SAML wire format with standard SAML XML | bug, security, authentication | -| 2 | [02-session-timeout-service-tests.md](02-session-timeout-service-tests.md) | Add unit tests for SessionTimeoutService | testing, reliability | -| 3 | [03-null-virus-scan-observability.md](03-null-virus-scan-observability.md) | NullVirusScanService — add monitoring and observability for bypassed scans | security, observability | -| 4 | [04-rabbitmq-eventbus-resilience.md](04-rabbitmq-eventbus-resilience.md) | RabbitMqEventBus — fix sync-over-async startup registration and add resilience | bug, reliability, infrastructure | -| 5 | [05-document-storage-scan-missing-file.md](05-document-storage-scan-missing-file.md) | LocalDocumentStorageService — fix silent clean-result for missing files in ScanAsync | bug, security | -| 6 | [06-session-event-emitter-cleanup.md](06-session-event-emitter-cleanup.md) | InMemorySessionEventEmitter — document channel eviction behaviour and add stale-channel cleanup | reliability, observability | -| 7 | [07-frontend-unit-tests.md](07-frontend-unit-tests.md) | Frontend — add unit tests for StepRenderer, useOnboarding, and App | testing, frontend | -| 8 | [08-frontend-app-error-boundary.md](08-frontend-app-error-boundary.md) | Frontend — add top-level error boundary for auth/routing failures | reliability, frontend | -| 9 | [09-webhook-cancellation-token.md](09-webhook-cancellation-token.md) | WebhookService — CancellationToken not propagated through delivery retry loop | bug, reliability | -| 10 | [10-cloud-document-storage.md](10-cloud-document-storage.md) | Add cloud/blob storage adapter for document storage (production readiness) | enhancement, infrastructure | -| 11 | [11-browser-api-key-grants-operator.md](11-browser-api-key-grants-operator.md) | Frontend API key grants full Operator access to every browser visitor | security, authentication, frontend | -| 12 | [12-operator-ui-on-public-route.md](12-operator-ui-on-public-route.md) | Operator-only UI is rendered on the unauthenticated applicant route | security, frontend | -| 13 | [13-frontend-unit-tests-not-in-ci.md](13-frontend-unit-tests-not-in-ci.md) | npm test fails on a clean checkout and frontend unit tests never run in CI | bug, testing, frontend | -| 14 | [14-sse-single-instance.md](14-sse-single-instance.md) | SSE session progress events break when the API runs more than one replica | bug, reliability, infrastructure | -| 15 | [15-saml-assertion-encryption.md](15-saml-assertion-encryption.md) | SAML metadata advertises assertion encryption the ACS cannot decrypt | bug, security, authentication | -| 16 | [16-analytics-no-durable-sink.md](16-analytics-no-durable-sink.md) | Journey analytics events have no durable sink and browser events never reach the backend | enhancement, observability | - -## Priority summary - -| Priority | Issues | -|----------|--------| -| 🔴 Critical | #11 (browser-held API key grants full Operator access), #12 (operator data on the public applicant route) | -| 🟠 High | #13 (`npm test` is red and unenforced in CI), #14 (SSE silently breaks above one replica) | -| 🟡 Medium | #15 (SAML metadata advertises encryption that does not work) | -| 🟢 Low / Enhancement | #16 (analytics event stream has no durable sink) | - -Items #1–#10 were the first backlog sweep and are all resolved; #11–#16 come from the September 2026 gap review. +The workflow is idempotent: it checks for an existing issue with the same title (open or closed) +before creating a new one, so re-running it is safe. It also fires automatically whenever a numbered +issue markdown file (`docs/issues/[0-9]*.md`) is pushed to `main`, and exits cleanly when there are +none. + +## After an item is delivered + +Delete the markdown file. Leaving it behind creates a second, stale copy of requirements that no +longer match the code, and a reader cannot tell which items are outstanding. The closed GitHub issue +and the commits that reference it are the durable record. + +## History + +Two sweeps have been run through this directory, both fully delivered: + +| Sweep | Files | GitHub issues | Subject | +|-------|-------|---------------|---------| +| First backlog sweep | 01–10 | [#76–#86](https://github.com/MaximumTrainer/Waymark/issues?q=is%3Aissue+is%3Aclosed+76..86) | SAML wire format, service test gaps, virus-scan observability, broker resilience, storage scan defects, event emitter cleanup, frontend tests and error boundary, webhook cancellation, cloud blob storage | +| September 2026 gap review | 11–16 | [#90–#95](https://github.com/MaximumTrainer/Waymark/issues?q=is%3Aissue+is%3Aclosed+90..95) | Browser-held API key, operator UI on the public route, frontend tests absent from CI, single-instance SSE, SAML assertion encryption, analytics durability | + +A follow-up review of that second sweep produced [#99–#102](https://github.com/MaximumTrainer/Waymark/issues?q=is%3Aissue+is%3Aclosed+99..102) +and [#104](https://github.com/MaximumTrainer/Waymark/issues/104), which were raised directly on the +tracker rather than through this directory. diff --git a/docs/runbook.md b/docs/runbook.md index 35a08f1..280583f 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -103,7 +103,7 @@ All configuration can be set via environment variables or `appsettings.json`. | `Authentication__ApiKey` | optional | — | **Server-to-server only.** Grants a full Operator principal — never send it from a browser. See [API credentials](#api-credentials). | | `Authentication__ApplicantToken__SigningKey` | ✅ (non-dev) | — | HMAC key signing per-session applicant tokens, min 32 chars. **Store as a secret.** Startup fails without it outside Development. | | `Authentication__ApplicantToken__LifetimeMinutes` | optional | `SessionTimeoutMinutes` | Applicant token lifetime. Defaults to the session timeout, since the token is useless once its session is abandoned. | -| `SessionTimeoutMinutes` | optional | `60` | Inactivity timeout before sessions are auto-abandoned. | +| `SessionTimeoutMinutes` | optional | `1440` | Inactivity timeout before sessions are auto-abandoned. Also the default applicant token lifetime. Set to `0` or less to disable the sweep. | | `Analytics__DatabaseProvider__Enabled` | optional | `true` | Persist analytics events so a session's trail can be read back. `false` leaves only the console provider. | | `Analytics__ConsoleProvider__Enabled` | optional | `true` | Write every event to the application log. | | `Analytics__RetentionDays` | optional | `365` | Age at which stored analytics events are deleted. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 431c922..23ff70c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,6 +8,7 @@ Common issues and how to resolve them. - [API Startup Issues](#api-startup-issues) - [Authentication Errors](#authentication-errors) +- [Rate Limiting](#rate-limiting) - [Database Issues](#database-issues) - [Workflow / Session Errors](#workflow--session-errors) - [Webhook Issues](#webhook-issues) @@ -82,13 +83,26 @@ Common issues and how to resolve them. **Symptoms**: Request is authenticated but returns `403 Forbidden`. -**Cause**: The caller's role does not have permission for the requested operation. +**Cause**: The caller is authenticated but not entitled to this operation on this resource. There +are three distinct causes, and they need different fixes. **Resolution**: -- Check the caller's claims: `GET /auth/me` will return the identity and roles. -- Operator-only endpoints (flow CRUD, webhooks, analytics) require the `Operator` role. -- Session submission requires the `Applicant` role or higher. -- Applicants can only access their own sessions (ownership is validated per-request). + +1. **Wrong role.** Check the caller's claims with `GET /api/auth/me`. Operator-only endpoints (flow + CRUD, webhooks, analytics reads) require the `Operator` role. +2. **Right role, wrong session.** An applicant token names exactly one session and is refused for + any other — including document upload and download, which are scoped to the session in the + route. Confirm the `sessionId` in the path matches the token you are sending. +3. **The session is finished.** Once a session reaches `Completed` or `Abandoned`, its own token is + refused for *writes* — step submission, document upload and `POST /api/analytics/events` all + return `403`. Reads still succeed, which is what lets a completion screen render. This is + expected; the journey must be restarted rather than resumed. + +A browser client should treat `401` (expired) and `403` on a session it was completing as the same +outcome — the credential is spent — and both as distinct from a `5xx`, which is retryable. + +> A document that exists but is not recorded against the session in the route returns `404`, not +> `403`. That is deliberate: a `403` would confirm the file id names a real document. --- @@ -103,6 +117,59 @@ Common issues and how to resolve them. --- +## Rate Limiting + +### HTTP 429 Too Many Requests + +**Symptoms**: Requests return `429` with a `Retry-After: 60` header and a problem-details body. + +**Cause**: A rate limit policy's budget for *your partition* is exhausted. Limits are partitioned +per caller, so this is about one caller's traffic, not the service being globally busy. + +**Resolution**: + +1. Identify which policy applies. `general` covers every controller endpoint that does not name a + tighter one, so most endpoints fall under it. + + | Policy | Applies to | Partitioned by | Setting | Default | + |---|---|---|---|---| + | `session-start` | `POST /api/workflow/sessions/start` | Client IP | `RateLimiting__SessionStartPerMinute` | 100 | + | `analytics-ingest` | `POST /api/analytics/events` | Applicant session | `RateLimiting__AnalyticsIngestPerMinute` | 120 | + | `webhook-registration` | webhook registration | Principal | `RateLimiting__WebhookRegistrationPerMinute` | 20 | + | `general` | everything else | Principal, else client IP | `RateLimiting__GeneralPerMinute` | 300 | + | global ceiling | every request | not partitioned | `RateLimiting__GlobalCeilingPerMinute` | 3000 | + +2. Check whether callers are **sharing a partition**. The two common causes: + - **A shared credential.** Every integration using the same API key spends one `general` budget + between them, because the partition key is the principal. Give each integration its own + credential. + - **An unconfigured proxy.** Behind a load balancer the connection address is the balancer's, so + every caller collapses into one partition. Set `ForwardedHeaders__KnownProxies` (or + `ForwardedHeaders__KnownNetworks`) to your proxy's address. Until you do, `X-Forwarded-For` is + deliberately ignored — an untrusted header would let any caller choose their own partition key. +3. Raise the relevant limit if the traffic is legitimate. Remember limits are **per-instance**: with + *N* replicas the effective budget is up to *N* × the configured value, so size accordingly. + +--- + +### Rate limits appear to do nothing + +**Cause**: Limiters are disabled entirely in the `Testing` environment, where every policy is +replaced with a no-op so unrelated tests are not throttled. + +**Resolution**: Run in `Development` or a real environment to exercise them. Test suites that drive +the API hard from a single address set large budgets explicitly rather than relying on this. + +--- + +### Health checks are being rate limited + +They are not — `/health`, `/health/live` and `/health/ready` are deliberately outside rate limiting. +Throttling the endpoint a load balancer polls would turn a traffic spike into an instance being +pulled out of service. If health checks are failing, the cause is elsewhere. + +--- + ## Database Issues ### "FATAL: remaining connection slots are reserved for non-replication superuser connections" @@ -183,7 +250,7 @@ Or restart the API container (it calls `MigrateAsync()` on startup). ### Session status is "Abandoned" unexpectedly -**Cause**: The session timeout background service marked the session as abandoned because it was inactive beyond `SessionTimeoutMinutes` (default: 60 minutes). +**Cause**: The session timeout background service marked the session as abandoned because it was inactive beyond `SessionTimeoutMinutes` (default: 1440 minutes, i.e. 24 hours). **Resolution**: - Start a new session @@ -264,6 +331,32 @@ if (!timingSafeEqual(Buffer.from(expectedSig), Buffer.from(receivedSig))) { ## File Upload Issues +### Document upload or download returns HTTP 403 + +**Cause**: Both document endpoints are scoped to the session named in the route. A token for one +session cannot upload to or read from another, and a session that has reached `Completed` or +`Abandoned` accepts no further uploads. + +**Resolution**: +- Confirm the `sessionId` in the path is the one the token was issued for. +- Check the session status. A finished journey must be restarted, not resumed. +- Operators reach every session's documents; an applicant token reaches exactly one. + +--- + +### Document download returns HTTP 404 for a file that exists + +**Cause**: The download scopes the *lookup*, not just the caller. A `fileId` resolves only when an +upload recorded against that session and node carries it, so a file belonging to another session is +reported as missing rather than refused. + +This is deliberate: returning `403` would confirm the id names a real document. + +**Resolution**: Fetch the file through the session and node it was uploaded to. The `fileId` values +for a session appear in that session's submissions (`GET /api/workflow/sessions/{sessionId}/submissions`). + +--- + ### POST /sessions/{id}/steps/{nodeId}/documents returns HTTP 422: "File failed virus scan" **Cause**: The uploaded file was flagged by the virus scanner. @@ -284,9 +377,18 @@ if (!timingSafeEqual(Buffer.from(expectedSig), Buffer.from(receivedSig))) { ### Browser not receiving SSE events after submission -1. Verify the browser is connected: `GET /sessions/{id}/stream` should return `Content-Type: text/event-stream` -2. Check for proxy/CDN timeout — many proxies time out idle SSE connections after 60s. Configure keep-alive or use a proxy that supports SSE. -3. Check for CORS issues if the frontend and API are on different origins — SSE requires the same CORS headers as regular requests. +1. Verify the browser is connected: `GET /api/workflow/sessions/{sessionId}/events` should return + `Content-Type: text/event-stream`. +2. **Check whether the API runs more than one replica.** This is the most common cause and it fails + silently: an SSE stream is pinned to the instance that accepted it, so an event raised while + handling a request on a *different* instance never reaches it. The stream stays open and simply + delivers nothing. Set `SessionEvents__Transport=rabbitmq` (see + [the runbook](./runbook.md#horizontal-scaling-multiple-api-instances)) so events fan out to every instance. A + non-Development environment running the in-memory emitter logs a startup warning naming this. +3. Check for proxy/CDN timeout — many proxies time out idle SSE connections after 60s. Configure + keep-alive or use a proxy that supports SSE. +4. Check for CORS issues if the frontend and API are on different origins — SSE requires the same + CORS headers as regular requests. --- @@ -305,9 +407,13 @@ if (!timingSafeEqual(Buffer.from(expectedSig), Buffer.from(receivedSig))) { **Cause**: The `EventSource` client keeps reconnecting because the server is closing the connection with an error. **Resolution**: -- Check API logs for exceptions in the `/stream` endpoint +- Check API logs for exceptions in the `/events` endpoint - Ensure the session ID is valid and the session has not been deleted -- Check for authentication expiry — if the JWT expires while connected, the next reconnect will return 401, causing a loop. Handle `onerror` in the frontend to stop reconnecting on auth errors. +- Check for authentication expiry — if the credential expires while connected, the next reconnect + returns 401, causing a loop. Handle `onerror` in the frontend to stop reconnecting on auth errors. +- The browser `EventSource` API cannot set request headers, so this endpoint also accepts the + applicant token as an `access_token` query parameter. A 403 here means the token names a different + session; ownership is enforced identically on both paths. --- diff --git a/getting-started.md b/getting-started.md index de99a20..8e8fc35 100644 --- a/getting-started.md +++ b/getting-started.md @@ -6,7 +6,7 @@ This guide is for engineers and SDETs onboarding to the repository. - .NET SDK 10.x - Node.js 22.x -- Docker (for local PostgreSQL) +- Docker (for local PostgreSQL, and optionally RabbitMQ) ## 2. Clone and inspect @@ -18,7 +18,7 @@ cd open-onboarding ## 3. Start local dependencies ```bash -docker compose up -d +docker compose up -d postgres ``` Local PostgreSQL from `docker-compose.yml`: @@ -27,6 +27,20 @@ Local PostgreSQL from `docker-compose.yml`: - database: `onboarding` - user/password: `postgres` / `postgres` +`docker compose up -d` with no service name also builds and starts the `api` container, which you do +not want while running the API from source. Name the service you need. + +### RabbitMQ (optional) + +```bash +docker compose up -d rabbitmq +``` + +Only needed for two things: running more than one API replica, where a broker carries session +events between them (`SessionEvents__Transport=rabbitmq`), and running the broker-backed tests, +which skip themselves when nothing is listening. Management UI on `http://localhost:15672` +(`guest`/`guest`). + ## 4. Start backend API Use a connection string that matches the local compose database: @@ -63,7 +77,7 @@ Frontend dev server: `http://localhost:5173` Available routes: - `/` — the applicant onboarding journey. `/?flowId=` selects a specific journey. - `/admin` — operator console: flow authoring, version history, analytics, sessions, webhook deliveries (requires Operator SSO) -- `/admin/journey-builder` — Visual Journey Builder admin UI (requires Operator SSO — see [`user-guide.md`](./user-guide.md) section 8 for usage) +- `/admin/journey-builder` — Visual Journey Builder admin UI (requires Operator SSO — see [`user-guide.md`](./user-guide.md) section 10 for usage) ## 6. Run validation commands @@ -83,9 +97,25 @@ npm run test:pact ```bash dotnet restore src/backend/OpenOnboarding.slnx dotnet build src/backend/OpenOnboarding.slnx --no-restore -c Release -dotnet test src/backend/OpenOnboarding.slnx -c Release +dotnet test src/backend/OpenOnboarding.slnx -c Release --filter "FullyQualifiedName!~OpenOnboarding.Pact.Tests.E2E" ``` +The filter excludes the end-to-end suite, which needs a running PostgreSQL and is what CI runs it +against. Without it, `dotnet test` fails on a machine with no database. + +The main suite runs against an in-memory database and mocked transports, so it needs nothing +installed. A few tests are backed by a real RabbitMQ broker and **skip with a clear reason** when +none is reachable — start one as in section 3 to run them: + +```bash +docker compose up -d rabbitmq +dotnet test src/backend/OpenOnboarding.Application.Tests +``` + +Point them at a different broker with `SESSIONEVENTS__RABBITMQ__URI`. CI sets +`REQUIRE_BROKER_TESTS=1`, which turns a missing broker into a failure rather than a skip, so a +service-container problem cannot pass as a green build. + ## 7. Install the git hooks ```bash diff --git a/user-guide.md b/user-guide.md index b23af23..8e5b553 100644 --- a/user-guide.md +++ b/user-guide.md @@ -9,7 +9,9 @@ The API uses role-based access control: - `Applicant`: run onboarding sessions, submit steps, and access owned sessions - `ReadOnly`: read-only access to operator endpoints where permitted by policy -In local development, `X-Api-Key` authentication maps requests to operator behavior. +`X-Api-Key` maps a request to a full `Operator` principal. It is for server-to-server callers and +local API exploration only — **never send it from a browser**, where it would hand every visitor +operator access. Browser-based callers use the credentials in section 4 instead. ## 2. Create or reuse a flow @@ -47,17 +49,54 @@ Use external IDs and metadata to model persona variants (for example: region, ri - Endpoint: `POST /api/workflow/sessions/start` - Payload: `flowId` plus optional `customerProfileId` +- **Anonymous.** This is the entry point of a public journey, so there is no credential to present + yet. + +The response carries `applicantToken` — a bearer token scoped to the single session it names — plus +`applicantTokenExpiresAt`. Send it as `Authorization: Bearer ` on every subsequent request +for that session. It carries no operator rights, so a leaked one exposes the journey its holder was +already completing and nothing else. + +An operator who starts a session gets no token back: their own credential already covers it. ### Submit steps - Endpoint: `POST /api/workflow/sessions/{sessionId}/steps/{nodeId}/submit` - Payload format: `{ "payload": { ... } }` +- Response: `sessionId`, `isCompleted`, `currentNode` (null once complete), and a **renewed** + `applicantToken` + +Store the renewed token in place of the one you hold. Activity is what extends the credential, so a +long journey never outlives its own token — but only if the caller keeps the renewal. + +### Token lifetime and refusal + +| Situation | Result | +|-----------|--------| +| Token renewed on each step submission | Journey stays alive indefinitely while being worked on | +| Token expired (`Authentication:ApplicantToken:LifetimeMinutes`, default `SessionTimeoutMinutes`) | `401` | +| Session reached `Completed` or `Abandoned` | Writes refused with `403`; reads still work so a completion screen can render | + +A client should treat `401` and `403` on a session it was completing as the same dead end — the +credential is spent and the journey must be restarted — and distinguish both from a `5xx`, which is +retryable. ### Upload documents - Endpoint: `POST /api/workflow/sessions/{sessionId}/steps/{nodeId}/documents` - Multipart field name: `files` - File size limit controlled by `DocumentUpload:MaxFileSizeBytes` +- Accepted types and a maximum file count come from the node's `jsonContent` + +### Download documents + +- Endpoint: `GET /api/workflow/sessions/{sessionId}/steps/{nodeId}/documents/{fileId}` + +Both document endpoints are **scoped to the session in the route**. A token for one session cannot +upload to or read from another. The download additionally scopes the *lookup*: a `fileId` resolves +only when an upload recorded against that session and node carries it, so holding another +applicant's file id does not make it readable through your own session. A file that exists but +belongs elsewhere returns `404`, never `403`. ### Observe progress @@ -89,18 +128,79 @@ Validation points: ## 7. Frontend configuration for journey testing -In the frontend `.env.local` file: -- `VITE_API_BASE_URL` should point to backend host (for example `http://localhost:5072`) -- `VITE_API_KEY` should match backend API key for local operator testing +In the frontend `.env.local` file, one value is needed: + +- `VITE_API_BASE_URL` — backend host, for example `http://localhost:5072` + +**Do not set an API key.** Vite inlines every `VITE_*` variable into the built bundle as a literal +string, and `Authentication:ApiKey` maps to a full `Operator` principal — so a key configured here +ships to every visitor of the public onboarding page. The app needs no credential of its own: the +journey starts anonymously and is handed a token scoped to that session (section 4), and operator +screens use the SSO cookie. The frontend includes: -- **Visual Journey Builder** (admin UI for creating and editing flows — see section 8) +- **Visual Journey Builder** (admin UI for creating and editing flows — see section 10) - **JourneyBuilder** (read-only React Flow graph showing branch-path progress during an active session) - **StepRenderer** (schema-driven form/doc upload/redirect/info/logic rendering) Use these components for manual exploratory testing and branch-path verification. -## 8. Visual Journey Builder (admin UI) +### Routes + +| Route | Audience | Credential | +|-------|----------|------------| +| `/` (`/?flowId=` selects a journey) | Applicant | None to start; the per-session token thereafter | +| `/admin` | Operator | SSO cookie | +| `/admin/journey-builder` | Operator | SSO cookie | + +Operator surfaces render only behind the SSO check. Nothing on the applicant route reads operator +data, and the applicant page never calls an operator endpoint. + +## 8. Inspecting a session as an operator + +Open **`/admin` → Sessions → (a session)**. Two complementary views sit side by side: + +- **Submissions** — what the applicant entered, per step. +- **Event trail** — how they got there: events in the order they occurred, each with its type, step, + absolute timestamp, source, payload summary, and the gap since the previous event. The gap is what + makes a stall visible. + +Reading the trail: + +- Events are labelled `server` or `client`. Client events are self-reported by the applicant's + browser and can be **absent entirely** — an ad blocker or a tab closed before the batch flushed + stops them reaching the API. A trail showing only server events is normal, and the panel says so. +- An empty trail means either that no events were raised or that they aged past + `Analytics__RetentionDays` and were deleted. After the retention sweep the two are + indistinguishable, so treat an empty panel on an old session as expected rather than as data loss. + +Aggregate drop-off in the flow analytics view is still derived from session status and submissions +rather than from this event stream, so per-step timing is available per session but not across a +flow. + +## 9. Rate limits when testing + +Limits are **partitioned per caller**, so one client exhausting a budget does not affect another. A +rejected request returns `429` with `Retry-After: 60`. + +| Policy | Applies to | Partitioned by | Default (per minute) | +|--------|-----------|----------------|----------------------| +| `session-start` | `POST /api/workflow/sessions/start` | Client IP | 100 | +| `analytics-ingest` | `POST /api/analytics/events` | Applicant session | 120 | +| `webhook-registration` | webhook registration | Authenticated principal | 20 | +| `general` | every other controller endpoint | Authenticated principal, else client IP | 300 | +| global ceiling | every request | not partitioned | 3000 | + +Two things to know when load-testing: + +- Callers sharing a credential share a partition. A test harness using one API key spends one + `general` budget across all of it. +- Limits are per-instance, not cluster-wide, and are disabled entirely in the `Testing` environment. + +Configure any of them with `RateLimiting__`. See +[the runbook](./docs/runbook.md#rate-limits) for proxy configuration and multi-replica behaviour. + +## 10. Visual Journey Builder (admin UI) The Visual Journey Builder is a drag-and-drop admin interface for designing and publishing onboarding flows without touching raw JSON. diff --git a/website/docs.html b/website/docs.html index 0b5723c..f6867ee 100644 --- a/website/docs.html +++ b/website/docs.html @@ -104,6 +104,7 @@
  • Sessions
  • Steps & Documents
  • Events & Webhooks
  • +
  • Rate Limits
  • @@ -203,7 +204,7 @@

    4. Start the frontend

    cd src/frontend
     cp .env.example .env.local
     # Set VITE_API_BASE_URL=http://localhost:5072
    -# Set VITE_API_KEY=dev-api-key-change-in-production
    +# Do NOT set an API key here - see Authentication below
     npm install
     npm run dev
    @@ -699,12 +700,26 @@

    Flow Authoring Panel

    Authentication

    - The API supports two authentication schemes. Requests are routed via a "Combined" policy scheme: + Requests are routed to a scheme by a "Combined" policy scheme. Which credential you use depends on who is calling.

      -
    • API Key — Pass X-Api-Key: <key> header. Local development key: dev-api-key-change-in-production.
    • +
    • Applicant session token — what a browser uses. POST /api/workflow/sessions/start is anonymous and returns applicantToken, scoped to that one session. Send it as Authorization: Bearer <token> for the rest of the journey.
    • +
    • API Key — Pass X-Api-Key: <key> header, for server-to-server callers and local API exploration. Maps to a full Operator principal. Local development key: dev-api-key-change-in-production.
    • JWT Bearer — Pass a signed JWT in the Authorization: Bearer <token> header.
    • +
    • Admin session cookie — established by SAML SSO, used by the operator console.
    • +
    +
    +

    Never send an API key from a browser. It maps to a full Operator principal, and a bundler inlines environment variables as literal strings — so a key configured in a web app ships to everyone who loads the page. Browsers use the per-session applicant token, which reaches exactly one session and carries no operator rights.

    +
    +

    Applicant token lifetime

    +
      +
    • Renewal — every step submission returns a fresh applicantToken. Store it in place of the one you hold; activity is what keeps a long journey from outliving its own credential.
    • +
    • Expiry — an expired token returns 401.
    • +
    • Finished sessions — once a session is Completed or Abandoned, its token is refused for writes with 403. Reads still work, so a completion screen can render.
    +

    + Treat 401 and 403 on a session you were completing as the same dead end — the credential is spent and the journey must be restarted — and both as distinct from a 5xx, which is retryable. +

    Roles: Operator (full access), Applicant (session and step submission), ReadOnly (read-only operator endpoints).

    @@ -825,6 +840,39 @@

    Steps & Documents

    + +
    +

    Documents are scoped to the session in the route. A token for one session cannot upload to or read from another. The download scopes the lookup as well as the caller: a fileId resolves only when an upload recorded against that session and node carries it, so holding another applicant's file id does not make it readable through your own session. A file that exists but belongs elsewhere returns 404, never 403.

    +
    +
    + + +
    +

    Rate Limits

    +

    + Every policy is partitioned per caller, so one client exhausting its budget does not affect any other. A rejected request returns 429 with Retry-After: 60 and a problem-details body. +

    +
    + + + + + + + + + +
    PolicyApplies toPartitioned byDefault / min
    session-startPOST /api/workflow/sessions/startClient IP — the endpoint is anonymous by design100
    analytics-ingestPOST /api/analytics/eventsApplicant session from the token120
    webhook-registrationPOST /api/flows/{flowId}/webhooksAuthenticated principal20
    generalEvery other controller endpointAuthenticated principal, else client IP300
    global ceilingEvery requestNot partitioned3000
    +
    +

    + The global ceiling runs in addition to the endpoint policy, so a flood spread across many callers still has a bound. An endpoint naming a specific policy is not additionally bound by general — the two do not stack. Health check endpoints are outside rate limiting entirely. +

    +
    +

    Callers sharing a credential share a partition: every integration using the same API key spends one budget between them, because the partition key is the principal. Give separate integrations separate credentials. Limits are also per-instance rather than cluster-wide, so with N replicas the effective budget is up to N times the configured value.

    +
    +

    + Configure any limit with RateLimiting__<Key>, for example RateLimiting__GeneralPerMinute. Behind a proxy, set ForwardedHeaders__KnownProxies so the partition key is the real client rather than the load balancer — until you do, X-Forwarded-For is ignored and every caller shares one partition. +

    @@ -876,23 +924,26 @@

    React / Next.js Integration

    Initialize and start a session

    React
    -
    import { useState } from 'react'
    +            
    import { useRef, useState } from 'react'
     
     const API = import.meta.env.VITE_API_BASE_URL
    -const KEY = import.meta.env.VITE_API_KEY
    +// No API key. The browser holds only the token for the session it started.
     
     export function useWaymark(flowId: string) {
       const [sessionId, setSessionId] = useState<string | null>(null)
       const [currentNode, setCurrentNode] = useState<NodeDto | null>(null)
    -  const [status, setStatus] = useState<'idle' | 'active' | 'completed'>('idle')
    +  const [status, setStatus] = useState<'idle' | 'active' | 'completed' | 'expired'>('idle')
    +  const token = useRef<string | null>(null)
     
       async function start() {
    +    // Anonymous: the entry point of a public journey, so there is no credential yet.
         const res = await fetch(`${API}/api/workflow/sessions/start`, {
           method: 'POST',
    -      headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY },
    +      headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ flowId }),
         })
         const data = await res.json()
    +    token.current = data.applicantToken
         setSessionId(data.sessionId)
         setCurrentNode(data.currentNode)
         setStatus('active')
    @@ -903,16 +954,28 @@ 

    Initialize and start a session

    `${API}/api/workflow/sessions/${sessionId}/steps/${nodeId}/submit`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token.current}`, + }, body: JSON.stringify({ payload }), } ) - const data = await res.json() - if (data.nextNode) { - setCurrentNode(data.nextNode) - } else { - setStatus('completed') + + // 401 expired, 403 the session ended: the credential is spent either way and + // the journey cannot resume. Distinct from a 5xx, which is worth retrying. + if (res.status === 401 || res.status === 403) { + setStatus('expired') + return } + if (!res.ok) throw new Error(`submit failed with status ${res.status}`) + + const data = await res.json() + // Keep the renewal, or a long journey outlives its own token. + if (data.applicantToken) token.current = data.applicantToken + + setCurrentNode(data.currentNode ?? null) + if (data.isCompleted) setStatus('completed') } return { start, submit, currentNode, status } @@ -962,15 +1025,19 @@

    Vue / Nuxt Integration

    const isComplete = ref(false) const API = import.meta.env.VITE_API_BASE_URL - const KEY = import.meta.env.VITE_API_KEY + // No API key. The browser holds only the token for the session it started. + const token = ref<string | null>(null) + const isExpired = ref(false) async function start() { + // Anonymous: the entry point of a public journey, so there is no credential yet. const response = await fetch(`${API}/api/workflow/sessions/start`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ flowId }), }) const data = await response.json() + token.value = data.applicantToken sessionId.value = data.sessionId currentNode.value = data.currentNode } @@ -980,16 +1047,30 @@

    Vue / Nuxt Integration

    `${API}/api/workflow/sessions/${sessionId.value}/steps/${nodeId}/submit`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token.value}`, + }, body: JSON.stringify({ payload }), } ) + + // 401 expired, 403 the session ended: the credential is spent either way. + if (response.status === 401 || response.status === 403) { + isExpired.value = true + return + } + if (!response.ok) throw new Error(`submit failed with status ${response.status}`) + const data = await response.json() - currentNode.value = data.nextNode ?? null - if (!data.nextNode) isComplete.value = true + // Keep the renewal, or a long journey outlives its own token. + if (data.applicantToken) token.value = data.applicantToken + + currentNode.value = data.currentNode ?? null + if (data.isCompleted) isComplete.value = true } - return { start, submit, currentNode, isComplete } + return { start, submit, currentNode, isComplete, isExpired } }
    @@ -998,16 +1079,22 @@

    Vue / Nuxt Integration

    Node.js Integration

    Node.js (fetch API)
    -
    const BASE = process.env.WAYMARK_API_URL
    +            
    // Server-side, so an API key is appropriate here - it never reaches a browser.
    +// It maps to a full Operator principal, so keep it in server config only.
    +const BASE = process.env.WAYMARK_API_URL
     const KEY  = process.env.WAYMARK_API_KEY
     
    +const headers = { 'Content-Type': 'application/json', 'X-Api-Key': KEY }
    +
     async function startSession(flowId) {
    -  const res  = await fetch(`${BASE}/api/workflow/sessions/start`, {
    +  const res = await fetch(`${BASE}/api/workflow/sessions/start`, {
         method: 'POST',
    -    headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY },
    +    headers,
         body: JSON.stringify({ flowId }),
       })
    -  return res.json() // { sessionId, currentNode }
    +  // An operator-authenticated start returns no applicantToken: the API key already
    +  // covers the session, so there is nothing to carry forward.
    +  return res.json() // { sessionId, isCompleted, currentNode }
     }
     
     async function submitStep(sessionId, nodeId, payload) {
    @@ -1015,16 +1102,23 @@ 

    Node.js Integration

    `${BASE}/api/workflow/sessions/${sessionId}/steps/${nodeId}/submit`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY }, + headers, body: JSON.stringify({ payload }), } ) - return res.json() // { nextNode } or null when complete + if (!res.ok) throw new Error(`submit failed with status ${res.status}`) + return res.json() // { sessionId, isCompleted, currentNode } - currentNode is null once complete } // Usage const { sessionId, currentNode } = await startSession('my-flow-id') -const result = await submitStep(sessionId, currentNode.id, { Country: 'USA' })
    +const result = await submitStep(sessionId, currentNode.id, { Country: 'USA' }) + +if (result.isCompleted) { + console.log('journey complete') +} else { + console.log('next step:', result.currentNode.title) +}
    diff --git a/website/website.css b/website/website.css index 03d78d7..a817c7f 100644 --- a/website/website.css +++ b/website/website.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.left-0{left:0}.right-8{right:2rem}.top-0{top:0}.top-14{top:3.5rem}.top-2{top:.5rem}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.min-h-\[100px\]{min-height:100px}.min-h-\[140px\]{min-height:140px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-brand-400\/50{border-color:rgba(74,107,250,.5)}.border-brand-500\/40{border-color:rgba(47,77,224,.4)}.border-emerald-400\/50{border-color:rgba(52,211,153,.5)}.border-green-900\/40{border-color:rgba(20,83,45,.4)}.border-red-900\/40{border-color:rgba(127,29,29,.4)}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-violet-400\/50{border-color:rgba(167,139,250,.5)}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-amber-500\/15{background-color:rgba(245,158,11,.15)}.bg-amber-500\/20{background-color:rgba(245,158,11,.2)}.bg-brand-500{--tw-bg-opacity:1;background-color:rgb(47 77 224/var(--tw-bg-opacity,1))}.bg-brand-500\/10{background-color:rgba(47,77,224,.1)}.bg-brand-500\/15{background-color:rgba(47,77,224,.15)}.bg-brand-500\/20{background-color:rgba(47,77,224,.2)}.bg-emerald-500\/10{background-color:rgba(16,185,129,.1)}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-emerald-500\/20{background-color:rgba(16,185,129,.2)}.bg-green-500\/20{background-color:rgba(34,197,94,.2)}.bg-green-500\/80{background-color:rgba(34,197,94,.8)}.bg-green-950\/20{background-color:rgba(5,46,22,.2)}.bg-red-500\/20{background-color:rgba(239,68,68,.2)}.bg-red-500\/80{background-color:rgba(239,68,68,.8)}.bg-red-950\/20{background-color:rgba(69,10,10,.2)}.bg-rose-500\/15{background-color:rgba(244,63,94,.15)}.bg-sky-500\/10{background-color:rgba(14,165,233,.1)}.bg-sky-500\/15{background-color:rgba(14,165,233,.15)}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-900\/30{background-color:rgba(15,23,42,.3)}.bg-slate-900\/50{background-color:rgba(15,23,42,.5)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-slate-950\/90{background-color:rgba(2,6,23,.9)}.bg-violet-500\/10{background-color:rgba(139,92,246,.1)}.bg-violet-500\/15{background-color:rgba(139,92,246,.15)}.bg-violet-500\/20{background-color:rgba(139,92,246,.2)}.bg-yellow-500\/80{background-color:rgba(234,179,8,.8)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-amber-500{--tw-gradient-from:#f59e0b var(--tw-gradient-from-position);--tw-gradient-to:rgba(245,158,11,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brand-400{--tw-gradient-from:#4a6bfa var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,107,250,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brand-500{--tw-gradient-from:#2f4de0 var(--tw-gradient-from-position);--tw-gradient-to:rgba(47,77,224,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-500{--tw-gradient-from:#10b981 var(--tw-gradient-from-position);--tw-gradient-to:rgba(16,185,129,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-600{--tw-gradient-from:#059669 var(--tw-gradient-from-position);--tw-gradient-to:rgba(5,150,105,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-violet-500{--tw-gradient-from:#8b5cf6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(139,92,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-violet-600{--tw-gradient-from:#7c3aed var(--tw-gradient-from-position);--tw-gradient-to:rgba(124,58,237,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-amber-600{--tw-gradient-to:#d97706 var(--tw-gradient-to-position)}.to-brand-600{--tw-gradient-to:#2236c2 var(--tw-gradient-to-position)}.to-emerald-600{--tw-gradient-to:#059669 var(--tw-gradient-to-position)}.to-emerald-700{--tw-gradient-to:#047857 var(--tw-gradient-to-position)}.to-violet-400{--tw-gradient-to:#a78bfa var(--tw-gradient-to-position)}.to-violet-600{--tw-gradient-to:#7c3aed var(--tw-gradient-to-position)}.to-violet-700{--tw-gradient-to:#6d28d9 var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-24{padding-bottom:6rem}.pt-14{padding-top:3.5rem}.pt-32{padding-top:8rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-loose{line-height:2}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-brand-100{--tw-text-opacity:1;color:rgb(220 230 255/var(--tw-text-opacity,1))}.text-brand-300{--tw-text-opacity:1;color:rgb(132 158 255/var(--tw-text-opacity,1))}.text-brand-400{--tw-text-opacity:1;color:rgb(74 107 250/var(--tw-text-opacity,1))}.text-emerald-100{--tw-text-opacity:1;color:rgb(209 250 229/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-transparent{color:transparent}.text-violet-100{--tw-text-opacity:1;color:rgb(237 233 254/var(--tw-text-opacity,1))}.text-violet-300{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.text-violet-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-30{opacity:.3}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-black\/60{--tw-shadow-color:rgba(0,0,0,.6);--tw-shadow:var(--tw-shadow-colored)}.shadow-brand-500\/25{--tw-shadow-color:rgba(47,77,224,.25);--tw-shadow:var(--tw-shadow-colored)}.blur-md{--tw-blur:blur(12px)}.blur-md,.blur-xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur:blur(24px)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.animate-pulse-slow{animation:pulse 4s cubic-bezier(.4,0,.6,1) infinite}.animate-slide-up{animation:slideUp .6s ease-out}.gradient-mesh{background:radial-gradient(at 27% 37%,rgba(58,139,253,.15) 0,transparent 50%),radial-gradient(at 97% 21%,rgba(114,254,125,.1) 0,transparent 50%),radial-gradient(at 52% 99%,rgba(253,58,78,.1) 0,transparent 50%),radial-gradient(at 10% 29%,rgba(133,90,252,.15) 0,transparent 50%),radial-gradient(at 97% 96%,hsla(38,60%,74%,.1) 0,transparent 50%),radial-gradient(at 33% 50%,rgba(140,168,232,.15) 0,transparent 50%),radial-gradient(at 79% 53%,rgba(238,165,186,.1) 0,transparent 50%)}@keyframes float{0%,to{transform:translateY(0)}50%{transform:translateY(-20px)}}@keyframes slideUp{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}.hover\:border-amber-500\/50:hover{border-color:rgba(245,158,11,.5)}.hover\:border-brand-500\/50:hover{border-color:rgba(47,77,224,.5)}.hover\:border-emerald-500\/50:hover{border-color:rgba(16,185,129,.5)}.hover\:border-rose-500\/50:hover{border-color:rgba(244,63,94,.5)}.hover\:border-sky-500\/50:hover{border-color:rgba(14,165,233,.5)}.hover\:border-slate-500:hover{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.hover\:border-violet-500\/50:hover{border-color:rgba(139,92,246,.5)}.hover\:bg-brand-400:hover{--tw-bg-opacity:1;background-color:rgb(74 107 250/var(--tw-bg-opacity,1))}.hover\:bg-slate-800:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:text-slate-300:hover{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-amber-500\/10:hover{--tw-shadow-color:rgba(245,158,11,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-brand-500\/10:hover{--tw-shadow-color:rgba(47,77,224,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-emerald-500\/10:hover{--tw-shadow-color:rgba(16,185,129,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-rose-500\/10:hover{--tw-shadow-color:rgba(244,63,94,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-sky-500\/10:hover{--tw-shadow-color:rgba(14,165,233,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-violet-500\/10:hover{--tw-shadow-color:rgba(139,92,246,.1);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:scale-110{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:bg-amber-500\/25{background-color:rgba(245,158,11,.25)}.group:hover .group-hover\:bg-amber-500\/30{background-color:rgba(245,158,11,.3)}.group:hover .group-hover\:bg-brand-500\/25{background-color:rgba(47,77,224,.25)}.group:hover .group-hover\:bg-brand-500\/30{background-color:rgba(47,77,224,.3)}.group:hover .group-hover\:bg-emerald-500\/25{background-color:rgba(16,185,129,.25)}.group:hover .group-hover\:bg-emerald-500\/30{background-color:rgba(16,185,129,.3)}.group:hover .group-hover\:bg-rose-500\/25{background-color:rgba(244,63,94,.25)}.group:hover .group-hover\:bg-sky-500\/25{background-color:rgba(14,165,233,.25)}.group:hover .group-hover\:bg-violet-500\/25{background-color:rgba(139,92,246,.25)}.group:hover .group-hover\:bg-violet-500\/30{background-color:rgba(139,92,246,.3)}.group:hover .group-hover\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-brand-400{--tw-text-opacity:1;color:rgb(74 107 250/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-violet-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}}@media (min-width:768px){.md\:flex{display:flex}.md\:inline-flex{display:inline-flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:gap-12{gap:3rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-7xl{font-size:4.5rem;line-height:1}}@media (min-width:1024px){.lg\:ml-64{margin-left:16rem}.lg\:block{display:block}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.left-0{left:0}.right-8{right:2rem}.top-0{top:0}.top-14{top:3.5rem}.top-2{top:.5rem}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.min-h-\[100px\]{min-height:100px}.min-h-\[140px\]{min-height:140px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-brand-400\/50{border-color:rgba(74,107,250,.5)}.border-brand-500\/40{border-color:rgba(47,77,224,.4)}.border-emerald-400\/50{border-color:rgba(52,211,153,.5)}.border-green-900\/40{border-color:rgba(20,83,45,.4)}.border-red-900\/40{border-color:rgba(127,29,29,.4)}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-violet-400\/50{border-color:rgba(167,139,250,.5)}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-amber-500\/15{background-color:rgba(245,158,11,.15)}.bg-amber-500\/20{background-color:rgba(245,158,11,.2)}.bg-brand-500{--tw-bg-opacity:1;background-color:rgb(47 77 224/var(--tw-bg-opacity,1))}.bg-brand-500\/10{background-color:rgba(47,77,224,.1)}.bg-brand-500\/15{background-color:rgba(47,77,224,.15)}.bg-brand-500\/20{background-color:rgba(47,77,224,.2)}.bg-emerald-500\/10{background-color:rgba(16,185,129,.1)}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-emerald-500\/20{background-color:rgba(16,185,129,.2)}.bg-green-500\/20{background-color:rgba(34,197,94,.2)}.bg-green-500\/80{background-color:rgba(34,197,94,.8)}.bg-green-950\/20{background-color:rgba(5,46,22,.2)}.bg-indigo-500\/15{background-color:rgba(99,102,241,.15)}.bg-red-500\/20{background-color:rgba(239,68,68,.2)}.bg-red-500\/80{background-color:rgba(239,68,68,.8)}.bg-red-950\/20{background-color:rgba(69,10,10,.2)}.bg-rose-500\/15{background-color:rgba(244,63,94,.15)}.bg-sky-500\/10{background-color:rgba(14,165,233,.1)}.bg-sky-500\/15{background-color:rgba(14,165,233,.15)}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-900\/30{background-color:rgba(15,23,42,.3)}.bg-slate-900\/50{background-color:rgba(15,23,42,.5)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-slate-950\/90{background-color:rgba(2,6,23,.9)}.bg-violet-500\/10{background-color:rgba(139,92,246,.1)}.bg-violet-500\/15{background-color:rgba(139,92,246,.15)}.bg-violet-500\/20{background-color:rgba(139,92,246,.2)}.bg-yellow-500\/80{background-color:rgba(234,179,8,.8)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-amber-500{--tw-gradient-from:#f59e0b var(--tw-gradient-from-position);--tw-gradient-to:rgba(245,158,11,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brand-400{--tw-gradient-from:#4a6bfa var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,107,250,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brand-500{--tw-gradient-from:#2f4de0 var(--tw-gradient-from-position);--tw-gradient-to:rgba(47,77,224,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-500{--tw-gradient-from:#10b981 var(--tw-gradient-from-position);--tw-gradient-to:rgba(16,185,129,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-600{--tw-gradient-from:#059669 var(--tw-gradient-from-position);--tw-gradient-to:rgba(5,150,105,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-violet-500{--tw-gradient-from:#8b5cf6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(139,92,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-violet-600{--tw-gradient-from:#7c3aed var(--tw-gradient-from-position);--tw-gradient-to:rgba(124,58,237,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-amber-600{--tw-gradient-to:#d97706 var(--tw-gradient-to-position)}.to-brand-600{--tw-gradient-to:#2236c2 var(--tw-gradient-to-position)}.to-emerald-600{--tw-gradient-to:#059669 var(--tw-gradient-to-position)}.to-emerald-700{--tw-gradient-to:#047857 var(--tw-gradient-to-position)}.to-violet-400{--tw-gradient-to:#a78bfa var(--tw-gradient-to-position)}.to-violet-600{--tw-gradient-to:#7c3aed var(--tw-gradient-to-position)}.to-violet-700{--tw-gradient-to:#6d28d9 var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-24{padding-bottom:6rem}.pt-14{padding-top:3.5rem}.pt-32{padding-top:8rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-loose{line-height:2}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-brand-100{--tw-text-opacity:1;color:rgb(220 230 255/var(--tw-text-opacity,1))}.text-brand-300{--tw-text-opacity:1;color:rgb(132 158 255/var(--tw-text-opacity,1))}.text-brand-400{--tw-text-opacity:1;color:rgb(74 107 250/var(--tw-text-opacity,1))}.text-emerald-100{--tw-text-opacity:1;color:rgb(209 250 229/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-transparent{color:transparent}.text-violet-100{--tw-text-opacity:1;color:rgb(237 233 254/var(--tw-text-opacity,1))}.text-violet-300{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.text-violet-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-30{opacity:.3}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-black\/40{--tw-shadow-color:rgba(0,0,0,.4);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/60{--tw-shadow-color:rgba(0,0,0,.6);--tw-shadow:var(--tw-shadow-colored)}.shadow-brand-500\/25{--tw-shadow-color:rgba(47,77,224,.25);--tw-shadow:var(--tw-shadow-colored)}.blur-md{--tw-blur:blur(12px)}.blur-md,.blur-xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur:blur(24px)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.animate-pulse-slow{animation:pulse 4s cubic-bezier(.4,0,.6,1) infinite}.animate-slide-up{animation:slideUp .6s ease-out}.gradient-mesh{background:radial-gradient(at 27% 37%,rgba(58,139,253,.15) 0,transparent 50%),radial-gradient(at 97% 21%,rgba(114,254,125,.1) 0,transparent 50%),radial-gradient(at 52% 99%,rgba(253,58,78,.1) 0,transparent 50%),radial-gradient(at 10% 29%,rgba(133,90,252,.15) 0,transparent 50%),radial-gradient(at 97% 96%,hsla(38,60%,74%,.1) 0,transparent 50%),radial-gradient(at 33% 50%,rgba(140,168,232,.15) 0,transparent 50%),radial-gradient(at 79% 53%,rgba(238,165,186,.1) 0,transparent 50%)}@keyframes float{0%,to{transform:translateY(0)}50%{transform:translateY(-20px)}}@keyframes slideUp{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}.hover\:border-amber-500\/50:hover{border-color:rgba(245,158,11,.5)}.hover\:border-brand-500\/50:hover{border-color:rgba(47,77,224,.5)}.hover\:border-emerald-500\/50:hover{border-color:rgba(16,185,129,.5)}.hover\:border-indigo-500\/50:hover{border-color:rgba(99,102,241,.5)}.hover\:border-rose-500\/50:hover{border-color:rgba(244,63,94,.5)}.hover\:border-sky-500\/50:hover{border-color:rgba(14,165,233,.5)}.hover\:border-slate-500:hover{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.hover\:border-violet-500\/50:hover{border-color:rgba(139,92,246,.5)}.hover\:bg-brand-400:hover{--tw-bg-opacity:1;background-color:rgb(74 107 250/var(--tw-bg-opacity,1))}.hover\:bg-slate-800:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:text-brand-300:hover{--tw-text-opacity:1;color:rgb(132 158 255/var(--tw-text-opacity,1))}.hover\:text-slate-300:hover{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-amber-500\/10:hover{--tw-shadow-color:rgba(245,158,11,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-brand-500\/10:hover{--tw-shadow-color:rgba(47,77,224,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-emerald-500\/10:hover{--tw-shadow-color:rgba(16,185,129,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-indigo-500\/10:hover{--tw-shadow-color:rgba(99,102,241,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-rose-500\/10:hover{--tw-shadow-color:rgba(244,63,94,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-sky-500\/10:hover{--tw-shadow-color:rgba(14,165,233,.1);--tw-shadow:var(--tw-shadow-colored)}.hover\:shadow-violet-500\/10:hover{--tw-shadow-color:rgba(139,92,246,.1);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:scale-110{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:bg-amber-500\/25{background-color:rgba(245,158,11,.25)}.group:hover .group-hover\:bg-amber-500\/30{background-color:rgba(245,158,11,.3)}.group:hover .group-hover\:bg-brand-500\/25{background-color:rgba(47,77,224,.25)}.group:hover .group-hover\:bg-brand-500\/30{background-color:rgba(47,77,224,.3)}.group:hover .group-hover\:bg-emerald-500\/25{background-color:rgba(16,185,129,.25)}.group:hover .group-hover\:bg-emerald-500\/30{background-color:rgba(16,185,129,.3)}.group:hover .group-hover\:bg-indigo-500\/25{background-color:rgba(99,102,241,.25)}.group:hover .group-hover\:bg-rose-500\/25{background-color:rgba(244,63,94,.25)}.group:hover .group-hover\:bg-sky-500\/25{background-color:rgba(14,165,233,.25)}.group:hover .group-hover\:bg-violet-500\/25{background-color:rgba(139,92,246,.25)}.group:hover .group-hover\:bg-violet-500\/30{background-color:rgba(139,92,246,.3)}.group:hover .group-hover\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-brand-400{--tw-text-opacity:1;color:rgb(74 107 250/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-violet-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}}@media (min-width:768px){.md\:flex{display:flex}.md\:inline-flex{display:inline-flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:gap-12{gap:3rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-7xl{font-size:4.5rem;line-height:1}}@media (min-width:1024px){.lg\:ml-64{margin-left:16rem}.lg\:block{display:block}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} \ No newline at end of file