diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..c26788d --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,220 @@ +--- +icon: material/history +hide: + - toc +--- + +# Changelog + +All notable changes to Nexus are documented here. This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +
+ +## Unreleased 2026-05-14 + +
+
+sangalo20 +ekizito96 +
+View commits on GitHub → +
+ +**Added** + +- **Python SDK** (`nexus-sdk-python`): full-feature-parity Python client — `get_token_by_connection_id`, `resolve_token`, `request_connection`, `check_connection`. Zero external dependencies. +- **TypeScript SDK** (`@dromos/nexus-sdk`): evolved from `nexus-mcp-adapter`. MCP token resolution, in-memory caching, authenticated transport, and `resolveToken` for stateless MCP clients. Built to `dist/`, ESM imports hardened. +- **Go SDK MCP integration**: `ResolveToken` endpoint for stateless MCP clients — workspace and provider-scoped token resolution with TTL caching. +- **Multi-strategy credential support** across all three SDKs: handles `oauth2`, `api_key`, `basic_auth`, `aws_sigv4`, `query_param`, and `hmac_payload` strategies without caller-side branching. +- **Automated release workflow**: GitHub Actions CI/CD pipeline, bumped `VERSION` to `0.2.3`. +- **Agent auth proposal** (`AGENT_AUTH_PROPOSAL.md`): full design document for agent identity, OBO sessions, scoped session TTLs, and custom scope enforcement. +- **SDK documentation**: comprehensive reference pages for Go, TypeScript, and Python SDKs including install, method signatures, MCP integration examples, and error handling. + +**Fixed** + +- TypeScript SDK: `Bearer` token type normalized to RFC 6750 capitalization (was `bearer`). +- TypeScript SDK: package entry pointed at compiled `dist/`, not `.ts` source. +- Gateway: `resolve` route wired; ESM import errors resolved in adapter. +- Adapter/Gateway: token TTL hardened, stdio safety improved, error handling tightened. +- MCP adapter smoke test added against live Gateway. + +
+ +--- + +
+ +## 0.2.0 2026-05-05 + +
+
+sangalo20 +Abdullahi254 +
+View release on GitHub → +
+ +**Added** + +- **Security-as-Code CLI** (`nexus-cli`): Terraform-style `plan → confirm → apply` workflow for declarative provider management via YAML manifest. PATCH-based reconciliation (no accidental overwrites), concurrent profile fetching with bounded worker pool, field-level diff output with secret masking, fail-fast on unresolved env vars, non-zero exit on partial apply failure. +- **Audit subsystem** (`audit.Service`): structured event logging to `audit_events` table with IP validation, User-Agent capture, and `audit.Logger` interface for test mocking. Events: `provider.created`, `provider.updated`, `provider.deleted`, `connection.created`, `token.retrieved`, `token.refresh_fatal`. +- **`GET /audit` endpoint**: queryable audit log with `event_type`, `resource_id`, `since`, `until`, `limit`, and `offset` filters. +- **Credential redaction**: `PATCH` audit payloads redact `client_secret` and `client_id` before writing to the audit log. +- **Provider `category` field**: `category` added to provider profiles with migration. Gateway `MetadataResponse` patched to include `category` in the OpenAPI-generated response. +- **`capture-schema` and `capture-credential` endpoints**: Gateway proxies for static credential capture flow, enabling API key and basic auth connections without OAuth redirects. + +**Fixed** + +- Gateway: manually patched `MetadataResponse` to include `category` field, avoiding `oapi-codegen` version mismatch. +- Documentation: all examples standardized to `localhost:8090` — internal Azure URLs removed. +- OpenAPI: `description` and `category` added to `MetadataResponse` and `ProviderProfile` schemas; gateway broker client regenerated. + +
+ +--- + +
+ +## 0.1.5 2026-04-13 + +
+
+sangalo20 +ashioyajotham +
+View release on GitHub → +
+ +**Changed** + +- Bridge: replaced `goto Retry` with `for`-loop in `MaintainGRPCConnection` — cleaner control flow, no goto jumps. ([@ashioyajotham](https://github.com/ashioyajotham)) +- Broker: replaced streaming `json.Encoder` with marshal-then-write pattern — eliminates partial-write race on slow connections. ([@ashioyajotham](https://github.com/ashioyajotham)) +- Security documentation hardened: shared secrets, key rotation, and deployment guidance expanded. + +**Fixed** + +- Broker: handle SQL `NULL` values for non-OAuth2 provider profiles — `api_key` and `basic_auth` providers no longer cause null pointer panics in the profile store. + +
+ +--- + +
+ +## 0.1.4 2026-04-01 + +
+
+sangalo20 +
+View release on GitHub → +
+ +**Added** + +- Broker: `skip_scope_on_auth` provider parameter — bypasses strict scope validation on the authorization URL for providers that reject scope in the initial redirect (Salesforce). + +
+ +--- + +
+ +## 0.1.3 2026-04-01 + +
+
+sangalo20 +ashioyajotham +
+View release on GitHub → +
+ +**Added** + +- Broker: validate `api_key` and `basic_auth` credentials before storing — rejects malformed or empty credentials at capture time rather than at retrieval. + +**Fixed** + +- Broker: enforce one token row per connection via upsert — eliminates duplicate token rows on reconnect. ([@ashioyajotham](https://github.com/ashioyajotham)) +- Security: `ENCRYPTION_KEY` and `STATE_KEY` are now required at startup — Broker and Gateway fatal-exit with a clear message if either is absent. ([@ashioyajotham](https://github.com/ashioyajotham)) +- Tests: `TestMain` used for binary lifecycle management; assertions refined. +- Gateway: `gofmt` formatting applied to main files. + +
+ +--- + +
+ +## 0.1.2 2026-04-01 + +
+
+sangalo20 +
+View release on GitHub → +
+ +**Fixed** + +- Docker: corrected image names to `nexus-broker` and `nexus-gateway` — was using incorrect names that broke `docker pull` and Compose service references. + +
+ +--- + +
+ +## 0.1.1 2026-04-01 + +
+
+sangalo20 +Abdullahi254 +
+View release on GitHub → +
+ +**Added** + +- Docker Hub publishing GitHub Actions workflow. +- Gateway: `capture-schema` and `capture-credential` proxy endpoints for static credential flows. ([@Abdullahi254](https://github.com/Abdullahi254)) +- Open-core refactor: internal packages made public to support the OSS consumption model. + +**Fixed** + +- Go module paths updated to `github.com/Prescott-Data/nexus-framework` throughout. +- Broken database migration corrected. + +
+ +--- + +
+ +## 0.1.0 2026-02-19 + +
+
+sangalo20 +
+View release on GitHub → +
+ +Initial public release. + +**Added** + +- **Nexus Broker**: OAuth 2.0 and OIDC connection management — token storage (AES-GCM 256-bit at rest), background refresh loop, OIDC discovery with JWKS caching, nonce/id_token verification, Prometheus metrics. +- **Nexus Gateway**: public-facing API for agents and backends. Versioned at `/v1`. gRPC-first communication to Broker with REST fallback. +- **Nexus Bridge**: Go library for embedding in agent processes — `MaintainWebSocket` and `MaintainGRPCConnection` with automatic credential injection, token refresh, exponential backoff reconnection, and Prometheus metrics. +- **Go SDK** (`nexus-sdk`): zero-dependency HTTP client for the Gateway API. +- **Provider support**: Google (OIDC discovery), Azure AD (common tenant), GitHub, Salesforce, and arbitrary OAuth2 providers with manual endpoint configuration. +- **Security guardrails**: IP allowlisting (`ALLOWED_CIDRS`), allowed return domain validation, API key enforcement. +- **Docker Compose**: single `make up` command runs Broker, Gateway, PostgreSQL, and Redis. +- **Bitbucket Pipelines**: initial CI/CD configuration. + +
diff --git a/docs/assets/nexus-logo-black.png b/docs/assets/nexus-logo-black.png new file mode 100644 index 0000000..2d776a7 Binary files /dev/null and b/docs/assets/nexus-logo-black.png differ diff --git a/docs/assets/nexus-logo-blue.png b/docs/assets/nexus-logo-blue.png new file mode 100644 index 0000000..93158e7 Binary files /dev/null and b/docs/assets/nexus-logo-blue.png differ diff --git a/docs/assets/nexus-logo-white.png b/docs/assets/nexus-logo-white.png new file mode 100644 index 0000000..5079528 Binary files /dev/null and b/docs/assets/nexus-logo-white.png differ diff --git a/docs/concepts/agent-identity.md b/docs/concepts/agent-identity.md new file mode 100644 index 0000000..80df694 --- /dev/null +++ b/docs/concepts/agent-identity.md @@ -0,0 +1,135 @@ +--- +icon: material/badge-account-outline +--- + +# Agent Identity + +The base Nexus connection model is anonymous: any process that holds a `connection_id` can retrieve tokens from it. There is no record of which agent is which, what it is allowed to do, or how long its authorization should last. + +The agent identity model adds agents as first-class principals. An agent has a registered identity, a declared set of allowed scopes, and requests short-lived scoped sessions rather than holding a connection token directly. This model is currently in development. + +## The agent registry + +Each agent is registered once by an administrator: + +```bash +curl -X POST https://your-gateway.example.com/admin/v1/agents \ + -H "X-API-Key: your-admin-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "agent_id": "crm-agent", + "description": "Reads and updates customer records in Salesforce", + "allowed_scopes": ["crm:contacts:read", "crm:contacts:write"] + }' +``` + +| Field | Description | +|---|---| +| `agent_id` | Stable identifier for this agent — used in session requests and audit records | +| `description` | Human-readable label for the agent registry UI | +| `allowed_scopes` | The complete set of scopes this agent is ever permitted to request | + +The `allowed_scopes` list is the agent's authorization ceiling. An agent can request any subset of these scopes at session time, but can never request a scope that is not in this list. + +## Agent sessions + +An agent session is a short-lived, scoped credential grant. The agent requests a session specifying exactly which scopes it needs for the current operation: + +```bash +curl -X POST https://your-gateway.example.com/v1/agent-sessions \ + -H "X-API-Key: your-gateway-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "agent_id": "crm-agent", + "provider_name": "salesforce", + "scopes": ["crm:contacts:read"], + "ttl_seconds": 900 + }' +``` + +Response: + +```json +{ + "session_id": "sess_a1b2c3", + "access_token": "eyJ...", + "scopes_granted": ["crm:contacts:read"], + "expires_at": "2026-05-12T21:00:00Z" +} +``` + +The Broker enforces two constraints before issuing a session: + +1. Every requested scope must be in the agent's `allowed_scopes` list. +2. The agent's `allowed_scopes` are themselves a subset of what the underlying connection's provider grants. + +If either check fails, the Broker returns `403`. The agent receives exactly what it requests — never more. + +## Session lifecycle + +| Field | Description | +|---|---| +| `session_id` | Stable identifier — use for audit queries and session closure | +| `access_token` | Short-lived token to use against the provider's API | +| `scopes_granted` | The actual scopes issued — confirm these match what you requested | +| `expires_at` | Hard expiry — the session cannot be refreshed, only replaced | + +When the agent finishes its operation, close the session explicitly: + +```bash +curl -X DELETE https://your-gateway.example.com/v1/agent-sessions/sess_a1b2c3 \ + -H "X-API-Key: your-gateway-api-key" +``` + +Closing a session revokes the token server-side. A token intercepted after session closure cannot be replayed. + +Sessions that are not explicitly closed expire at `expires_at`. The default TTL is 900 seconds (15 minutes). + +## Custom scopes + +Not every permission maps to an OAuth provider scope. Internal business operations have their own authorization requirements: `acme:gliding`, `acme:flaring`, `pipeline:trigger`, `reports:generate`. These are custom scopes. + +Custom scopes are declared in the agent's `allowed_scopes` list exactly like provider scopes: + +```bash +curl -X POST https://your-gateway.example.com/admin/v1/agents \ + -H "X-API-Key: your-admin-api-key" \ + -d '{ + "agent_id": "ops-agent", + "description": "Performs authorized internal financial operations", + "allowed_scopes": [ + "acme:gliding", + "acme:flaring", + "crm:contacts:read" + ] + }' +``` + +The enforcement mechanism differs from provider scopes: + +| Scope type | How the Broker resolves the session token | +|---|---| +| Provider scope (`crm:contacts:read`) | Fetches the underlying OAuth token from the stored connection, returns it scoped to the requested permissions | +| Custom scope (`acme:gliding`) | Returns a signed session token asserting the agent is authorized for this scope — your downstream service validates the assertion | + +For custom scopes, the session response includes `token_type: session` rather than `token_type: bearer`: + +```json +{ + "session_id": "sess_xyz", + "session_token": "eyJ...", + "scopes_granted": ["acme:gliding"], + "expires_at": "2026-05-12T21:00:00Z", + "token_type": "session" +} +``` + +Your downstream service verifies this session token against the Broker's public key, or by calling `GET /v1/agent-sessions/{session_id}` to confirm it is active. + +## Why this matters + +The connection model does not restrict what an agent can do with a token it retrieves. If the token has `crm:delete` scope, any agent with the `connection_id` can delete records. There is no enforcement point between the token's capabilities and the specific agent's intended use. + +The agent identity model enforces least-privilege at the session layer. The `crm-agent` registered with `["crm:contacts:read"]` cannot request `crm:delete` even if the underlying Salesforce connection has it. The agent can only ever operate within its declared scope boundary, regardless of how the connection was authorized. + +This is the difference between "the token allows this" and "this agent is allowed to do this." diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md new file mode 100644 index 0000000..0752971 --- /dev/null +++ b/docs/concepts/architecture.md @@ -0,0 +1,81 @@ +--- +icon: material/layers-triple +--- + +# Architecture + +Nexus is split into a control plane and a data plane. The control plane manages the lifecycle of credentials: registering providers, completing OAuth handshakes, storing tokens, and refreshing them before they expire. The data plane serves credentials to agents when they need them and injects those credentials into outgoing requests. + +Understanding this separation is the mental model for everything else in this documentation. + +--- + +## The four components + +### The Broker + +The Broker is the most sensitive component in the system. It is the only service that ever holds refresh tokens or provider API keys. Everything it stores is encrypted at rest using AES-GCM 256-bit with a key you supply and manage. The Broker never sends a refresh token to any other service. When the Gateway asks for credentials, the Broker decrypts the stored token, fetches a fresh access token from the provider, and returns only that. + +The Broker runs a background refresh loop. Before any stored token expires, the Broker fetches a new one using the stored refresh token. If a provider rejects the refresh (for example, because a user revoked access), the Broker transitions that connection to `attention_required` and records the failure in the audit log. The agent does not need to handle this case; it will simply receive an error on the next credential request and can surface it to the user. + +See [The Broker](broker.md) for database schema, encryption details, and refresh loop internals. + +### The Gateway + +The Gateway is the public-facing API that agents call. It accepts REST requests, proxies them to the Broker using an internal API key, and returns the response. Agents never reach the Broker directly. This decoupling means you can expose the Gateway to agents running outside your network without exposing the Broker. + +The Gateway handles CORS, request validation, and API versioning. From an agent's perspective, the Gateway is Nexus. + +See [The Gateway](gateway.md) for API endpoints, the resolve workflow, and health checks. + +### The Bridge + +The Bridge is a Go library that runs inside your agent process. You instantiate it with a Gateway client, and it handles credential retrieval and request signing automatically. For a persistent WebSocket or gRPC connection, you call `bridge.MaintainWebSocket()` or `bridge.MaintainGRPCConnection()` and the Bridge keeps the connection authenticated through token rotations, including exponential backoff on failures. It also exposes a Prometheus `/metrics` endpoint for operational visibility. + +Use the Bridge when your agent is written in Go and makes ongoing connections to provider services. + +See [The Bridge](bridge.md) for transport details, the authentication engine, and observability hooks. + +### The SDKs + +The SDKs are thin HTTP clients for the Gateway API. They expose `GetToken()`, `ResolveToken()`, and related methods, giving you direct control over credential retrieval without the connection management logic the Bridge provides. Use an SDK when you want to fetch credentials explicitly, when you are building an MCP server, or when your agent is written in TypeScript or Python. + +Nexus ships three SDKs with full feature parity: Go, TypeScript, and Python. See the [SDK Reference](client-libraries.md) for the full method surface. + +--- + +## The OAuth handshake flow + +When a user connects a new provider account, the flow runs as follows. + +Your application backend calls `POST /v1/request-connection` on the Gateway, passing the user identifier, the provider name, the requested scopes, and a return URL. The Gateway forwards this to the Broker, which generates a unique `state` parameter signed with the `STATE_KEY` and returns an authorization URL and a `connection_id`. + +Your application redirects the user's browser to the authorization URL. The user authenticates with the provider and grants consent. The provider redirects back to the Broker's callback endpoint, which validates the `state` parameter, exchanges the authorization code for tokens, encrypts the tokens, and stores them in PostgreSQL. + +The Broker then redirects the user's browser to your `return_url` with the `connection_id` and a `status=success` parameter. Your application captures the `connection_id` and stores it. That is the only thing your application needs to hold. The actual tokens stay in the Broker. + +--- + +## The credential retrieval flow + +When your agent needs to call a provider, the flow is simpler. + +Your agent calls `GET /v1/token/{connection_id}` on the Gateway. The Gateway forwards the request to the Broker, which retrieves the stored tokens, decrypts them in RAM, and returns a credential payload to the Gateway. The payload contains a `strategy` field that describes how to use the credentials and a `credentials` object with the actual values. + +The `strategy` field exists because Nexus supports multiple credential types. An OAuth2 connection returns `{"type": "oauth2"}` with an `access_token`. An API key connection returns `{"type": "api_key"}` with the key value in a field matching the provider's schema. The Bridge handles strategy interpretation automatically. If you are making direct HTTP calls, you inspect `strategy.type` and apply the credentials accordingly. + +--- + +## Connection states + +A connection moves through a small set of states during its lifetime. + +`pending` is the initial state after `request-connection` returns but before the user has completed consent. `active` is the normal operating state: tokens are valid and the background refresh loop is keeping them current. `attention_required` means the last refresh attempt failed with a provider error that cannot be retried, typically because the user revoked the application's access. `failed` means the initial token exchange failed. + +Your application should surface `attention_required` to the user so they can reconnect the provider account. See [Handling Attention State](../guides/attention-state.md) for implementation details. + +--- + +## What Nexus does not do + +Nexus does not make API calls to providers on behalf of agents. It provides credentials; agents make the calls. Nexus does not manage authorization within your own application (which users can access which connections). It manages authentication with external providers. Nexus does not run inside your agent. The Bridge is an in-process library, but the Broker and Gateway are network services that you deploy separately. diff --git a/docs/concepts/auth-strategies.md b/docs/concepts/auth-strategies.md new file mode 100644 index 0000000..323982e --- /dev/null +++ b/docs/concepts/auth-strategies.md @@ -0,0 +1,95 @@ +--- +icon: material/shield-key-outline +--- + +# Authentication Strategies + +An authentication strategy defines how the Bridge applies a connection's credentials to an outgoing request. The strategy is stored on the provider profile and returned as part of every token response. Your agent code does not select or configure strategies at runtime — the Bridge reads and applies them automatically. + +There are six strategy types. + +## oauth2 + +Injects the `access_token` as an HTTP Bearer token. Used by all standard OAuth2 providers. + +``` +Authorization: Bearer +``` + +No additional configuration. This strategy is set automatically when you register an OAuth2 provider. + +## header + +Places a credential value into a named HTTP header with an optional prefix. Use for any provider that authenticates via a custom header. + +| Config field | Required | Description | +|---|---|---| +| `header_name` | yes | The header to set | +| `credential_field` | yes | Which key in the credentials map to use | +| `value_prefix` | no | String prepended to the value (e.g. `"Token "`) | + +Example — `X-API-Key: `: +```json +{ "header_name": "X-API-Key", "credential_field": "api_key" } +``` + +Example — `Authorization: Token `: +```json +{ "header_name": "Authorization", "credential_field": "api_key", "value_prefix": "Token " } +``` + +## query_param + +Appends a credential value to the request URL as a query parameter. Not supported for gRPC. + +| Config field | Required | Description | +|---|---|---| +| `param_name` | yes | Query parameter name | +| `credential_field` | yes | Which key in the credentials map to use | + +## basic_auth + +Encodes username and password as HTTP Basic Auth. + +``` +Authorization: Basic +``` + +| Config field | Required | Description | +|---|---|---| +| `username_field` | no | Credentials key for the username (default: `"username"`) | +| `password_field` | no | Credentials key for the password (default: `"password"`) | + +Supported for gRPC — injects as `authorization` metadata. + +## hmac_payload + +Signs the request body with HMAC and writes the signature to a header. Used by Stripe, Twilio, GitHub webhooks, and similar request-signing patterns. + +| Config field | Required | Description | +|---|---|---| +| `header_name` | yes | Header to write the signature into | +| `secret_field` | yes | Credentials key holding the signing secret | +| `algo` | no | `sha256` (default) or `sha1` | +| `encoding` | no | `hex` (default) or `base64` | + +The Bridge reads the body, computes the HMAC, restores the body, and sets the header. If the request has no body, the HMAC is computed over an empty byte slice. + +## aws_sigv4 + +Signs requests with AWS Signature Version 4. Required for all AWS service APIs — S3, DynamoDB, Bedrock, SageMaker, and others. + +| Config field | Required | Description | +|---|---|---| +| `region` | no | AWS region (default: `us-east-1`) | +| `service` | yes | AWS service name (e.g. `s3`, `bedrock`) | + +The stored credentials must include: + +| Credential field | Required | Description | +|---|---|---| +| `access_key` | yes | AWS access key ID | +| `secret_key` | yes | AWS secret access key | +| `session_token` | no | Session token for temporary / assumed-role credentials | + +The Bridge sets `X-Amz-Content-Sha256`, computes the payload hash, and calls the AWS SDK's `v4.Signer` to complete full SigV4 signing. diff --git a/docs/concepts/bridge.md b/docs/concepts/bridge.md new file mode 100644 index 0000000..1eeac2c --- /dev/null +++ b/docs/concepts/bridge.md @@ -0,0 +1,150 @@ +--- +icon: material/transit-connection-variant +--- + +# The Bridge + +The Bridge is a Go library you embed in your agent process. It maintains an authenticated connection, fetching tokens, injecting credentials into requests, and refreshing tokens before expiry, so your agent code handles only application logic. + +## How it works + +When you call `MaintainWebSocket` or `MaintainGRPCConnection`, the Bridge runs a `manageConnection` loop: + +1. Calls `GET /v1/token/{connection_id}` to fetch the current token and auth strategy. +2. Applies the strategy to authenticate the underlying connection (WebSocket headers or gRPC metadata). +3. Starts three goroutines: a read pump, a write pump, and a ping ticker. +4. Monitors `ExpiresAt` and fires a background `RefreshConnection` before the token expires. The connection stays open during refresh. +5. On any error, calls `OnDisconnect`, closes the connection, and returns to the retry loop. + +## The authentication engine + +The Bridge does not hardcode authentication logic. When it receives a token response from the Gateway, the `strategy` field tells it exactly how to authenticate the outgoing request. The Bridge supports six strategy types: + +| Strategy | How credentials are applied | +|---|---| +| `oauth2` | `Authorization: Bearer ` header | +| `basic_auth` | `Authorization: Basic ` header | +| `header` | Custom header name with the key value | +| `query_param` | Key injected into the URL query string | +| `hmac_payload` | Request body signed with HMAC and placed in a header | +| `aws_sigv4` | Full AWS Signature Version 4 request signing | + +This means the Bridge can authenticate against any provider type without code changes. You register the provider, the Broker stores the strategy, and the Bridge reads and applies it at runtime. + +## WebSocket usage + +```go +authClient := oauthsdk.New("http://nexus-gateway.example.com") + +agentLabels := map[string]string{"agent_id": "my-agent"} +b := bridge.NewStandard(authClient, agentLabels) + +err := b.MaintainWebSocket(ctx, connectionID, endpointURL, &myHandler{}) +``` + +## gRPC usage + +For gRPC, use `MaintainGRPCConnection`. You provide a connection ID, a target address, and a callback function that receives an authenticated `*grpc.ClientConn`. The Bridge injects strategy-appropriate credentials as gRPC metadata on every connection and re-authentication. + +```go +runLogic := func(ctx context.Context, conn *grpc.ClientConn) error { + client := grpc_health_v1.NewHealthClient(conn) + resp, err := client.Check(ctx, &grpc_health_v1.HealthCheckRequest{Service: "my-service"}) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + fmt.Printf("Health status: %s\n", resp.Status) + return nil +} + +err := b.MaintainGRPCConnection(ctx, connectionID, target, runLogic, + grpc.WithTransportCredentials(tlsCreds), +) +``` + +Returning an error from the callback triggers a reconnect with backoff. Returning `nil` triggers a clean reconnect after the backoff interval. + +## The Handler interface + +Implement `Handler` to receive WebSocket connection events: + +```go +type Handler interface { + OnConnect(send func([]byte) error) + OnMessage(message []byte) + OnDisconnect(err error) +} +``` + +`OnConnect` gives you a thread-safe `send` function for writing messages. `OnMessage` is called for every inbound frame. `OnDisconnect` tells you why the connection closed. + +## Reconnection + +The outer retry loop applies exponential backoff with jitter between reconnect attempts. Jitter prevents thundering herd when many agents reconnect after a Gateway restart. + +| Error type | Behaviour | +|---|---| +| Transient (network failure, abnormal close) | Retry with backoff | +| `PermanentError` (connection does not exist) | Stop immediately | +| `ErrInteractionRequired` (connection in `attention` state) | Stop immediately; surface to user | + +Initial token fetch failures are always permanent. If `GetToken` fails on startup, the Bridge does not retry. + +## Observability + +### Prometheus metrics + +The `NewStandard` constructor enables production-ready structured logging (JSON to stdout) and Prometheus metrics automatically. The `agentLabels` map is applied as constant labels to all metrics, allowing you to filter by agent in your observability stack. + +| Metric | Type | Description | +|---|---|---| +| `nexus_bridge_connections_total` | Counter | Connections established | +| `nexus_bridge_disconnections_total` | Counter | Connections closed | +| `nexus_bridge_connection_status` | Gauge | `1` when connected, `0` when not | +| `nexus_bridge_token_refreshes_total` | Counter | In-place token refreshes completed | + +Expose the metrics endpoint: + +```go +http.Handle("/metrics", telemetry.Handler()) +go http.ListenAndServe(":9090", nil) +``` + +### Custom Logger and Metrics + +If you need your own logging or metrics implementation, use the `New` constructor with `With...` options: + +```go +b := bridge.New( + authClient, + bridge.WithLogger(myCustomLogger), + bridge.WithMetrics(myCustomMetrics), + bridge.WithRetryPolicy(bridge.RetryPolicy{ + MinBackoff: 1 * time.Second, + MaxBackoff: 60 * time.Second, + Jitter: 500 * time.Millisecond, + }), +) +``` + +The Logger and Metrics interfaces: + +```go +type Logger interface { + Info(msg string, keysAndValues ...interface{}) + Error(err error, msg string, keysAndValues ...interface{}) +} + +type Metrics interface { + IncConnections() + IncDisconnects() + IncTokenRefreshes() + SetConnectionStatus(status float64) +} +``` + +## Bridge vs SDK + +Use the **Bridge** when your agent holds a persistent WebSocket or gRPC connection and you want automatic lifecycle management. + +Use an **SDK** directly when your agent makes discrete HTTP calls and you want to fetch credentials explicitly per call, or when your agent is written in TypeScript or Python. diff --git a/docs/concepts/broker.md b/docs/concepts/broker.md new file mode 100644 index 0000000..43677d2 --- /dev/null +++ b/docs/concepts/broker.md @@ -0,0 +1,63 @@ +--- +icon: material/database-lock-outline +--- + +# The Broker + +The Broker is the only service in a Nexus deployment that holds durable credential material. No other service receives a refresh token, client secret, or plaintext key. + +## Database schema + +| Table | Purpose | +|---|---| +| `provider_profiles` | OAuth2 and static provider configuration — client ID, client secret, auth/token URLs, scopes, auth strategy | +| `connections` | One row per authorization grant — workspace ID, provider ID, status, PKCE verifier, scopes, return URL | +| `tokens` | Encrypted credential material — one row per connection, enforced by `ON CONFLICT (connection_id) DO UPDATE` | +| `audit_events` | Append-only event log — consent created, token issued, refresh succeeded or failed, connection cleaned up | + +## Encryption + +All token material is encrypted with AES-GCM 256-bit using your `ENCRYPTION_KEY`. The key must be exactly 32 bytes. A fresh 12-byte nonce from `crypto/rand` is generated per write and prepended to the ciphertext before base64 encoding. + +```bash +# Generate ENCRYPTION_KEY +openssl rand -base64 32 +``` + +Losing `ENCRYPTION_KEY` makes every stored token permanently unreadable. There is no recovery. Key rotation requires a migration that decrypts and re-encrypts every token row. + +## Token refresh + +`POST /connections/{connection_id}/refresh` — the Bridge calls this before each access token expires. + +The Broker decrypts the stored token, extracts `refresh_token`, calls the provider's token endpoint, encrypts the new response, and upserts it into `tokens`. + +| Provider response | Broker action | +|---|---| +| `2xx` | Encrypt and store new token, return it | +| `4xx` | Set connection status to `attention`, return `attention_required` | +| `5xx` or network error | Return error without changing connection status — Bridge retries | + +Static connections (`api_key`, `basic_auth`) cannot be refreshed. Calling refresh on a static connection returns `400 static_token`. + +## Connection states + +| Status | Meaning | +|---|---| +| `pending` | Consent URL issued, user has not yet authorized | +| `active` | Token stored and usable | +| `attention` | Refresh failed with a `4xx` — user must re-authorize | +| `failed` | Unrecoverable. Delete and recreate. | + +## OAuth state signing + +The Broker signs every OAuth `state` parameter with HMAC-SHA256 using `STATE_KEY`. The state encodes `{workspace_id, provider_id, nonce, iat}` and expires after 10 minutes. Both the Broker and the Gateway must share the same `STATE_KEY`. + +```bash +# Generate STATE_KEY +openssl rand -base64 32 +``` + +## Network isolation + +The Broker should only accept connections from the Gateway's IP range. It should not be reachable from the public internet or from agent processes. The `API_KEY` on the Broker should be known only to the Gateway. diff --git a/docs/concepts/client-libraries.md b/docs/concepts/client-libraries.md new file mode 100644 index 0000000..a6ac382 --- /dev/null +++ b/docs/concepts/client-libraries.md @@ -0,0 +1,48 @@ +--- +icon: material/code-braces +--- + +# Client Libraries + +Nexus ships three client libraries for agents: the Bridge (Go), and SDKs for Go, TypeScript, and Python. They all talk to the same Gateway API. The difference is what they manage for you. + +## The Bridge + +The Bridge is a Go library that runs inside your agent process and manages the full lifecycle of a persistent connection. You call `MaintainWebSocket` or `MaintainGRPCConnection`, hand it a handler, and the Bridge handles everything: token fetch, credential injection, in-place token refresh before expiry, reconnection with exponential backoff, and Prometheus metrics. + +Use the Bridge when your agent holds a long-lived WebSocket or gRPC connection to a provider service and you want the connection to stay authenticated automatically across token rotations. + +See [The Bridge](bridge.md) for the full API and configuration options. + +## The SDKs + +The SDKs are thin HTTP clients for the Gateway API. They expose `GetToken`, `ResolveToken`, `RequestConnection`, and related methods. You call them explicitly when you need a credential and apply it yourself. There is no automatic injection or connection management. + +Three SDKs ship with Nexus, all with the same method surface: + +| Language | Package | Install | +|---|---|---| +| Go | `nexus-sdk` | `go get github.com/Prescott-Data/nexus-framework/nexus-sdk` | +| TypeScript | `@dromos/nexus-sdk` | `npm install @dromos/nexus-sdk` | +| Python | `nexus-sdk` | `pip install nexus-sdk` | + +Use an SDK when your agent makes discrete HTTP calls rather than holding a persistent connection, when you are building an MCP server, or when your agent is written in TypeScript or Python. + +## MCP servers + +Both the Go and TypeScript SDKs include a `ResolveToken` method designed for MCP server integration. An MCP server receives a workspace ID and provider name, calls `ResolveToken` to get the current credential, and injects it into the outgoing request to the provider. The SDK handles token caching and expiry internally. + +See [MCP Server Integration](../guides/mcp-integration.md) for complete examples in all three languages. + +## Choosing between Bridge and SDK + +| | Bridge | SDK | +|---|---|---| +| Connection type | Persistent WebSocket or gRPC | Discrete HTTP calls | +| Credential injection | Automatic | Manual | +| Token refresh | In-place, transparent | Caller responsibility | +| Reconnection | Built-in with backoff | Not applicable | +| Languages | Go only | Go, TypeScript, Python | +| MCP servers | Not the right tool | Designed for this | + +If your agent is Go and holds a persistent connection, use the Bridge. In all other cases, use an SDK. diff --git a/docs/concepts/connections.md b/docs/concepts/connections.md new file mode 100644 index 0000000..8c56f7e --- /dev/null +++ b/docs/concepts/connections.md @@ -0,0 +1,95 @@ +--- +icon: material/link-variant +--- + +# Connections + +A connection represents a user's authorization grant to a specific provider. Every token fetch and audit event is tied to a connection ID. + +## Fields + +| Field | Type | Description | +|---|---|---| +| `id` | UUID | The `connection_id` your application stores and uses for token fetches | +| `workspace_id` | string | Your identifier for the user who authorized this connection — opaque to Nexus | +| `provider_id` | UUID | The provider this connection belongs to | +| `status` | string | Current state — see state machine below | +| `scopes` | `[]string` | The scopes requested for this connection — what you asked for, not necessarily what was granted | +| `code_verifier` | string | PKCE verifier for the OAuth code exchange — removed after completion | +| `return_url` | string | Where the Broker redirects after consent completes | +| `expires_at` | timestamp | Expiry for `pending` connections that are never completed | + +## State machine + +``` +pending ──► active ──► attention + │ │ + └──────────────────────┴──► failed +``` + +| Status | Meaning | Next action | +|---|---|---| +| `pending` | Consent URL issued, user has not authorized yet | Poll `check-connection` or wait for redirect | +| `active` | Token stored — connection is usable | Normal operation | +| `attention` | Token refresh failed with a provider `4xx` | Initiate a new consent flow for this workspace + provider | +| `failed` | Unrecoverable | Delete and recreate | + +## Scopes + +Nexus has three scope contexts. Confusing them causes subtle bugs. + +### Provider scopes + +`provider_profiles.scopes` — the default scope list for a provider. The Broker uses these when you call `POST /v1/request-connection` without passing a `scopes` array. + +```json +{ "scopes": ["openid", "email", "profile"] } +``` + +These are configured once on the provider. They represent the broadest set of scopes your application needs from this provider. + +### Per-connection scopes + +Override the provider defaults for a specific connection by passing `scopes` to `POST /v1/request-connection`: + +```json +{ + "workspace_id": "user_abc", + "provider_id": "3fa85f64-...", + "scopes": ["read:reports"] +} +``` + +The Broker stores this array on the connection record and uses it to build the authorization URL: `scope=read:reports`. Two connections to the same provider can carry completely different scopes. Use this to apply least-privilege — a reporting agent gets `["read:reports"]`, an admin agent gets `["read:reports", "write:data", "admin:users"]`. + +### Requested vs granted + +The scopes your agent requested and the scopes the provider actually granted are not always the same. Providers can downscope a grant based on their own policies or the user's own permission level. + +The `scope` field in the token response reflects what was actually granted: + +```go +token, _ := client.GetToken(ctx, connectionID) +granted := token.Scope // "read:reports" — even if you requested more +``` + +Do not assume your agent has a scope because it was requested. If your agent's operations depend on specific scopes, check `token.Scope` after the connection becomes active and surface a clear error if the required scope is absent. + +### Provider-specific scope quirks + +Some providers handle scopes in non-standard ways. These are controlled via the `params` field on the provider profile: + +| Param | Type | Providers | Effect | +|---|---|---|---| +| `skip_scope_on_auth` | bool | Salesforce | Omits `scope` from the authorization URL | +| `skip_scope_on_exchange` | bool | Salesforce | Omits `scope` from the token exchange request | + +Both default to `false`. Set them only if the provider rejects requests that include `scope`. + +## Static credential connections + +For `api_key` and `basic_auth` providers, there is no OAuth redirect. Your backend calls `GET /v1/capture-schema` to get the field schema, presents it to the user, and submits the completed values via `POST /v1/capture-credential`. The connection goes directly to `active`. Static connections cannot be refreshed. + +## Token storage + +The `tokens` table keeps exactly one row per connection. Every refresh upserts via `ON CONFLICT (connection_id) DO UPDATE`. There is no token history. The current encrypted token is always the only row. diff --git a/docs/concepts/gateway.md b/docs/concepts/gateway.md new file mode 100644 index 0000000..2e6c5b6 --- /dev/null +++ b/docs/concepts/gateway.md @@ -0,0 +1,59 @@ +--- +icon: material/api +--- + +# The Gateway + +The Gateway is the public API for Nexus. Your application backend and agents communicate exclusively with the Gateway. The Broker is unreachable from outside. + +## APIs + +The Gateway ships two binaries from the same OpenAPI spec: + +| Binary | Protocol | Use case | +|---|---|---| +| `nexus-rest` | HTTP/1.1 REST | Default. Works with any HTTP client. | +| `nexus-grpc` | gRPC / HTTP/2 | High-concurrency agents that benefit from multiplexing. | + +## Endpoints + +### Provider management + +| Method | Path | Description | +|---|---|---| +| `POST` | `/v1/providers` | Register a new provider profile | +| `GET` | `/v1/providers` | List all providers | +| `GET` | `/v1/providers/{id}` | Get a provider by ID | +| `PATCH` | `/v1/providers/{id}` | Update provider fields | +| `DELETE` | `/v1/providers/{id}` | Delete a provider | + +### OAuth consent flow + +| Method | Path | Description | +|---|---|---| +| `POST` | `/v1/request-connection` | Initiate OAuth consent — returns `auth_url` and `connection_id` | +| `GET` | `/v1/callback` | OAuth redirect target — proxied to the Broker | +| `GET` | `/v1/capture-schema` | Get credential field schema for static providers | +| `POST` | `/v1/capture-credential` | Submit static credentials — returns `connection_id` | + +### Token operations + +| Method | Path | Description | +|---|---|---| +| `GET` | `/v1/token/{connection_id}` | Fetch current credentials for a connection | +| `POST` | `/v1/refresh/{connection_id}` | Force an immediate token refresh | +| `GET` | `/v1/check-connection/{connection_id}` | Get connection status | + +## Authentication + +Pass your Gateway API key in the `X-API-Key` header on every request. + +``` +X-API-Key: +``` + +The Gateway uses a separate `API_KEY` when forwarding requests to the Broker. These should be different values. + +## CORS + +CORS is only relevant if your frontend JavaScript calls the Gateway directly during the OAuth consent flow. Configure `ALLOWED_ORIGINS` with your frontend domain. Server-side agents do not need CORS configured. diff --git a/docs/concepts/provider-types.md b/docs/concepts/provider-types.md new file mode 100644 index 0000000..334c866 --- /dev/null +++ b/docs/concepts/provider-types.md @@ -0,0 +1,61 @@ +--- +icon: material/puzzle-outline +--- + +# Provider Types + +A provider profile tells Nexus how to authenticate users against a third-party service. The provider type determines the authorization flow and the shape of stored credentials. + +## OAuth2 + +OAuth2 providers use the Authorization Code flow with PKCE. Nexus manages the full token lifecycle — your agents always receive a current access token. + +### OIDC discovery + +Set `enable_discovery: true` and provide an `issuer` URL. Nexus fetches `{issuer}/.well-known/openid-configuration` to populate `authorization_endpoint` and `token_endpoint` automatically. + +Use this for Google, Microsoft Entra ID, Auth0, and any provider with a published discovery document. + +### Manual configuration + +Set `auth_url` and `token_url` explicitly. Use this for GitHub and other OAuth2 providers without a discovery document. + +### Provider profile fields + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Unique name for this provider within your Nexus instance | +| `auth_type` | yes | `oauth2`, `api_key`, or `basic_auth` | +| `client_id` | OAuth2 | OAuth2 application client ID | +| `client_secret` | OAuth2 | OAuth2 application client secret | +| `auth_url` | OAuth2 (manual) | Authorization endpoint | +| `token_url` | OAuth2 (manual) | Token endpoint | +| `issuer` | OAuth2 (discovery) | OIDC issuer URL | +| `enable_discovery` | no | `true` to use OIDC discovery | +| `scopes` | no | Default OAuth2 scopes for this provider | +| `auth_header` | static | Header name for static-key injection | +| `params` | no | Additional provider-specific parameters as JSON | + +### PKCE + +All OAuth2 flows use PKCE (RFC 7636). The Broker generates a random `code_verifier`, sends the SHA-256 `code_challenge` to the provider, and verifies the exchange on callback. You do not configure this — it is always enabled. + +## Static credentials + +Static providers authenticate with credentials that do not expire and cannot be refreshed. + +### api_key + +A single opaque key. Your backend calls `GET /v1/capture-schema` to get the field definition, presents it to the user, and submits via `POST /v1/capture-credential`. The connection goes directly to `active`. Set `auth_strategy` to `header` or `query_param` on the provider profile to control how the key is injected. + +### basic_auth + +Username and password pair. The capture flow is identical to `api_key`. The stored credentials map has `username` and `password` keys. The auth strategy is always `basic_auth`. + +## Scopes + +The `scopes` array on the provider profile is the default for new connections. Individual connections can request a different subset by passing `scopes` to `POST /v1/request-connection`. Static providers ignore scopes entirely. + +## Registration and deletion + +Register providers via `POST /v1/providers`. Each provider has a unique name. Deleting a provider with `DELETE /v1/providers/{id}` does not delete its connections — clean up connections first to avoid orphaned records. diff --git a/docs/concepts/security-model.md b/docs/concepts/security-model.md new file mode 100644 index 0000000..df48d8f --- /dev/null +++ b/docs/concepts/security-model.md @@ -0,0 +1,63 @@ +--- +icon: material/shield-lock-outline +--- + +# Security Model + +Nexus is built on one rule: agents never hold durable secrets. An access token expires within hours. A refresh token, API key, or client secret does not. Nexus ensures agents receive only the former. + +## Secret separation + +| Service | What it holds | +|---|---| +| Broker | Refresh tokens, API keys, client secrets — encrypted at rest | +| Gateway | Nothing. Proxies requests, holds no credential state. | +| Bridge | Short-lived access token in memory, for the duration of one connection. Discarded on close. | + +If a Bridge process is compromised, the attacker gets an access token valid for at most the remaining token lifetime. They cannot get the refresh token. + +## Encryption at rest + +All token material is encrypted with **AES-GCM 256-bit**. The `ENCRYPTION_KEY` must be exactly 32 bytes. A fresh 12-byte nonce from `crypto/rand` is generated per write. + +```bash +openssl rand -base64 32 # ENCRYPTION_KEY +``` + +Losing this key makes all stored tokens permanently unreadable. There is no built-in rotation — rotation requires a migration script that decrypts and re-encrypts every token row. + +## OAuth state signing + +Every OAuth `state` parameter is signed with **HMAC-SHA256** using `STATE_KEY`. The state payload encodes `{workspace_id, provider_id, nonce, iat}` and is rejected if older than 10 minutes or if the signature does not verify. + +```bash +openssl rand -base64 32 # STATE_KEY +``` + +The Broker and Gateway must share the same `STATE_KEY`. A mismatch causes all OAuth callbacks to fail. + +## PKCE + +All OAuth2 flows use PKCE (RFC 7636). The Broker generates a random `code_verifier` per consent request and sends the SHA-256 `code_challenge` to the provider. The verifier is submitted on callback. This is always enabled — you cannot disable it. + +## Network hardening + +API keys are not sufficient on their own. Layer network controls on top: + +- The Broker should only accept connections from the Gateway's IP range. +- The Gateway's admin paths should only accept connections from your application backend's IP range. +- Agents should only reach the Gateway's token and check-connection endpoints. + +CIDR allowlisting is configurable on both the Broker and the Gateway. + +## Audit log + +Every significant event is written to `audit_events`: consents created, tokens issued, refreshes succeeded or failed, connections cleaned up. Each row includes IP address and User-Agent. + +The audit log has no TTL. Implement a scheduled archival job to move rows older than your retention window to cold storage. Without archival, the table grows without bound. + +## What Nexus does not enforce + +Nexus does not control which agents may request tokens for which connections. If your agent has a `connection_id`, it can call `GET /v1/token/{connection_id}`. Access control over which agents may use which connections belongs to your application layer. + +Mutual TLS between internal services is not yet implemented. Rely on network isolation and TLS termination at the load balancer for current deployments. diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..05a39ff --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,65 @@ +--- +icon: material/cog-outline +--- + +# Environment Variables + +This page documents every environment variable accepted by the Broker and Gateway. Variables marked **Required** will cause the service to refuse to start if absent. + +--- + +## Shared + +Both the Broker and the Gateway must receive the same value for `STATE_KEY`. If they differ, all OAuth callbacks will fail. + +| Variable | Required | Description | +|---|---|---| +| `STATE_KEY` | Yes | 32-byte Base64 string used to sign and verify OAuth `state` and `nonce` parameters. Generate with `openssl rand -base64 32`. | + +--- + +## Broker + +| Variable | Required | Description | +|---|---|---| +| `DATABASE_URL` | Yes | PostgreSQL connection string. Example: `postgres://nexus:password@localhost:5432/nexus` | +| `REDIS_URL` | Yes | Redis URL for caching and peer discovery. Example: `redis://localhost:6379` | +| `ENCRYPTION_KEY` | Yes | 32-byte Base64 string for AES-GCM 256-bit token encryption. Generate with `openssl rand -base64 32`. This key must never change while connections exist in the database. | +| `STATE_KEY` | Yes | Same as the shared `STATE_KEY`. Must match the Gateway exactly. | +| `API_KEY` | Yes | Key that the Gateway and admin callers use to authenticate with the Broker. | +| `BASE_URL` | Yes | The public URL of the Broker, used to construct the OAuth callback URL. Example: `https://broker.example.com` | +| `REDIRECT_PATH` | No | The path appended to `BASE_URL` for the OAuth callback. Default: `/auth/callback` | +| `ALLOWED_CIDRS` | No | Comma-separated list of IP ranges allowed to reach the Broker. In production, restrict this to the Gateway's IP. Example: `10.0.0.0/8` | +| `ALLOWED_RETURN_DOMAINS` | No | Comma-separated list of allowed domains for the `return_url` parameter in connection requests. Prevents open redirect abuse. | +| `REQUIRE_API_KEY` | No | When `true`, the Broker rejects requests without a valid `X-API-Key` header. Default: `true` | +| `REQUIRE_ALLOWLIST` | No | When `true`, the Broker enforces `ALLOWED_CIDRS` for all requests. Default: `false` | +| `PORT` | No | Port the Broker listens on. Default: `8080` | + +--- + +## Gateway + +| Variable | Required | Description | +|---|---|---| +| `BROKER_BASE_URL` | Yes | Internal URL of the Broker. Example: `http://nexus-broker:8080` | +| `BROKER_API_KEY` | Yes | API key used to authenticate the Gateway with the Broker. Must match the Broker's `API_KEY`. | +| `STATE_KEY` | Yes | Same as the shared `STATE_KEY`. Must match the Broker exactly. | +| `PORT` | No | Port the Gateway listens on. Default: `8090` | + +--- + +## Key generation + +Both `ENCRYPTION_KEY` and `STATE_KEY` are 32-byte values encoded as Base64. Generate them with: + +```bash +openssl rand -base64 32 +``` + +Run this command twice, once for each key. Do not reuse the same value for both. + +--- + +## Next steps + +For production deployment configuration including Docker, Kubernetes, and Azure Container Apps, see [Deploying Nexus](../infrastructure/deploying-nexus.md). diff --git a/docs/getting-started/first-connection.md b/docs/getting-started/first-connection.md new file mode 100644 index 0000000..fffb029 --- /dev/null +++ b/docs/getting-started/first-connection.md @@ -0,0 +1,132 @@ +--- +icon: material/lan-connect +--- + +# Your First Connection + +This walkthrough takes you from a running Nexus stack with a registered provider to a working credential retrieval. It uses the Google provider registered in the [quickstart](quickstart.md) and covers each step of the OAuth handshake through to the first token fetch. + +By the end you will have a `connection_id` and know exactly how to use it to retrieve credentials from your application or agent. + +--- + +## What your application is responsible for + +Before walking through the flow, establish the division of responsibility. Nexus stores OAuth tokens, manages refresh, and handles the provider handshake. Your application stores exactly one thing: a `connection_id`. + +The `connection_id` is an opaque string that references a user's authorized connection with a specific provider. You persist it in your own database, associated with your user record. When your agent needs credentials, it presents the `connection_id` to the Gateway and receives a short-lived access token. That is the full integration surface. + +Your application never sees a refresh token. It never handles token expiry. It never implements provider-specific auth logic. + +--- + +## Step 1: Initiate the connection + +Your backend calls the Gateway to create a pending connection. Pass the workspace identifier for the user, the provider name, the scopes needed, and a `return_url` on your frontend where Nexus will redirect the user after consent. + +```bash +curl -s -X POST http://localhost:8090/v1/request-connection \ + -H "Content-Type: application/json" \ + -H "X-API-Key: " \ + -d '{ + "workspace_id": "user_abc123", + "provider_name": "google", + "scopes": ["openid", "email", "profile"], + "return_url": "https://app.example.com/oauth/return" + }' | jq . +``` + +The response: + +```json +{ + "auth_url": "https://accounts.google.com/o/oauth2/auth?client_id=...&state=...", + "connection_id": "conn_01HXYZ..." +} +``` + +Store the `connection_id` immediately, associated with `user_abc123`. Then redirect the user's browser to `auth_url`. + +--- + +## Step 2: The user completes consent + +The user lands on Google's consent screen, selects the account they want to connect, and grants the requested permissions. Google redirects the browser to the Broker's callback endpoint (`BASE_URL/auth/callback`). + +The Broker validates the `state` parameter against the signed value it issued in step 1, exchanges the authorization code for access and refresh tokens, encrypts the tokens with `ENCRYPTION_KEY`, and stores them in PostgreSQL. + +The Broker then redirects the user's browser to your `return_url` with query parameters: + +``` +https://app.example.com/oauth/return?status=success&connection_id=conn_01HXYZ... +``` + +Your frontend extracts `connection_id` from the query string and confirms it against what your backend stored in step 1. + +--- + +## Step 3: Verify the connection is active + +Check the connection status to confirm the handshake completed successfully before presenting the connection to the user as ready: + +```bash +curl -s http://localhost:8090/v1/check-connection/conn_01HXYZ... | jq . +``` + +Response when active: + +```json +{ + "status": "active" +} +``` + +| Status | Meaning | +|---|---| +| `pending` | User has not yet completed consent | +| `active` | Tokens stored, connection is usable | +| `attention_required` | Refresh failed — user must reconnect | +| `failed` | Initial token exchange failed | + +--- + +## Step 4: Retrieve credentials + +Once the connection is active, retrieve credentials by calling the Gateway: + +```bash +curl -s http://localhost:8090/v1/token/conn_01HXYZ... \ + -H "X-API-Key: " | jq . +``` + +Response: + +```json +{ + "strategy": { "type": "oauth2" }, + "credentials": { + "access_token": "ya29.A0AfH6...", + "expires_at": 1715000000 + }, + "expires_at": 1715000000 +} +``` + +Inspect `strategy.type` to know how to apply the credentials: + +| Strategy type | How to use | +|---|---| +| `oauth2` | `Authorization: Bearer ` header | +| `api_key` | Header or query param as defined in the provider's `credential_schema` | +| `basic_auth` | `Authorization: Basic ` | +| `aws_sigv4` | AWS Signature Version 4 — the Bridge handles this automatically | + +The Bridge and all three SDKs handle strategy interpretation and credential injection automatically. See [Integrating Agents](../guides/integrating-agents.md) for implementation examples in Go, TypeScript, and Python. + +--- + +## Handling attention_required in production + +If `/v1/token/{connection_id}` returns a non-200 response with `"status": "attention_required"`, the provider rejected the last refresh attempt — typically because the user revoked the application's access. Your application should surface this to the user and prompt them to go through the consent flow again. The new flow creates a fresh connection with a new `connection_id`. + +Do not retry automatically. A provider rejection is not a transient error. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..a052eff --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,109 @@ +--- +icon: material/rocket-launch-outline +--- + +# Deploy in Five Minutes + +This guide gets a Nexus stack running locally. By the end you will have a Broker, a Gateway, PostgreSQL, and Redis running in Docker, with the admin API accessible and ready to accept provider registrations. + +--- + +## Prerequisites + +- Docker and Docker Compose installed +- `openssl` on your PATH + +--- + +## Step 1: Generate secrets + +Nexus requires two symmetric keys before it will start. Generate them now. + +```bash +openssl rand -base64 32 # ENCRYPTION_KEY +openssl rand -base64 32 # STATE_KEY — run separately, do not reuse the same value +``` + +**ENCRYPTION_KEY** encrypts all stored tokens using AES-GCM 256-bit. If this key is lost or rotated while connections exist, every stored token becomes permanently unreadable. Treat it like a master key and back it up accordingly. + +**STATE_KEY** signs OAuth state parameters to prevent CSRF attacks. Both the Broker and the Gateway must receive the same value, or every OAuth callback will fail with a state mismatch. + +--- + +## Step 2: Configure the environment + +```bash +cp .env.example .env +``` + +Open `.env` and set the following fields: + +```bash +ENCRYPTION_KEY= +STATE_KEY= +API_KEY= +BROKER_API_KEY= +``` + +The remaining variables in `.env.example` have sensible defaults for local development. `BASE_URL` defaults to `http://localhost:8080`, which means the OAuth callback URL is `http://localhost:8080/auth/callback`. Register that URI in your provider's developer console. + +--- + +## Step 3: Start the stack + +```bash +make up +``` + +If you do not have `make` installed: + +```bash +docker-compose up -d --build +``` + +This builds and starts four containers: + +| Container | Port | Purpose | +|---|---|---| +| `nexus-broker` | 8080 | Stores tokens, runs OAuth flows, background refresh | +| `nexus-gateway` | 8090 | Public API — your agents and backend call this | +| `nexus-postgres` | 5432 | Encrypted token storage | +| `nexus-redis` | 6379 | Caching and state | + +Wait for migrations to complete (a few seconds), then verify both services are healthy: + +```bash +curl http://localhost:8080/health # {"status": "ok"} +curl http://localhost:8090/health # {"status": "ok"} +``` + +--- + +## Step 4: Register your first provider + +Provider registration goes through the **Gateway** at port 8090. This example registers Google with OIDC discovery: + +```bash +curl -s -X POST http://localhost:8090/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: " \ + -d '{ + "name": "google", + "auth_type": "oauth2", + "client_id": "YOUR_GOOGLE_CLIENT_ID", + "client_secret": "YOUR_GOOGLE_CLIENT_SECRET", + "issuer": "https://accounts.google.com", + "enable_discovery": true, + "scopes": ["openid", "email", "profile", "offline_access"] + }' | jq . +``` + +A successful registration returns the provider object with a UUID. The `name` field (`google`) is the alias you use in all subsequent operations. + +--- + +## What is next + +Your stack is running and you have a provider registered. Continue to [Your First Connection](first-connection.md) to walk through the full OAuth handshake and retrieve your first credential. + +For all configuration options, see [Configuration](configuration.md). For production deployment on Docker, Kubernetes, or Azure Container Apps, see [Deploying Nexus](../infrastructure/deploying-nexus.md). diff --git a/docs/guides/agent-sessions.md b/docs/guides/agent-sessions.md new file mode 100644 index 0000000..8ec3908 --- /dev/null +++ b/docs/guides/agent-sessions.md @@ -0,0 +1,206 @@ +--- +icon: material/account-key-outline +--- + +# Agent Sessions + +This guide covers the complete developer journey for building an agent that uses Nexus for authentication — from registering providers and agents to making scoped requests and handling the full OBO flow. + +## What you are building + +An agent that calls Salesforce with read-only access, calls Google Calendar with read-only access, and executes internal business operations only when a human user with the right permission triggers them. No OAuth code in the agent. No credentials in environment variables. No refresh token logic. Nexus handles all of it. + +## Step 1 — Register your providers (one-time admin) + +```bash +# Salesforce +curl -X POST https://your-gateway.example.com/v1/providers \ + -H "X-API-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "salesforce", + "auth_type": "oauth2", + "client_id": "SF_CLIENT_ID", + "client_secret": "SF_CLIENT_SECRET", + "auth_url": "https://login.salesforce.com/services/oauth2/authorize", + "token_url": "https://login.salesforce.com/services/oauth2/token", + "scopes": ["crm:contacts:read", "crm:contacts:write"], + "params": { "skip_scope_on_exchange": true } + }' + +# Google Calendar +curl -X POST https://your-gateway.example.com/v1/providers \ + -H "X-API-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "google-calendar", + "auth_type": "oauth2", + "client_id": "GOOGLE_CLIENT_ID", + "client_secret": "GOOGLE_CLIENT_SECRET", + "issuer": "https://accounts.google.com", + "enable_discovery": true, + "scopes": ["https://www.googleapis.com/auth/calendar.events.readonly"] + }' +``` + +## Step 2 — Register your agents (one-time admin) + +Each agent is registered with its maximum allowed scope set. An agent can never request more than what is declared here. + +```bash +# CRM agent — read-only access to Salesforce contacts +curl -X POST https://your-gateway.example.com/admin/v1/agents \ + -H "X-API-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "agent_id": "crm-agent", + "description": "Reads customer records from Salesforce", + "allowed_scopes": ["crm:contacts:read"] + }' + +# Calendar agent +curl -X POST https://your-gateway.example.com/admin/v1/agents \ + -H "X-API-Key: your-api-key" \ + -d '{ + "agent_id": "calendar-agent", + "description": "Reads calendar events", + "allowed_scopes": ["https://www.googleapis.com/auth/calendar.events.readonly"] + }' + +# Ops agent — custom scopes for internal operations +curl -X POST https://your-gateway.example.com/admin/v1/agents \ + -H "X-API-Key: your-api-key" \ + -d '{ + "agent_id": "ops-agent", + "description": "Executes authorized internal financial operations", + "allowed_scopes": ["acme:gliding", "acme:flaring"] + }' +``` + +## Step 3 — Connect users to providers + +Each user who will authorize an agent must complete the OAuth flow for their account. Your backend initiates this: + +```bash +curl -X POST https://your-gateway.example.com/v1/request-connection \ + -H "X-API-Key: your-api-key" \ + -d '{ + "workspace_id": "user_sarah", + "provider_id": "SALESFORCE_PROVIDER_UUID", + "scopes": ["crm:contacts:read", "crm:contacts:write"], + "return_url": "https://your-app.com/connections/callback" + }' +``` + +Redirect the user to the returned `auth_url`. After they authorize, poll `check-connection` until `status` is `active`. Store the `connection_id` against the user. + +## Step 4 — Agent requests a scoped session + +When the agent needs to call Salesforce for a user, it requests a session. In Go: + +```go +session, err := nexusClient.RequestAgentSession(ctx, oauthsdk.AgentSessionInput{ + AgentID: "crm-agent", + ProviderName: "salesforce", + Scopes: []string{"crm:contacts:read"}, + TTL: 15 * time.Minute, +}) +if err != nil { + return err +} +defer nexusClient.CloseAgentSession(ctx, session.SessionID) + +resp, err := http.Get("https://api.salesforce.com/v1/contacts?q=" + filter, + // session.AccessToken is scoped to crm:contacts:read only + withBearer(session.AccessToken), +) +``` + +In Python: + +```python +session = nexus.request_agent_session( + agent_id="crm-agent", + provider="salesforce", + scopes=["crm:contacts:read"], +) +try: + resp = httpx.get( + "https://api.salesforce.com/v1/contacts", + params={"q": filter}, + headers={"Authorization": f"Bearer {session.access_token}"}, + ) + return resp.json()["records"] +finally: + nexus.close_agent_session(session.session_id) +``` + +In TypeScript: + +```typescript +const session = await nexus.requestAgentSession({ + agentId: 'crm-agent', + provider: 'salesforce', + scopes: ['crm:contacts:read'], + ttl: 900, +}) +try { + const resp = await fetch('https://api.salesforce.com/v1/contacts', { + headers: { Authorization: `Bearer ${session.accessToken}` }, + }) + return resp.json() +} finally { + await nexus.closeAgentSession(session.sessionId) +} +``` + +## Step 5 — OBO session for user-triggered operations + +When a human user triggers an operation that requires their specific authorization, use an OBO session. The Broker validates the user's permissions before issuing the session. + +```python +def run_gliding(user_token: str, customer_ids: list) -> dict: + # Raises NexusAuthError if the user does not have acme:gliding permission + obo = nexus.request_obo_session( + agent_id="ops-agent", + provider="internal-ops", + scopes=["acme:gliding"], + user_context_token=user_token, + ) + try: + return internal_ops.glide( + customer_ids=customer_ids, + tenant_id=obo.tenant_id, # enforced downstream + clearance_level=obo.clearance_level, + ) + finally: + nexus.close_agent_session(obo.session_id) +``` + +The Broker validates both gates before issuing the OBO session: +- The user must have `acme:gliding` in their permissions (verified via your `BACKEND_AUTH_URL`) +- The `ops-agent` must have `acme:gliding` in its `allowed_scopes` + +If either check fails, the request returns `403`. + +## What the agent never wrote + +| Responsibility | Who handles it | +|---|---| +| OAuth implementation | Broker | +| Refresh token logic | Broker | +| Token storage | Broker | +| Scope enforcement | Broker — at session request time | +| JWT validation for OBO | Broker — calls your backend, extracts claims | +| User context stamping | Broker — stamps `acting_for`, `tenant_id`, `clearance_level` | +| Credential rotation | Broker | +| Token expiry | Broker — session has explicit `expires_at` | + +## Session vs connection — when to use which + +| Use connection tokens (`GetToken`) | Use agent sessions (`RequestAgentSession`) | +|---|---| +| Agent has no registered identity | Agent is registered with `allowed_scopes` | +| You want maximum flexibility | You want enforced least-privilege | +| Bridge-based persistent connections | Discrete per-operation credential grants | +| Existing integrations | New agent builds | diff --git a/docs/guides/attention-state.md b/docs/guides/attention-state.md new file mode 100644 index 0000000..4cb6eef --- /dev/null +++ b/docs/guides/attention-state.md @@ -0,0 +1,117 @@ +--- +icon: material/alert-circle-outline +--- + +# Handling Attention State + +A connection enters `attention` state when the Broker attempts to refresh the access token and the provider responds with a `4xx` error. This indicates the user's authorization grant has been revoked, the refresh token has expired, or the provider requires the user to re-authorize. + +This guide covers how to detect `attention` state, surface it to the user, and restore the connection. + +## How attention state is triggered + +During a token refresh (`POST /connections/{id}/refresh`), the Broker calls the provider's token endpoint. If the provider returns a `4xx` response, the Broker: + +1. Updates the connection status to `attention`. +2. Returns an `attention_required` error to the caller (the Bridge or your backend). +3. Writes an `attention_required` event to the audit log. + +The stored access token may still be valid for the remainder of its lifetime. Once it expires, the connection becomes unusable. + +## Detecting attention state + +### Via the Bridge + +The Bridge returns `ErrInteractionRequired` when it detects `attention_required` during a refresh. The retry loop stops immediately — the Bridge does not retry because retrying cannot help. + +```go +err := bridge.MaintainWebSocket(ctx, connectionID, endpointURL, handler) +if errors.Is(err, nexusbridge.ErrInteractionRequired) { + // Connection is in attention state. + // Notify your application to re-authorize this connection. + notifyReconsentRequired(connectionID) +} +``` + +### Via the SDK + +If you are managing the refresh cycle manually, check the error code: + +```go +_, err := client.RefreshConnection(ctx, connectionID) +if err != nil { + var e oauthsdk.ErrorEnvelope + if errors.As(err, &e) && e.Code == "attention_required" { + notifyReconsentRequired(connectionID) + return + } + // Other errors are transient — retry according to your policy. +} +``` + +### Via polling + +If your application polls connection status, `check-connection` returns `attention` for connections in this state: + +```bash +curl -s "https://your-gateway.example.com/v1/check-connection/CONNECTION_ID" \ + -H "X-API-Key: your-gateway-api-key" +# {"status": "attention"} +``` + +## Notifying the user + +`attention` state is a user-facing event. The user who authorized the connection must re-authorize it. Your application is responsible for surfacing this. + +Recommended pattern: + +1. Store the `connection_id` alongside the `workspace_id` in your application database. +2. When `attention_required` is detected, mark the connection as needing re-authorization in your database. +3. On the user's next session, display a re-authorization prompt identifying which provider needs attention. +4. Initiate a new consent flow for that workspace and provider. + +Do not silently swallow `attention_required` errors or retry indefinitely. Users will lose access to the agent's capabilities without knowing why. + +## Re-authorizing the connection + +Initiate a new consent flow for the same `workspace_id` and `provider_id`: + +```bash +curl -s -X POST https://your-gateway.example.com/v1/request-connection \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-gateway-api-key" \ + -d '{ + "workspace_id": "user_abc", + "provider_id": "SAME_PROVIDER_UUID", + "scopes": ["openid", "email", "offline_access"], + "return_url": "https://your-app.com/connections/callback" + }' | jq . +``` + +This creates a new connection with a new `connection_id`. The old connection in `attention` state remains in the database until you delete it or the cleanup job removes it. + +Update your application's connection registry to use the new `connection_id` for this workspace and provider. Restart any Bridge instances using the old connection ID with the new one. + +## Cleaning up the old connection + +The old connection in `attention` state does not delete itself. Delete it explicitly after the new connection is active: + +```bash +# Verify the new connection is active first +curl -s "https://your-gateway.example.com/v1/check-connection/NEW_CONNECTION_ID" \ + -H "X-API-Key: your-gateway-api-key" +# {"status": "active"} + +# Then delete the old one +curl -s -X DELETE "https://your-gateway.example.com/v1/connections/OLD_CONNECTION_ID" \ + -H "X-API-Key: your-gateway-api-key" +``` + +## Common causes of attention state + +| Cause | How to confirm | Resolution | +|---|---|---| +| User revoked access in the provider's settings | Audit log shows `token_refresh_fatal` with `400` or `401` status | User must re-authorize | +| Refresh token expired (some providers set a max lifetime) | Same as above | User must re-authorize | +| Provider OAuth app credentials rotated | All connections for this provider enter `attention` | Update `client_secret` on the provider profile with `PATCH /v1/providers/{id}`, then users re-authorize | +| Provider API outage | Audit log shows `token_refresh_fatal` with `5xx` status | This should not set `attention` — only `4xx` triggers it. File a bug if you see `5xx` causing attention state. | diff --git a/docs/guides/integrating-agents.md b/docs/guides/integrating-agents.md index 3bbe660..f997bf6 100644 --- a/docs/guides/integrating-agents.md +++ b/docs/guides/integrating-agents.md @@ -1,155 +1,148 @@ -# Agent Integrations Guide +--- +icon: material/robot-love-outline +--- + +# Integrating Agents -This guide explains how agents and services integrate with the OAuth framework. +This guide covers the two ways an agent retrieves credentials from Nexus at runtime: the Bridge library for Go agents, and the manual HTTP flow for agents written in other languages or for cases where you want direct control over credential retrieval. -## Recommended Integration: The Bridge Client (for Go) +--- -For Go-based agents and services, the **`bridge` client library** is the recommended integration path. It is a universal connector that handles the entire connection lifecycle, including authentication, polling, refreshing, and reconnection for both **WebSocket** and **gRPC** transports. +## The Bridge library (Go) -Using the Bridge abstracts away the manual HTTP calls detailed below and provides production-ready observability out of the box. +The Bridge is the recommended integration path for Go agents. It handles everything after the OAuth handshake: authenticating requests, maintaining persistent connections through token rotations, and retrying on transient failures. -### Example: Persistent WebSocket Connection +Import the library and instantiate it with a Gateway client: ```go import ( - "context" - "net/http" + "context" + "net/http" - "nexus.io/nexus-bridge" - "nexus.io/nexus-bridge/telemetry" - "github.com/Prescott-Data/nexus-framework/nexus-sdk" + bridge "nexus.io/nexus-bridge" + "nexus.io/nexus-bridge/telemetry" + oauthsdk "github.com/Prescott-Data/nexus-framework/nexus-sdk" ) func main() { - // 1. Create a client for the Nexus Gateway - authClient := oauthsdk.New("http://nexus-gateway.example.com") - - // 2. Instantiate the Bridge with standard logging and metrics - // agentLabels are applied as const_labels to all Prometheus metrics - agentLabels := map[string]string{"agent_id": "my-stable-id"} - b := bridge.NewStandard(authClient, agentLabels) - - // 3. Expose the /metrics endpoint - http.Handle("/metrics", telemetry.Handler()) - go http.ListenAndServe(":9090", nil) - - // 4. Run the connection loop - // The Bridge will fetch the correct credentials (OAuth2, Basic, API Key, etc.) - // and keep the connection alive indefinitely. - connectionID := "your-persistent-connection-id" - endpointURL := "wss://external.service.com/stream" - - b.MaintainWebSocket(context.Background(), connectionID, endpointURL, &myAppHandler{}) + authClient := oauthsdk.New("http://nexus-gateway.example.com") + + agentLabels := map[string]string{"agent_id": "my-agent"} + b := bridge.NewStandard(authClient, agentLabels) + + http.Handle("/metrics", telemetry.Handler()) + go http.ListenAndServe(":9090", nil) + + connectionID := "conn_01HXYZ..." + endpointURL := "wss://external.service.com/stream" + + b.MaintainWebSocket(context.Background(), connectionID, endpointURL, &myHandler{}) } ``` -See the [`bridge/README.md`](../../bridge/README.md) for full documentation. ---- +`MaintainWebSocket` runs a loop. When the current access token approaches expiry, the Bridge fetches a new one from the Gateway and seamlessly re-authenticates the connection without interrupting your handler. Exponential backoff handles transient network failures. + +The `agentLabels` map is applied as constant labels to all Prometheus metrics the Bridge emits. This allows you to filter Bridge metrics by agent in your observability stack. + +### gRPC connections + +For gRPC, use `MaintainGRPC` instead of `MaintainWebSocket`. The API is the same: you provide a `connection_id`, a target endpoint, and a handler. The Bridge injects the strategy-appropriate credentials as gRPC metadata headers on the initial connection and on each re-authentication. -## Manual HTTP Integration Flow +--- -This flow is for non-Go clients or for understanding the low-level mechanics of the Gateway API. Go clients should prefer the `bridge` library. +## Manual HTTP integration -### Concepts -- `connection_id`: Opaque handle representing a user-approved connection. Agents store this; do not store tokens. -- Gateway: Front door API for agent access. -- Broker: Handles provider OAuth flows, token storage, and refresh; keep private. +Use this approach if your agent is not written in Go, or if you want explicit control over when credentials are fetched rather than having the Bridge manage them. -### Typical Flow -1) **Initiate connection (for user-interactive OAuth2):** - - `POST /v1/request-connection` - - Body: `{"user_id":"...", "provider_name":"Google", "scopes":[...], "return_url":"..."}` - - Response: `{"authUrl":"...", "connection_id":"..."}` - - Redirect the user to `authUrl` to consent. +### Fetching credentials -2) **User completes consent:** - - The provider redirects to the Broker, which stores tokens and redirects the user to your `return_url` with `status=success&connection_id=...`. +Call `GET /v1/token/{connection_id}` on the Gateway: -3) **Poll connection status (optional):** - - `GET /v1/check-connection/{connection_id}` → `{ "status": "active|pending|failed" }` +```bash +curl -s http://nexus-gateway.example.com/v1/token/conn_01HXYZ... +``` -4) **Use the connection:** - - `GET /v1/token/{connection_id}` - - The response is a **generic credential payload**, not just a simple token. You must inspect the `strategy` field to determine how to authenticate. - ```json - { - "strategy": { "type": "oauth2" }, - "credentials": { "access_token": "...", "expires_at": 123456 }, - "expires_at": 123456 - } - // OR - { - "strategy": { "type": "basic_auth" }, - "credentials": { "username": "...", "password": "..." } - } - ``` +The response includes a `strategy` field and a `credentials` object. The strategy tells you how to use the credentials: -5) **Refresh (until gateway proxy is added):** - - `POST Broker /connections/{connection_id}/refresh` with header `X-API-Key: `. +```json +{ + "strategy": { "type": "oauth2" }, + "credentials": { + "access_token": "ya29.A0AfH...", + "expires_at": 1715000000 + }, + "expires_at": 1715000000 +} +``` -### Frontend Integration (browser flow) -The browser initiates consent; server(s) hold secrets and call the gateway. +For `oauth2`, set `Authorization: Bearer ` on your outgoing request. -1) **Agent/server asks gateway to create a connection:** - - Your backend calls `POST /v1/request-connection` with a `return_url` hosted by your frontend (e.g., `https://app.example.com/oauth/return`). +For `basic_auth`, the credentials object contains `username` and `password`. Encode them as Base64 and set `Authorization: Basic `. -2) **Redirect the user:** - - From your frontend, redirect the browser to the `authUrl` in the response. +For `api_key`, the credentials object contains the key fields defined by the provider's schema. The schema tells you the field name and where to inject it (header, query parameter, or request body). -3) **Provider → Broker → Frontend:** - - After consent, the Broker redirects the user back to your `return_url` with `status=success&connection_id=...`. +### When to re-fetch -4) **Frontend → Backend to consume connection:** - - Your frontend extracts `connection_id` and sends it to your backend. Your backend stores only the `connection_id`. +The `expires_at` field is a Unix timestamp. Fetch a new token before this time. A safe strategy is to re-fetch when the remaining lifetime drops below five minutes. Do not cache a token beyond its expiry. The Broker runs the background refresh loop, so a fresh fetch is always cheap and always returns a valid token. -5) **Backend fetches credentials on-demand:** - - Your backend calls Gateway `GET /v1/token/{connection_id}` to retrieve the generic credential payload. +### Using the Go SDK directly -### Using the Go SDK (server-side) -The `nexus-sdk` is a thin client for the Gateway API. +If your agent is Go but you want explicit fetches rather than automatic management, use the SDK: ```go import ( - "context" - oauthsdk "github.com/Prescott-Data/nexus-framework/nexus-sdk" + "context" + oauthsdk "github.com/Prescott-Data/nexus-framework/nexus-sdk" ) -client := oauthsdk.New("https://") +client := oauthsdk.New("https://nexus-gateway.example.com") -// Fetch the credential payload: -payload, _ := client.GetToken(context.Background(), "your-connection-id") +payload, err := client.GetToken(context.Background(), "conn_01HXYZ...") +if err != nil { + return err +} -// Inspect the strategy to decide how to authenticate strategyType := payload.Strategy["type"] ``` -See the [Go SDK Reference](../sdks/go.md) for full documentation. -### Using the TypeScript SDK (server-side) +Inspect `strategyType` and use the `payload.Credentials` map to extract the values you need. See [Client Libraries](../concepts/client-libraries.md) for the full SDK method reference. + +### Using the TypeScript SDK ```typescript -import { NexusClient } from '@dromos/nexus-sdk'; +import { NexusClient } from '@prescott/nexus-sdk'; -const client = new NexusClient({ gatewayUrl: 'https://' }); +const client = new NexusClient({ gatewayUrl: 'https://nexus-gateway.example.com' }); -const token = await client.getTokenByConnectionId('your-connection-id'); -console.log(token.accessToken); +const token = await client.getTokenByConnectionId('conn_01HXYZ...'); +// Apply based on strategy type +if (token.strategy.type === 'oauth2') { + headers['Authorization'] = `Bearer ${token.credentials.access_token}`; +} ``` -See the [TypeScript SDK Reference](../sdks/typescript.md) for full documentation. -### Using the Python SDK (server-side) +### Using the Python SDK ```python from nexus_sdk import NexusClient, NexusClientOptions -client = NexusClient(NexusClientOptions(gateway_url='https://')) +client = NexusClient(NexusClientOptions(gateway_url='https://nexus-gateway.example.com')) -token = client.get_token_by_connection_id('your-connection-id') -print(token.access_token) +token = client.get_token_by_connection_id('conn_01HXYZ...') +if token.strategy['type'] == 'oauth2': + headers['Authorization'] = f"Bearer {token.credentials['access_token']}" ``` -See the [Python SDK Reference](../sdks/python.md) for full documentation. + +--- + +## Connection IDs in agent deployments + +Your application stores connection IDs and passes them to agents at task dispatch time. A well-designed agent integration keeps connection ID management out of agent code. The agent receives the connection ID as task input, uses it to fetch credentials, and does not store it beyond the current task. + +If your agent system uses a task queue or orchestrator, inject the relevant connection IDs into the task payload when the task is enqueued. The agent retrieves them from the payload, fetches credentials at the start of execution, and proceeds. --- ## MCP Server Integration -For building MCP servers that automatically resolve and inject tokens, see the dedicated [MCP Server Integration Guide](mcp-integration.md). \ No newline at end of file +For building MCP servers that automatically resolve and inject tokens, see the dedicated [MCP Server Integration Guide](mcp-integration.md). diff --git a/docs/guides/managing-providers.md b/docs/guides/managing-providers.md index 6a96220..610f6eb 100644 --- a/docs/guides/managing-providers.md +++ b/docs/guides/managing-providers.md @@ -1,421 +1,146 @@ -# Provider Registration and Management Guide - -This guide provides a comprehensive overview of how to register, manage, and test identity providers within the Nexus OAuth Broker. - -!!! tip "Prefer a GitOps workflow?" - For production deployments, consider using **[`nexus-cli`](security-as-code.md)** — a declarative reconciler that manages providers via a YAML manifest committed to your repository. It gives you version history, code review, and an automatic audit trail for every change. - - -## Provider Types - -The broker supports two primary types of providers: - -1. **OAuth 2.0 / OIDC Providers**: Standard identity providers like Google, Microsoft Entra ID, or Okta that use the OAuth 2.0 authorization code flow. These are typically configured using an OIDC discovery issuer URL. -2. **Non-OAuth (API Key) Providers**: Services that use static credentials, such as API keys, tokens, or username/password combinations. These are configured using a flexible JSON schema. - +--- +icon: material/puzzle-edit-outline --- -## 1. OAuth 2.0 / OIDC Providers - -### Registration +# Managing Providers -The preferred method for registering OIDC-compliant providers is to use the `issuer` URL, which enables auto-discovery of the necessary endpoints. +A provider in Nexus represents the configuration for a third-party service your agents connect to. This guide covers how to register, update, and delete providers through the Gateway's REST API, and how to list the providers available in your workspace. -#### **Payload Fields:** +For declarative provider management using `nexus-cli`, see the [Security-as-Code](security-as-code.md) guide. For production environments where provider configuration is sensitive infrastructure, the declarative approach is preferred. -* `name` (string, required): A unique name for the provider (e.g., "google"). -* `issuer` (string, optional): The OIDC issuer URL for auto-discovery. -* `client_id` (string, required): The OAuth client ID from the provider. -* `client_secret` (string, required): The OAuth client secret from the provider. -* `scopes` (string array, required): A list of default scopes to request. -* `auth_url` (string, optional): Override for the authorization endpoint. -* `token_url` (string, optional): Override for the token endpoint. -* `auth_header` (string, optional): Authentication method for token exchange. Values: `"client_secret_post"` (default, credentials in body) or `"client_secret_basic"` (credentials in Basic Auth header). Required for Twitter/GitHub. -* `api_base_url` (string, optional): The root URL for the provider's API (e.g., "https://api.github.com"). Exposed to frontend for integration logic. -* `user_info_endpoint` (string, optional): Path to fetch user profile (e.g., "/user"). Exposed to frontend. -* `params` (json, optional): A JSON object for provider-specific parameters (e.g., `{"access_type": "offline"}`). +--- -#### **Example: Registering Google** +## Registering an OAuth 2.0 provider -```bash -curl -X POST http://localhost:8080/providers \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d '{ - "profile": { - "name": "google", - "issuer": "https://accounts.google.com", - "client_id": "YOUR_GOOGLE_CLIENT_ID", - "client_secret": "YOUR_GOOGLE_CLIENT_SECRET", - "scopes": ["openid", "email", "profile"], - "api_base_url": "https://www.googleapis.com", - "user_info_endpoint": "/oauth2/v3/userinfo", - "params": { - "access_type": "offline", - "prompt": "consent" - } - } - }' | jq . -``` +POST to `/v1/providers` on the Gateway with the provider configuration. The `X-API-Key` header must carry your `API_KEY`. -#### **Example: Registering Twitter (Basic Auth)** +### Discovery-based provider -Twitter requires `client_secret_basic` and manual endpoint configuration. +For providers that support OIDC discovery, set `enable_discovery: true` and supply the `issuer` URL. Nexus fetches the authorization endpoint, token endpoint, and JWKS URI from the discovery document automatically. ```bash -curl -X POST http://localhost:8080/providers \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d '{ - "profile": { - "name": "twitter", - "auth_type": "oauth2", - "auth_url": "https://twitter.com/i/oauth2/authorize", - "token_url": "https://api.twitter.com/2/oauth2/token", - "client_id": "YOUR_TWITTER_CLIENT_ID", - "client_secret": "YOUR_TWITTER_CLIENT_SECRET", - "scopes": ["tweet.read", "users.read"], - "auth_header": "client_secret_basic", - "api_base_url": "https://api.twitter.com/2", - "user_info_endpoint": "/users/me" - } - }' | jq . +curl -s -X POST http://localhost:8090/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "google-workspace", + "auth_type": "oauth2", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "issuer": "https://accounts.google.com", + "enable_discovery": true, + "scopes": ["openid", "email", "profile", "offline_access"] + }' ``` -### Testing the OAuth 2.0 Flow - -You can simulate the entire flow using `curl`. - -#### **Step 1: Get the Consent URL** +### Manual configuration -Request a consent specification from the broker. This is what a client application would do to start the login process. +For providers without OIDC discovery, supply the `auth_url` and `token_url` explicitly: ```bash -# Replace with the ID returned from the registration step -PROVIDER_ID="" - -curl -s -X POST http://localhost:8080/auth/consent-spec \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d '{ - "workspace_id": "ws-test-123", - "provider_id": "'$PROVIDER_ID'", - "scopes": ["openid", "email"], - "return_url": "http://localhost:3000/my-app-callback" - }' | jq . -``` - -This will return a JSON payload containing an `authUrl`. - -#### **Step 2: Complete Consent in a Browser** - -Copy the `authUrl` from the response and paste it into your web browser. You will be directed to the provider's login and consent screen. After you approve, the provider will redirect you back to the `return_url` you specified, which will have a `connection_id` and `status` in the query string. - -> **Note:** The `return_url` does not need to be a real, running application for this test. After the redirect, you can simply copy the `connection_id` from the browser's address bar. The final URL will also contain `status` and `provider` as query parameters. - -#### **Step 3: Retrieve the Token** - -Once you have the `connection_id`, you can use it to retrieve the token from the broker. - -```bash -# Replace with the ID from the redirect URL -CONNECTION_ID="" - -curl -s -H "X-API-Key: dev-api-key-12345" \ - "http://localhost:8080/connections/''$CONNECTION_ID''/token" | jq . +curl -s -X POST http://localhost:8090/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "github", + "auth_type": "oauth2", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "api_base_url": "https://api.github.com", + "enable_discovery": false, + "scopes": ["read:user", "user:email"] + }' ``` -This will return the access token, refresh token, and expiry information. - ---- - -## 2. Non-OAuth (API Key) Providers - -### Registration - -Non-OAuth providers are configured by defining a **JSON schema** that describes the credentials the broker needs to collect, AND an **Authentication Strategy** that tells the Bridge client how to use those credentials. - -#### **Payload Fields:** - -* `name` (string, required): A unique name for the provider. -* `auth_type` (string, required): The authentication strategy type. - * `"header"`: Inject a value into an HTTP header (e.g., API Keys). - * `"query_param"`: Inject a value into the query string. - * `"basic_auth"`: Standard HTTP Basic Auth (username/password). - * `"aws_sigv4"`: AWS Signature Version 4. - * `"hmac_payload"`: HMAC signature of the request body. - * `"api_key"`: (Legacy) Alias for `header` type. -* `params` (json, required): A JSON object containing configuration for both the frontend (schema) and the client (strategy). - * `credential_schema` (json, required): A valid JSON schema defining the fields to be collected from the user (e.g., "Enter your API Key"). - * **Strategy Config**: Any other fields in `params` are treated as configuration for the chosen `auth_type` (e.g., `header_name`, `region`). - -### Authentication Strategies & Configuration - -The following table shows the required configuration fields in `params` for each strategy. - -#### **1. Header Authentication (`header`)** -Injects a value into a specific HTTP header. - -**Params Config:** -* `header_name` (string): The header key (e.g., `X-API-Key`, `Authorization`). Default: `Authorization`. -* `credential_field` (string): The key from the collected credentials to use. Default: `api_key`. -* `value_prefix` (string): Optional prefix (e.g., `Bearer `). - -#### **2. Query Parameter Authentication (`query_param`)** -Injects a value into the query string. - -**Params Config:** -* `param_name` (string, required): The query param key (e.g., `api_key` for `?api_key=...`). -* `credential_field` (string): The key from the collected credentials. Default: `api_key`. - -#### **3. Basic Authentication (`basic_auth`)** -Uses standard HTTP Basic Auth (base64 encoded user:pass). - -**Params Config:** -* `username_field` (string): Key for the username in credentials. Default: `username`. -* `password_field` (string): Key for the password in credentials. Default: `password`. - -#### **4. AWS Signature V4 (`aws_sigv4`)** -Signs requests using AWS standard signing. - -**Params Config:** -* `service` (string, required): AWS Service (e.g., `s3`, `execute-api`). -* `region` (string): AWS Region. Default: `us-east-1`. - -*Note: The credentials map must contain `access_key` and `secret_key`.* - --- -### **Example: Registering a Custom API Provider (Header Auth)** +## Registering a static key provider -This provider requires an `api_key` which must be sent in the `X-Freedcamp-Key` header. +For providers that use API keys rather than OAuth, set `auth_type` to `api_key` and define a `credential_schema` that describes the shape of the credential: ```bash -# Define the schema and params as a shell variable -PARAMS='{ - "base_url": "https://freedcamp.com/api/v1/", - "header_name": "X-Freedcamp-Key", - "credential_field": "user_key", - "credential_schema": { - "type": "object", - "properties": { - "user_key": { - "type": "string", - "title": "API Key" - } - }, - "required": ["user_key"] - } -}' - -# Use jq to construct the final JSON payload -jq -n --argjson params "$PARAMS" '{ - "profile": { - "name": "freedcamp", - "auth_type": "header", - "params": $params - } -}' | curl -X POST http://localhost:8080/providers \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d @- | jq . -``` - -### **Example: Registering an AWS Service** - -This provider collects AWS credentials and signs requests for API Gateway. - -```bash -PARAMS='{ - "service": "execute-api", - "region": "us-west-2", - "credential_schema": { - "type": "object", - "properties": { - "access_key": { "type": "string", "title": "AWS Access Key ID" }, - "secret_key": { "type": "string", "title": "AWS Secret Access Key" } - }, - "required": ["access_key", "secret_key"] - } -}' - -jq -n --argjson params "$PARAMS" '{ - "profile": { - "name": "my-aws-service", - "auth_type": "aws_sigv4", - "params": $params +curl -s -X POST http://localhost:8090/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "stripe", + "auth_type": "api_key", + "api_base_url": "https://api.stripe.com", + "credential_schema": { + "fields": [ + { "name": "secret_key", "label": "Secret Key", "sensitive": true } + ] } -}' | curl -X POST http://localhost:8080/providers \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d @- | jq . + }' ``` -### Testing the Non-OAuth Flow - -The flow for non-OAuth providers is entirely API-driven and does not require a browser. - -#### **Step 1: Get the Schema Capture URL** +When a connection is established for this provider, the user supplies values for each field in the schema. Nexus encrypts and stores them. -Request a consent spec, just like in the OAuth flow. - -```bash -# Replace with the ID returned from registration -PROVIDER_ID="" - -# The `authUrl` will be captured into a shell variable -AUTH_URL=$(curl -s -X POST http://localhost:8080/auth/consent-spec \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d '{ - "workspace_id": "ws-test-123", - "provider_id": "'$PROVIDER_ID'", - "return_url": "http://localhost:3000/my-app-callback" - }' | jq -r .authUrl) - -echo "Schema URL: $AUTH_URL" -``` - -The `authUrl` returned will point to the broker's `/auth/capture-schema` endpoint. - -#### **Step 2: Fetch the JSON Schema** - -A client application would call this URL to get the schema needed to render a form. +--- -```bash -# The state parameter is extracted for the next step -STATE=$(echo "$AUTH_URL" | grep -o 'state=[^&]*' | cut -d= -f2) +## Provider fields reference + +| Field | Type | Description | +|---|---|---| +| `name` | string | Unique alias for the provider. Used in all subsequent operations. | +| `auth_type` | string | `oauth2` or `api_key` | +| `client_id` | string | OAuth 2.0 client ID | +| `client_secret` | string | OAuth 2.0 client secret | +| `issuer` | string | OIDC issuer URL (required when `enable_discovery: true`) | +| `auth_url` | string | Authorization endpoint (required when `enable_discovery: false`) | +| `token_url` | string | Token endpoint (required when `enable_discovery: false`) | +| `api_base_url` | string | Provider API root URL | +| `enable_discovery` | boolean | Fetch endpoints from OIDC discovery document | +| `scopes` | array | Default scopes to request during the OAuth handshake | +| `params` | object | Provider-specific extra parameters passed to the authorization request | +| `credential_schema` | object | Field definitions for `api_key` providers | -curl -s -L "$AUTH_URL" | jq . -``` - -This returns the provider's name and the `credential_schema` you registered. +--- -#### **Step 3: Submit the Credentials** +## Listing providers -Submit the user's credentials along with the `state` from the previous step. +To see all registered providers in a workspace: ```bash -curl -s -i -X POST http://localhost:8080/auth/capture-credential \ - -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d '{ - "state": "'$STATE'", - "credentials": { - "user_key": "my-user-supplied-api-key" - } - }' +curl -s http://localhost:8090/v1/providers \ + -H "X-API-Key: your-api-key" | jq . ``` -This will return a `302 Found` redirect. The `Location` header will contain the `connection_id`. - -#### **Step 4: Retrieve the Token** - -Extract the `connection_id` from the `Location` header of the previous response and use it to fetch the stored credentials. +To retrieve grouped metadata: ```bash -# Replace with the ID from the redirect -CONNECTION_ID="" - -curl -s -H "X-API-Key: dev-api-key-12345" \ - "http://localhost:8080/connections/''$CONNECTION_ID''/token" | jq . +curl -s http://localhost:8090/v1/providers/metadata \ + -H "X-API-Key: your-api-key" | jq . ``` -The response will now include the strategy: - -```json -{ - "strategy": { - "type": "header", - "config": { - "header_name": "X-Freedcamp-Key", - "credential_field": "user_key" - } - }, - "credentials": { - "user_key": "my-user-supplied-api-key" - } -} -``` +The metadata endpoint returns providers grouped by `auth_type`, with only the fields needed to render a connection UI: `api_base_url`, `user_info_endpoint`, and `scopes`. --- -## 3. General Provider Management - -The following API endpoints can be used to manage any provider, regardless of type. All management endpoints require an API key. - -#### **List All Active Providers** - -```bash -curl -s http://localhost:8080/providers \ - -H "X-API-Key: dev-api-key-12345" | jq . -``` - -#### **Describe a Specific Provider** - -Get the full configuration for a single provider (client secret is omitted). - -```bash -curl -s http://localhost:8080/providers/ \ - -H "X-API-Key: dev-api-key-12345" | jq . -``` - -#### **Update a Provider** +## Updating a provider -Update a provider's configuration. The request body should contain only the fields you want to change. +Updating a provider's `client_secret` or `scopes` is a PATCH operation. Only the fields you include in the request body are changed. ```bash -curl -s -X PUT http://localhost:8080/providers/ \ +curl -s -X PATCH http://localhost:8090/v1/providers/google-workspace \ -H "Content-Type: application/json" \ - -H "X-API-Key: dev-api-key-12345" \ - -d '{"name": "New Provider Name"}' + -H "X-API-Key: your-api-key" \ + -d '{"client_secret": "NEW_SECRET"}' ``` -#### **Delete a Provider** - -This performs a "soft delete," marking the provider as inactive but preserving it for existing connections. - -```bash -curl -s -X DELETE http://localhost:8080/providers/ \ - -H "X-API-Key: dev-api-key-12345" -``` +Every update is recorded in the [audit log](../reference/audit-log.md). --- -## 4. Provider Metadata (Frontend Integration) - -To assist frontend applications in rendering dynamic integration lists or performing client-side checks, the broker exposes a metadata endpoint. - -#### **Get Grouped Metadata** +## Deleting a provider -Returns a map of all providers, grouped by `auth_type` ("oauth2", "api_key"), containing only the fields necessary for frontend logic (`api_base_url`, `user_info_endpoint`, `scopes`). +Deleting a provider removes its configuration from the Broker. Existing connections that reference the provider will fail credential retrieval after deletion because the client credentials are gone. ```bash -curl -s http://localhost:8080/providers/metadata \ - -H "X-API-Key: dev-api-key-12345" | jq . +curl -s -X DELETE http://localhost:8090/v1/providers/google-workspace \ + -H "X-API-Key: your-api-key" ``` -**Response Example:** -```json -{ - "oauth2": { - "google": { - "api_base_url": "https://www.googleapis.com", - "user_info_endpoint": "/oauth2/v3/userinfo", - "scopes": ["openid", "email", "profile"] - }, - "twitter": { - "api_base_url": "https://api.twitter.com/2", - "user_info_endpoint": "/users/me", - "scopes": ["tweet.read", "users.read"] - } - }, - "api_key": { - "freedcamp": { - "api_base_url": "https://freedcamp.com/api/v1/", - "user_info_endpoint": "", - "scopes": null - } - } -} -``` \ No newline at end of file +Delete operations are also audit-logged with the caller IP and timestamp. \ No newline at end of file diff --git a/docs/guides/mcp-integration.md b/docs/guides/mcp-integration.md index db08625..1636126 100644 --- a/docs/guides/mcp-integration.md +++ b/docs/guides/mcp-integration.md @@ -1,3 +1,7 @@ +--- +icon: material/server-network-outline +--- + # MCP Server Integration This guide shows how to build a **Model Context Protocol (MCP) server** that uses the Nexus SDK to make authorized API calls on behalf of a workspace/tenant — in TypeScript, Go, and Python. diff --git a/docs/guides/obo-delegation.md b/docs/guides/obo-delegation.md new file mode 100644 index 0000000..e05d25e --- /dev/null +++ b/docs/guides/obo-delegation.md @@ -0,0 +1,131 @@ +--- +icon: material/account-arrow-right-outline +--- + +# OBO Delegation + +On Behalf Of (OBO) delegation is a session pattern for multi-agent systems where an agent needs to act with a specific user's authorization rather than with system-level access. OBO sessions tie the agent's credentials to the identity and permission tier of the user who initiated the operation. + +This matters in multi-tenant systems where agents serve multiple users and where the data a user can access varies by their role or clearance level. + +## The problem OBO solves + +An agent that holds a system-level credential can operate on any user's behalf. If the agent is compromised or makes an error, there is no connection between what the agent accessed and what the user who triggered the operation was actually permitted to see or modify. + +Consider: an agent executing `acme:gliding` — a finance operation that only members of the finance team are authorized to trigger. Without OBO, if any user can trigger the agent, the agent can run `acme:gliding` regardless of whether the triggering user has finance permissions. The agent's authorization is entirely separate from the user's. + +OBO sessions solve this by validating the user's permissions before issuing the session. The session is stamped with the user's identity and permission context. The agent can only do what the specific user who triggered it is authorized to do. + +## How OBO validation works + +OBO uses a two-gate enforcement model. Both gates must pass before a session is issued. + +**Gate 1 — Agent scope check** + +The requested scope must be in the agent's registered `allowed_scopes`. If `ops-agent` does not have `acme:gliding` in its allowed scopes, the request is rejected with `403` regardless of the user's permissions. + +**Gate 2 — User permission check** + +The Broker calls your backend's token verification endpoint to validate the user context token and extract the user's permissions: + +``` +POST {BACKEND_AUTH_URL}/auth/verify-agent-token +{ "token": "" } +``` + +Your backend responds with the user's identity and permissions: + +```json +{ + "user_id": "sarah@acme.com", + "tenant_id": "acme-finance", + "clearance_level": 2, + "permissions": ["acme:gliding", "acme:reporting"] +} +``` + +If the requested scope is not in the user's `permissions` array, the Broker rejects the request with `403`. If both gates pass, the Broker issues an OBO session stamped with the user context. + +## Configuration + +Set `BACKEND_AUTH_URL` on the Broker. The Broker appends `/auth/verify-agent-token` to this URL when validating user context tokens. + +```bash +BACKEND_AUTH_URL=https://your-backend.example.com +``` + +Your backend must implement `POST /auth/verify-agent-token` and return the response shape above. The Broker does not validate the user token itself — that is your backend's responsibility. The Broker trusts your backend's response. + +## Creating an OBO session + +```bash +curl -X POST https://your-gateway.example.com/v1/agent-sessions/obo \ + -H "X-API-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "agent_id": "ops-agent", + "provider_name": "internal-ops", + "scopes": ["acme:gliding"], + "user_context_token": "", + "ttl_seconds": 900 + }' +``` + +Response: + +```json +{ + "session_id": "obo_x9y8z7", + "access_token": "eyJ...", + "scopes_granted": ["acme:gliding"], + "expires_at": "2026-05-12T21:00:00Z", + "obo": true, + "acting_for": "sarah@acme.com", + "tenant_id": "acme-finance", + "clearance_level": 2 +} +``` + +## Using the OBO context + +The `acting_for`, `tenant_id`, and `clearance_level` fields are for your application to use — they are not enforced downstream by Nexus. Use them to scope data access in your internal services: + +```python +obo = nexus.request_obo_session( + agent_id="ops-agent", + provider="internal-ops", + scopes=["acme:gliding"], + user_context_token=user_token, +) +try: + result = internal_ops.glide( + customer_ids=customer_ids, + tenant_id=obo.tenant_id, # isolate to user's tenant + clearance_level=obo.clearance_level, # enforce level in downstream logic + ) + audit_log.write( + operation="acme:gliding", + actor=obo.acting_for, # full audit trail + tenant=obo.tenant_id, + ) + return result +finally: + nexus.close_agent_session(obo.session_id) +``` + +## The clearance level is fixed at session creation + +The clearance level cannot be changed after a session is issued. An agent that received a clearance level 2 session cannot escalate to clearance level 3 resources within that session. The Broker enforces this at issuance time. + +## Audit trail + +Every OBO session is written to `audit_events` with the full delegation chain: the agent ID, the user the agent is acting for, the tenant, the scopes granted, the clearance level, and the session timestamps. This gives your compliance team a complete record of every user-triggered agent operation without requiring agent-level instrumentation. + +## Error cases + +| Error | Cause | +|---|---| +| `403 scope_not_permitted_for_agent` | Requested scope is not in `ops-agent.allowed_scopes` | +| `403 user_not_authorized_for_scope` | User's permissions do not include the requested scope | +| `503 backend_auth_unavailable` | `BACKEND_AUTH_URL` is unreachable or returned a non-200 response | +| `401 invalid_user_context_token` | Your backend returned an error for the provided token | diff --git a/docs/guides/registering-a-provider.md b/docs/guides/registering-a-provider.md new file mode 100644 index 0000000..e90c96d --- /dev/null +++ b/docs/guides/registering-a-provider.md @@ -0,0 +1,318 @@ +--- +icon: material/plus-network-outline +--- + +# Providers + +A provider profile tells Nexus how to authenticate users against a third-party service and how to apply those credentials to outgoing requests. This guide covers registering new providers, the full range of auth types Nexus supports, and how to list, update, and delete providers after they are registered. + +For declarative provider management in production, see [Security-as-Code](security-as-code.md). + +--- + +## Step 1 — Set up the OAuth app in the provider console + +Every OAuth2 provider requires you to register an application in their developer portal before issuing credentials. The terminology varies — "OAuth Apps", "API Credentials", "Integrations" — but the process is the same: create an app, collect the client ID and secret, and register the Broker's callback URI. + +### What you need from the provider + +| Field | Description | +|---|---| +| Client ID | The public identifier for your application | +| Client Secret | The secret Nexus uses to exchange authorization codes for tokens | +| Authorization URL | The endpoint users are redirected to for consent | +| Token URL | The endpoint Nexus calls to exchange codes and refresh tokens | +| Issuer URL | For OIDC-capable providers — replaces auth URL and token URL | + +### The redirect URI + +Register the Broker's callback endpoint as the redirect URI in the provider console: + +``` +https://your-broker.example.com/auth/callback +``` + +For local development: + +``` +http://localhost:8080/auth/callback +``` + +The Broker's `BASE_URL` + `/auth/callback` must match this exactly. Most providers perform strict string matching — a trailing slash or `http` vs `https` mismatch causes every OAuth flow to fail. + +### Compliance fields + +Most providers sandbox new apps in "Development Mode" until compliance metadata is filled in. This limits you to a small number of test users and blocks production access. + +| Field | What to provide | +|---|---| +| App name | Your product name | +| App logo | Your company logo | +| Website URL | `https://your-company.com` | +| Privacy Policy URL | `https://your-company.com/privacy` | +| Terms of Service URL | `https://your-company.com/terms` | +| Support email | Your support address | + +Fill these in before requesting production access from the provider. + +--- + +## Step 2 — Register the provider in Nexus + +All provider registration goes through `POST /v1/providers` on the Gateway. The `X-API-Key` header must carry your `API_KEY`. + +### OAuth2 with OIDC discovery + +Use OIDC discovery when the provider supports it (Google, Microsoft Entra, Okta, Auth0). Nexus fetches the authorization endpoint, token endpoint, and JWKS URI automatically from `{issuer}/.well-known/openid-configuration`. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "google-workspace", + "auth_type": "oauth2", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "issuer": "https://accounts.google.com", + "enable_discovery": true, + "scopes": ["openid", "email", "profile", "offline_access"] + }' | jq . +``` + +### OAuth2 with manual endpoints + +Use manual configuration for providers without OIDC discovery (GitHub, Slack, Stripe, Salesforce). + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "github", + "auth_type": "oauth2", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scopes": ["repo", "read:user"] + }' | jq . +``` + +### API key provider + +Static credential providers do not use a redirect flow. The `credential_schema` defines the form your application presents to collect the credential from the user. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "airtable", + "auth_type": "api_key", + "params": { + "credential_schema": { + "type": "object", + "required": ["api_key"], + "properties": { + "api_key": { "type": "string", "title": "Personal Access Token" } + } + } + } + }' | jq . +``` + +### Basic auth provider + +Username and password credentials. The Bridge encodes them as Base64 and sets the `Authorization: Basic` header automatically. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "jira-basic", + "auth_type": "basic_auth", + "params": { + "credential_schema": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { "type": "string", "title": "Username" }, + "password": { "type": "string", "title": "Password", "format": "password" } + } + } + } + }' | jq . +``` + +### AWS SigV4 provider + +For services that use AWS Signature Version 4 request signing. `params.service` and `params.region` configure the signing scope. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "bedrock-us-east", + "auth_type": "aws_sigv4", + "params": { + "service": "bedrock", + "region": "us-east-1", + "credential_schema": { + "type": "object", + "required": ["access_key", "secret_key"], + "properties": { + "access_key": { "type": "string", "title": "AWS Access Key ID" }, + "secret_key": { "type": "string", "title": "AWS Secret Access Key" } + } + } + } + }' | jq . +``` + +### Query param provider + +Injects the API key into the URL query string instead of a header. `params.param_name` sets the query parameter name. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "weatherapi", + "auth_type": "query_param", + "params": { + "param_name": "api_token", + "credential_schema": { + "type": "object", + "required": ["api_key"], + "properties": { + "api_key": { "type": "string", "title": "API Token" } + } + } + } + }' | jq . +``` + +### HMAC signature provider + +For APIs that require request signing with a shared secret. `params.header_name`, `params.algo`, and `params.encoding` configure the signature format. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/providers \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "name": "webhook-hmac", + "auth_type": "hmac_payload", + "params": { + "header_name": "X-Signature", + "algo": "sha256", + "encoding": "hex", + "credential_schema": { + "type": "object", + "required": ["api_secret"], + "properties": { + "api_secret": { "type": "string", "title": "Signing Secret" } + } + } + } + }' | jq . +``` + +### Provider field reference + +| Field | Type | Description | +|---|---|---| +| `name` | string | Unique alias. Used in all subsequent operations. | +| `auth_type` | string | `oauth2`, `api_key`, `basic_auth`, `aws_sigv4`, `query_param`, `hmac_payload` | +| `client_id` | string | OAuth 2.0 client ID | +| `client_secret` | string | OAuth 2.0 client secret | +| `issuer` | string | OIDC issuer URL — required when `enable_discovery: true` | +| `auth_url` | string | Authorization endpoint — required when `enable_discovery: false` | +| `token_url` | string | Token endpoint — required when `enable_discovery: false` | +| `api_base_url` | string | Provider API root URL | +| `enable_discovery` | boolean | Fetch endpoints from OIDC discovery document | +| `scopes` | array | Default scopes to request during the OAuth handshake | +| `params` | object | Provider-specific configuration (schemas, signing params, quirks) | + +### Provider-specific quirks + +Some providers deviate from the OAuth2 spec in ways that require additional params: + +| Provider | Issue | Fix | +|---|---|---| +| Salesforce | Rejects `scope` on the authorization URL | `"params": { "skip_scope_on_auth": true }` | +| Salesforce | Rejects `scope` on the token exchange | `"params": { "skip_scope_on_exchange": true }` | +| Twitter/X | Requires Basic Auth for token exchange | `"auth_header": "client_secret_basic"` | +| Microsoft Entra | Requires `scope` on the token exchange | Default behaviour — no change needed | + +--- + +## Step 3 — Verify the registration + +Test an OAuth2 provider by requesting a connection URL and completing the flow in your browser: + +```bash +curl -s -X POST https://your-gateway.example.com/v1/request-connection \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "workspace_id": "test-user-001", + "provider_id": "PROVIDER_UUID_FROM_REGISTRATION", + "scopes": ["openid", "email"], + "return_url": "https://httpbin.org/get" + }' | jq . +``` + +Open the `auth_url` from the response in a browser. After authorizing, you should be redirected to `httpbin.org/get` with `connection_id` and `status=success` as query parameters. + +--- + +## Listing providers + +```bash +curl -s https://your-gateway.example.com/v1/providers \ + -H "X-API-Key: your-api-key" | jq . +``` + +To retrieve a condensed metadata view (useful for rendering a connection UI): + +```bash +curl -s https://your-gateway.example.com/v1/providers/metadata \ + -H "X-API-Key: your-api-key" | jq . +``` + +The metadata endpoint returns providers grouped by `auth_type`, with only the fields needed for connection UI rendering: `api_base_url`, `user_info_endpoint`, and `scopes`. + +--- + +## Updating a provider + +Use `PATCH` to update specific fields. Do not delete and recreate a provider — this orphans every active connection that references it. + +```bash +curl -s -X PATCH https://your-gateway.example.com/v1/providers/PROVIDER_ID \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{ + "client_secret": "ROTATED_SECRET" + }' | jq . +``` + +Every update is recorded in the [audit log](../reference/audit-log.md). + +--- + +## Deleting a provider + +Deleting a provider removes its configuration. Existing connections that reference the provider will fail credential retrieval immediately because the client credentials are gone. Verify you have migrated or decommissioned all dependent connections before deleting. + +```bash +curl -s -X DELETE https://your-gateway.example.com/v1/providers/PROVIDER_ID \ + -H "X-API-Key: your-api-key" +``` + +Delete operations are audit-logged with the caller IP and timestamp. diff --git a/docs/guides/security-as-code.md b/docs/guides/security-as-code.md index cbd3915..ad96f97 100644 --- a/docs/guides/security-as-code.md +++ b/docs/guides/security-as-code.md @@ -1,20 +1,12 @@ -# Security-as-Code: Declarative Provider Management - -The **`nexus-cli`** tool brings a GitOps-compatible, Terraform-style workflow to managing your Nexus provider configurations. Instead of managing providers through direct API calls (which leave no version history and are impossible to review), you declare your desired state in a YAML manifest, commit it to your repository, and let `nexus-cli` reconcile the live Broker against that source of truth. - -!!! tip "Why this matters" - Nexus holds Refresh Tokens and API Keys for every provider a workspace connects to — it is critical infrastructure. Without declarative management, a single bad API call can silently break all agents that depend on a provider, with no git history to recover from. - +--- +icon: material/file-code-outline --- -## How It Works +# Security-as-Code with nexus-cli -`nexus-cli` follows a **plan → confirm → apply** workflow: +`nexus-cli` is a command-line tool that applies a GitOps workflow to Nexus provider configuration. Instead of making ad-hoc API calls to register and update providers, you declare the desired state in a YAML manifest, commit it to your repository, and use `nexus-cli` to reconcile the live Broker against that manifest. -1. **Fetches** the current live state from `GET /providers`. -2. **Diffs** it against your `nexus-providers.yaml` manifest. -3. **Prints** a human-readable plan showing creates, updates, and orphaned providers. -4. **Applies** the changes only after you confirm with `yes` (or non-interactively in CI). +This matters for Nexus specifically because the Broker holds refresh tokens and API keys for every provider in your workspace. An undocumented API call that misconfigures or deletes a provider can silently break every agent that depends on it, with no record of what changed or who made the change. Declarative management gives you git history, peer review, and an audit trail for every provider mutation. --- @@ -27,7 +19,7 @@ cd nexus-cli go build -o nexus-cli . ``` -Or install directly: +Or install directly with Go: ```bash go install github.com/Prescott-Data/nexus-framework/nexus-cli@latest @@ -37,22 +29,22 @@ go install github.com/Prescott-Data/nexus-framework/nexus-cli@latest ## Configuration -`nexus-cli` is configured via environment variables: +`nexus-cli` is configured through environment variables: -| Variable | Description | Default | -| :--- | :--- | :--- | -| `BROKER_BASE_URL` | Base URL of the Nexus Broker | `http://localhost:8080` | -| `API_KEY` | API key for Broker authentication | *(none)* | +| Variable | Default | Description | +|---|---|---| +| `BROKER_BASE_URL` | `http://localhost:8080` | URL of the Nexus Broker | +| `API_KEY` | none | API key for Broker authentication | --- -## The Provider Manifest +## The provider manifest -Create a `nexus-providers.yaml` file and **commit it to your GitOps repository**. This file is your single source of truth for all provider configurations. +Create a file named `nexus-providers.yaml` and commit it to your infrastructure repository. This file is the single source of truth for all provider configurations in the target environment. -Environment variables are expanded at runtime, so secrets never need to be hardcoded. +Environment variable references in the manifest are expanded at runtime, so secrets never appear in the file itself: -```yaml title="nexus-providers.yaml" +```yaml providers: - name: google-workspace auth_type: oauth2 @@ -79,19 +71,19 @@ providers: - user:email ``` -### Manifest Fields +### Manifest fields | Field | Type | Description | -| :--- | :--- | :--- | -| `name` | string | Unique provider name (used as the reconciliation key) | +|---|---|---| +| `name` | string | Provider alias. Used as the reconciliation key. Must be unique. | | `auth_type` | string | `oauth2` or `api_key` | | `client_id` | string | OAuth client ID | | `client_secret` | string | OAuth client secret | | `issuer` | string | OIDC issuer URL for auto-discovery | -| `auth_url` | string | Authorization endpoint (if not using discovery) | -| `token_url` | string | Token endpoint (if not using discovery) | +| `auth_url` | string | Authorization endpoint (when not using discovery) | +| `token_url` | string | Token endpoint (when not using discovery) | | `api_base_url` | string | Provider API root URL | -| `enable_discovery` | bool | Use OIDC discovery if `true` | +| `enable_discovery` | bool | Fetch endpoints from OIDC discovery document | | `scopes` | list | Default scopes to request | | `params` | map | Provider-specific extra parameters | @@ -99,17 +91,16 @@ providers: ## Commands -### `plan` — Preview Changes +### plan -Show what would change without making any mutations: +`plan` fetches the current live state from the Broker, computes the diff against your manifest, and prints what would change without making any mutations: ```bash nexus-cli plan -# Or with a custom manifest path: -nexus-cli plan --file ./path/to/nexus-providers.yaml +nexus-cli plan --file ./infra/nexus-providers.prod.yaml ``` -**Example output:** +Example output: ``` Read 2 providers from nexus-providers.yaml @@ -117,93 +108,52 @@ Read 2 providers from nexus-providers.yaml --- Execution Plan --- + CREATE : github ~ UPDATE : google-workspace -! ORPHAN : old-slack-provider (would be deleted if --prune was passed) +! ORPHAN : old-slack-provider Plan complete. Run 'nexus-cli apply' to perform these actions. ``` -The symbols mean: +The symbols in the plan output: -| Symbol | Action | -| :--- | :--- | +| Symbol | Meaning | +|---|---| | `+` | Provider will be created | | `~` | Provider will be updated | -| `-` | Provider will be deleted (only shown with `--prune`) | -| `!` | Provider exists in live state but not in manifest (orphan) | +| `!` | Provider exists in the live state but not in the manifest (orphan) | +| `-` | Provider will be deleted (only shown when `--prune` is passed) | -### `apply` — Apply Changes +### apply -Apply the manifest, with an interactive confirmation prompt: +`apply` executes the plan after an interactive confirmation prompt: ```bash nexus-cli apply ``` -``` -Read 2 providers from nexus-providers.yaml - ---- Execution Plan --- -+ CREATE : github -~ UPDATE : google-workspace - -Do you want to perform these actions? - Nexus will perform the actions described above. - Only 'yes' will be accepted to approve. - - Enter a value: yes - ---- Applying Changes --- -Creating github... OK -Updating google-workspace... OK -``` - -#### Flags +Pass `--prune` to also delete orphaned providers. Do not use `--prune` until you are confident your manifest is the complete desired state for the environment. Deleting a provider immediately breaks all connections that reference it. | Flag | Default | Description | -| :--- | :--- | :--- | +|---|---|---| | `--file` | `nexus-providers.yaml` | Path to the manifest file | -| `--prune` | `false` | Also delete providers in live state not in the manifest | - -!!! warning "Using `--prune`" - The `--prune` flag will **delete** providers that exist in the Broker but are absent from your manifest. Only use this when you are certain your manifest is the complete desired state. Any agents depending on a pruned provider will immediately lose their connections. +| `--prune` | `false` | Delete providers not present in the manifest | --- -## CI/CD Integration (Optional) - -`nexus-cli` is a standalone binary — you can run it from your laptop, a bastion host, or a CI pipeline. If you want to integrate it into your own CI/CD, here's a recommended pattern: +## CI/CD integration -- **On pull requests**: run `nexus-cli plan` as an informational check so reviewers can see what would change. -- **Apply manually**: use a `workflow_dispatch` trigger or run `nexus-cli apply` from a trusted environment when you're ready. +Run `nexus-cli plan` as an informational check on pull requests so reviewers can see what would change before merging. Apply manually from a trusted environment when you are ready to change the live state. -> **Note:** Auto-applying on merge is discouraged. Provider configurations are live operational data — you should always review a plan before applying. - -### Example GitHub Actions Snippet +Automatic apply on merge is not recommended. Provider configuration is live operational data that affects all agents in the workspace. A plan review step before apply prevents accidental provider deletions or misconfigurations from reaching production silently. ```yaml -# Add this to your internal repo's workflow — not the open-source framework repo. -- name: Plan +# Example GitHub Actions snippet +- name: Nexus plan env: BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} API_KEY: ${{ secrets.BROKER_API_KEY }} - # Add all env vars referenced in your manifest + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} run: ./nexus-cli plan ``` -### Required Environment Variables - -| Variable | Description | -| :--- | :--- | -| `BROKER_BASE_URL` | URL of your target Nexus Broker (staging, prod, etc.) | -| `API_KEY` | API key for Broker authentication | -| `*_CLIENT_ID` / `*_CLIENT_SECRET` | Any provider credentials referenced via `${...}` in your manifest | - ---- - -## Best Practices - -1. **Treat `nexus-providers.yaml` as infrastructure code** — require PR reviews for all changes. -2. **Never hardcode secrets** — always use `${ENV_VAR}` expansion and inject via CI secrets. -3. **Start without `--prune`** — let orphans accumulate warnings first so you can audit them intentionally before deletion. -4. **One manifest per environment** — keep a `nexus-providers.prod.yaml` and `nexus-providers.staging.yaml` and set `BROKER_BASE_URL` accordingly in each CI environment. -5. **All mutations are audited** — every create, update, or delete applied by `nexus-cli` is recorded in the [Audit Log](../reference/audit-log.md). +Every `apply` run generates audit log entries on the Broker, giving you a record of which providers were created, updated, or deleted and at what time. Combined with the git history of your manifest file, you have two independent audit trails for every provider change. diff --git a/docs/guides/static-credentials.md b/docs/guides/static-credentials.md new file mode 100644 index 0000000..ecacbd1 --- /dev/null +++ b/docs/guides/static-credentials.md @@ -0,0 +1,158 @@ +--- +icon: material/key-outline +--- + +# Static Credential Flow + +OAuth2 providers redirect users to an authorization page. Static credential providers — `api_key` and `basic_auth` — do not. Instead, your application presents a form, the user fills it in, and you submit the credentials directly to Nexus. + +This guide covers the complete flow for static credential connections. + +## How it works + +``` +Your backend Gateway Broker + │ │ │ + │ POST /v1/request-connection │ │ + │─────────────────────────────►│ │ + │ ◄── { auth_url, connection_id } │ + │ │ │ + │ GET /v1/capture-schema │ │ + │─────────────────────────────►│ │ + │ ◄── { fields schema } │ │ + │ │ │ + │ (render form, user fills in credentials) │ + │ │ │ + │ POST /v1/capture-credential │ │ + │─────────────────────────────►│──────────────────────►│ + │ ◄── { connection_id, status: "success" } │ + │ │ │ + │ GET /v1/check-connection │ │ + │─────────────────────────────►│ │ + │ ◄── { status: "active" } │ │ +``` + +## Step 1 — Initiate the connection + +Call `POST /v1/request-connection` as you would for an OAuth2 provider. The response contains an `auth_url` with a signed `state` token. You will need the state for the credential submission. + +```bash +curl -s -X POST https://your-gateway.example.com/v1/request-connection \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-gateway-api-key" \ + -d '{ + "workspace_id": "user_abc", + "provider_id": "PROVIDER_UUID", + "return_url": "https://your-app.com/connections/callback" + }' | jq . +``` + +Response: + +```json +{ + "connection_id": "8f3a1c2d-...", + "auth_url": "https://your-broker.example.com/auth/capture-schema?state=eyJ..." +} +``` + +Store the `connection_id`. Extract the `state` parameter from the `auth_url`. + +## Step 2 — Fetch the credential schema + +Call `GET /v1/capture-schema` with the state token to get the field definitions for this provider. + +```bash +curl -s "https://your-gateway.example.com/v1/capture-schema?state=eyJ..." \ + -H "X-API-Key: your-gateway-api-key" | jq . +``` + +Response for an API key provider: + +```json +{ + "type": "object", + "required": ["api_key"], + "properties": { + "api_key": { + "type": "string", + "title": "Personal Access Token" + } + } +} +``` + +Response for a basic auth provider: + +```json +{ + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { "type": "string", "title": "Username" }, + "password": { "type": "string", "title": "Password", "format": "password" } + } +} +``` + +Use the schema to render a form in your application UI. The `title` field is the human-readable label. Fields with `"format": "password"` should use a masked input. + +## Step 3 — Submit the credentials + +When the user submits the form, post the credentials to `POST /v1/capture-credential`: + +```bash +curl -s -X POST https://your-gateway.example.com/v1/capture-credential \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-gateway-api-key" \ + -d '{ + "state": "eyJ...", + "credentials": { + "api_key": "pat_abc123xyz" + } + }' | jq . +``` + +The Gateway submits this to the Broker, which encrypts the credentials and stores them. The response: + +```json +{ + "connection_id": "8f3a1c2d-...", + "status": "success", + "redirect_url": "https://your-app.com/connections/callback?status=success&connection_id=8f3a1c2d-..." +} +``` + +The Broker also issues a redirect to the `return_url` you specified in step 1. If your application uses a browser-based flow, redirect the user there. If it is server-side only, the `connection_id` in the response is sufficient. + +## Step 4 — Verify the connection is active + +```bash +curl -s "https://your-gateway.example.com/v1/check-connection/8f3a1c2d-..." \ + -H "X-API-Key: your-gateway-api-key" | jq . +``` + +```json +{ "status": "active" } +``` + +## Fetching tokens for static connections + +The token fetch is identical to OAuth2. Call `GET /v1/token/{connection_id}`: + +```bash +curl -s "https://your-gateway.example.com/v1/token/8f3a1c2d-..." \ + -H "X-API-Key: your-gateway-api-key" | jq . +``` + +The `credentials` field in the response contains the stored key material. The `strategy` field tells the Bridge how to inject it into outgoing requests. + +## Static connections cannot be refreshed + +Calling `POST /v1/refresh/{connection_id}` on a static connection returns: + +```json +{ "error": "static_token", "message": "This connection uses a static token and cannot be refreshed" } +``` + +To update credentials — for example when a user rotates their API key — initiate a new capture flow for the same `workspace_id` and `provider_id`. The new connection replaces the old one in your application's connection registry. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md new file mode 100644 index 0000000..561348c --- /dev/null +++ b/docs/guides/troubleshooting.md @@ -0,0 +1,142 @@ +--- +icon: material/lifebuoy +--- + +# Troubleshooting + +Common issues and how to resolve them. + +--- + +## Stack won't start + +### Services exit immediately after `make up` + +Check the logs: + +```bash +docker-compose logs broker +docker-compose logs gateway +``` + +**`FATAL: STATE_KEY environment variable is required`** + +Both the Broker and Gateway require `STATE_KEY` to be set and identical. Copy your `.env.example` to `.env` and fill in `STATE_KEY` with the output of `openssl rand -base64 32`. See [Configuration](../getting-started/configuration.md). + +**`DATABASE_URL connection refused`** + +PostgreSQL is not healthy yet. Wait for the healthcheck to pass: + +```bash +docker-compose ps # check "healthy" status for nexus-postgres +``` + +If it never reaches healthy, check that port 5432 is not occupied by another process. + +--- + +## OAuth handshake fails + +### Redirect URI mismatch + +The provider returns a redirect URI mismatch error during consent. + +The provider's developer console has a registered redirect URI that does not match the Broker's callback endpoint. The Broker's callback is: + +``` +{BASE_URL}/auth/callback +``` + +Check what `BASE_URL` is set to in your `.env`, construct the callback URL, and verify it is registered exactly in the provider console. Common causes: `http` vs `https`, trailing slash, or wrong port. + +### `invalid state` on callback + +The Broker logs `invalid or expired state parameter` and redirects to your `return_url` with `status=failed`. + +This happens when `STATE_KEY` differs between the Broker and Gateway, or when the state token expires before the user completes consent (15-minute TTL). Verify `STATE_KEY` is identical in both services: + +```bash +docker-compose exec broker env | grep STATE_KEY +docker-compose exec gateway env | grep STATE_KEY +``` + +### OAuth callback returns `failed` + +Check the Broker logs for the token exchange error. The most common causes: + +| Error | Cause | Fix | +|---|---|---| +| `invalid_grant` | Authorization code already used or expired | The user must restart the consent flow | +| `invalid_client` | Wrong `client_id` or `client_secret` | Verify credentials in the provider console | +| `redirect_uri_mismatch` | Redirect URI not registered | Register `{BASE_URL}/auth/callback` in provider console | +| `access_denied` | User declined consent | Expected — surface a retry to the user | + +--- + +## Credential retrieval fails + +### `GET /v1/token/{id}` returns 404 + +The `connection_id` does not exist in the Broker's database. Verify the connection completed successfully with `GET /v1/check-connection/{id}` first. + +### Token returns 200 but the provider API returns 401 + +The access token is valid according to the Broker but the provider has already invalidated it. Force a refresh: + +```bash +curl -s -X POST https://your-gateway.example.com/v1/refresh/CONN_ID \ + -H "X-API-Key: your-api-key" +``` + +If the refresh returns `attention_required`, the user must reconnect. See [Handling Attention State](attention-state.md). + +--- + +## Agent session errors + +### `403 scope_not_permitted_for_agent` + +The agent's registered `allowed_scopes` does not include the requested scope. Update the agent's allowed scopes via `PATCH /admin/v1/agents/{id}` on the Broker, or register the agent with the correct scopes from the start. + +### `503 backend_auth_unavailable` + +The Broker cannot reach `BACKEND_AUTH_URL` to validate the user context token for an OBO session. Verify `BACKEND_AUTH_URL` is set correctly on the Broker and that the endpoint is reachable from within the Docker network. + +--- + +## nexus-cli issues + +### `plan` shows all providers as CREATE even after apply + +The `name` field in the manifest does not match the `name` stored on the Broker. `nexus-cli` uses `name` as the reconciliation key — it must match exactly. Check the live state: + +```bash +curl -s http://localhost:8090/v1/providers \ + -H "X-API-Key: your-api-key" | jq '.[].name' +``` + +### `apply` fails with `connection refused` + +`BROKER_BASE_URL` is set incorrectly or the Broker is not running. Verify: + +```bash +curl http://localhost:8080/health +``` + +--- + +## Encryption key issues + +### All connections return errors after a restart + +`ENCRYPTION_KEY` changed between restarts. All stored tokens are encrypted with the original key — a different key makes them permanently unreadable. Restore the original `ENCRYPTION_KEY` value. If it is lost, all connections must be re-established by users going through the consent flow again. + +`ENCRYPTION_KEY` must be stored as a managed secret, not in source control. Use your cloud provider's secret manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault). + +--- + +## Getting more help + +- **GitHub Issues**: [github.com/Prescott-Data/nexus-framework/issues](https://github.com/Prescott-Data/nexus-framework/issues) +- **Discord**: [discord.gg/AbskSXypq](https://discord.gg/AbskSXypq) +- **Audit Log**: Run `GET /audit` on the Broker to see the event trail for any connection or provider operation. See [Audit Log](../reference/audit-log.md). diff --git a/docs/index.md b/docs/index.md index 6de0a79..be9df52 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,38 +1,92 @@ -# Nexus Framework +--- +icon: material/home +--- -The Nexus Framework is a provider-agnostic, secure integration layer for managing OAuth 2.0 and OIDC connections. It abstracts away the complexity of managing tokens, refreshes, and provider quirks, allowing your agents and services to focus on business logic. +
-## Quick Start + -The fastest way to get started is with Docker Compose. This will spin up the Broker, Gateway, Postgres, and Redis. +# Nexus + +### Auth infrastructure for autonomous agents. + +One authority for every service your agents touch. Nexus orchestrates provider connections, token lifecycle, agent identity, scoped sessions, and on-behalf-of delegation — your agents never write auth code or hold a raw secret. + + +
+ +[Get started](getting-started/quickstart.md){ .nx-btn .nx-btn-primary } +[View on GitHub](https://github.com/Prescott-Data/nexus-framework){ .nx-btn .nx-btn-github target="_blank" rel="noopener" } + +
+ +
+ +--- + +## What Nexus does + +Every agent that connects to an external service hits the same wall. OAuth flows, token refresh loops, credential rotation, per-provider auth implementations — all of it written from scratch, per integration, per team. Nexus eliminates that wall entirely. + +Register a provider once. Every agent in your fleet connects to it through a single authority. The Broker handles the OAuth handshake, encrypts the token at rest, runs the refresh loop, and issues only a short-lived credential when an agent asks. The agent uses it and discards it. If the agent is compromised, the attacker has nothing durable. + +Beyond the OAuth layer, Nexus covers the full auth surface that production agent systems require. Agents are registered principals with declared scope ceilings — a `crm-agent` registered with `crm:contacts:read` cannot request `crm:delete`, even if the underlying connection has that scope. When a human user triggers an agent mission, the Broker validates the user's permission, stamps the session with their identity and tenant context, and enforces data isolation across every downstream operation. Internal business operations — `acme:gliding`, `pipeline:trigger` — are first-class scopes enforced at the same authority as OAuth tokens. Every credential request, session open, and session close is logged in a tamper-evident audit trail. + +
+ +
+Broker + +**The authority.** Holds all master secrets encrypted at rest. Runs the background refresh loop. Never exposed to agents directly. +
+ +
+Gateway + +**The public API.** Agents call the Gateway. It proxies to the Broker over an internal channel. Agents never reach the Broker. +
+ +
+Bridge + +**The Go library.** Runs inside your agent process. Fetches credentials and injects them into outgoing HTTP and gRPC requests automatically. +
+ +
+SDKs + +**Three first-class clients.** Go, TypeScript, and Python. Direct Gateway access for explicit credential fetches and MCP server integration. +
+ +
+ +--- + +## Quick start ```bash -# 1. Configure environment cp .env.example .env - -# 2. Start the stack +# Generate ENCRYPTION_KEY and STATE_KEY — see Getting Started make up - -# Or if you don't have make: -docker-compose up -d --build ``` -- **Broker**: http://localhost:8080 -- **Gateway**: http://localhost:8090 -- **Admin API Key**: Configured in `.env` (Default: `nexus-admin-key`) +Broker runs on `localhost:8080`. Gateway runs on `localhost:8090`. + +--- + +## Where to start + +Start with [Architecture](concepts/architecture.md) under the Concepts tab. It establishes the control plane and data plane split, the OAuth handshake flow, and the credential retrieval model. Every other page assumes that mental model. -## Documentation +Then follow [Deploy in Five Minutes](getting-started/quickstart.md) to run a stack and make your first connection. After that, the [Guides](guides/integrating-agents.md) cover the operational tasks you return to repeatedly. -- **[Architecture](architecture.md)**: System overview, components, and data flow. -- **[Deployment & Config](deployment.md)**: How to configure, build, and deploy the services. -- **[Agent Integration Guide](guides/integrating-agents.md)**: How to build agents that consume connections (including the Go Bridge). -- **[Provider Management Guide](guides/managing-providers.md)**: How to register and configure identity providers (OAuth2, API Keys). -- **[API Reference](reference/api.md)**: Links to OpenAPI specifications. -- **[Security Model](reference/security-model.md)**: Security guardrails and hardening. -- **[Technical Debt & Roadmap](reference/tech-debt.md)**: Known issues and future plans. +--- -## Quick Links +## Explore -- **[Broker Service](nexus-broker/README.md)**: Backend service details. -- **[Gateway Service](nexus-gateway/README.md)**: Frontend API service details. -- **[Bridge Library](nexus-bridge/README.md)**: Go client library details. \ No newline at end of file +| | | +|---|---| +| **Source** | Browse the code, open issues, and submit PRs — [GitHub](https://github.com/Prescott-Data/nexus-framework){ target="_blank" rel="noopener" } | +| **OpenAPI** | The full Gateway v1 contract — [openapi.yaml](https://github.com/Prescott-Data/nexus-framework/blob/main/openapi.yaml){ target="_blank" rel="noopener" } | +| **Community** | Questions, showcases, and early feature previews — [Discord](https://discord.gg/AbskSXypq){ target="_blank" rel="noopener" } | +| **Blog** | Engineering deep-dives and architecture walkthroughs — [read the blog](https://developers.prescottdata.io/blog){ target="_blank" rel="noopener" } | \ No newline at end of file diff --git a/docs/infrastructure/deploying-nexus.md b/docs/infrastructure/deploying-nexus.md new file mode 100644 index 0000000..7ebac70 --- /dev/null +++ b/docs/infrastructure/deploying-nexus.md @@ -0,0 +1,148 @@ +--- +icon: material/cloud-upload-outline +--- + +# Deploying Nexus + +This guide covers deploying the Broker and Gateway to a production environment. For local development setup, see [Deploy in Five Minutes](../getting-started/quickstart.md). + +## Prerequisites + +- PostgreSQL 14 or later +- Docker (for containerised deployment) or Go 1.22+ (for binary deployment) +- TLS termination at your load balancer or reverse proxy + +## Generate secrets + +Both services require secrets that must be generated before deployment and stored securely. + +```bash +openssl rand -base64 32 # ENCRYPTION_KEY — Broker only +openssl rand -base64 32 # STATE_KEY — must be identical on Broker and Gateway +openssl rand -hex 32 # BROKER_API_KEY — shared between Broker and Gateway +``` + +Store these in your secret manager (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, Kubernetes Secrets). Do not commit them to source control. + +## Broker environment variables + +| Variable | Required | Description | +|---|---|---| +| `DATABASE_URL` | yes | PostgreSQL connection string | +| `ENCRYPTION_KEY` | yes | 32-byte base64 key for AES-GCM token encryption | +| `STATE_KEY` | yes | 32-byte base64 key for OAuth state HMAC signing — must match Gateway | +| `BASE_URL` | yes | Public URL of the Broker, e.g. `https://broker.internal.example.com` | +| `API_KEY` | yes | Key the Gateway uses to authenticate with the Broker | +| `REDIRECT_PATH` | no | OAuth callback path (default: `/auth/callback`) | +| `ALLOWED_CIDRS` | no | Comma-separated CIDRs for IP allowlisting, e.g. `10.0.0.0/8` | +| `ALLOWED_RETURN_DOMAINS` | no | Comma-separated allowed domains for `return_url` validation | + +## Gateway environment variables + +| Variable | Required | Description | +|---|---|---| +| `STATE_KEY` | yes | Same value as the Broker's `STATE_KEY` | +| `BROKER_BASE_URL` | yes | Internal URL of the Broker, e.g. `http://broker.internal:8080` | +| `BROKER_API_KEY` | yes | The Broker's `API_KEY` | +| `PORT` | no | Port to listen on (default: `8090`) | + +## Network topology + +``` +Internet + │ + ▼ +Load Balancer / Reverse Proxy (TLS termination) + │ │ + ▼ ▼ +Gateway Broker +(public or (internal network only) + internal) │ + │ ▼ + └───────────────► PostgreSQL +``` + +The Broker handles OAuth callbacks and must be reachable from the public internet at its `BASE_URL/auth/callback`. All other Broker traffic should be internal only. + +The Gateway can be internal-only if your agents run inside the same network. Make it public only if agents run outside your network perimeter. + +## Database setup + +Run the migration before starting the Broker: + +```bash +cd nexus-broker +go run ./cmd/migrate +``` + +This creates the `provider_profiles`, `connections`, `tokens`, and `audit_events` tables. + +## Docker Compose (production-ready) + +```yaml +services: + broker: + image: ghcr.io/prescott-data/nexus-broker:latest + environment: + DATABASE_URL: postgres://nexus:${DB_PASSWORD}@postgres:5432/nexus + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + STATE_KEY: ${STATE_KEY} + BASE_URL: https://broker.internal.example.com + API_KEY: ${BROKER_API_KEY} + ALLOWED_CIDRS: "10.0.0.0/8" + networks: + - internal + + gateway: + image: ghcr.io/prescott-data/nexus-gateway:latest + environment: + STATE_KEY: ${STATE_KEY} + BROKER_BASE_URL: http://broker:8080 + BROKER_API_KEY: ${BROKER_API_KEY} + PORT: "8090" + ports: + - "8090:8090" + networks: + - internal + - public + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: nexus + POSTGRES_USER: nexus + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - internal + +networks: + internal: + public: + +volumes: + postgres_data: +``` + +## Key management + +`ENCRYPTION_KEY` must remain stable across deployments. Changing it makes every stored token permanently unreadable. Treat it as you would a database master password. + +`STATE_KEY` can be rotated, but doing so invalidates all in-flight OAuth flows (pending connections at the moment of rotation). Any user mid-authorization will see an "invalid state" error and need to restart the flow. + +Both keys must be identical across all instances of the same service. In Kubernetes, use a single `Secret` object mounted into both the Broker and Gateway pods for `STATE_KEY`. + +## Health checks + +The Broker exposes `GET /health` and the Gateway exposes `GET /health`. Both return `200 OK` when the service is ready. Configure your load balancer to use these endpoints. + +## Upgrading + +Run database migrations before starting the new Broker version: + +```bash +go run ./cmd/migrate +``` + +Migrations are additive and backward-compatible within the same minor version. Check the release notes for any breaking schema changes between major versions. diff --git a/docs/javascripts/header.js b/docs/javascripts/header.js new file mode 100644 index 0000000..23703f2 --- /dev/null +++ b/docs/javascripts/header.js @@ -0,0 +1,95 @@ +/* Nexus Docs — Header enhancements + - Injects version chip after site title (live from GitHub tags) + - Injects GitHub repo + stars widget (Prescott-Data/nexus ★ N) + - Moves theme palette toggle to far-right + - Opens external tabs in new window +*/ +function initHeader() { + + /* ── 1. Version chip — live from GitHub API ─────────── */ + var title = document.querySelector('.md-header__title'); + var chip = document.querySelector('.nx-version-chip'); + + // Skip if already injected (instant navigation guard) + if (!chip && title) { + chip = document.createElement('span'); + chip.className = 'nx-version-chip'; + chip.textContent = '…'; + title.insertAdjacentElement('afterend', chip); + } + + /* Fetch the LATEST tag from GitHub — single source of truth */ + if (chip) { + fetch('https://api.github.com/repos/Prescott-Data/nexus-framework/tags') + .then(function (r) { return r.json(); }) + .then(function (tags) { + if (Array.isArray(tags) && tags.length > 0) { + var latestVersion = tags[0].name; + if (chip) chip.textContent = latestVersion; + + /* Also update hero badge if present */ + var heroBadge = document.getElementById('nx-hero-version-badge'); + if (heroBadge) { + heroBadge.innerHTML = latestVersion + ' · Apache 2.0 · Production Ready'; + } + } + }) + .catch(function () { + /* On network failure, hide chip entirely rather than showing stale data */ + if (chip) chip.style.display = 'none'; + }); + } + + /* ── 2. GitHub stars widget ─────────────────────────── */ + var inner = document.querySelector('.md-header__inner'); + var palette = document.querySelector('form[data-md-component="palette"]'); + var existingGhBtn = document.querySelector('.nx-gh-btn'); + + // Skip if already injected (instant navigation guard) + if (inner && palette && !existingGhBtn) { + var ghBtn = document.createElement('a'); + ghBtn.href = 'https://github.com/Prescott-Data/nexus-framework'; + ghBtn.target = '_blank'; + ghBtn.rel = 'noopener noreferrer'; + ghBtn.className = 'nx-gh-btn'; + ghBtn.setAttribute('aria-label', 'Star Nexus on GitHub'); + ghBtn.innerHTML = + 'Prescott-Data/nexus' + + '' + + ''; + + inner.insertBefore(ghBtn, palette); + inner.appendChild(palette); + } + + /* ── 3. Async star count ────────────────────────────── */ + fetch('https://api.github.com/repos/Prescott-Data/nexus-framework') + .then(function (r) { return r.json(); }) + .then(function (data) { + var el = document.querySelector('.nx-stars-count'); + if (el && typeof data.stargazers_count === 'number') { + var n = data.stargazers_count; + el.textContent = n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); + } + }) + .catch(function () {}); + + /* ── 4. External tabs → new window ────────────────────── */ + var EXT = ['https://developers.prescottdata.io', 'https://discord.gg']; + document.querySelectorAll('.md-tabs__link').forEach(function (link) { + var href = link.getAttribute('href') || ''; + if (EXT.some(function (p) { return href.startsWith(p); })) { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } + }); + +} + +// Run on first load +document.addEventListener('DOMContentLoaded', initHeader); + +// Re-run on every instant navigation (Material for MkDocs SPA mode) +if (typeof document$ !== 'undefined') { + document$.subscribe(initHeader); +} diff --git a/docs/javascripts/llm-assist.js b/docs/javascripts/llm-assist.js new file mode 100644 index 0000000..4a944cd --- /dev/null +++ b/docs/javascripts/llm-assist.js @@ -0,0 +1,178 @@ +function initLLMWidget() { + var contentInner = document.querySelector('.md-content__inner'); + if (!contentInner) return; + + // Skip if already injected (instant navigation guard) + if (contentInner.querySelector('.nx-llm-widget')) return; + + // Hide ALL existing MkDocs action buttons (edit + view source) + var allButtons = contentInner.querySelectorAll('.md-content__button'); + var rawUrl = null; // resolved below — never default to the HTML page URL + allButtons.forEach(function(btn) { + if (btn.tagName === 'A' && btn.href) { + // Prefer the edit button URL for raw markdown (GitHub-hosted docs) + var u = btn.href + .replace('github.com', 'raw.githubusercontent.com') + .replace('/edit/', '/') + .replace('/blob/', '/'); + if (u !== btn.href) rawUrl = u; + } + btn.style.display = 'none'; + }); + + // Local dev fallback: derive the .md source path from the current URL. + // MkDocs serves /foo/bar/ from docs/foo/bar.md (or docs/foo/bar/index.md). + if (!rawUrl && window.location.hostname === 'localhost') { + var pathname = window.location.pathname.replace(/\/$/, '') || '/index'; + rawUrl = '/docs' + pathname + '.md'; + window._nxMdPathBase = pathname; + } + + var icons = { + copy: '', + check: '', + markdown: '', + chevron: '', + external: '' + }; + + var menuItems = [ + { id: 'copy', icon: icons.copy, title: 'Copy page', sub: 'Copy page as Markdown for LLMs' }, + { id: 'markdown', icon: icons.markdown, title: 'View as Markdown', sub: 'View this page as plain text' } + ]; + + var container = document.createElement('div'); + container.className = 'nx-llm-widget'; + + var trigger = document.createElement('div'); + trigger.className = 'nx-llm-trigger'; + + var triggerMain = document.createElement('div'); + triggerMain.className = 'nx-llm-btn-main'; + triggerMain.innerHTML = icons.copy; + triggerMain.title = 'Copy page'; + + var triggerChev = document.createElement('div'); + triggerChev.className = 'nx-llm-btn-chev'; + triggerChev.innerHTML = icons.chevron; + triggerChev.title = 'More options'; + + trigger.appendChild(triggerMain); + trigger.appendChild(triggerChev); + + var dropdown = document.createElement('div'); + dropdown.className = 'nx-llm-dropdown'; + + menuItems.forEach(function(item) { + var el = document.createElement('div'); + el.className = 'nx-llm-item'; + var textHtml = '
' + item.title + '' + item.sub + '
'; + el.innerHTML = '
' + item.icon + '
' + textHtml + (item.id === 'markdown' ? icons.external : ''); + + el.addEventListener('click', function(e) { + e.preventDefault(); + dropdown.classList.remove('open'); + if (item.id === 'copy') fetchAndCopy(); + if (item.id === 'markdown') viewAsMarkdown(); + }); + + dropdown.appendChild(el); + }); + + container.appendChild(trigger); + container.appendChild(dropdown); + + function getText(callback) { + // Primary: read from the raw markdown embedded by a MkDocs hook (if present) + var embedded = document.getElementById('nx-page-source'); + if (embedded) { + var src = embedded.textContent || embedded.innerHTML || ''; + if (src.trim()) { + callback(src); + return; + } + } + + // Fallback: fetch from GitHub raw source or local docs + if (!rawUrl) { + var article = document.querySelector('article') || document.querySelector('.md-content'); + callback(article ? article.innerText : '(no content)'); + return; + } + + var candidates = [rawUrl]; + if (window.location.hostname === 'localhost' && window._nxMdPathBase) { + candidates.push('/docs' + window._nxMdPathBase + '/index.md'); + } + + function tryNext(urls) { + if (!urls.length) { + var article = document.querySelector('article') || document.querySelector('.md-content'); + callback(article ? article.innerText : '(no content)'); + return; + } + var url = urls.shift(); + fetch(url).then(function(r) { + if (!r.ok) throw new Error('not ok'); + return r.text(); + }).then(function(text) { + if (text.trimStart().startsWith('' + icons.check + ''; + getText(function(text) { + navigator.clipboard.writeText(text); + setTimeout(function() { triggerMain.innerHTML = icons.copy; }, 2000); + }); + } + + function viewAsMarkdown() { + getText(function(text) { + var win = window.open('', '_blank'); + if (win) { + win.document.write('
' + text.replace(//g, '>') + '
'); + win.document.close(); + } + }); + } + + // Inject widget — prepend to content area + var wrapper = document.createElement('div'); + wrapper.className = 'md-content__button'; + wrapper.style.cssText = 'position:relative;z-index:1;'; + wrapper.appendChild(container); + contentInner.insertBefore(wrapper, contentInner.firstChild); + + // Split-button event handlers + triggerChev.addEventListener('click', function(e) { + e.stopPropagation(); + dropdown.classList.toggle('open'); + }); + + triggerMain.addEventListener('click', function(e) { + e.stopPropagation(); + dropdown.classList.remove('open'); + fetchAndCopy(); + }); + + document.addEventListener('click', function(e) { + if (!container.contains(e.target)) dropdown.classList.remove('open'); + }); +} + +// Run on first load +document.addEventListener('DOMContentLoaded', initLLMWidget); + +// Re-run on every instant navigation (Material for MkDocs SPA mode) +if (typeof document$ !== 'undefined') { + document$.subscribe(initLLMWidget); +} diff --git a/docs/reference/api.md b/docs/reference/api.md index 8420fbf..23f1b34 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -1,16 +1,100 @@ +--- +icon: material/code-json +--- + # API Reference -The Nexus Framework uses OpenAPI 3.0 specifications to define its contracts. +Nexus exposes two API surfaces: the **Gateway API**, which agents and applications call, and the **Broker API**, which is internal and used only by the Gateway and administrative tooling. + +The full OpenAPI 3.0 specification is at [`openapi.yaml`](https://github.com/Prescott-Data/nexus-framework/blob/main/openapi.yaml). The v1 surface is frozen — no breaking changes without a major version bump and a deprecation period. + +## Gateway API + +The stable, public-facing surface for all agent integrations. Versioned at `/v1`. + +### Connection endpoints + +| Method | Path | Description | +|---|---|---| +| `POST` | `/v1/request-connection` | Initiate an OAuth handshake or static credential capture | +| `GET` | `/v1/check-connection/{id}` | Poll connection status | +| `GET` | `/v1/token/{id}` | Retrieve credentials for an active connection | +| `POST` | `/v1/refresh/{id}` | Force a token refresh for a connection | +| `GET` | `/v1/capture-schema` | Fetch the credential schema for a static provider | +| `POST` | `/v1/capture-credential` | Submit credentials for a static provider | + +### Agent session endpoints + +| Method | Path | Description | +|---|---|---| +| `POST` | `/v1/agent-sessions` | Request a scoped session for a registered agent | +| `GET` | `/v1/agent-sessions/{id}` | Check session status and metadata | +| `DELETE` | `/v1/agent-sessions/{id}` | Close a session and revoke the token | +| `POST` | `/v1/agent-sessions/obo` | Request an OBO session tied to a user context token | + +### Provider endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/v1/providers` | List all registered providers | +| `GET` | `/v1/providers/metadata` | Grouped metadata for frontend rendering | +| `POST` | `/v1/providers` | Register a new provider | +| `GET` | `/v1/providers/{id}` | Get provider by ID | +| `PATCH` | `/v1/providers/{id}` | Update specific fields of a provider | +| `DELETE` | `/v1/providers/{id}` | Delete a provider | + +### System + +| Method | Path | Description | +|---|---|---| +| `GET` | `/v1/health` | Health check — returns `200 OK` when ready | + +### Authentication + +Connection and token endpoints do not require authentication headers. Access control is enforced by the `connection_id` — only callers holding a valid connection ID can retrieve its credentials. Treat `connection_id` values as session tokens. + +Provider management and agent session endpoints require the `X-API-Key` header. + +### Token response shape + +`GET /v1/token/{id}` returns a structured payload regardless of credential type: + +```json +{ + "strategy": { "type": "oauth2" }, + "credentials": { + "access_token": "eyJ...", + "expires_at": 1715000000 + }, + "scope": "openid email profile", + "expires_at": 1715000000 +} +``` + +The `strategy.type` field tells you how to apply the credentials. See [Authentication Strategies](../concepts/auth-strategies.md) for all strategy types and their credential shapes. + +--- + +## Broker API + +Internal surface. Called by the Gateway and administrative tooling (`nexus-cli`). Do not expose the Broker to agents or untrusted networks. + +The Broker's OpenAPI spec is at [`nexus-broker/openapi.yaml`](https://github.com/Prescott-Data/nexus-framework/blob/main/nexus-broker/openapi.yaml). It evolves between minor versions — fields and endpoints may change. + +### Agent registry endpoints (admin) -## Gateway API (Public) -The Gateway provides the stable, public-facing API for agents and services. +| Method | Path | Description | +|---|---|---| +| `POST` | `/admin/v1/agents` | Register an agent with its allowed scopes | +| `GET` | `/admin/v1/agents` | List all registered agents | +| `GET` | `/admin/v1/agents/{id}` | Get a specific agent | +| `PATCH` | `/admin/v1/agents/{id}` | Update agent allowed scopes | +| `DELETE` | `/admin/v1/agents/{id}` | Deregister an agent | -- **Spec File:** [`openapi.yaml`](../../openapi.yaml) -- **Status:** v1 Frozen. -- **Client SDK:** [`nexus-sdk`](../../nexus-sdk) +### Audit -## Broker API (Internal) -The Broker provides the internal API for provider management and token operations. +| Method | Path | Description | +|---|---|---| +| `GET` | `/audit` | Query audit log events | -- **Spec File:** [`nexus-broker/openapi.yaml`](../../nexus-broker/openapi.yaml) -- **Status:** Internal / Evolving. +All Broker API calls require the `X-API-Key` header. See the [Audit Log](audit-log.md) guide for query parameters and response schema. diff --git a/docs/reference/audit-log.md b/docs/reference/audit-log.md index c6e6574..a489a53 100644 --- a/docs/reference/audit-log.md +++ b/docs/reference/audit-log.md @@ -1,129 +1,89 @@ -# Audit Log Reference - -The Nexus Broker maintains a tamper-evident **audit log** of every control-plane mutation. Every time a provider is created, updated, or deleted — or an OAuth connection is established — a structured record is written to the `audit_events` table. +--- +icon: material/clipboard-text-clock-outline +--- -This provides a queryable history of who changed what and when, which is essential for operating Nexus as critical infrastructure. +# Audit Log ---- +The Nexus Broker maintains a tamper-evident audit log of every control-plane mutation. The log is written to the `audit_events` table in PostgreSQL and is queryable through the Broker's REST API. -## Audited Events - -| Event Type | Trigger | -| :--- | :--- | -| `provider.created` | A new provider profile is registered via `POST /providers` | -| `provider.updated` | A provider's configuration is modified (`PUT` or `PATCH`) | -| `provider.deleted` | A provider is deleted (by ID or by name) | -| `oauth_flow_completed` | An OAuth callback completes successfully and a connection is established | -| `token_exchange_failed` | The authorization code → token exchange failed | -| `token_storage_failed` | Tokens were exchanged but could not be encrypted/stored | -| `token_retrieved` | A downstream service fetched a connection's token via `GET /connections/{id}/token` | -| `token_retrieval_failed` | A token fetch failed (not found, decryption error, inactive connection, etc.) | -| `token_refresh_fatal` | A refresh token was rejected by the provider (4xx), connection moved to `attention` | -| `oauth_error` | The provider returned an error on the OAuth callback (e.g. `access_denied`) | +The audit log records who did what to which resource, and when. It is the primary tool for answering compliance questions ("when was this provider created?"), forensic questions ("why did this agent lose access?"), and operational questions ("which connections were affected by the provider update on the 8th?"). --- -## Query the Audit Log - -``` -GET /audit -``` +## What is logged -Returns recent audit events in descending chronological order. This endpoint is protected by `ApiKeyMiddleware`. +Every event in the audit log carries an event type, structured event data, the caller's IP address, and the User-Agent string. -> **Note:** The Nexus Broker API is unversioned — all routes are mounted at the root (e.g., `/providers`, `/audit`). The `/v1/audit` path referenced elsewhere is aspirational and will apply if/when the Broker adopts a versioned API prefix. +| Event type | When it is written | +|---|---| +| `provider.created` | On every successful `POST /providers` | +| `provider.updated` | On every `PUT` or `PATCH` to a provider | +| `provider.deleted` | On every successful provider deletion | +| `connection.created` | On every successful OAuth callback (token exchange completed) | +| `token.retrieved` | On every successful `GET /connections/{id}/token` | +| `token.exchange_failed` | When the OAuth token exchange fails during a callback | +| `token.storage_failed` | When token encryption or database write fails | +| `token.refresh_fatal` | When a background refresh fails permanently (4xx from provider) | -### Query Parameters +--- -| Parameter | Type | Description | -| :--- | :--- | :--- | -| `event_type` | string | Filter by event type (e.g. `provider.deleted`) | -| `since` | string | RFC3339 timestamp — only return events after this time | -| `limit` | integer | Maximum records to return (default: `50`, max: `1000`) | +## Querying the audit log -### Examples +The audit log is available at `GET /audit` on the Broker. All requests require the `X-API-Key` header. -**Fetch the last 50 audit events:** ```bash curl -s "http://localhost:8080/audit" \ - -H "X-API-Key: " | jq . -``` - -**Filter by event type:** -```bash -curl -s "http://localhost:8080/audit?event_type=provider.deleted" \ - -H "X-API-Key: " | jq . + -H "X-API-Key: your-api-key" | jq . ``` -**Filter by time window:** -```bash -curl -s "http://localhost:8080/audit?since=2026-05-01T00:00:00Z&limit=100" \ - -H "X-API-Key: " | jq . -``` - -**Combine filters:** -```bash -curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-01T00:00:00Z" \ - -H "X-API-Key: " | jq . -``` +### Query parameters ---- +| Parameter | Type | Description | +|---|---|---| +| `event_type` | string | Filter by event type. Example: `provider.deleted` | +| `resource_id` | string | Filter by provider ID or connection ID | +| `since` | ISO 8601 | Return events after this timestamp | +| `until` | ISO 8601 | Return events before this timestamp | +| `limit` | integer | Maximum number of events to return. Default: 50, max: 500 | +| `offset` | integer | Pagination offset | -## Response Schema +### Response schema ```json -[ - { - "id": "a1b2c3d4-...", - "connection_id": "f5e6d7c8-...", - "event_type": "oauth_flow_completed", - "event_data": "{\"provider_id\": \"...\"}", - "ip_address": "10.0.0.1", - "user_agent": "nexus-gateway/1.0", - "created_at": "2026-05-05T10:30:00Z" - }, - { - "id": "b2c3d4e5-...", - "event_type": "provider.deleted", - "event_data": "{\"provider_id\": \"...\", \"provider_name\": \"old-slack\"}", - "ip_address": "192.168.1.5", - "user_agent": "curl/7.88.1", - "created_at": "2026-05-05T09:15:00Z" - } -] +{ + "events": [ + { + "id": "evt_01HXYZ...", + "event_type": "provider.created", + "created_at": "2026-05-08T09:12:34Z", + "caller_ip": "10.0.0.2", + "user_agent": "nexus-cli/0.4.0", + "data": { + "provider_id": "prov_01HABC...", + "provider_name": "google-workspace" + } + } + ], + "total": 142, + "limit": 50, + "offset": 0 +} ``` -> **Note:** Fields with `omitempty` (`connection_id`, `event_data`, `ip_address`, `user_agent`) are omitted from the response when their value is null, rather than being rendered as `null`. +--- -### Field Descriptions +## IP address handling -| Field | Type | Description | -| :--- | :--- | :--- | -| `id` | UUID | Unique audit event identifier | -| `connection_id` | UUID \| null | Associated connection, if applicable | -| `event_type` | string | The event type (see table above) | -| `event_data` | string \| null | JSON payload with event-specific context | -| `ip_address` | string \| null | IP of the caller (respects `X-Forwarded-For`) | -| `user_agent` | string \| null | User-Agent of the caller | -| `created_at` | RFC3339 | Timestamp of the event | +The Broker respects the `X-Forwarded-For` header when recording the caller IP. If your Gateway is behind a load balancer or reverse proxy, ensure the proxy sets `X-Forwarded-For` correctly so the audit log reflects the originating client IP rather than the proxy address. --- -## Database - -Audit events are stored in the `audit_events` PostgreSQL table, created in the initial migration (`00_create_tables.sql`). An index on `created_at DESC` (migration `11_add_audit_created_at_index.sql`) ensures fast time-range queries even at high volume. +## Retention -!!! note "Retention Policy" - There is currently no automatic retention/pruning policy for audit events. For long-running production deployments, consider adding a scheduled job to archive or delete records older than your compliance window (e.g., 90 days). +The audit log is stored in the same PostgreSQL database as provider and connection data. There is no automatic retention policy. Events are not deleted on a schedule. If you need to bound the size of the audit log, implement a periodic archival job that copies events older than your retention window to cold storage and deletes them from the `audit_events` table. --- -## Audit via `nexus-cli` +## Using the audit log with nexus-cli -Every mutation performed by [`nexus-cli apply`](../guides/security-as-code.md) is automatically recorded in the audit log. You can correlate CLI runs with audit events using the `ip_address` field (the IP of your CI runner) and the `event_data.provider_name` field. - -```bash -# See all provider changes from a CI apply run -curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-05T13:00:00Z" \ - -H "X-API-Key: " | jq . -``` +When you manage providers through `nexus-cli apply`, every provider create, update, and delete generates audit log entries. This gives you an automated, traceable record of every declarative change with the `nexus-cli` User-Agent in the `user_agent` field, making it straightforward to distinguish CLI-driven changes from manual API calls. diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 0000000..d3042af --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,151 @@ +--- +icon: material/console +--- + +# CLI Reference + +`nexus-cli` is a command-line tool for managing Nexus provider configuration declaratively. It reads a YAML manifest, compares it against the live Broker state, and applies changes. + +## Install + +```bash +# Build from source +cd nexus-cli && go build -o nexus-cli . + +# Install with Go +go install github.com/Prescott-Data/nexus-framework/nexus-cli@latest +``` + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `BROKER_BASE_URL` | yes | Base URL of the Broker, e.g. `http://localhost:8080` | +| `API_KEY` | yes | Broker API key — same value as the Broker's `API_KEY` env var | + +## Commands + +### `plan` + +Computes the diff between the manifest and the live Broker state. Prints the actions that would be taken. Makes no changes. + +```bash +nexus-cli plan [flags] +``` + +| Flag | Default | Description | +|---|---|---| +| `-file` | `nexus-providers.yaml` | Path to the providers manifest file | +| `-prune` | `false` | Include deletion of providers not in the manifest | + +**Output:** + +``` ++ CREATE : google-workspace +~ UPDATE : salesforce + client_secret: [redacted] → [redacted] + scopes: ["crm:read"] → ["crm:read","crm:write"] +! ORPHAN : old-provider (would be deleted if --prune was passed) + +Plan complete. Run 'nexus-cli apply' to perform these actions. +``` + +`client_secret` and `client_id` values are always masked in plan output. + +--- + +### `apply` + +Applies the manifest against the live Broker. Creates new providers, updates drifted fields, and optionally deletes orphans. + +```bash +nexus-cli apply [flags] +``` + +| Flag | Default | Description | +|---|---|---| +| `-file` | `nexus-providers.yaml` | Path to the providers manifest file | +| `-prune` | `false` | Delete providers that exist in the Broker but not in the manifest | + +`apply` runs `plan` internally first and prints the same diff before executing. Use `-prune` with care — it permanently deletes providers and orphans every active connection tied to them. + +--- + +## Manifest format + +The manifest is a YAML file declaring the desired state of all providers. + +```yaml +providers: + - name: google-workspace + auth_type: oauth2 + client_id: ${GOOGLE_CLIENT_ID} + client_secret: ${GOOGLE_CLIENT_SECRET} + issuer: https://accounts.google.com + enable_discovery: true + scopes: + - openid + - email + - profile + - offline_access + + - name: salesforce + auth_type: oauth2 + client_id: ${SF_CLIENT_ID} + client_secret: ${SF_CLIENT_SECRET} + auth_url: https://login.salesforce.com/services/oauth2/authorize + token_url: https://login.salesforce.com/services/oauth2/token + scopes: + - crm:contacts:read + params: + skip_scope_on_exchange: true + + - name: internal-api + auth_type: api_key + params: + credential_schema: + type: object + required: [api_key] + properties: + api_key: + type: string + title: API Key +``` + +### Provider fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Unique slug — used as the identifier in connection requests | +| `auth_type` | string | yes | `oauth2`, `api_key`, `basic_auth`, `aws_sigv4`, `query_param`, `hmac_payload` | +| `client_id` | string | OAuth2 only | OAuth2 client ID | +| `client_secret` | string | OAuth2 only | OAuth2 client secret | +| `auth_url` | string | OAuth2 (no OIDC) | Authorization endpoint | +| `token_url` | string | OAuth2 (no OIDC) | Token endpoint | +| `issuer` | string | OIDC only | Issuer URL — enables OIDC discovery | +| `enable_discovery` | bool | OIDC only | Must be `true` when using `issuer` | +| `scopes` | []string | OAuth2 | Default scopes for this provider | +| `auth_header` | string | rarely | Auth header style, e.g. `client_secret_basic` for Twitter/X | +| `api_base_url` | string | no | Base URL for the provider's API — informational | +| `params` | object | varies | Strategy-specific and provider-specific parameters | + +### Environment variable substitution + +The CLI expands `${VAR}` references in the manifest at runtime using the process environment. Use this to keep secrets out of the manifest file: + +```bash +export GOOGLE_CLIENT_ID=my-client-id +export GOOGLE_CLIENT_SECRET=my-client-secret +nexus-cli apply -file nexus-providers.yaml +``` + +## Drift detection + +`nexus-cli` computes drift by comparing each field in the manifest against the live Broker response. Fields with semantic equivalence (empty array vs null) are not flagged as drift. Secret fields (`client_id`, `client_secret`) are always included in the update payload when any drift is detected on the provider — they cannot be compared because the Broker does not return secret values in GET responses. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success — no errors | +| `1` | Error — manifest read failure, API error, or unrecoverable drift | diff --git a/docs/reference/security-model.md b/docs/reference/security-model.md index 1d6584e..56ef7fd 100644 --- a/docs/reference/security-model.md +++ b/docs/reference/security-model.md @@ -1,3 +1,7 @@ +--- +icon: material/shield-check-outline +--- + # Security Model The Nexus Framework is built on the principle of **Least Privilege for Agents**. Agents should never hold the "keys to the kingdom" (Refresh Tokens); they should only hold short-lived "Usage Secrets." diff --git a/docs/sdks/go.md b/docs/sdks/go.md index 67e0d55..310a237 100644 --- a/docs/sdks/go.md +++ b/docs/sdks/go.md @@ -1,3 +1,7 @@ +--- +icon: material/language-go +--- + # Go SDK The `nexus-sdk` Go package is a zero-dependency client for the Nexus Gateway. It supports both standard OAuth connection management and MCP server token injection. diff --git a/docs/sdks/index.md b/docs/sdks/index.md index 6c11a9f..306af86 100644 --- a/docs/sdks/index.md +++ b/docs/sdks/index.md @@ -1,3 +1,7 @@ +--- +icon: material/package-variant-closed +--- + # SDK Overview Nexus ships three first-class client SDKs. Each provides identical functionality — choose the one that matches your application's language. diff --git a/docs/sdks/python.md b/docs/sdks/python.md index dffd7df..d02a597 100644 --- a/docs/sdks/python.md +++ b/docs/sdks/python.md @@ -1,3 +1,7 @@ +--- +icon: material/language-python +--- + # Python SDK The `nexus-sdk` Python package is a zero-dependency client for the Nexus Gateway supporting both standard OAuth connection management and MCP server token injection. diff --git a/docs/sdks/typescript.md b/docs/sdks/typescript.md index 9561062..f627d7a 100644 --- a/docs/sdks/typescript.md +++ b/docs/sdks/typescript.md @@ -1,3 +1,7 @@ +--- +icon: material/language-typescript +--- + # TypeScript SDK The `@dromos/nexus-sdk` package is a TypeScript/JavaScript client for the Nexus Gateway supporting both standard OAuth connection management and MCP server token injection. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..2038ecd --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,2422 @@ +/* ============================================================ + Nexus Docs — Custom Theme + ============================================================ */ + +/* ---------- Material Custom Palette Overrides ---------- */ +[data-md-color-primary="custom"] { + --md-primary-fg-color: #0071F7; + --md-primary-fg-color--light: #35B0FE; + --md-primary-fg-color--dark: #0049C6; + /* Do NOT set --md-primary-bg-color here — Material uses it as + active-tab text colour, which would render as white-on-white + in light mode. Let it fall through to Material's default (#fff + only on coloured primary backgrounds, not on our white header). */ +} + +[data-md-color-accent="custom"] { + --md-accent-fg-color: #0CF0E3; + --md-accent-fg-color--light: rgba(12, 240, 227, 0.1); + --md-accent-bg-color-light: rgba(12, 240, 227, 0.1); +} + +/* ---------- Google Fonts ---------- */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ---------- Design Tokens ---------- */ +:root { + --nx-brand: #0071F7; + --nx-brand-light: #35B0FE; + --nx-brand-dark: #0049C6; + --nx-accent: #0CF0E3; + --nx-purple: #4951F3; + + /* UI Semantic Colors */ + --nx-green: #0CC572; /* UI Green — success, active, healthy states */ + --nx-green-subtle: rgba(12, 197, 114, 0.10); + --nx-yellow: #E8A302; /* UI Yellow — warnings, in-progress, caution */ + --nx-yellow-subtle: rgba(232, 163, 2, 0.10); + + --nx-surface: #f8fafc; + --nx-border: #e2e8f0; + --nx-text-muted: #64748b; + + --nx-gradient: linear-gradient(135deg, #35B0FE 0%, #2457F7 35%, #443ACC 68%, #752CEA 100%); + --nx-gradient-subtle: linear-gradient(135deg, rgba(23, 88, 245, 0.06) 0%, rgba(12, 240, 227, 0.04) 100%); + --nx-gradient-brand: linear-gradient(135deg, #0049C6 0%, #0971EE 50%, #023DF3 100%); + + --nx-radius: 10px; + --nx-shadow: 0 4px 24px rgba(23, 88, 245, 0.10); + --nx-shadow-hover: 0 8px 32px rgba(23, 88, 245, 0.18); + --nx-transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + + /* Sidebar width — wider than Material default (12rem) so labels never truncate */ + --md-sidebar-width: 15rem; +} + +/* ---------- Logo ---------- */ +.md-header__button.md-logo { + height: auto !important; + padding: 0.25rem 0.5rem !important; + margin: 0 0.2rem 0 0 !important; + display: flex !important; + align-items: center !important; +} + +.md-header__button.md-logo img { + height: 2.2rem !important; + width: 2.2rem !important; + max-width: none !important; + max-height: none !important; + display: block !important; + object-fit: cover !important; + border-radius: 8px !important; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15) !important; + transition: transform 0.2s ease, box-shadow 0.2s ease !important; +} + +.md-header__button.md-logo img:hover { + transform: scale(1.06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important; +} + +[data-md-color-scheme="slate"] { + --nx-surface: #0f1117; + --nx-border: #1e2130; + --nx-text-muted: #94a3b8; + --nx-gradient-subtle: linear-gradient(135deg, rgba(23, 88, 245, 0.12) 0%, rgba(12, 240, 227, 0.08) 100%); + --nx-shadow: 0 4px 24px rgba(0, 0, 0, 0.40); + --nx-shadow-hover: 0 8px 40px rgba(23, 88, 245, 0.25); +} + +/* ---------- Base Typography ---------- */ +body, +.md-content { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ── Heading scale & site-wide colour hierarchy ─────────────────────────── + H1 — page title → default (dark/white per scheme), large + bold + H2 — section grouper → brand blue + left border accent + H3 — sub-section → brand-light blue + H4 — individual item → slightly muted, normal weight + ─────────────────────────────────────────────────────────────────────── */ + +.md-typeset h1 { + font-weight: 700; + letter-spacing: -0.025em; +} + +.md-typeset h2 { + font-weight: 600; + letter-spacing: -0.015em; + color: var(--nx-brand); + border-left: 3px solid var(--nx-brand); + padding-left: 0.7rem; + margin-top: 2rem; +} + +.md-typeset h3 { + font-weight: 600; + color: var(--nx-brand-light); +} + +.md-typeset h4 { + font-weight: 500; + color: var(--nx-text-muted); +} + +/* Dark mode heading colours */ +[data-md-color-scheme="slate"] .md-typeset h2 { + color: var(--nx-brand-light); + border-left-color: var(--nx-brand-light); +} + +[data-md-color-scheme="slate"] .md-typeset h3 { + color: #7ec8f8; +} + +[data-md-color-scheme="slate"] .md-typeset h4 { + color: var(--nx-text-muted); +} + +/* ── Body copy density — tight prose ── */ +.md-typeset { + font-size: 0.7875rem !important; + line-height: 1.5 !important; +} + +.md-typeset p { + font-size: 0.7875rem; + line-height: 1.5; + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.md-typeset li { + font-size: 0.7875rem; + line-height: 1.45; +} + +.md-typeset dt, +.md-typeset dd { + font-size: 0.75rem; + line-height: 1.45; +} + +.md-typeset .admonition p, +.md-typeset details p { + font-size: 0.75rem; + line-height: 1.45; +} + +.md-typeset table td, +.md-typeset table th { + font-size: 0.72rem; + line-height: 1.4; +} + +.md-typeset table th { + font-size: 0.64rem !important; + font-weight: 600 !important; + letter-spacing: 0.05em !important; + text-transform: uppercase !important; +} + +.md-typeset code, +.md-typeset pre code { + font-size: 0.72rem; + line-height: 1.38; +} + +/* Headings stay prominent but tighter */ +.md-typeset h1 { + font-size: 1.75rem; + line-height: 1.2; +} + +.md-typeset h2 { + font-size: 1.05rem; + line-height: 1.25; +} + +.md-typeset h3 { + font-size: 0.9rem; + line-height: 1.3; +} + +.md-typeset h4 { + font-size: 0.85rem; + line-height: 1.35; +} + +/* ============================================================ + HEADER — adaptive: white in light mode, #0c0c0e in dark + ============================================================ */ + +/* Light mode */ +.md-header { + background: #ffffff !important; + box-shadow: none !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +.md-header[data-md-state="shadow"] { + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06) !important; +} + +/* Dark mode */ +[data-md-color-scheme="slate"] .md-header { + background: #0c0c0e !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +[data-md-color-scheme="slate"] .md-header[data-md-state="shadow"] { + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06) !important; +} + +/* Push theme-toggle (palette form) to the far right */ +.md-header__inner { + display: flex; + align-items: center; +} + +/* Prevent title from collapsing due to flex rules */ +.md-header__title { + flex-grow: 0 !important; + flex-shrink: 0 !important; + min-width: max-content !important; + font-family: 'Inter', sans-serif; + font-size: 0.9375rem; + font-weight: 700; + letter-spacing: -0.025em; + color: #0c0c0e !important; + margin-left: 0.45rem; +} + +/* On mobile the title must shrink to fit next to hamburger + logo */ +@media screen and (max-width: 76.1875em) { + .md-header__title { + min-width: 0 !important; + flex-shrink: 1 !important; + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +[data-md-color-scheme="slate"] .md-header__title { + color: #fff !important; +} + +/* Center the search bar absolutely in the header — desktop only. + On mobile Material hides the pill behind a magnifying glass icon + button that opens a full-screen modal. */ +@media screen and (min-width: 76.25em) { + .md-header .md-header__inner.md-grid { + position: relative !important; + } + + .md-header .md-header__inner .md-search, + .md-header .md-search[data-md-component="search"] { + position: absolute !important; + left: 50% !important; + transform: translateX(-50%) !important; + width: 380px !important; + z-index: 2 !important; + } +} + +.md-header .md-search__form { + width: 100%; +} + +/* Push right-side items (GH widget, palette toggle) to far right */ +.nx-version-chip { + margin-right: auto; +} + +/* Hide Material's built-in repo source link — we don't use it */ +.md-header__source { + display: none !important; +} + +/* Hide edit/view-source icon button in header */ +[data-md-component="header"] .md-header__button[title*="Edit"], +[data-md-component="header"] .md-header__button[title*="View"] { + display: none !important; +} + + + +/* All header icons/buttons */ +.md-header .md-icon, +.md-header .md-header__button { + color: rgba(0, 0, 0, 0.45) !important; + transition: color 0.15s ease; +} + +.md-header .md-header__button:hover { + color: rgba(0, 0, 0, 0.8) !important; +} + +[data-md-color-scheme="slate"] .md-header .md-icon, +[data-md-color-scheme="slate"] .md-header .md-header__button { + color: rgba(255, 255, 255, 0.5) !important; +} + +[data-md-color-scheme="slate"] .md-header .md-header__button:hover { + color: #fff !important; +} + +/* ============================================================ + SEARCH UI (Pill & Modal) + ============================================================ */ + +/* 1. Idle Search Bar (Pill) */ +.md-header .md-search__form { + background: rgba(0, 0, 0, 0.05) !important; + border-radius: 99px !important; + border: none !important; + height: 36px !important; + position: relative; + transition: all 0.2s ease; +} + +.md-header .md-search__input { + font-family: 'Inter', sans-serif; + font-size: 0.85rem; + color: #0c0c0e !important; + padding-left: 2.2rem !important; + padding-right: 2.5rem !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + background: transparent !important; +} + +.md-header .md-search__input::placeholder { + color: rgba(0, 0, 0, 0.4) !important; +} + +/* Show the search icon */ +.md-search__icon[for="__search"] { + display: block !important; + position: absolute; + left: 0.7rem; + top: 50%; + transform: translateY(-50%); + width: 1rem; + height: 1rem; + color: rgba(0, 0, 0, 0.4); +} + +/* Add the ⌘K badge */ +.md-header .md-search__form::after { + content: "⌘K"; + position: absolute; + right: 0.8rem; + top: 50%; + transform: translateY(-50%); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 0.75rem; + font-weight: 500; + color: rgba(0, 0, 0, 0.4); + pointer-events: none; +} + +/* 2. Active Search State (Modal) */ +[data-md-toggle="search"]:checked~.md-header .md-search { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: 100vh !important; + z-index: 100 !important; + transform: none !important; + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: 15vh; +} + +/* Modal Overlay (Blur + Click to close) */ +[data-md-toggle="search"]:checked~.md-header .md-search__overlay { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: 100vh !important; + background: rgba(0, 0, 0, 0.4) !important; + backdrop-filter: blur(8px) !important; + -webkit-backdrop-filter: blur(8px) !important; + z-index: 0 !important; + opacity: 1 !important; + cursor: default !important; + display: block !important; +} + +/* Modal Box (Inner) */ +[data-md-toggle="search"]:checked~.md-header .md-search__inner { + position: relative !important; + width: 600px !important; + max-width: 90vw !important; + background: transparent !important; + box-shadow: none !important; + top: 0 !important; + transform: none !important; + left: 0 !important; + z-index: 10 !important; +} + +/* Active Modal Header (Form) */ +[data-md-toggle="search"]:checked~.md-header .md-search__form { + background: #fff !important; + border-radius: 12px 12px 0 0 !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.05) !important; + height: 56px !important; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important; +} + +[data-md-toggle="search"]:checked~.md-header .md-search__form::after { + display: none !important; + /* Hide ⌘K when active */ +} + +/* Active Modal Body (Output/Results) */ +[data-md-toggle="search"]:checked~.md-header .md-search__output { + position: static !important; + background: #fff !important; + border-radius: 0 0 12px 12px !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + border-top: none !important; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15) !important; + max-height: 60vh !important; + overflow: hidden !important; +} + +/* Dark Mode Tweaks */ +[data-md-color-scheme="slate"] .md-header .md-search__form { + background: rgba(255, 255, 255, 0.08) !important; +} + +[data-md-color-scheme="slate"] .md-header .md-search__input { + color: #fff !important; +} + +[data-md-color-scheme="slate"] .md-header .md-search__input::placeholder, +[data-md-color-scheme="slate"] .md-search__icon[for="__search"], +[data-md-color-scheme="slate"] .md-header .md-search__form::after { + color: rgba(255, 255, 255, 0.5) !important; +} + +[data-md-color-scheme="slate"] [data-md-toggle="search"]:checked~.md-header .md-search__form { + background: #1e1e1e !important; + border-color: rgba(255, 255, 255, 0.1) !important; +} + +[data-md-color-scheme="slate"] [data-md-toggle="search"]:checked~.md-header .md-search__output { + background: #1e1e1e !important; + border-color: rgba(255, 255, 255, 0.1) !important; +} + +/* ── Version chip ─────────────────────────────────────── */ +.nx-version-chip { + display: inline-flex; + align-items: center; + font-family: 'Inter', sans-serif; + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.03em; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + color: rgba(0, 0, 0, 0.4); + margin-left: 0.6rem; + white-space: nowrap; + flex-shrink: 0; +} + +[data-md-color-scheme="slate"] .nx-version-chip { + border-color: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.4); +} + +/* ── GitHub stars widget ──────────────────────────────── */ +.nx-gh-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.12); + font-family: 'Inter', sans-serif; + font-size: 0.75rem; + font-weight: 500; + color: rgba(0, 0, 0, 0.5) !important; + text-decoration: none !important; + margin: 0 0.5rem; + transition: border-color 0.15s, color 0.15s, background 0.15s; + flex-shrink: 0; + white-space: nowrap; +} + +.nx-gh-btn:hover { + border-color: var(--nx-brand); + color: var(--nx-brand) !important; + background: rgba(23, 88, 245, 0.05); + text-decoration: none !important; +} + +.nx-gh-repo { + font-weight: 500; + letter-spacing: -0.01em; +} + +.nx-gh-sep { + opacity: 0.4; + font-size: 0.7rem; + margin: 0 1px; +} + +.nx-stars-count { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +[data-md-color-scheme="slate"] .nx-gh-btn { + border-color: rgba(255, 255, 255, 0.13); + color: rgba(255, 255, 255, 0.48) !important; +} + +[data-md-color-scheme="slate"] .nx-gh-btn:hover { + border-color: var(--nx-brand-light); + color: var(--nx-brand-light) !important; + background: rgba(23, 88, 245, 0.1); +} + + + + +/* ============================================================ + NAVIGATION TABS — adaptive: white light / dark in slate + ============================================================ */ + +/* ============================================================ + NAVIGATION TABS — tight bottom-hugging active underline + ============================================================ */ + +.md-tabs { + background: #ffffff !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + margin-top: 0; + min-height: 0; +} + +[data-md-color-scheme="slate"] .md-tabs { + background: #0c0c0e !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + margin-top: 0; +} + +/* The tab list — zero padding so links sit flush */ +.md-tabs__list { + padding: 0; + margin: 0; + display: flex; + align-items: stretch; + /* children fill full height */ +} + +.md-tabs__item { + /* let the item height be driven by the link */ + display: flex; + align-items: stretch; +} + +.md-tabs__link { + font-family: 'Inter', sans-serif; + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0; + color: #292929 !important; + opacity: 1 !important; + /* tight vertical padding — 6px top, only 4px bottom so underline is RIGHT at the bar */ + padding: 6px 12px 4px; + position: relative; + transition: color 0.15s ease; + margin-top: 0 !important; + display: inline-flex; + align-items: center; + gap: 5px; + text-decoration: none; + /* no bottom gap — the 2px underline will touch the border */ + box-sizing: border-box; +} + +[data-md-color-scheme="slate"] .md-tabs__link { + color: rgba(255, 255, 255, 0.42) !important; +} + +/* Active underline — pinned to very bottom of the tab row */ +.md-tabs__link::after { + content: ''; + position: absolute; + bottom: -1px; + /* sit on top of the border-bottom of .md-tabs */ + left: 12px; + right: 12px; + height: 2px; + background: var(--nx-brand); + border-radius: 2px 2px 0 0; + transform: scaleX(0); + transform-origin: center; + transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1); +} + +.md-tabs__link:hover { + color: #1a1a1a !important; + text-decoration: none; +} + +[data-md-color-scheme="slate"] .md-tabs__link:hover { + color: rgba(255, 255, 255, 0.8) !important; +} + +/* Active tab — explicit colours that beat Material's primary-bg-color bleed */ +.md-tabs__item--active .md-tabs__link, +.md-tabs__item--active .md-tabs__link:link, +.md-tabs__item--active .md-tabs__link:visited { + color: var(--nx-brand) !important; + font-weight: 600 !important; +} + +[data-md-color-scheme="slate"] .md-tabs__item--active .md-tabs__link, +[data-md-color-scheme="slate"] .md-tabs__item--active .md-tabs__link:link, +[data-md-color-scheme="slate"] .md-tabs__item--active .md-tabs__link:visited { + color: var(--nx-brand-light) !important; + font-weight: 600 !important; +} + +.md-tabs__item--active .md-tabs__link::after { + transform: scaleX(1); +} + +/* Hover preview of underline */ +.md-tabs__item:not(.md-tabs__item--active) .md-tabs__link:hover::after { + transform: scaleX(0.6); + opacity: 0.35; +} + +/* ============================================================ + TAB BAR ICONS — injected for sections that link to pages + without navigation.indexes (so Material doesn't auto-render + an icon from frontmatter). We match by the tab's href. + ============================================================ */ + +/* Shared pseudo-element setup for injected tab icons */ +.md-tabs__link[href*="getting-started"]::before, +.md-tabs__link[href*="concepts/"]::before, +.md-tabs__link[href*="guides/"]::before, +.md-tabs__link[href*="reference/"]::before, +.md-tabs__link[href*="infrastructure/"]::before { + content: ''; + display: inline-block; + width: 1rem; + height: 1rem; + flex-shrink: 0; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; + vertical-align: middle; + margin-right: 2px; + opacity: 0.85; +} + +/* Getting Started — rocket launch */ +.md-tabs__link[href*="getting-started"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2.81 14.12L5.64 11.29L8.17 13.82C9.78 12.5 11.5 11.39 13.5 10.62L13.57 10.69C15 8.5 17.5 7 20.23 7A9.98 9.98 0 0 1 22 12C22 17.52 17.52 22 12 22C8.91 22 6.19 20.55 4.43 18.3L6.56 16.17C7.72 17.65 9.5 18.6 11.5 18.91L11.5 15.79C9.28 15.36 7.19 14.36 5.64 13L2.81 14.12M13.5 13.43V18.91C15.5 18.6 17.28 17.65 18.44 16.17L16.36 14.09C15.5 14.82 14.53 15.22 13.5 15.43M5.5 5A2 2 0 0 0 3.5 7A2 2 0 0 0 5.5 9A2 2 0 0 0 7.5 7A2 2 0 0 0 5.5 5Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2.81 14.12L5.64 11.29L8.17 13.82C9.78 12.5 11.5 11.39 13.5 10.62L13.57 10.69C15 8.5 17.5 7 20.23 7A9.98 9.98 0 0 1 22 12C22 17.52 17.52 22 12 22C8.91 22 6.19 20.55 4.43 18.3L6.56 16.17C7.72 17.65 9.5 18.6 11.5 18.91L11.5 15.79C9.28 15.36 7.19 14.36 5.64 13L2.81 14.12M13.5 13.43V18.91C15.5 18.6 17.28 17.65 18.44 16.17L16.36 14.09C15.5 14.82 14.53 15.22 13.5 15.43M5.5 5A2 2 0 0 0 3.5 7A2 2 0 0 0 5.5 9A2 2 0 0 0 7.5 7A2 2 0 0 0 5.5 5Z'/%3E%3C/svg%3E"); +} + +/* Concepts — lightbulb-on */ +.md-tabs__link[href*="concepts/"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2A7 7 0 0 0 5 9C5 11.38 6.19 13.47 8 14.74V17A1 1 0 0 0 9 18H15A1 1 0 0 0 16 17V14.74C17.81 13.47 19 11.38 19 9A7 7 0 0 0 12 2M9 21A1 1 0 0 0 10 22H14A1 1 0 0 0 15 21V20H9V21Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2A7 7 0 0 0 5 9C5 11.38 6.19 13.47 8 14.74V17A1 1 0 0 0 9 18H15A1 1 0 0 0 16 17V14.74C17.81 13.47 19 11.38 19 9A7 7 0 0 0 12 2M9 21A1 1 0 0 0 10 22H14A1 1 0 0 0 15 21V20H9V21Z'/%3E%3C/svg%3E"); +} + +/* Guides — book open */ +.md-tabs__link[href*="guides/"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17 22V20H20V17H22V20.5C22 20.89 21.84 21.24 21.54 21.54C21.24 21.84 20.89 22 20.5 22H17M7 22H3.5C3.11 22 2.76 21.84 2.46 21.54C2.16 21.24 2 20.89 2 20.5V17H4V20H7V22M17 2H20.5C20.89 2 21.24 2.16 21.54 2.46C21.84 2.76 22 3.11 22 3.5V7H20V4H17V2M7 2V4H4V7H2V3.5C2 3.11 2.16 2.76 2.46 2.46C2.76 2.16 3.11 2 3.5 2H7M13 17H11C10.45 17 10 16.55 10 16S10.45 15 11 15H13C13.55 15 14 15.45 14 16S13.55 17 13 17M17 13H7C6.45 13 6 12.55 6 12S6.45 11 7 11H17C17.55 11 18 11.45 18 12S17.55 13 17 13M17 9H7C6.45 9 6 8.55 6 8S6.45 7 7 7H17C17.55 7 18 7.45 18 8S17.55 9 17 9Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17 22V20H20V17H22V20.5C22 20.89 21.84 21.24 21.54 21.54C21.24 21.84 20.89 22 20.5 22H17M7 22H3.5C3.11 22 2.76 21.84 2.46 21.54C2.16 21.24 2 20.89 2 20.5V17H4V20H7V22M17 2H20.5C20.89 2 21.24 2.16 21.54 2.46C21.84 2.76 22 3.11 22 3.5V7H20V4H17V2M7 2V4H4V7H2V3.5C2 3.11 2.16 2.76 2.46 2.46C2.76 2.16 3.11 2 3.5 2H7M13 17H11C10.45 17 10 16.55 10 16S10.45 15 11 15H13C13.55 15 14 15.45 14 16S13.55 17 13 17M17 13H7C6.45 13 6 12.55 6 12S6.45 11 7 11H17C17.55 11 18 11.45 18 12S17.55 13 17 13M17 9H7C6.45 9 6 8.55 6 8S6.45 7 7 7H17C17.55 7 18 7.45 18 8S17.55 9 17 9Z'/%3E%3C/svg%3E"); +} + +/* Reference — code tags */ +.md-tabs__link[href*="reference/"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14.6 16.6L19.2 12L14.6 7.4L16 6L22 12L16 18L14.6 16.6M9.4 16.6L4.8 12L9.4 7.4L8 6L2 12L8 18L9.4 16.6Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14.6 16.6L19.2 12L14.6 7.4L16 6L22 12L16 18L14.6 16.6M9.4 16.6L4.8 12L9.4 7.4L8 6L2 12L8 18L9.4 16.6Z'/%3E%3C/svg%3E"); +} + +/* Enterprise — office building */ +.md-tabs__link[href*="infrastructure/"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18 15H16V17H18M18 11H16V13H18M20 19H12V17H14V15H12V13H14V11H12V9H20M10 7H8V5H10M10 11H8V9H10M10 15H8V13H10M10 19H8V17H10M6 7H4V5H6M6 11H4V9H6M6 15H4V13H6M6 19H4V17H6M12 7V3H2V21H22V7H12Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18 15H16V17H18M18 11H16V13H18M20 19H12V17H14V15H12V13H14V11H12V9H20M10 7H8V5H10M10 11H8V9H10M10 15H8V13H10M10 19H8V17H10M6 7H4V5H6M6 11H4V9H6M6 15H4V13H6M6 19H4V17H6M12 7V3H2V21H22V7H12Z'/%3E%3C/svg%3E"); +} + +/* ============================================================ + WELCOME PAGE — Website / Community / Blog sidebar icons + Injected as ::before pseudo on links matching those hrefs. + ============================================================ */ +.md-nav__link[href*="developers.prescottdata.io"]:not([href*="blog"])::before, +.md-nav__link[href*="discord.gg"]::before, +.md-nav__link[href*="blog"]::before { + content: ''; + display: inline-block; + width: 0.85rem; + height: 0.85rem; + flex-shrink: 0; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; + margin-right: 0.45rem; + opacity: 0.6; + vertical-align: middle; +} + +/* Website — globe/web icon */ +.md-nav__link[href*="developers.prescottdata.io"]:not([href*="blog"])::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M16.36 14C16.44 13.34 16.5 12.68 16.5 12S16.44 10.66 16.36 10H19.74C19.9 10.64 20 11.31 20 12S19.9 13.36 19.74 14M14.59 19.56C15.19 18.45 15.65 17.25 15.97 16H18.92C17.96 17.65 16.43 18.93 14.59 19.56M14.34 14H9.66C9.56 13.34 9.5 12.68 9.5 12S9.56 10.65 9.66 10H14.34C14.43 10.65 14.5 11.32 14.5 12S14.43 13.34 14.34 14M12 19.96C11.17 18.76 10.5 17.43 10.09 16H13.91C13.5 17.43 12.83 18.76 12 19.96M8 8H5.08C6.03 6.34 7.57 5.06 9.4 4.44C8.8 5.55 8.35 6.75 8 8M5.08 16H8C8.35 17.25 8.8 18.45 9.4 19.56C7.57 18.93 6.03 17.65 5.08 16M4.26 14C4.1 13.36 4 12.69 4 12S4.1 10.64 4.26 10H7.64C7.56 10.66 7.5 11.32 7.5 12S7.56 13.34 7.64 14M12 4.03C12.83 5.23 13.5 6.57 13.91 8H10.09C10.5 6.57 11.17 5.23 12 4.03M18.92 8H15.97C15.65 6.75 15.19 5.55 14.59 4.44C16.43 5.07 17.96 6.34 18.92 8M12 2C6.47 2 2 6.5 2 12A10 10 0 0 0 12 22A10 10 0 0 0 22 12A10 10 0 0 0 12 2Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M16.36 14C16.44 13.34 16.5 12.68 16.5 12S16.44 10.66 16.36 10H19.74C19.9 10.64 20 11.31 20 12S19.9 13.36 19.74 14M14.59 19.56C15.19 18.45 15.65 17.25 15.97 16H18.92C17.96 17.65 16.43 18.93 14.59 19.56M14.34 14H9.66C9.56 13.34 9.5 12.68 9.5 12S9.56 10.65 9.66 10H14.34C14.43 10.65 14.5 11.32 14.5 12S14.43 13.34 14.34 14M12 19.96C11.17 18.76 10.5 17.43 10.09 16H13.91C13.5 17.43 12.83 18.76 12 19.96M8 8H5.08C6.03 6.34 7.57 5.06 9.4 4.44C8.8 5.55 8.35 6.75 8 8M5.08 16H8C8.35 17.25 8.8 18.45 9.4 19.56C7.57 18.93 6.03 17.65 5.08 16M4.26 14C4.1 13.36 4 12.69 4 12S4.1 10.64 4.26 10H7.64C7.56 10.66 7.5 11.32 7.5 12S7.56 13.34 7.64 14M12 4.03C12.83 5.23 13.5 6.57 13.91 8H10.09C10.5 6.57 11.17 5.23 12 4.03M18.92 8H15.97C15.65 6.75 15.19 5.55 14.59 4.44C16.43 5.07 17.96 6.34 18.92 8M12 2C6.47 2 2 6.5 2 12A10 10 0 0 0 12 22A10 10 0 0 0 22 12A10 10 0 0 0 12 2Z'/%3E%3C/svg%3E"); +} + +/* Community — Discord bubble */ +.md-nav__link[href*="discord.gg"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028zM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028zM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38z'/%3E%3C/svg%3E"); +} + +/* Blog — RSS feed */ +.md-nav__link[href*="blog"]::before { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6.18 15.64A2.18 2.18 0 0 1 8.36 17.82C8.36 19 7.38 20 6.18 20C4.98 20 4 19 4 17.82A2.18 2.18 0 0 1 6.18 15.64M4 4.44A15.56 15.56 0 0 1 19.56 20H16.73A12.73 12.73 0 0 0 4 7.27V4.44M4 10.1A9.9 9.9 0 0 1 13.9 20H11.07A7.07 7.07 0 0 0 4 12.93V10.1Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6.18 15.64A2.18 2.18 0 0 1 8.36 17.82C8.36 19 7.38 20 6.18 20C4.98 20 4 19 4 17.82A2.18 2.18 0 0 1 6.18 15.64M4 4.44A15.56 15.56 0 0 1 19.56 20H16.73A12.73 12.73 0 0 0 4 7.27V4.44M4 10.1A9.9 9.9 0 0 1 13.9 20H11.07A7.07 7.07 0 0 0 4 12.93V10.1Z'/%3E%3C/svg%3E"); +} + + +/* ============================================================ + LEFT SIDEBAR — premium redesign + ============================================================ */ + +/* Sidebar container */ +.md-sidebar--primary .md-sidebar__scrollwrap { + padding: 0.15rem 0 2rem; +} + +/* Top-level section labels — desktop only. + On desktop with navigation.tabs these are just visual labels in the + sidebar (e.g. "GUIDES") that should not be clickable. + On mobile these same elements become the ACTUAL nav items in the + drawer (Home, Concepts, Guides, etc.) and MUST be clickable. + So we scope pointer-events:none to desktop only. */ +@media screen and (min-width: 76.25em) { + .md-nav--primary>.md-nav__list>.md-nav__item>.md-nav__link { + font-family: 'Inter', sans-serif; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--nx-text-muted) !important; + padding: 0.9rem 0.75rem 0.3rem; + opacity: 0.7; + cursor: default; + pointer-events: none; + white-space: nowrap; + } +} + +/* .md-nav__title — on desktop it's a static label, on mobile it's the + drawer back button. Scope the non-interactive styling to desktop. */ +.md-nav__title { + font-family: 'Inter', sans-serif; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--nx-text-muted) !important; + padding: 0.9rem 0.75rem 0.3rem; + opacity: 0.7; + white-space: nowrap; +} + +@media screen and (min-width: 76.25em) { + .md-nav__title { + cursor: default; + pointer-events: none; + } +} + +/* All nav links */ +.md-nav__link { + font-family: 'Inter', sans-serif; + font-size: 0.65rem; + font-weight: 400; + color: var(--md-default-fg-color) !important; + opacity: 0.88; + padding: 0.24rem 0.6rem; + border-radius: 6px; + margin: 1px 0.25rem; + display: flex; + align-items: center; + transition: background 0.13s ease, color 0.13s ease, opacity 0.13s ease; + position: relative; + text-decoration: none !important; + /* Allow text to wrap — never clip or ellipsis */ + white-space: normal; + overflow: visible; + word-break: break-word; +} + +.md-nav__link:hover { + background: rgba(23, 88, 245, 0.07); + color: var(--nx-brand) !important; + opacity: 1; + text-decoration: none !important; +} + +[data-md-color-scheme="slate"] .md-nav__link:hover { + background: rgba(23, 88, 245, 0.12); + color: var(--nx-brand-light) !important; +} + +/* Active left-bar indicator */ +.md-nav__link--active { + font-weight: 600; + color: var(--nx-brand) !important; + opacity: 1; + background: rgba(23, 88, 245, 0.08); +} + +.md-nav__link--active::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--nx-brand); + border-radius: 0 2px 2px 0; +} + +[data-md-color-scheme="slate"] .md-nav__link--active { + color: var(--nx-brand-light) !important; + background: rgba(23, 88, 245, 0.15); +} + +/* Nested sub-items — indented, slightly smaller */ +.md-nav--secondary .md-nav__link, +.md-nav__item--nested .md-nav .md-nav__link { + font-size: 0.63rem; + opacity: 0.75; + padding-left: 1rem; + white-space: normal; + overflow: visible; + word-break: break-word; +} + +.md-nav--secondary .md-nav__link:hover, +.md-nav__item--nested .md-nav .md-nav__link:hover { + opacity: 1; +} + +/* Sidebar section dividers */ +.md-nav__item+.md-nav__item { + margin-top: 0; +} + +/* Remove default Material nav item bullet */ +.md-nav__item { + list-style: none; +} + +/* ---------- Custom Scrollbar ---------- */ +::-webkit-scrollbar { + width: 3px; + height: 3px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--nx-brand); + border-radius: 99px; + opacity: 0.5; +} + +/* ============================================================ + HERO SECTION + ============================================================ */ + +.nx-hero { + text-align: center; + padding: 1.75rem 1.5rem 1.25rem; + position: relative; + overflow: hidden; + clear: both; +} + +.nx-hero::before { + content: ''; + position: absolute; + inset: 0; + background: var(--nx-gradient-subtle); + z-index: 0; +} + +.nx-hero>* { + position: relative; + z-index: 1; +} + +/* Hide the ugly ¶ anchor inside hero heading */ +.nx-hero h1 .headerlink, +.nx-hero h1 a.headerlink { + display: none !important; +} + +.nx-hero-logo { + display: block; + margin: 0 auto 1.5rem !important; + height: 90px !important; + width: auto !important; +} + + + +.nx-hero h1 { + font-size: clamp(1.35rem, 3.5vw, 1.85rem) !important; + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1.1; + margin-bottom: 0.5rem !important; + margin-top: 0 !important; + color: var(--md-default-fg-color); +} + +.nx-hero p { + font-size: 0.74rem !important; + color: var(--nx-text-muted); + max-width: 480px; + margin: 0 auto 0.85rem !important; + line-height: 1.5; + font-weight: 400; +} + +/* Hero code block — compact, inline-ish */ +.nx-hero .highlight, +.nx-hero pre { + max-width: 420px; + margin: 0 auto !important; + border-radius: 6px !important; + box-shadow: none !important; + border: 1px solid var(--nx-border) !important; +} + +.nx-hero .highlight pre code { + font-size: 0.8rem !important; + padding: 0.8rem 1.2rem !important; +} + +/* --- CTA Text Links --- */ +.nx-cta-text { + margin-bottom: 1.2rem; + font-family: 'Inter', sans-serif; + font-size: 0.82rem; + font-weight: 500; +} + +.nx-cta-text p { + display: flex; + gap: 1.25rem; + justify-content: center; + flex-wrap: wrap; + margin: 0; +} + +.nx-cta-text a { + color: var(--md-default-fg-color) !important; + opacity: 0.7; + text-decoration: none !important; + transition: opacity 0.15s ease, color 0.15s ease; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.nx-cta-text a:first-child { + color: var(--nx-brand) !important; + opacity: 1; + font-weight: 600; +} + +.nx-cta-text a:first-child::after { + content: "→"; + margin-left: 2px; +} + +.nx-cta-text a:hover { + opacity: 1; + color: var(--nx-brand) !important; +} + +/* --- CTA Buttons --- */ +.nx-cta { + display: flex; + align-items: center; + gap: 0.6rem; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 0.8rem; +} + +.nx-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.5rem 1.1rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + line-height: 1; + text-decoration: none !important; + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + cursor: pointer; + letter-spacing: 0.01em; + white-space: nowrap; + vertical-align: middle; +} + +.nx-btn:hover { + text-decoration: none !important; + transform: translateY(-1px); +} + +.nx-btn-primary { + background: #0071F7; + color: #fff !important; + box-shadow: 0 3px 12px rgba(0, 113, 247, 0.28); +} + +.nx-btn-primary:hover { + background: #0060d6; + box-shadow: 0 6px 22px rgba(0, 113, 247, 0.38); + color: #fff !important; +} + +.nx-btn-secondary { + border: 1px solid var(--nx-border); + color: var(--md-default-fg-color) !important; + background: var(--md-default-bg-color); +} + +.nx-btn-secondary:hover { + border-color: var(--nx-brand); + color: var(--nx-brand) !important; + box-shadow: var(--nx-shadow); +} + +/* GitHub CTA button */ +.nx-btn-github { + background: #0c0c0e; + color: #fff !important; + border: 1px solid #0c0c0e; + gap: 0.4rem; +} + +.nx-btn-github::before { + content: ''; + display: inline-block; + width: 1rem; + height: 1rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-size: contain; + flex-shrink: 0; +} + +.nx-btn-github:hover { + background: #1a1a1e; + color: #fff !important; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); +} + +[data-md-color-scheme="slate"] .nx-btn-github { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: #fff !important; +} + +/* Discord CTA button */ +.nx-btn-discord { + background: #5865f2; + color: #fff !important; + border: 1px solid #5865f2; +} + +.nx-btn-discord:hover { + background: #4752c4; + color: #fff !important; + box-shadow: 0 4px 16px rgba(23, 88, 245, 0.3); +} + +/* ============================================================ + FEATURE CARDS GRID + ============================================================ */ + +.nx-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.25rem; + margin: 2.5rem 0; +} + +.nx-card { + background: var(--md-default-bg-color); + border: 1px solid var(--nx-border); + border-radius: var(--nx-radius); + padding: 1.6rem 1.5rem; + transition: transform var(--nx-transition), box-shadow var(--nx-transition), border-color var(--nx-transition); + position: relative; + overflow: hidden; +} + +.nx-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 3px; + height: 100%; + background: var(--nx-gradient); + opacity: 0; + transition: opacity var(--nx-transition); +} + +.nx-card:hover { + transform: translateY(-4px); + box-shadow: var(--nx-shadow-hover); + border-color: var(--nx-brand); +} + +.nx-card:hover::before { + opacity: 1; +} + +.nx-card-label { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--nx-brand); + margin-bottom: 0.85rem; +} + +.nx-card h3 { + font-size: 0.88rem; + font-weight: 700; + margin: 0 0 0.35rem; + letter-spacing: -0.01em; + color: var(--md-default-fg-color); +} + +.nx-card p { + font-size: 0.76rem; + color: var(--nx-text-muted); + line-height: 1.5; + margin: 0; +} + +/* ============================================================ + STAT ROW + ============================================================ */ + +.nx-stats { + display: flex; + gap: 0; + border: 1px solid var(--nx-border); + border-radius: 6px; + overflow: hidden; + margin: 1rem 0; + background: var(--md-default-bg-color); +} + +.nx-stat { + flex: 1; + text-align: center; + padding: 0.6rem 0.5rem; + border-right: 1px solid var(--nx-border); +} + +.nx-stat:last-child { + border-right: none; +} + +.nx-stat-value { + display: block; + font-size: 0.95rem; + font-weight: 800; + background: var(--nx-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.03em; + line-height: 1.2; +} + +.nx-stat-label { + font-size: 0.6rem; + color: var(--nx-text-muted); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 0.1rem; +} + +/* ============================================================ + ECOSYSTEM / COMMUNITY CTA SECTION + ============================================================ */ + +.nx-ecosystem { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin: 3rem 0 1.5rem; + padding: 2.5rem; + background: var(--nx-gradient-subtle); + border: 1px solid var(--nx-border); + border-radius: var(--nx-radius); + position: relative; + overflow: hidden; +} + +.nx-ecosystem::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 30% 50%, rgba(23, 88, 245, 0.07) 0%, transparent 60%), + radial-gradient(ellipse at 70% 50%, rgba(23, 88, 245, 0.07) 0%, transparent 60%); + pointer-events: none; +} + +.nx-ecosystem-card { + text-align: center; + padding: 1.75rem 1.5rem; + background: var(--md-default-bg-color); + border: 1px solid var(--nx-border); + border-radius: calc(var(--nx-radius) - 2px); + position: relative; + z-index: 1; +} + +.nx-ecosystem-card h3 { + font-size: 0.85rem; + font-weight: 700; + margin: 0 0 0.4rem !important; + letter-spacing: -0.02em; +} + +.nx-ecosystem-card p { + font-size: 0.78rem; + color: var(--nx-text-muted); + line-height: 1.5; + margin: 0 0 1rem !important; +} + +/* ============================================================ + CODE BLOCKS + ============================================================ */ + +.md-typeset pre>code { + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.74rem; + line-height: 1.55; + font-feature-settings: "liga" 1, "calt" 1; +} + +.md-typeset .highlight { + border-radius: var(--nx-radius); + overflow: hidden; + border: 1px solid var(--nx-border); + box-shadow: var(--nx-shadow); +} + +[data-md-color-scheme="slate"] .md-typeset .highlight { + border-color: #2a2d3e; +} + +.md-typeset .highlight .filename { + font-family: 'Inter', sans-serif; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +/* ============================================================ + TABLES + ============================================================ */ + +.md-typeset table:not([class]) { + border-collapse: collapse; + border-radius: var(--nx-radius); + overflow: hidden; + border: 1px solid var(--nx-border); + font-size: 0.72rem; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); + /* auto-fit: columns size to content, table expands to content not container */ + table-layout: auto; + width: auto; + max-width: 100%; + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.md-typeset table:not([class]) th { + background: var(--nx-gradient-subtle); + font-weight: 600 !important; + font-size: 0.62rem !important; + letter-spacing: 0.06em !important; + text-transform: uppercase !important; + padding: 0.4rem 0.75rem !important; + border-bottom: 1.5px solid var(--nx-border); + white-space: nowrap; + color: var(--nx-text-muted); +} + +.md-typeset table:not([class]) td { + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--nx-border); + vertical-align: middle; + word-break: break-word; + min-width: 80px; +} + +.md-typeset table:not([class]) td code { + font-size: 0.68rem; + white-space: nowrap; +} + +.md-typeset table:not([class]) tr:last-child td { + border-bottom: none; +} + +.md-typeset table:not([class]) tr:hover td { + background: var(--nx-gradient-subtle); + transition: background var(--nx-transition); +} + +/* ============================================================ + ADMONITIONS + ============================================================ */ + +.md-typeset .admonition, +.md-typeset details { + border-radius: var(--nx-radius); + border-left-width: 3px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +/* ============================================================ + INLINE CODE + ============================================================ */ + +.md-typeset code:not([class]) { + font-family: 'JetBrains Mono', monospace; + font-size: 0.83em; + padding: 0.15em 0.45em; + border-radius: 5px; + font-weight: 500; +} + +/* ============================================================ + CONTENT AREA — light mode subtle gradient wash + ============================================================ */ + +.md-content__inner { + padding-bottom: 3rem; +} + +/* Subtle page-level background wash in light mode */ +.md-main { + background: linear-gradient(180deg, #fafbff 0%, #ffffff 120px) !important; +} + +[data-md-color-scheme="slate"] .md-main { + background: none !important; +} + +.md-typeset h2 { + padding-top: 0.5rem; + padding-bottom: 0.3rem; + border-bottom: 1.5px solid var(--nx-border); + margin-top: 2.5rem; +} + +/* ============================================================ + FOOTER + ============================================================ */ + +.md-footer { + border-top: 1px solid var(--nx-border); +} + +/* ── Footer nav (prev/next arrows) ── */ +.md-footer__inner { + background: transparent !important; + box-shadow: none !important; +} + +.md-footer-nav, +.md-footer__link { + background: transparent !important; +} + +/* Prev/next title typography */ +.md-footer__title { + font-family: 'Inter', sans-serif; + font-size: 0.8rem; + font-weight: 500; + opacity: 0.7; +} + +.md-footer__direction { + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.45; +} + +/* Divider between nav and meta */ +.md-footer__inner { + border-top: 1px solid var(--nx-border); +} + +/* ── Footer meta (social icons + Made with...) ── */ +.md-footer-meta { + background: transparent !important; + border-top: 1px solid var(--nx-border); +} + +.md-footer-meta__inner { + background: transparent !important; +} + +/* Social icons */ +.md-social { + gap: 4px; + padding: 0.6rem 0; +} + +.md-social__link { + color: var(--nx-text-muted) !important; + opacity: 0.6; + transition: color 0.2s ease, opacity 0.2s ease; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; +} + +.md-social__link:hover { + color: var(--nx-brand) !important; + opacity: 1; + background: var(--nx-gradient-subtle); +} + +.md-social__link svg { + width: 15px; + height: 15px; +} + +/* ── Footer copyright — explicit per-scheme to beat Material's footer fg vars ── */ + +/* Light mode: slate grey, visible on white bg */ +[data-md-color-scheme="default"] .md-copyright, +[data-md-color-scheme="default"] .md-copyright__highlight, +[data-md-color-scheme="default"] .md-copyright a, +[data-md-color-scheme="default"] .md-copyright span { + font-family: 'Inter', sans-serif; + font-size: 0.72rem; + color: #64748b !important; + opacity: 1 !important; +} + +[data-md-color-scheme="default"] .md-copyright a:hover { + color: #0071F7 !important; +} + +/* Dark mode: lighter muted grey, visible on dark bg */ +[data-md-color-scheme="slate"] .md-copyright, +[data-md-color-scheme="slate"] .md-copyright__highlight, +[data-md-color-scheme="slate"] .md-copyright a, +[data-md-color-scheme="slate"] .md-copyright span { + font-family: 'Inter', sans-serif; + font-size: 0.72rem; + color: #94a3b8 !important; + opacity: 1 !important; +} + +[data-md-color-scheme="slate"] .md-copyright a:hover { + color: #35B0FE !important; +} + +/* ── Light mode footer backgrounds ── */ +[data-md-color-scheme="default"] .md-footer, +[data-md-color-scheme="default"] .md-footer__inner, +[data-md-color-scheme="default"] .md-footer-nav, +[data-md-color-scheme="default"] .md-footer-meta, +[data-md-color-scheme="default"] .md-footer-meta__inner { + background: var(--md-default-bg-color) !important; + color: var(--md-default-fg-color) !important; +} + +[data-md-color-scheme="default"] .md-footer__link:hover { + background: var(--nx-gradient-subtle) !important; +} + +/* ── Dark mode footer backgrounds ── */ +[data-md-color-scheme="slate"] .md-footer, +[data-md-color-scheme="slate"] .md-footer__inner, +[data-md-color-scheme="slate"] .md-footer-nav, +[data-md-color-scheme="slate"] .md-footer-meta, +[data-md-color-scheme="slate"] .md-footer-meta__inner { + background: var(--md-default-bg-color) !important; +} + + + +/* ============================================================ + INLINE BADGES / CHIPS + Use in markdown as: Primary + ============================================================ */ + +.nx-badge { + display: inline-flex; + align-items: center; + padding: 0.18em 0.6em; + border-radius: 5px; + font-family: 'Inter', sans-serif; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + line-height: 1.4; + white-space: nowrap; + vertical-align: middle; + margin-left: 0.4rem; + position: relative; + top: -1px; +} + +/* Green — primary / recommended */ +.nx-badge-primary { + background: var(--nx-green-subtle); + color: #0a9957; + border: 1px solid rgba(12, 197, 114, 0.25); +} + +[data-md-color-scheme="slate"] .nx-badge-primary { + background: rgba(12, 197, 114, 0.15); + color: #2de68a; + border-color: rgba(12, 197, 114, 0.3); +} + +/* Blue — required / key */ +.nx-badge-required { + background: rgba(23, 88, 245, 0.08); + color: #0071F7; + border: 1px solid rgba(23, 88, 245, 0.2); +} + +[data-md-color-scheme="slate"] .nx-badge-required { + background: rgba(53, 176, 254, 0.12); + color: #35B0FE; + border-color: rgba(53, 176, 254, 0.25); +} + +/* Grey — optional / secondary */ +.nx-badge-optional { + background: rgba(100, 116, 139, 0.08); + color: #64748b; + border: 1px solid rgba(100, 116, 139, 0.2); +} + +[data-md-color-scheme="slate"] .nx-badge-optional { + background: rgba(148, 163, 184, 0.1); + color: #94a3b8; + border-color: rgba(148, 163, 184, 0.2); +} + +/* Teal/accent — free / no config */ +.nx-badge-free { + background: rgba(12, 240, 227, 0.08); + color: #0891b2; + border: 1px solid rgba(12, 240, 227, 0.2); +} + +[data-md-color-scheme="slate"] .nx-badge-free { + background: rgba(12, 240, 227, 0.1); + color: #22d3ee; + border-color: rgba(12, 240, 227, 0.2); +} + +/* Yellow — beta / experimental */ +.nx-badge-beta { + background: var(--nx-yellow-subtle); + color: #b45309; + border: 1px solid rgba(232, 163, 2, 0.25); +} + +[data-md-color-scheme="slate"] .nx-badge-beta { + background: rgba(232, 163, 2, 0.12); + color: #fbbf24; + border-color: rgba(232, 163, 2, 0.3); +} + +/* ============================================================ + RIGHT SIDEBAR — Table of Contents + ============================================================ */ + +.md-sidebar--secondary .md-sidebar__scrollwrap { + padding: 0.75rem 0 2rem; + padding-left: 1.5rem; /* gap between main content and TOC */ +} + +/* TOC header label */ +.md-nav--secondary .md-nav__title { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--nx-text-muted) !important; + opacity: 0.6; + padding: 0 0 0.5rem 0; + pointer-events: none; + cursor: default; +} + +/* TOC links */ +.md-nav--secondary .md-nav__link { + font-family: 'Inter', sans-serif; + font-size: 0.68rem; + font-weight: 400; + color: var(--md-default-fg-color) !important; + opacity: 0.55; + padding: 0.18rem 0; + border-left: 2px solid transparent; + padding-left: 0.75rem; + margin: 0; + border-radius: 0; + transition: opacity 0.13s ease, border-color 0.13s ease, color 0.13s ease; + background: transparent !important; +} + +.md-nav--secondary .md-nav__link::before { + display: none; +} + +.md-nav--secondary .md-nav__link:hover { + opacity: 1; + color: var(--nx-brand) !important; + border-left-color: rgba(23, 88, 245, 0.35); + background: transparent !important; +} + +.md-nav--secondary .md-nav__link--active { + color: var(--nx-brand) !important; + font-weight: 600; + opacity: 1; + border-left-color: var(--nx-brand); + background: transparent !important; +} + +[data-md-color-scheme="slate"] .md-nav--secondary .md-nav__link--active { + color: var(--nx-brand-light) !important; + border-left-color: var(--nx-brand-light); +} + +/* Nested TOC (h3 etc) */ +.md-nav--secondary .md-nav__item .md-nav .md-nav__link { + font-size: 0.645rem; + padding-left: 1.25rem; + opacity: 0.45; +} + +.md-nav--secondary .md-nav__item .md-nav .md-nav__link:hover, +.md-nav--secondary .md-nav__item .md-nav .md-nav__link.md-nav__link--active { + opacity: 1; +} + +/* ============================================================ + CHANGELOG PAGE + ============================================================ */ + +/* ── Release card ── */ +.changelog-release { + position: relative; + padding: 1.5rem 1.75rem 1.25rem; + margin: 0.5rem 0 1rem; + border-radius: var(--nx-radius); + background: var(--nx-gradient-subtle); + border: 1px solid var(--nx-border); + border-left: 3px solid var(--nx-brand); + transition: border-color var(--nx-transition), box-shadow var(--nx-transition); +} + +.changelog-release:hover { + border-left-color: var(--nx-accent); + box-shadow: var(--nx-shadow); +} + +/* Version heading inside card */ +.changelog-release h2 { + margin-top: 0 !important; + margin-bottom: 0.25rem !important; + font-size: 1.35rem !important; + display: flex; + align-items: baseline; + gap: 0.75rem; +} + +/* Date chip */ +.changelog-date { + font-size: 0.7rem; + font-weight: 500; + color: var(--nx-text-muted); + background: rgba(23, 88, 245, 0.08); + padding: 0.15rem 0.6rem; + border-radius: 99px; + letter-spacing: 0.02em; + vertical-align: middle; + white-space: nowrap; +} + +[data-md-color-scheme="slate"] .changelog-date { + background: rgba(23, 88, 245, 0.15); +} + +/* ── Meta row: contributors + release link ── */ +.changelog-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0.75rem 0 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--nx-border); +} + +/* Contributor avatars — overlapping stack */ +.changelog-contributors { + display: flex; + align-items: center; +} + +.changelog-contributors a { + display: inline-block; + line-height: 0; + border-radius: 50%; + margin-right: -6px; + position: relative; + transition: transform var(--nx-transition), box-shadow var(--nx-transition), z-index 0s; +} + +.changelog-contributors a:last-child { + margin-right: 0; +} + +.changelog-contributors a:hover { + transform: scale(1.15); + box-shadow: 0 0 0 2px var(--nx-brand-light); + z-index: 2; +} + +.changelog-contributors img { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--nx-surface); + object-fit: cover; + background: var(--nx-surface); +} + +/* GitHub release CTA */ +.changelog-release-link { + font-size: 0.7rem; + font-weight: 500; + color: var(--nx-brand) !important; + border: 1px solid rgba(23, 88, 245, 0.25); + padding: 0.3rem 0.8rem; + border-radius: 99px; + text-decoration: none !important; + transition: all var(--nx-transition); + white-space: nowrap; +} + +.changelog-release-link:hover { + background: rgba(23, 88, 245, 0.08); + border-color: var(--nx-brand); + color: var(--nx-brand-dark) !important; +} + +/* Category labels (bold text in changelog) */ +.changelog-release strong { + color: var(--nx-brand); + font-size: 0.65rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +/* Tighten paragraph spacing inside cards */ +.changelog-release p { + margin-top: 0.35em; + margin-bottom: 0.45em; +} + +/* Lists inside cards */ +.changelog-release ul { + margin-top: 0.25em; + margin-bottom: 0.5em; +} + +.changelog-release li { + margin-bottom: 0.15em; +} + +/* Code blocks inside cards — compact */ +.changelog-release .highlight { + margin-top: 0.4em; + margin-bottom: 0.6em; +} + +/* ── Hide the right-hand TOC on the changelog page ── */ +.md-content[data-md-component="content"] .md-sidebar--secondary { + /* Fallback: handled by front matter hide: toc */ +} + +/* ============================================================ + SEARCH + ============================================================ */ + +.md-search__form { + border-radius: 99px; +} + +/* ============================================================ + RESPONSIVE + ============================================================ */ + +@media (max-width: 600px) { + /* --- Stats strip --- */ + .nx-stats { + flex-direction: column; + } + + .nx-stat { + border-right: none; + border-bottom: 1px solid var(--nx-border); + } + + .nx-stat:last-child { + border-bottom: none; + } + + /* --- Grids --- */ + .nx-ecosystem { + grid-template-columns: 1fr; + padding: 1.5rem; + } + + .nx-grid { + grid-template-columns: 1fr; + } + + /* --- Hero CTAs: stack vertically and full-width --- */ + .nx-cta { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .nx-cta-btn { + width: 100%; + justify-content: center; + } + + /* --- Hide decorative chips in header on tiny screens --- */ + .nx-gh-btn, + .nx-version-chip { + display: none; + } + + /* --- Code blocks: scroll don't overflow --- */ + .md-content pre { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* --- Changelog --- */ + .changelog-meta { + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; + } + + .changelog-release { + padding: 1rem 1.25rem; + } + + /* --- Tables: scroll horizontally --- */ + .md-typeset table { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +/* --- Tablet (601–960px): 2-col grids --- */ +@media (max-width: 960px) and (min-width: 601px) { + .nx-grid { + grid-template-columns: repeat(2, 1fr); + } + + .nx-cta { + flex-wrap: wrap; + gap: 0.75rem; + } + + .md-content pre { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +/* ============================================================ + MOBILE DRAWER — full interactivity + On mobile (< 76.25em) Material collapses navigation.tabs + into a full-width overlay drawer. Every item must be + tappable, the back button must work, and touch targets + must be at least 44px. + ============================================================ */ + +@media screen and (max-width: 76.1875em) { + + /* Back button / section title — fully interactive */ + .md-nav__title { + pointer-events: auto !important; + cursor: pointer !important; + opacity: 1 !important; + font-size: 0.8rem !important; + padding: 0.75rem 1rem !important; + letter-spacing: 0 !important; + text-transform: none !important; + font-weight: 600 !important; + color: var(--md-default-fg-color) !important; + } + + /* ALL nav links in drawer — interactive with 44px touch targets. + This includes top-level items (Home, Concepts, Guides, etc.) + which are the primary navigation on mobile. */ + .md-nav__link { + pointer-events: auto !important; + cursor: pointer !important; + min-height: 44px !important; + display: flex !important; + align-items: center !important; + padding: 0.6rem 1rem !important; + font-size: 0.875rem !important; + opacity: 1 !important; + border-radius: 0 !important; + margin: 0 !important; + } + + /* Remove active left-bar indicator in drawer — looks odd */ + .md-nav__link--active::before { + display: none !important; + } + + /* Drawer overlay — captures taps to close */ + .md-overlay { + pointer-events: auto !important; + } + + /* Hide right TOC sidebar on mobile — no room */ + .md-sidebar--secondary { + display: none !important; + } +} + + + +/* Suppress Material's sidebar nav tooltips — labels are now fully visible. + Target only Material's .md-tooltip component, NOT arbitrary [title] elements + (the broad [title] selector was killing pointer-events on changelog links). */ +.md-tooltip, +.md-nav__link[title]::after, +.md-nav__item .md-tooltip { + display: none !important; + visibility: hidden !important; + pointer-events: none !important; +} + +/* ============================================================ + LLM ASSIST WIDGET + ============================================================ */ +.nx-llm-widget { + position: relative; + display: inline-block; + font-family: 'Inter', sans-serif; +} + +.nx-llm-trigger { + display: flex; + align-items: stretch; + background: #ffffff; + border: 1px solid var(--nx-border); + border-radius: 8px; + font-size: 0.8rem; + font-weight: 500; + color: var(--nx-text); + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.03); +} + +.nx-llm-trigger:hover { + border-color: var(--nx-brand-light); + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.nx-llm-btn-main { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + transition: background 0.15s; + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + color: var(--nx-text-muted); +} + +.nx-llm-btn-main:hover { + background: #f8fafc; + color: var(--nx-text); +} + +.nx-llm-btn-chev { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + border-left: 1px solid var(--nx-border); + color: var(--nx-text-muted); + transition: background 0.15s; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; +} + +.nx-llm-btn-chev:hover { + background: #f8fafc; + color: var(--nx-text); +} + +[data-md-color-scheme="slate"] .nx-llm-trigger { + background: #1e2130; + border-color: rgba(255,255,255,0.1); +} +[data-md-color-scheme="slate"] .nx-llm-btn-main:hover, +[data-md-color-scheme="slate"] .nx-llm-btn-chev:hover { + background: rgba(255,255,255,0.05); + color: #fff; +} +[data-md-color-scheme="slate"] .nx-llm-btn-chev { + border-left-color: rgba(255,255,255,0.1); +} + +.nx-llm-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 250px; + background: #ffffff; + border: 1px solid var(--nx-border); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0,0,0,0.12); + opacity: 0; + visibility: hidden; + transform: translateY(-5px); + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 100; + display: flex; + flex-direction: column; + padding: 8px; +} + +.nx-llm-dropdown.open { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +[data-md-color-scheme="slate"] .nx-llm-dropdown { + background: #1e2130; + border-color: rgba(255,255,255,0.1); + box-shadow: 0 10px 40px rgba(0,0,0,0.5); +} + +.nx-llm-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border-radius: 8px; + text-decoration: none !important; + color: var(--md-default-fg-color) !important; + transition: background 0.15s ease; + cursor: pointer; +} + +.nx-llm-item:hover { + background: #f8fafc; +} + +[data-md-color-scheme="slate"] .nx-llm-item:hover { + background: rgba(255,255,255,0.06); +} + +.nx-llm-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--nx-border); + border-radius: 8px; + color: var(--nx-text-muted); + flex-shrink: 0; +} + +[data-md-color-scheme="slate"] .nx-llm-icon-box { + border-color: rgba(255,255,255,0.1); +} + +.nx-llm-item:hover .nx-llm-icon-box { + border-color: var(--nx-brand-light); + color: var(--nx-brand); +} + +.nx-llm-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.nx-llm-text strong { + font-size: 0.75rem; + font-weight: 500; + line-height: 1.1; +} + +.nx-llm-text span { + font-size: 0.65rem; + color: var(--nx-text-muted); + line-height: 1.2; +} + +/* ============================================================ + PAGE ICONS — Material `icon:` front-matter rendering + Material for MkDocs injects an before the nav link + text when a page has an icon: metadata key. + ============================================================ */ + +/* Icon SVG inline in left sidebar nav items */ +.md-nav--primary .md-nav__link .md-icon, +.md-nav--primary .md-nav__link > svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + margin-right: 0.5rem; + opacity: 0.55; + transition: opacity 0.13s ease, color 0.13s ease; + color: inherit; + vertical-align: middle; + display: inline-flex; + align-items: center; +} + +.md-nav--primary .md-nav__link:hover .md-icon, +.md-nav--primary .md-nav__link:hover > svg { + opacity: 1; + color: var(--nx-brand); +} + +[data-md-color-scheme="slate"] .md-nav--primary .md-nav__link:hover .md-icon, +[data-md-color-scheme="slate"] .md-nav--primary .md-nav__link:hover > svg { + color: var(--nx-brand-light); +} + +.md-nav--primary .md-nav__link--active .md-icon, +.md-nav--primary .md-nav__link--active > svg { + opacity: 1; + color: var(--nx-brand); +} + +[data-md-color-scheme="slate"] .md-nav--primary .md-nav__link--active .md-icon, +[data-md-color-scheme="slate"] .md-nav--primary .md-nav__link--active > svg { + color: var(--nx-brand-light); +} + +/* ============================================================ + LEFT vs RIGHT SIDEBAR — structural differentiation + Left (primary) = nav tree with a subtle background tint and + right-border. Right (secondary / TOC) = pure left-border + inline style with no background. + ============================================================ */ + +/* Primary sidebar — subtle background tint to distinguish from content */ +.md-sidebar--primary { + border-right: 1px solid rgba(0, 0, 0, 0.06); +} + +[data-md-color-scheme="slate"] .md-sidebar--primary { + border-right-color: rgba(255, 255, 255, 0.06); +} + +/* Ensure TOC sidebar has NO background tint — it should float */ +.md-sidebar--secondary { + border-left: none; + background: transparent; +} + +/* Section group header in left nav — add a top hairline separator + between major nav groups for scannability */ +.md-nav--primary > .md-nav__list > .md-nav__item + .md-nav__item { + padding-top: 0.5rem; +} + +/* Nav section label treatment — pill-style group label */ +.md-nav--primary > .md-nav__list > .md-nav__item > .md-nav__link { + font-size: 0.625rem; + letter-spacing: 0.12em; + padding-top: 0.85rem; + border-top: 1px solid rgba(0, 0, 0, 0.055); + margin-top: 0.4rem; +} + +[data-md-color-scheme="slate"] .md-nav--primary > .md-nav__list > .md-nav__item > .md-nav__link { + border-top-color: rgba(255, 255, 255, 0.07); +} + +/* ============================================================ + SIDEBAR WIDTH ENFORCEMENT + Override Material's default ~12rem with 15rem so all nav + labels have room to display without truncation or ellipsis. + ============================================================ */ +/* Sidebar width: only enforce on desktop. On mobile Material uses a + full-width overlay drawer — forcing 15rem breaks it completely. */ +@media screen and (min-width: 76.25em) { + .md-sidebar--primary { + min-width: var(--md-sidebar-width, 15rem); + width: var(--md-sidebar-width, 15rem); + } + + .md-sidebar--primary .md-nav { + width: 100%; + } +} + +/* Belt-and-braces tooltip suppression for Material's ellipsis tooltip */ +.md-nav__link .md-ellipsis ~ .md-tooltip, +.md-tooltip--active { + display: none !important; + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} + +/* ============================================================ + HTTP METHOD BADGES — Chat API & any endpoint reference page + Use in markdown as: POST + or wrap the heading: ## GET /chat/stream/{workflow_id} + ============================================================ */ + +.nx-http-get, +.nx-http-post, +.nx-http-put, +.nx-http-patch, +.nx-http-delete { + display: inline-block; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.15rem 0.45rem; + border-radius: 4px; + vertical-align: middle; + line-height: 1.5; + position: relative; + top: -1px; +} + +.nx-http-get { background: rgba(12, 197, 114, 0.12); color: #0a9e5c; border: 1px solid rgba(12, 197, 114, 0.30); } +.nx-http-post { background: rgba(23, 88, 245, 0.10); color: var(--nx-brand); border: 1px solid rgba(23, 88, 245, 0.25); } +.nx-http-put { background: rgba(232, 163, 2, 0.12); color: #b07d00; border: 1px solid rgba(232, 163, 2, 0.30); } +.nx-http-patch { background: rgba(73, 81, 243, 0.10); color: var(--nx-purple); border: 1px solid rgba(73, 81, 243, 0.25); } +.nx-http-delete { background: rgba(220, 38, 38, 0.10); color: #b91c1c; border: 1px solid rgba(220, 38, 38, 0.25); } + +[data-md-color-scheme="slate"] .nx-http-get { background: rgba(12, 197, 114, 0.15); color: #34d27a; border-color: rgba(12, 197, 114, 0.25); } +[data-md-color-scheme="slate"] .nx-http-post { background: rgba(23, 88, 245, 0.15); color: var(--nx-brand-light); border-color: rgba(53, 176, 254, 0.25); } +[data-md-color-scheme="slate"] .nx-http-put { background: rgba(232, 163, 2, 0.15); color: #f0b429; border-color: rgba(232, 163, 2, 0.25); } +[data-md-color-scheme="slate"] .nx-http-patch { background: rgba(73, 81, 243, 0.15); color: #818cf8; border-color: rgba(73, 81, 243, 0.25); } +[data-md-color-scheme="slate"] .nx-http-delete { background: rgba(220, 38, 38, 0.15); color: #f87171; border-color: rgba(220, 38, 38, 0.25); } + +/* ============================================================ + SIDEBAR SCROLL FIX + + Root cause: Material's sticky sidebar labels use + var(--md-default-bg-color) for background + box-shadow halo. + Our custom palette never defines that variable, so it resolves + transparent and items bleed through the sticky labels. + + Left sidebar (GUIDES): + Selector: .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link + This is a