From 1158ad2f598850405105c79b5fb69d50a1dfb0d8 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 7 May 2026 10:37:20 -0700 Subject: [PATCH 1/2] chore: remove dead API token references from GUI, docs, and config PR #475 deleted the REST API and Bearer token auth, replacing it with pure Nostr (Schnorr signatures via NIP-42/NIP-98). This commit removes the leftover client-side references, documentation, and config that pointed at the now-dead token system. Desktop: delete features/tokens/ directory, token settings tab, token types/hooks/mutations, agent creation token UI, token E2E tests and mocks. Mobile: clean test fixtures. Docs: update 7 files to reflect NIP-42/NIP-98 auth model. Config: remove SPROUT_API_TOKEN env vars, clean justfile token parameters. CI: add dead-token-guard job to prevent reintroduction. Relay-side auth code (api_tokens table, require_auth_token config, X-Auth-Token media path) intentionally left for a follow-up PR. Co-Authored-By: Claude Opus 4.6 --- .env.example | 11 - .github/workflows/ci.yml | 21 + ARCHITECTURE.md | 25 +- NOSTR.md | 27 +- README.md | 24 +- SECURITY.md | 15 +- TESTING.md | 4 +- VISION.md | 10 +- crates/sprout-cli/README.md | 30 +- desktop/scripts/check-file-sizes.mjs | 1 - desktop/src/features/agents/channelAgents.ts | 13 - desktop/src/features/agents/hooks.ts | 15 - desktop/src/features/agents/ui/AgentsView.tsx | 13 - .../features/agents/ui/CreateAgentDialog.tsx | 75 +- .../agents/ui/CreateAgentDialogSections.tsx | 105 +-- .../features/agents/ui/ManagedAgentRow.tsx | 14 - .../agents/ui/ManagedAgentsSection.tsx | 3 - .../features/agents/ui/SecretRevealDialog.tsx | 24 +- .../features/agents/ui/TokenRevealDialog.tsx | 65 -- .../agents/ui/useManagedAgentActions.ts | 28 +- .../features/settings/ui/SettingsPanels.tsx | 10 - desktop/src/features/tokens/hooks.ts | 85 -- .../src/features/tokens/lib/scopeOptions.ts | 56 -- .../features/tokens/ui/TokenSettingsCard.tsx | 758 ------------------ desktop/src/shared/api/tauri.ts | 109 --- desktop/src/shared/api/types.ts | 54 -- desktop/src/testing/e2eBridge.ts | 233 ------ desktop/tests/e2e/tokens.spec.ts | 83 -- desktop/tests/helpers/bridge.ts | 12 - justfile | 6 +- .../features/channels/compose_bar_test.dart | 12 +- .../test/shared/relay/media_upload_test.dart | 3 +- 32 files changed, 79 insertions(+), 1865 deletions(-) delete mode 100644 desktop/src/features/agents/ui/TokenRevealDialog.tsx delete mode 100644 desktop/src/features/tokens/hooks.ts delete mode 100644 desktop/src/features/tokens/lib/scopeOptions.ts delete mode 100644 desktop/src/features/tokens/ui/TokenSettingsCard.tsx delete mode 100644 desktop/tests/e2e/tokens.spec.ts diff --git a/.env.example b/.env.example index 502d58279..60541caff 100644 --- a/.env.example +++ b/.env.example @@ -46,9 +46,6 @@ RELAY_URL=ws://localhost:3000 # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. # SPROUT_RELAY_PRIVATE_KEY=<32-byte hex private key> -# Set to true in production to require bearer token authentication -SPROUT_REQUIRE_AUTH_TOKEN=false - # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev # (use `just web` for Vite HMR instead). @@ -57,10 +54,6 @@ SPROUT_REQUIRE_AUTH_TOKEN=false # ----------------------------------------------------------------------------- # Auth # ----------------------------------------------------------------------------- -# Set to false for dev (accepts NIP-42 without JWT, allows X-Pubkey header). -# Set to true in production to require bearer token authentication. -SPROUT_REQUIRE_AUTH_TOKEN=false - # JWKS endpoint for verifying JWT access tokens. # Claim that carries the user's Nostr public key (hex, 32 bytes). OKTA_PUBKEY_CLAIM=nostr_pubkey @@ -126,9 +119,6 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu # Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay. # SPROUT_PRIVATE_KEY=<32-byte hex or nsec1… private key> -# Bearer token for relay authentication (when SPROUT_REQUIRE_AUTH_TOKEN=true). -# SPROUT_API_TOKEN= - # Relay WebSocket URL the harness connects to. # Note: the relay itself uses RELAY_URL (above); this is the ACP harness's # connection target — they happen to point at the same place in local dev. @@ -224,4 +214,3 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu # These are accepted for backward compatibility but the canonical names above # are preferred: # SPROUT_ACP_PRIVATE_KEY → SPROUT_PRIVATE_KEY -# SPROUT_ACP_API_TOKEN → SPROUT_API_TOKEN diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b251565a..075674cf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,6 +276,27 @@ jobs: - name: Dependency policy run: cargo-deny check + dead-token-guard: + name: Dead Token Reference Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Check for dead API token references in client code + run: | + # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. + # Relay crates are excluded — they still use token auth internally. + PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_' + PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example' + EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool' + if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then + echo "::error::Dead API token references found in client code. See above." + exit 1 + fi + echo "No dead token references found." + server-cross-compile: name: Server Cross-Compile runs-on: ubuntu-latest diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c1af66ca..d3361691d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -177,8 +177,7 @@ The client must respond with `["AUTH", ]` before submitting events |------|-----------|---------| | NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay | | NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta | -| NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts | -| HTTP Bearer JWT | `Authorization: Bearer ` header on REST endpoints | REST API clients | +| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients | On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`. @@ -355,14 +354,13 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) |------|-------------|-------| | NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) | | NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated | -| NIP-42 + API token | Relay AUTH handler → DB lookup | `auth_token` tag with `sprout_` prefix; relay intercepts before `verify_auth_event()` (which has no DB access) | -| HTTP Bearer JWT | `validate_bearer_jwt()` | REST endpoints; skips pubkey cross-check; always adds `ChannelsRead` | +| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event | **Key types:** ```rust pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec, pub auth_method: AuthMethod } -pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip42ApiToken } +pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip98 } pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite, AdminChannels, UsersRead, UsersWrite, AdminUsers, JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite, @@ -373,9 +371,7 @@ pub trait RateLimiter: Send + Sync { ... } **Security details:** - JWKS double-checked locking: two read-lock checks before fetching, HTTP fetch with no lock held, write-lock re-check after. Cache TTL: 300 seconds. -- Token comparison: `subtle::ConstantTimeEq` — constant-time, prevents timing attacks. -- Token format: `sprout_<64-hex-chars>` (71 chars). `hash_token()` → SHA-256 → stored hash. -- Scopeless JWT defaults to `[MessagesRead]` only (not read+write). +- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags. - NIP-42 timestamp tolerance: ±60 seconds. - Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments. @@ -396,7 +392,6 @@ pub trait RateLimiter: Send + Sync { ... } | `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` | | `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens | | `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables | -| `api_token.rs` | Token creation; receives pre-hashed token from caller | | `dm.rs` | DM channel management | | `reaction.rs` | Reaction storage and retrieval | | `thread.rs` | Thread/reply tracking | @@ -415,8 +410,7 @@ pub trait RateLimiter: Send + Sync { ... } - Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it. - Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit. - `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`. -- API tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`. -- Approval tokens: separate path — `create_approval` receives the raw token and hashes it internally. +- Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256. - DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators. **Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres. @@ -664,8 +658,6 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), | POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) | | POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) | | POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) | -| GET/POST/DELETE | `/api/tokens` | List/create/delete all API tokens | -| DELETE | `/api/tokens/{id}` | Delete specific API token | | GET | `/api/dms` | List DM channels | | POST | `/api/dms` | Open a DM channel | | POST | `/api/dms/{channel_id}/members` | Add DM member | @@ -715,7 +707,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), - Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically. - Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr). - Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect. -- REST calls use `Authorization: Bearer ` when `SPROUT_API_TOKEN` is set; falls back to `X-Pubkey: ` in dev mode. +- REST calls use NIP-98 Schnorr-signed auth when `SPROUT_PRIVATE_KEY` is set; falls back to `X-Pubkey: ` in dev mode. - `create_channel` sends a signed Nostr kind 9007 event (NIP-29 group creation, not a REST call). - `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID. - UUID validation at tool boundary before any network call. @@ -812,12 +804,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic | Concern | Mechanism | |---------|-----------| -| Token comparison | `subtle::ConstantTimeEq` — prevents timing attacks | -| Token storage | SHA-256 hash only — raw token shown once at mint, never stored | | JWKS cache | Double-checked locking; HTTP fetch with no lock held (prevents global DoS) | | NIP-42 timestamp | ±60 second tolerance — prevents replay attacks | | AUTH events | Never stored in Postgres, never logged in audit chain | -| Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default | +| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` events — URL and method verification | ### Input Validation @@ -887,7 +877,6 @@ Docker Compose provides the full local development stack. All services include h | `workflows` | Workflow definitions (YAML stored as canonical JSON) | | `workflow_runs` | Execution records with trigger context and trace | | `workflow_approvals` | Approval gates (token stored as SHA-256 hash) | -| `api_tokens` | API token records (hash only, never plaintext) | | `audit_log` | Hash-chain audit entries | | `delivery_log` | Delivery tracking (partitioned; Rust module pending) | diff --git a/NOSTR.md b/NOSTR.md index 3455637a9..daf98b088 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -10,7 +10,7 @@ third-party Nostr clients to connect: **Direct** is simpler — no extra process, no translation layer. Use it when your client speaks NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28 -clients and don't have company Okta/API-token credentials. +clients and don't have company Okta credentials. Both paths require NIP-42 authentication. @@ -83,10 +83,10 @@ PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \ ### Pubkey Allowlist When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey -(no JWT, no API token) are checked against the `pubkey_allowlist` table. This lets you open the -relay to specific external Nostr identities without granting full Okta/API-token access. +(no JWT) are checked against the `pubkey_allowlist` table. This lets you open the +relay to specific external Nostr identities without granting full Okta access. -- Users with valid **API tokens** (`sprout_*`) or **Okta JWTs** bypass the allowlist. +- Users with valid **Okta JWTs** bypass the allowlist. - **Fail-closed:** if the DB lookup fails, the connection is denied. - Default: `false` (all authenticated pubkeys accepted). - Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message). @@ -212,11 +212,11 @@ external user maps to a consistent identity on the relay. export SPROUT_PROXY_SERVER_KEY=$(openssl rand -hex 32) PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public) -# 3. Mint a proxy API token -cargo run -p sprout-admin -- mint-token \ - --name "sprout-proxy" \ - --scopes "proxy:submit,channels:read,messages:read" \ - --pubkey $PROXY_PUBKEY +# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth) +export SPROUT_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \ + -H "Authorization: Nostr " \ + -H "Content-Type: application/json" \ + -d '{"name":"proxy"}' | jq -r .token) # 4. Get the relay's public key (needed for attribution trust) # This is the pubkey of the relay's signing keypair. If SPROUT_RELAY_PRIVATE_KEY @@ -227,7 +227,6 @@ export SPROUT_RELAY_PUBKEY= # 5. Start the proxy export SPROUT_UPSTREAM_URL=ws://localhost:3000 export SPROUT_PROXY_SALT=$(openssl rand -hex 32) -export SPROUT_PROXY_API_TOKEN= export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16) cargo run -p sprout-proxy # proxy on :4869 @@ -312,7 +311,7 @@ curl -X DELETE http://localhost:4869/admin/guests \ -d '{"pubkey": ""}' ``` -> **Private channels:** The proxy authenticates upstream using its own server key and API token. +> **Private channels:** The proxy authenticates upstream using its own server key via NIP-42. > `GET /api/channels` and relay REQ filters only return channels accessible to that identity. > For the proxy to expose a private channel, the proxy's server pubkey must itself be a member > of that channel. Guest registration alone is not sufficient for private channels. @@ -464,9 +463,9 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay | +| `SPROUT_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) | | `SPROUT_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) | | `SPROUT_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) | -| `SPROUT_PROXY_API_TOKEN` | ✅ | — | API token with `proxy:submit,channels:read,messages:read` | | `SPROUT_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) | | `SPROUT_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address | | `SPROUT_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. | @@ -481,7 +480,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 |----------|:--------:|---------|-------------| | `SPROUT_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth | | `SPROUT_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) | -| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require JWT/API token for all connections | +| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections | --- @@ -489,7 +488,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 ### Direct Path - **Pubkey allowlist is fail-closed.** DB errors deny the connection. -- **API token / Okta JWT users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. +- **Okta JWT users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. - **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected. - **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed). - **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005). diff --git a/README.md b/README.md index 0985df6f8..f4918b23b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Nostr relay built for the agentic era — agents and humans share the same pro Sprout is a self-hosted WebSocket relay implementing a subset of the Nostr protocol, extended with structured channels, per-channel canvases, full-text search, and an MCP server so AI agents can -participate in conversations natively. Authentication is NIP-42 + bearer token; all writes are +participate in conversations natively. Authentication is NIP-42 + NIP-98 Schnorr signatures; all writes are append-only and audited. ## Quick Start @@ -85,7 +85,7 @@ That's it — you're running Sprout locally. | [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) | Relay-based groups | ✅ Partial (kinds 9000–9002, 9005, 9007–9008, 9021–9022 implemented; 9009 stubbed) | | [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) | Authentication of clients to relays | ✅ Implemented | | [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md) | Search capability | ✅ Implemented | -| [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) | HTTP Auth | ✅ Partial (`POST /api/tokens` bootstrap only) | +| [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) | HTTP Auth | ✅ Implemented | ## Architecture @@ -169,23 +169,10 @@ That's it — you're running Sprout locally. ## Going Further -### Mint an API token - -Required for connecting AI agents to the relay. - -```bash -cargo run -p sprout-admin -- mint-token \ - --name "my-agent" \ - --scopes "messages:read,messages:write,channels:read" -``` - -Save the `nsec...` private key and API token from the output — they are shown only once. - ### Launch an agent (MCP) ```bash SPROUT_RELAY_URL=ws://localhost:3000 \ -SPROUT_API_TOKEN= \ SPROUT_PRIVATE_KEY=nsec1... \ goose run --no-profile \ --with-extension "cargo run -p sprout-mcp --bin sprout-mcp-server" \ @@ -225,7 +212,6 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `TYPESENSE_COLLECTION` | `events` | Typesense collection name | | `SPROUT_BIND_ADDR` | `0.0.0.0:3000` | Relay bind address (host:port) | | `RELAY_URL` | `ws://localhost:3000` | Public URL (used in NIP-42 challenges) | -| `SPROUT_REQUIRE_AUTH_TOKEN` | `false` | Require bearer token for auth (set `true` in production) | | `SPROUT_RELAY_PRIVATE_KEY` | auto-generated | Relay keypair for signing system messages | | `OKTA_ISSUER` | — | Okta OIDC issuer URL (optional) | | `OKTA_AUDIENCE` | — | Expected JWT audience (optional) | @@ -234,7 +220,6 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `SPROUT_UPSTREAM_URL` | — | Upstream relay URL for the proxy (e.g., `ws://localhost:3000`) | | `SPROUT_PROXY_SERVER_KEY` | — | Hex private key for the proxy server keypair | | `SPROUT_PROXY_SALT` | — | Hex 32-byte salt for shadow key derivation | -| `SPROUT_PROXY_API_TOKEN` | — | Sprout API token with `proxy:submit` scope | | `SPROUT_PROXY_ADMIN_SECRET` | — | Bearer secret for proxy admin endpoints (optional — omit for dev mode) | | `SPROUT_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (unset = permissive) | | `SPROUT_HEALTH_PORT` | `8080` | Port for health check endpoint (separate from main bind) | @@ -249,13 +234,12 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `SPROUT_S3_SECRET_KEY` | `sprout_dev_secret` | S3 secret key | | `SPROUT_S3_BUCKET` | `sprout-media` | S3 bucket name for media uploads | | `SPROUT_METRICS_PORT` | `9102` | Port for Prometheus metrics endpoint | -| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`); API token and Okta JWT auth bypass | +| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`); Okta JWT auth bypasses | | `SPROUT_SEND_BUFFER` | `1000` | WebSocket send buffer size | | `SPROUT_UDS_PATH` | — | Unix domain socket path (alternative to TCP) | | `OKTA_JWKS_URI` | — | Okta JWKS endpoint URI for JWT verification | | `SPROUT_TOOLSETS` | `default` | MCP toolsets to enable (comma-separated: `default`, `channel_admin`, `dms`, `canvas`, `workflow_admin`, `identity`, `forums`, `all`, `none`; append `:ro` for read-only) | -| `SPROUT_MINT_RATE_LIMIT` | `50` | Max API token mints per pubkey per hour | -| `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` when no API token is set | +| `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` | ## MCP Tools diff --git a/SECURITY.md b/SECURITY.md index be8e5b968..b6dcca38f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,10 +48,10 @@ challenge/response before writing events. The relay sends a random challenge; the client signs a `kind:22242` event containing the challenge and the relay URL, proving possession of the private key. -API tokens (bearer tokens minted by `sprout-admin`) are presented inside the -NIP-42 signed event as an `auth_token` tag. The relay validates the token -against the database before granting elevated scopes. Tokens are stored as -SHA-256 hashes — the plaintext is shown once at mint time and never stored. +REST endpoints authenticate via +[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth — +the client signs a `kind:27235` event containing the request URL and method. +The relay verifies the Schnorr signature and extracts the pubkey. ### Authorization — Channel Membership as the Gate @@ -64,13 +64,6 @@ Private channels are invisible to non-members: they do not appear in channel listings, and subscription filters for private channel events return nothing unless the subscriber is a member. -### Scope-Based Token Permissions - -API tokens carry a set of scopes (e.g., `messages:read`, `channels:write`). -The relay enforces scopes on every REST endpoint and WebSocket write. A token -without `channels:write` cannot create channels, regardless of channel -membership. - ### Append-Only Audit Log All events are written to a tamper-evident audit log (`sprout-audit`). Each diff --git a/TESTING.md b/TESTING.md index f8bd50bd2..5e2e97fc5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,8 +70,8 @@ sleep 3 && curl -s http://localhost:3000/health # → "ok" ### 3. Generate Keys -Each agent needs a Nostr keypair. In dev mode (`SPROUT_REQUIRE_AUTH_TOKEN=false`), -the `X-Pubkey` header authenticates all REST calls — no tokens needed. +Each agent needs a Nostr keypair. Authentication uses NIP-42 (WebSocket) and +NIP-98 Schnorr signatures (REST). ```bash # Agent identity diff --git a/VISION.md b/VISION.md index d2eaae684..1e786ed77 100644 --- a/VISION.md +++ b/VISION.md @@ -78,10 +78,10 @@ Humans and agents get the same thing: - secp256k1 keypair (Nostr-native) - `alice@example.com` NIP-05 handle -- Okta SSO → keypair bridge (humans) or API token (agents) +- Okta SSO → keypair bridge (humans) or NIP-98 Schnorr auth (agents) - Bot role on agent channel membership. Visual badges are next. -Auth is simple — authenticated or not. Channel membership gates content visibility. Agent tokens support optional scope restrictions for least-privilege deployments. +Auth is simple — authenticated or not. Channel membership gates content visibility. --- @@ -142,7 +142,7 @@ See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the proj ## Agent CLI -`sprout-cli` is a 48-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, three-tier auth (API token → auto-mint keypair → dev pubkey). Agents can script the entire platform without a GUI. +`sprout-cli` is a 44-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, two-tier auth (NIP-98 keypair → dev pubkey). Agents can script the entire platform without a GUI. --- @@ -199,9 +199,9 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence | | ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete | | ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers | -| ✅ | Identity — NIP-05, public profiles, self-service token minting, agent protection | +| ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection | | ✅ | NIP-28 proxy — third-party Nostr clients (Coracle, nak, Amethyst) via `sprout-proxy` | -| ✅ | Agent CLI — `sprout-cli`, 48 commands, full MCP surface | +| ✅ | Agent CLI — `sprout-cli`, 44 commands, full MCP surface | | ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined | | 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) | | 🚧 | Huddles — LiveKit token minting in place; relay-side lifecycle events not yet wired | diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md index d1c6cda00..51cd86662 100644 --- a/crates/sprout-cli/README.md +++ b/crates/sprout-cli/README.md @@ -10,25 +10,21 @@ cargo install --path crates/sprout-cli ## Authentication -Three modes, checked in order: +Two modes, checked in order: | Priority | Env Var | Mode | Use Case | |----------|---------|------|----------| -| 1 | `SPROUT_API_TOKEN` | Bearer token | Production — fastest, no extra HTTP call | -| 2 | `SPROUT_PRIVATE_KEY` | Auto-mint short-lived token via NIP-98 | Agents with a keypair | -| 3 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development | +| 1 | `SPROUT_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair | +| 2 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development | ```bash -# Option 1: Pre-minted token -export SPROUT_API_TOKEN="sprout_tok_..." -sprout list-channels - -# Option 2: Private key (auto-mints a 1-day token at startup) +# Option 1: Private key (NIP-98 signed requests) export SPROUT_PRIVATE_KEY="nsec1..." sprout list-channels -# Option 3: Mint a long-lived token explicitly -export SPROUT_API_TOKEN=$(SPROUT_PRIVATE_KEY=nsec1... sprout auth) +# Option 2: Dev mode (no auth) +export SPROUT_PUBKEY="" +sprout list-channels ``` ## Usage @@ -83,17 +79,11 @@ sprout vote-on-post --event --direction up sprout get-canvas --channel sprout set-canvas --channel --content "# Welcome" -# Tokens -sprout auth # mint token, print to stdout -sprout list-tokens -sprout delete-token --id -sprout delete-all-tokens - # Pipe to jq sprout list-channels | jq '.[].name' ``` -## All 48 Commands +## All 44 Commands | Command | Description | |---------|-------------| @@ -141,10 +131,6 @@ sprout list-channels | jq '.[].name' | `approve-step` | Approve/deny a workflow step | | `get-feed` | Get your activity feed | | `vote-on-post` | Vote on a forum post | -| `auth` | Mint a long-lived API token | -| `list-tokens` | List your API tokens | -| `delete-token` | Delete a token | -| `delete-all-tokens` | Delete all tokens | ## Architecture diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 890333980..87418322e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -42,7 +42,6 @@ const overrides = new Map([ ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav - ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], ["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index c8815c3dc..04872f916 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -1,4 +1,3 @@ -import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { addChannelMembers, @@ -170,12 +169,6 @@ function pickPreferredManagedAgent(agents: ManagedAgent[]) { return rightRunningScore - leftRunningScore; } - const leftTokenScore = left.hasApiToken ? 1 : 0; - const rightTokenScore = right.hasApiToken ? 1 : 0; - if (leftTokenScore !== rightTokenScore) { - return rightTokenScore - leftTokenScore; - } - return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt); })[0]; } @@ -256,9 +249,6 @@ export async function ensureChannelAgentPresetInChannel( agentCommand: input.provider.command, agentArgs: input.provider.defaultArgs, mcpCommand: "sprout-mcp-server", - mintToken: true, - tokenName: `${expectedName} agent`, - tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES, spawnAfterCreate: false, }); const attached = await attachManagedAgentToChannel(channelId, { @@ -310,9 +300,6 @@ export async function createChannelManagedAgent( agentCommand: input.provider.command, agentArgs: input.provider.defaultArgs, mcpCommand: "sprout-mcp-server", - mintToken: true, - tokenName: `${trimmedName} agent`, - tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES, personaId: input.personaId ?? undefined, systemPrompt: input.systemPrompt?.trim() || undefined, avatarUrl: resolvedAvatarUrl, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index cf5aa9d7b..6674414fb 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -16,7 +16,6 @@ import { getManagedAgentLog, listManagedAgents, listRelayAgents, - mintManagedAgentToken, startManagedAgent, stopManagedAgent, updateManagedAgent, @@ -43,7 +42,6 @@ import type { CreatePersonaInput, CreateTeamInput, ManagedAgent, - MintManagedAgentTokenInput, UpdateManagedAgentInput, UpdatePersonaInput, UpdateTeamInput, @@ -344,19 +342,6 @@ export function useDeleteManagedAgentMutation() { }); } -export function useMintManagedAgentTokenMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (input: MintManagedAgentTokenInput) => - mintManagedAgentToken(input), - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); - await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); - }, - }); -} - export function useAttachManagedAgentToChannelMutation( channelId: string | null, ) { diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a3e2c5c74..cea6a26ec 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -15,7 +15,6 @@ import { TeamDialog } from "./TeamDialog"; import { TeamImportDialog } from "./TeamImportDialog"; import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog"; import { TeamsSection } from "./TeamsSection"; -import { TokenRevealDialog } from "./TokenRevealDialog"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -143,9 +142,6 @@ export function AgentsView() { onDelete={(pubkey) => { void agents.handleDelete(pubkey); }} - onMintToken={(pubkey, name) => { - void agents.handleMintToken(pubkey, name); - }} onSelectLogAgent={agents.setLogAgentPubkey} onStart={(pubkey) => { void agents.handleStart(pubkey); @@ -202,15 +198,6 @@ export function AgentsView() { } }} /> - { - if (!open) { - agents.setRevealedToken(null); - } - }} - token={agents.revealedToken?.token ?? null} - /> >( - () => new Set(DEFAULT_MANAGED_AGENT_SCOPES), - ); const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320"); const [parallelism, setParallelism] = React.useState("3"); const [systemPrompt, setSystemPrompt] = React.useState(""); @@ -97,13 +89,9 @@ export function CreateAgentDialog({ [backendProviders, runOn], ); const isProviderMode = runOn !== "local"; - // Provider agents always mint — ownership is established during mint. - // Use this everywhere instead of raw `mintToken` for validation/rendering. - const effectiveMintToken = isProviderMode || mintToken; const isSpawnSupported = prereqs?.acp.available === true && prereqs?.mcp.available === true; - const mintToggleDisabled = prereqsQuery.isLoading; const spawnToggleDisabled = prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; @@ -207,11 +195,8 @@ export function CreateAgentDialog({ function reset() { setName(""); setRelayUrl(""); - setMintToken(true); setSpawnAfterCreate(true); setStartOnAppLaunch(true); - setTokenName(""); - setSelectedScopes(new Set(DEFAULT_MANAGED_AGENT_SCOPES)); setAcpCommand("sprout-acp"); setAgentCommand("goose"); setAgentArgs("acp"); @@ -238,35 +223,6 @@ export function CreateAgentDialog({ onOpenChange(next); } - // Scopes required for remote agent controllability (!shutdown path). - // These cannot be removed in provider mode. - const PROVIDER_REQUIRED_SCOPES: TokenScope[] = [ - "users:read" as TokenScope, - "messages:read" as TokenScope, - "messages:write" as TokenScope, - "channels:read" as TokenScope, - ]; - - function toggleScope(scope: TokenScope) { - // Prevent removing required scopes in provider mode. - if ( - isProviderMode && - PROVIDER_REQUIRED_SCOPES.includes(scope) && - selectedScopes.has(scope) - ) { - return; // locked — required for remote agent controllability - } - setSelectedScopes((previous) => { - const next = new Set(previous); - if (next.has(scope)) { - next.delete(scope); - } else { - next.add(scope); - } - return next; - }); - } - function handleProviderChange(nextProviderId: string) { setSelectedProviderId(nextProviderId); @@ -306,7 +262,6 @@ export function CreateAgentDialog({ const canSubmit = name.trim().length > 0 && - (!effectiveMintToken || selectedScopes.size > 0) && !isDiscoveryPending && !( !isProviderMode && @@ -335,9 +290,6 @@ export function CreateAgentDialog({ ? Number.parseInt(parallelism, 10) : undefined, systemPrompt: systemPrompt.trim() || undefined, - mintToken: true, // Required: ownership established during mint - tokenName: tokenName.trim() || undefined, - tokenScopes: [...selectedScopes], spawnAfterCreate: true, startOnAppLaunch: false, // Remote agents don't auto-start with the desktop backend: { @@ -369,9 +321,6 @@ export function CreateAgentDialog({ ? Number.parseInt(parallelism, 10) : undefined, systemPrompt: systemPrompt.trim() || undefined, - mintToken: effectiveMintToken, - tokenName: tokenName.trim() || undefined, - tokenScopes: [...selectedScopes], spawnAfterCreate, startOnAppLaunch, backend: { type: "local" }, @@ -393,8 +342,7 @@ export function CreateAgentDialog({ Create agent This creates a local agent identity, syncs its display name when - possible, optionally mints a relay token, and can spawn - `sprout-acp` immediately. + possible, and can spawn `sprout-acp` immediately. @@ -467,13 +415,6 @@ export function CreateAgentDialog({ { - if (!mintToggleDisabled) { - setMintToken((current) => !current); - } - }} onToggleStartOnAppLaunch={() => { setStartOnAppLaunch((current) => !current); }} @@ -489,20 +430,6 @@ export function CreateAgentDialog({ spawnToggleDisabled={isProviderMode || spawnToggleDisabled} /> - {effectiveMintToken ? ( - (PROVIDER_REQUIRED_SCOPES) - : undefined - } - onScopeToggle={toggleScope} - onTokenNameChange={setTokenName} - selectedScopes={selectedScopes} - tokenName={tokenName} - /> - ) : null} -
- +
- ); - })} -
-
- - ); -} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 93b3ffc00..f4b0d12d9 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -6,7 +6,6 @@ import { Clipboard, Ellipsis, FileText, - KeyRound, Pencil, Play, Power, @@ -49,7 +48,6 @@ export function ManagedAgentRow({ presenceLookup, onAddToChannel, onDelete, - onMintToken, onSelectLogAgent, onStart, onStop, @@ -67,7 +65,6 @@ export function ManagedAgentRow({ presenceLookup: PresenceLookup; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; - onMintToken: (pubkey: string, name: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -158,7 +155,6 @@ export function ManagedAgentRow({ isActive={isActive} onAddToChannel={onAddToChannel} onDelete={onDelete} - onMintToken={onMintToken} onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)} onStart={onStart} onStop={onStop} @@ -307,7 +303,6 @@ function AgentActionsMenu({ isActive, onAddToChannel, onDelete, - onMintToken, onOpenLogs, onStart, onStop, @@ -318,7 +313,6 @@ function AgentActionsMenu({ isActive: boolean; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; - onMintToken: (pubkey: string, name: string) => void; onOpenLogs: (pubkey: string) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -391,14 +385,6 @@ function AgentActionsMenu({ Add to channel - onMintToken(agent.pubkey, agent.name)} - > - - Mint token - - { await navigator.clipboard.writeText(agent.pubkey); diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx index 49fcd67e3..903dc823a 100644 --- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx @@ -34,7 +34,6 @@ export function ManagedAgentsSection({ onBulkStopRunning, onCreate, onDelete, - onMintToken, onSelectLogAgent, onStart, onStop, @@ -59,7 +58,6 @@ export function ManagedAgentsSection({ onBulkStopRunning: () => void; onCreate: () => void; onDelete: (pubkey: string) => void; - onMintToken: (pubkey: string, name: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -176,7 +174,6 @@ export function ManagedAgentsSection({ presenceLookup={presenceLookup} onAddToChannel={onAddToChannel} onDelete={onDelete} - onMintToken={onMintToken} onSelectLogAgent={onSelectLogAgent} onStart={onStart} onStop={onStop} diff --git a/desktop/src/features/agents/ui/SecretRevealDialog.tsx b/desktop/src/features/agents/ui/SecretRevealDialog.tsx index a49f4295e..ad563879e 100644 --- a/desktop/src/features/agents/ui/SecretRevealDialog.tsx +++ b/desktop/src/features/agents/ui/SecretRevealDialog.tsx @@ -23,8 +23,8 @@ export function SecretRevealDialog({ Agent created - Save the private key and token now. The app can keep running the - harness locally, but these secrets are only revealed here. + Save the private key now. The app can keep running the harness + locally, but this secret is only revealed here. @@ -51,26 +51,6 @@ export function SecretRevealDialog({ - {created.apiToken ? ( -
-
-
-

- API token -

-

- Optional for local dev, required when the relay - enforces bearer auth. -

-
- -
- - {created.apiToken} - -
- ) : null} - {created.profileSyncError ? (

{created.profileSyncError} diff --git a/desktop/src/features/agents/ui/TokenRevealDialog.tsx b/desktop/src/features/agents/ui/TokenRevealDialog.tsx deleted file mode 100644 index 20113870b..000000000 --- a/desktop/src/features/agents/ui/TokenRevealDialog.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { CopyButton } from "./CopyButton"; - -export function TokenRevealDialog({ - name, - token, - onOpenChange, -}: { - name: string | null; - token: string | null; - onOpenChange: (open: boolean) => void; -}) { - return ( -

- -
- - Agent token minted - - Save this token now. Restart the harness if you want the running - agent to pick it up immediately. - - - -
-
-
-
-

{name}

-

- Token shown once only. -

-
- {token ? : null} -
- {token ? ( - - {token} - - ) : null} -
-
- -
- -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index ddaf7f829..1e8e539a7 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -4,7 +4,6 @@ import { type AttachManagedAgentToChannelResult, useManagedAgentLogQuery, useManagedAgentsQuery, - useMintManagedAgentTokenMutation, useRelayAgentsQuery, useSetManagedAgentStartOnAppLaunchMutation, useStartManagedAgentMutation, @@ -36,17 +35,11 @@ export function useManagedAgentActions() { const stopMutation = useStopManagedAgentMutation(); const deleteMutation = useDeleteManagedAgentMutation(); const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); - const mintTokenMutation = useMintManagedAgentTokenMutation(); - const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [agentToAddToChannel, setAgentToAddToChannel] = React.useState(null); const [createdAgent, setCreatedAgent] = React.useState(null); - const [revealedToken, setRevealedToken] = React.useState<{ - name: string; - token: string; - } | null>(null); const [logAgentPubkey, setLogAgentPubkey] = React.useState( null, ); @@ -210,21 +203,6 @@ export function useManagedAgentActions() { } } - async function handleMintToken(pubkey: string, name: string) { - clearFeedback(); - try { - const result = await mintTokenMutation.mutateAsync({ - pubkey, - tokenName: `${name}-token`, - }); - setRevealedToken({ name, token: result.token }); - } catch (error) { - setActionErrorMessage( - error instanceof Error ? error.message : "Failed to mint token.", - ); - } - } - function handleAddedToChannel( channel: Channel, result: AttachManagedAgentToChannelResult, @@ -318,8 +296,7 @@ export function useManagedAgentActions() { startMutation.isPending || stopMutation.isPending || startOnLaunchMutation.isPending || - deleteMutation.isPending || - mintTokenMutation.isPending; + deleteMutation.isPending; return { // Queries @@ -339,8 +316,6 @@ export function useManagedAgentActions() { setAgentToAddToChannel, createdAgent, setCreatedAgent, - revealedToken, - setRevealedToken, logAgentPubkey, setLogAgentPubkey, actionNoticeMessage, @@ -352,7 +327,6 @@ export function useManagedAgentActions() { handleStop, handleDelete, handleToggleStartOnAppLaunch, - handleMintToken, handleAddedToChannel, handleBulkStopRunning, handleBulkRemoveStopped, diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index eba24096f..25922ff49 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -5,7 +5,6 @@ import { Check, Download, Keyboard, - KeyRound, LockKeyhole, MonitorCog, Moon, @@ -21,7 +20,6 @@ import type { NotificationSettings, } from "@/features/notifications/hooks"; import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard"; -import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard"; import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider"; import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; @@ -39,7 +37,6 @@ export type SettingsSection = | "agents" | "appearance" | "shortcuts" - | "tokens" | "relay-members" | "mobile" | "updates" @@ -92,11 +89,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Shortcuts", icon: Keyboard, }, - { - value: "tokens", - label: "Tokens", - icon: KeyRound, - }, { value: "relay-members", label: "Relay Access", @@ -270,8 +262,6 @@ export function renderSettingsSection( return ; case "shortcuts": return ; - case "tokens": - return ; case "relay-members": return ; case "mobile": diff --git a/desktop/src/features/tokens/hooks.ts b/desktop/src/features/tokens/hooks.ts deleted file mode 100644 index a0794cf5c..000000000 --- a/desktop/src/features/tokens/hooks.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { - listTokens, - mintToken, - revokeAllTokens, - revokeToken, -} from "@/shared/api/tauri"; -import type { MintTokenInput, Token } from "@/shared/api/types"; - -export const tokensQueryKey = ["tokens"] as const; - -export function useTokensQuery() { - return useQuery({ - queryKey: tokensQueryKey, - queryFn: listTokens, - staleTime: 30_000, - }); -} - -export function useMintTokenMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (input: MintTokenInput) => mintToken(input), - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: tokensQueryKey }); - }, - }); -} - -export function useRevokeTokenMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (tokenId: string) => revokeToken(tokenId), - onMutate: async (tokenId) => { - await queryClient.cancelQueries({ queryKey: tokensQueryKey }); - const previous = queryClient.getQueryData(tokensQueryKey); - - queryClient.setQueryData(tokensQueryKey, (old) => - old?.map((t) => - t.id === tokenId ? { ...t, revokedAt: new Date().toISOString() } : t, - ), - ); - - return { previous }; - }, - onError: (_err, _tokenId, context) => { - if (context?.previous) { - queryClient.setQueryData(tokensQueryKey, context.previous); - } - }, - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: tokensQueryKey }); - }, - }); -} - -export function useRevokeAllTokensMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: () => revokeAllTokens(), - onMutate: async () => { - await queryClient.cancelQueries({ queryKey: tokensQueryKey }); - const previous = queryClient.getQueryData(tokensQueryKey); - const now = new Date().toISOString(); - - queryClient.setQueryData(tokensQueryKey, (old) => - old?.map((t) => (t.revokedAt ? t : { ...t, revokedAt: now })), - ); - - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous) { - queryClient.setQueryData(tokensQueryKey, context.previous); - } - }, - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: tokensQueryKey }); - }, - }); -} diff --git a/desktop/src/features/tokens/lib/scopeOptions.ts b/desktop/src/features/tokens/lib/scopeOptions.ts deleted file mode 100644 index 89dbd1319..000000000 --- a/desktop/src/features/tokens/lib/scopeOptions.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { TokenScope } from "@/shared/api/types"; - -export const TOKEN_SCOPE_OPTIONS: Array<{ - value: TokenScope; - label: string; - description: string; -}> = [ - { - value: "messages:read", - label: "Messages: Read", - description: "Read messages in joined channels", - }, - { - value: "messages:write", - label: "Messages: Write", - description: "Send messages and replies", - }, - { - value: "channels:read", - label: "Channels: Read", - description: "List and view channel metadata", - }, - { - value: "channels:write", - label: "Channels: Write", - description: "Create and update channels", - }, - { - value: "users:read", - label: "Users: Read", - description: "View user profiles and presence", - }, - { - value: "files:read", - label: "Files: Read", - description: "Download uploaded files", - }, - { - value: "files:write", - label: "Files: Write", - description: "Upload files to channels", - }, -]; - -export const MANAGED_AGENT_SCOPE_OPTIONS: Array<{ - value: TokenScope; - label: string; - description: string; -}> = [...TOKEN_SCOPE_OPTIONS]; - -export const DEFAULT_MANAGED_AGENT_SCOPES: TokenScope[] = [ - "messages:read", - "messages:write", - "channels:read", - "users:read", -]; diff --git a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx deleted file mode 100644 index ff8a60958..000000000 --- a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx +++ /dev/null @@ -1,758 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Copy, KeyRound, Plus, Trash2, TriangleAlert } from "lucide-react"; -import * as React from "react"; -import { toast } from "sonner"; - -import { useChannelsQuery } from "@/features/channels/hooks"; -import { - useMintTokenMutation, - useRevokeAllTokensMutation, - useRevokeTokenMutation, - useTokensQuery, -} from "@/features/tokens/hooks"; -import { TOKEN_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions"; -import { getChannelMembers } from "@/shared/api/tauri"; -import type { Channel, Token, TokenScope } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; - -const EXPIRY_OPTIONS = [ - { value: 7, label: "7 days" }, - { value: 30, label: "30 days" }, - { value: 90, label: "90 days" }, - { value: 365, label: "1 year" }, - { value: 0, label: "No expiry" }, -] as const; - -const MAX_ACTIVE_TOKENS = 10; - -function tokenStatus(token: Token): "active" | "revoked" | "expired" { - if (token.revokedAt) return "revoked"; - if (token.expiresAt && new Date(token.expiresAt) < new Date()) - return "expired"; - return "active"; -} - -function StatusBadge({ status }: { status: "active" | "revoked" | "expired" }) { - return ( - - {status} - - ); -} - -function ScopeBadge({ scope }: { scope: string }) { - return ( - - {scope} - - ); -} - -function formatRelativeDate(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60_000); - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 30) return `${diffDays}d ago`; - return date.toLocaleDateString(); -} - -function formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function channelLabel(channelId: string, channelsById: Map) { - return ( - channelsById.get(channelId)?.name ?? `Channel ${channelId.slice(0, 8)}` - ); -} - -function TokenRow({ - channelsById, - token, - onRevoke, - isRevoking, -}: { - channelsById: Map; - token: Token; - onRevoke: (id: string) => void; - isRevoking: boolean; -}) { - const status = tokenStatus(token); - const visibleChannelIds = token.channelIds.slice(0, 4); - const hiddenChannelCount = token.channelIds.length - visibleChannelIds.length; - - return ( -
-
-
- {token.name} - -
-
- {token.scopes.map((scope) => ( - - ))} -
-

- Created {formatRelativeDate(token.createdAt)} - {token.lastUsedAt - ? ` · Last used ${formatRelativeDate(token.lastUsedAt)}` - : " · Never used"} - {token.expiresAt ? ` · Expires ${formatDate(token.expiresAt)}` : ""} -

-

- {token.channelIds.length === 0 - ? "All accessible channels" - : `Scoped to ${token.channelIds.length} channel${token.channelIds.length === 1 ? "" : "s"}`} -

- {visibleChannelIds.length > 0 ? ( -
- {visibleChannelIds.map((channelId) => ( - - ))} - {hiddenChannelCount > 0 ? ( - - ) : null} -
- ) : null} -
- {status === "active" ? ( - - ) : null} -
- ); -} - -function CreateTokenDialog({ - activeTokenCount, - currentPubkey, - channels, - hiddenChannelsCount, - channelsError, - isLoadingChannels, - open, - onOpenChange, -}: { - activeTokenCount: number; - currentPubkey?: string; - channels: Channel[]; - hiddenChannelsCount: number; - channelsError: Error | null; - isLoadingChannels: boolean; - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const mintMutation = useMintTokenMutation(); - const [name, setName] = React.useState(""); - const [selectedScopes, setSelectedScopes] = React.useState>( - new Set(), - ); - const [channelAccessMode, setChannelAccessMode] = React.useState< - "all" | "selected" - >("all"); - const [selectedChannelIds, setSelectedChannelIds] = React.useState< - Set - >(new Set()); - const [expiryDays, setExpiryDays] = React.useState(30); - const [mintedToken, setMintedToken] = React.useState(null); - - const canCreate = - activeTokenCount < MAX_ACTIVE_TOKENS && - name.trim().length > 0 && - name.trim().length <= 100 && - selectedScopes.size > 0 && - (channelAccessMode === "all" || selectedChannelIds.size > 0) && - !mintMutation.isPending; - - function reset() { - setName(""); - setSelectedScopes(new Set()); - setChannelAccessMode("all"); - setSelectedChannelIds(new Set()); - setExpiryDays(30); - setMintedToken(null); - mintMutation.reset(); - } - - function handleOpenChange(next: boolean) { - if (!next) { - reset(); - } - onOpenChange(next); - } - - function toggleScope(scope: TokenScope) { - setSelectedScopes((prev) => { - const next = new Set(prev); - if (next.has(scope)) { - next.delete(scope); - } else { - next.add(scope); - } - return next; - }); - } - - function toggleChannel(channelId: string) { - setSelectedChannelIds((prev) => { - const next = new Set(prev); - if (next.has(channelId)) { - next.delete(channelId); - } else { - next.add(channelId); - } - return next; - }); - } - - async function handleCreate() { - const result = await mintMutation.mutateAsync({ - name: name.trim(), - scopes: [...selectedScopes], - channelIds: - channelAccessMode === "selected" ? [...selectedChannelIds] : undefined, - expiresInDays: expiryDays === 0 ? undefined : expiryDays, - }); - setMintedToken(result.token); - } - - async function handleCopy() { - if (!mintedToken) return; - await navigator.clipboard.writeText(mintedToken); - toast.success("Copied to clipboard"); - } - - if (mintedToken) { - return ( - - -
- - Token created - - Copy this token now. You will not be able to see it again. - - - -
-
-
- - {mintedToken} - - -
-
- - - This is the only time this token will be shown. Store it - securely. - -
-
-
- -
- -
-
-
-
- ); - } - - return ( - - -
- - Create API token - - Tokens allow agents and scripts to authenticate with the relay on - your behalf. - - - -
-
-
- - setName(e.target.value)} - placeholder="e.g. my-agent-bot" - spellCheck={false} - value={name} - /> -
- -
-

Scopes

-
- {TOKEN_SCOPE_OPTIONS.map(({ value, label }) => { - const isSelected = selectedScopes.has(value); - return ( - - ); - })} -
-
- -
-
-

Channel access

- - {channelAccessMode === "all" - ? "All accessible channels" - : `${selectedChannelIds.size} selected`} - -
-
- {[ - { - value: "all" as const, - label: "All channels", - description: - "Unrestricted across the channels you can access.", - }, - { - value: "selected" as const, - label: "Selected channels", - description: "Limit this token to specific channels.", - }, - ].map((option) => { - const isSelected = channelAccessMode === option.value; - return ( - - ); - })} -
- - {channelAccessMode === "selected" ? ( - isLoadingChannels ? ( -

- Loading channels... -

- ) : channelsError ? ( -

- {channelsError.message} -

- ) : channels.length > 0 ? ( -
- {channels.map((channel) => { - const isSelected = selectedChannelIds.has(channel.id); - return ( - - ); - })} -
- ) : ( -

- No accessible channels available for scoping yet. -

- ) - ) : null} - -

- Use channel-scoped tokens for guests and single-purpose - agents. -

- {currentPubkey ? ( -

- Only channels where you are a member can be added to a - scoped token. - {hiddenChannelsCount > 0 - ? ` ${hiddenChannelsCount} accessible channel${hiddenChannelsCount === 1 ? "" : "s"} hidden because you are not a member.` - : ""} -

- ) : ( -

- Your identity is still loading, so channel membership cannot - be checked yet. -

- )} -
- -
-

Expiry

-
- {EXPIRY_OPTIONS.map(({ value, label }) => ( - - ))} -
-
- - {activeTokenCount >= MAX_ACTIVE_TOKENS ? ( -

- You already have {MAX_ACTIVE_TOKENS} active tokens. Revoke one - before creating another. -

- ) : null} - - {mintMutation.error instanceof Error ? ( -

- {mintMutation.error.message} -

- ) : null} -
-
- -
- - -
-
-
-
- ); -} - -function RevokeAllDialog({ - open, - onOpenChange, - onConfirm, - isPending, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - isPending: boolean; -}) { - return ( - - - - Revoke all tokens? - - This will immediately revoke every active token. Agents using these - tokens will lose access. - - -
- - -
-
-
- ); -} - -export function TokenSettingsCard({ - currentPubkey, -}: { - currentPubkey?: string; -}) { - const channelsQuery = useChannelsQuery(); - const tokensQuery = useTokensQuery(); - const revokeTokenMutation = useRevokeTokenMutation(); - const revokeAllMutation = useRevokeAllTokensMutation(); - - const [createOpen, setCreateOpen] = React.useState(false); - const [revokeAllOpen, setRevokeAllOpen] = React.useState(false); - - const allChannels = channelsQuery.data ?? []; - const channels = allChannels.filter((channel) => channel.archivedAt === null); - const scopeableChannelsQuery = useQuery({ - enabled: - createOpen && - typeof currentPubkey === "string" && - currentPubkey.length > 0 && - channels.length > 0, - queryKey: [ - "token-scopeable-channels", - currentPubkey?.toLowerCase() ?? "", - ...channels.map((channel) => channel.id), - ], - queryFn: async () => { - if (!currentPubkey) { - return [] as Channel[]; - } - - const memberships = await Promise.all( - channels.map(async (channel) => { - const members = await getChannelMembers(channel.id); - return { - channel, - isMember: members.some( - (member) => - member.pubkey.toLowerCase() === currentPubkey.toLowerCase(), - ), - }; - }), - ); - - return memberships - .filter((entry) => entry.isMember) - .map((entry) => entry.channel); - }, - staleTime: 30_000, - }); - const scopeableChannels = scopeableChannelsQuery.data ?? []; - const hiddenChannelsCount = scopeableChannelsQuery.isSuccess - ? Math.max(channels.length - scopeableChannels.length, 0) - : 0; - const channelsById = new Map( - allChannels.map((channel) => [channel.id, channel]), - ); - const tokens = tokensQuery.data ?? []; - const activeTokens = tokens.filter((t) => tokenStatus(t) === "active"); - const hasReachedTokenLimit = activeTokens.length >= MAX_ACTIVE_TOKENS; - - return ( -
-
-
-
- -

API Tokens

-
-

- Create tokens for agents, guests, and integrations to access the - relay. {activeTokens.length}/{MAX_ACTIVE_TOKENS} active. -

-
- -
- {activeTokens.length > 0 ? ( - - ) : null} - -
-
- - {hasReachedTokenLimit ? ( -

- You've reached the active token limit. Revoke an existing token to - mint another. -

- ) : null} - - {tokensQuery.error instanceof Error ? ( -

- {tokensQuery.error.message} -

- ) : null} - - {tokens.length > 0 ? ( -
- {tokens.map((token) => ( - revokeTokenMutation.mutate(id)} - token={token} - /> - ))} -
- ) : tokensQuery.isSuccess ? ( -

- No tokens yet. Create one to get started. -

- ) : null} - - - { - revokeAllMutation.mutate(undefined, { - onSuccess: () => setRevokeAllOpen(false), - }); - }} - onOpenChange={setRevokeAllOpen} - open={revokeAllOpen} - /> -
- ); -} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 910f0f139..d4e8cb5b3 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -14,9 +14,6 @@ import type { GetHomeFeedInput, HomeFeedResponse, Identity, - MintTokenInput, - MintTokenResponse, - MintManagedAgentTokenInput, ManagedAgent, ManagedAgentBackend, RelayAgent, @@ -34,8 +31,6 @@ import type { SetPresenceResult, SetChannelPurposeInput, SetChannelTopicInput, - Token, - TokenScope, UpdateProfileInput, UpdateChannelInput, UserProfileSummary, @@ -193,31 +188,6 @@ type RawSendChannelMessageResult = { created_at: number; }; -type RawToken = { - id: string; - name: string; - scopes: TokenScope[]; - channel_ids: string[]; - created_at: string; - expires_at: string | null; - last_used_at: string | null; - revoked_at: string | null; -}; - -type RawListTokensResponse = { - tokens: RawToken[]; -}; - -type RawMintTokenResponse = { - id: string; - token: string; - name: string; - scopes: TokenScope[]; - channel_ids: string[]; - created_at: string; - expires_at: string | null; -}; - type RawRelayAgent = { pubkey: string; name: string; @@ -244,7 +214,6 @@ export type RawManagedAgent = { system_prompt: string | null; model: string | null; mcp_toolsets: string | null; - has_api_token: boolean; status: ManagedAgent["status"]; pid: number | null; created_at: string; @@ -262,16 +231,10 @@ export type RawManagedAgent = { type RawCreateManagedAgentResponse = { agent: RawManagedAgent; private_key_nsec: string; - api_token: string | null; profile_sync_error: string | null; spawn_error: string | null; }; -type RawMintManagedAgentTokenResponse = { - agent: RawManagedAgent; - token: string; -}; - type RawManagedAgentLog = { content: string; log_path: string; @@ -828,19 +791,6 @@ export async function createAuthEvent(input: { return JSON.parse(eventJson) as RelayEvent; } -function fromRawToken(token: RawToken): Token { - return { - id: token.id, - name: token.name, - scopes: token.scopes, - channelIds: token.channel_ids, - createdAt: token.created_at, - expiresAt: token.expires_at, - lastUsedAt: token.last_used_at, - revokedAt: token.revoked_at, - }; -} - function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { return { pubkey: agent.pubkey, @@ -870,7 +820,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { systemPrompt: agent.system_prompt, model: agent.model, mcpToolsets: agent.mcp_toolsets, - hasApiToken: agent.has_api_token, status: agent.status, pid: agent.pid, createdAt: agent.created_at, @@ -906,42 +855,6 @@ function fromRawCommandAvailability( }; } -export async function listTokens(): Promise { - const response = await invokeTauri("list_tokens"); - return response.tokens.map(fromRawToken); -} - -export async function mintToken( - input: MintTokenInput, -): Promise { - const response = await invokeTauri("mint_token", { - name: input.name, - scopes: input.scopes, - channelIds: input.channelIds, - expiresInDays: input.expiresInDays, - }); - return { - id: response.id, - token: response.token, - name: response.name, - scopes: response.scopes, - channelIds: response.channel_ids, - createdAt: response.created_at, - expiresAt: response.expires_at, - }; -} - -export async function revokeToken(tokenId: string): Promise { - await invokeTauri("revoke_token", { tokenId }); -} - -export async function revokeAllTokens(): Promise<{ revokedCount: number }> { - const response = await invokeTauri<{ revoked_count: number }>( - "revoke_all_tokens", - ); - return { revokedCount: response.revoked_count }; -} - // ── Relay Members ──────────────────────────────────────────────────────────── function fromRawRelayMember(raw: RawRelayMember): RelayMember { @@ -1027,9 +940,6 @@ export async function createManagedAgent(input: CreateManagedAgentInput) { systemPrompt: input.systemPrompt, avatarUrl: input.avatarUrl, model: input.model, - mintToken: input.mintToken, - tokenScopes: input.tokenScopes, - tokenName: input.tokenName, spawnAfterCreate: input.spawnAfterCreate, startOnAppLaunch: input.startOnAppLaunch, backend: input.backend, @@ -1040,7 +950,6 @@ export async function createManagedAgent(input: CreateManagedAgentInput) { return { agent: fromRawManagedAgent(response.agent), privateKeyNsec: response.private_key_nsec, - apiToken: response.api_token, profileSyncError: response.profile_sync_error, spawnError: response.spawn_error, }; @@ -1070,24 +979,6 @@ export async function deleteManagedAgent( }); } -export async function mintManagedAgentToken(input: MintManagedAgentTokenInput) { - const response = await invokeTauri( - "mint_managed_agent_token", - { - input: { - pubkey: input.pubkey, - tokenName: input.tokenName, - scopes: input.scopes, - }, - }, - ); - - return { - agent: fromRawManagedAgent(response.agent), - token: response.token, - }; -} - export async function getManagedAgentLog(pubkey: string, lineCount?: number) { const response = await invokeTauri( "get_managed_agent_log", diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 45b0b54ba..62c6b36b7 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -248,44 +248,6 @@ export type RelayMember = { createdAt: string; }; -export type TokenScope = - | "messages:read" - | "messages:write" - | "channels:read" - | "channels:write" - | "users:read" - | "users:write" - | "files:read" - | "files:write"; - -export type Token = { - id: string; - name: string; - scopes: TokenScope[]; - channelIds: string[]; - createdAt: string; - expiresAt: string | null; - lastUsedAt: string | null; - revokedAt: string | null; -}; - -export type MintTokenInput = { - name: string; - scopes: TokenScope[]; - channelIds?: string[]; - expiresInDays?: number; -}; - -export type MintTokenResponse = { - id: string; - token: string; - name: string; - scopes: TokenScope[]; - channelIds: string[]; - createdAt: string; - expiresAt: string | null; -}; - export type RelayAgent = { pubkey: string; name: string; @@ -316,7 +278,6 @@ export type ManagedAgent = { systemPrompt: string | null; model: string | null; mcpToolsets: string | null; - hasApiToken: boolean; status: "running" | "stopped" | "deployed" | "not_deployed"; pid: number | null; createdAt: string; @@ -360,9 +321,6 @@ export type CreateManagedAgentInput = { avatarUrl?: string; model?: string; mcpToolsets?: string; - mintToken?: boolean; - tokenScopes?: TokenScope[]; - tokenName?: string; spawnAfterCreate?: boolean; startOnAppLaunch?: boolean; backend?: ManagedAgentBackend; @@ -371,22 +329,10 @@ export type CreateManagedAgentInput = { export type CreateManagedAgentResponse = { agent: ManagedAgent; privateKeyNsec: string; - apiToken: string | null; profileSyncError: string | null; spawnError: string | null; }; -export type MintManagedAgentTokenInput = { - pubkey: string; - tokenName?: string; - scopes?: TokenScope[]; -}; - -export type MintManagedAgentTokenResponse = { - agent: ManagedAgent; - token: string; -}; - export type ManagedAgentLog = { content: string; logPath: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a409ade49..c7f9fceaa 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -33,29 +33,15 @@ type E2eConfig = { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; }; - mintTokenError?: string; profileReadDelayMs?: number; profileReadError?: string; profileUpdateError?: string; - seededTokens?: RawMockTokenSeed[]; }; relayHttpUrl?: string; relayWsUrl?: string; identity?: TestIdentity; }; -type RawMockTokenSeed = { - id: string; - name: string; - scopes: string[]; - channel_ids: string[]; - created_at: string; - expires_at: string | null; - last_used_at: string | null; - revoked_at: string | null; - token?: string; -}; - type RawRelayMember = { pubkey: string; role: "owner" | "admin" | "member"; @@ -274,29 +260,6 @@ type RawSendChannelMessageResponse = { created_at: number; }; -type RawToken = { - id: string; - name: string; - scopes: string[]; - channel_ids: string[]; - created_at: string; - expires_at: string | null; - last_used_at: string | null; - revoked_at: string | null; -}; - -type RawListTokensResponse = { - tokens: RawToken[]; -}; - -type RawMintTokenResponse = RawToken & { - token: string; -}; - -type RawRevokeAllTokensResponse = { - revoked_count: number; -}; - type RawRelayAgent = { pubkey: string; name: string; @@ -322,7 +285,6 @@ type RawManagedAgent = { parallelism: number; system_prompt: string | null; model: string | null; - has_api_token: boolean; status: "running" | "stopped" | "deployed" | "not_deployed"; pid: number | null; created_at: string; @@ -342,16 +304,10 @@ type RawManagedAgent = { type RawCreateManagedAgentResponse = { agent: RawManagedAgent; private_key_nsec: string; - api_token: string | null; profile_sync_error: string | null; spawn_error: string | null; }; -type RawMintManagedAgentTokenResponse = { - agent: RawManagedAgent; - token: string; -}; - type RawManagedAgentLog = { content: string; log_path: string; @@ -397,13 +353,8 @@ type RawTeam = { updated_at: string; }; -type MockToken = RawToken & { - token: string; -}; - type MockManagedAgent = RawManagedAgent & { private_key_nsec: string; - api_token: string | null; log_lines: string[]; }; @@ -682,21 +633,6 @@ function cloneProfile(profile: RawProfile): RawProfile { return { ...profile }; } -function cloneToken(token: RawToken): RawToken { - return { - ...token, - channel_ids: [...token.channel_ids], - scopes: [...token.scopes], - }; -} - -function cloneMintedToken(token: MockToken): RawMintTokenResponse { - return { - ...cloneToken(token), - token: token.token, - }; -} - function cloneRelayAgent(agent: RawRelayAgent): RawRelayAgent { return { ...agent, @@ -722,7 +658,6 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { parallelism: agent.parallelism, system_prompt: agent.system_prompt, model: agent.model, - has_api_token: agent.has_api_token, status: agent.status, pid: agent.pid, created_at: agent.created_at, @@ -738,20 +673,6 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { }; } -function toMockToken(seed: RawMockTokenSeed): MockToken { - return { - ...cloneToken(seed), - token: - seed.token ?? - `spr_tok_mock_${seed.id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24)}`, - }; -} - -function resetMockTokens(config: E2eConfig | undefined) { - mockTokens = (config?.mock?.seededTokens ?? []).map(toMockToken); - mockMintTokenError = config?.mock?.mintTokenError ?? null; -} - function resetMockRelayMembers(config: E2eConfig | undefined) { const pubkey = getMockMemberPubkey(config); mockRelayMembers = [ @@ -1150,8 +1071,6 @@ const mockMessages = new Map(); let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); const realSockets = new Map(); -let mockTokens: MockToken[] = []; -let mockMintTokenError: string | null = null; let mockManagedAgents: MockManagedAgent[] = []; let mockPersonas: RawPersona[] = []; let mockTeams: RawTeam[] = []; @@ -3510,107 +3429,6 @@ async function handleGetFeed( }; } -async function handleListTokens( - config: E2eConfig | undefined, -): Promise { - const identity = getIdentity(config); - if (!identity) { - return { - tokens: mockTokens.map(cloneToken), - }; - } - - // Tokens are deleted in pure-nostr — return empty list - return { tokens: [] }; -} - -async function handleMintToken( - args: { - name: string; - scopes: string[]; - channelIds?: string[]; - expiresInDays?: number; - }, - config: E2eConfig | undefined, -): Promise { - const identity = getIdentity(config); - if (!identity) { - if (mockMintTokenError) { - throw mockMintTokenError; - } - - const now = new Date(); - const token: MockToken = { - id: crypto.randomUUID(), - name: args.name, - scopes: [...args.scopes], - channel_ids: [...(args.channelIds ?? [])], - created_at: now.toISOString(), - expires_at: - typeof args.expiresInDays === "number" - ? new Date( - now.getTime() + args.expiresInDays * 24 * 60 * 60 * 1_000, - ).toISOString() - : null, - last_used_at: null, - revoked_at: null, - token: `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`, - }; - - mockTokens.unshift(token); - return cloneMintedToken(token); - } - - // Tokens are deleted in pure-nostr — return error - throw new Error( - "Token minting is not available in pure-nostr mode. Auth uses keypairs directly.", - ); -} - -async function handleRevokeToken( - args: { tokenId: string }, - config: E2eConfig | undefined, -) { - const identity = getIdentity(config); - if (!identity) { - const token = mockTokens.find((candidate) => candidate.id === args.tokenId); - if (!token) { - throw new Error(`Token ${args.tokenId} not found.`); - } - - token.revoked_at = new Date().toISOString(); - return; - } - - // Tokens deleted in pure-nostr — no-op -} - -async function handleRevokeAllTokens( - config: E2eConfig | undefined, -): Promise { - const identity = getIdentity(config); - if (!identity) { - const now = new Date().toISOString(); - let revokedCount = 0; - - for (const token of mockTokens) { - if (token.revoked_at) { - continue; - } - - token.revoked_at = now; - revokedCount += 1; - } - - return { - revoked_count: revokedCount, - }; - } - - // Tokens deleted in pure-nostr — no-op - return { revoked_count: 0 }; -} - async function handleListRelayAgents(): Promise { syncMockRelayAgentsFromManagedAgents(); return mockRelayAgents.map(cloneRelayAgent); @@ -3961,9 +3779,6 @@ async function handleCreateManagedAgent(args: { systemPrompt?: string; avatarUrl?: string; model?: string; - mintToken?: boolean; - tokenScopes?: string[]; - tokenName?: string; spawnAfterCreate?: boolean; startOnAppLaunch?: boolean; backend?: @@ -3981,10 +3796,6 @@ async function handleCreateManagedAgent(args: { .replace(/-/g, "") .padEnd(64, "0") .slice(0, 64); - const token = - args.input.mintToken === false - ? null - : `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`; const managedAgent: MockManagedAgent = { pubkey, name, @@ -4003,7 +3814,6 @@ async function handleCreateManagedAgent(args: { parallelism: args.input.parallelism ?? 1, system_prompt: args.input.systemPrompt?.trim() || null, model: args.input.model?.trim() || null, - has_api_token: token !== null, status: args.input.spawnAfterCreate ? "running" : "stopped", pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null, created_at: now, @@ -4017,7 +3827,6 @@ async function handleCreateManagedAgent(args: { backend: args.input.backend ?? { type: "local" as const }, backend_agent_id: null, private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`, - api_token: token, log_lines: [ `sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey} parallelism=${args.input.parallelism ?? 1}`, args.input.systemPrompt?.trim() @@ -4035,7 +3844,6 @@ async function handleCreateManagedAgent(args: { return { agent: cloneManagedAgent(managedAgent), private_key_nsec: managedAgent.private_key_nsec, - api_token: managedAgent.api_token, profile_sync_error: null, spawn_error: null, }; @@ -4114,28 +3922,6 @@ async function handleSetManagedAgentStartOnAppLaunch(args: { return cloneManagedAgent(agent); } -async function handleMintManagedAgentToken(args: { - input: { - pubkey: string; - tokenName?: string; - scopes?: string[]; - }; -}): Promise { - const agent = getMockManagedAgent(args.input.pubkey); - const now = new Date().toISOString(); - agent.api_token = `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`; - agent.has_api_token = true; - agent.updated_at = now; - agent.log_lines.push( - `minted token ${args.input.tokenName ?? `${agent.name}-token`} at ${now}`, - ); - - return { - agent: cloneManagedAgent(agent), - token: agent.api_token ?? "", - }; -} - async function handleGetManagedAgentLog(args: { pubkey: string; lineCount?: number; @@ -4679,7 +4465,6 @@ export function maybeInstallE2eTauriMocks() { return; } - resetMockTokens(config); resetMockRelayMembers(config); resetMockManagedAgents(); resetMockPersonas(); @@ -4811,20 +4596,6 @@ export function maybeInstallE2eTauriMocks() { (payload as Parameters[0]) ?? {}, activeConfig, ); - case "list_tokens": - return handleListTokens(activeConfig); - case "mint_token": - return handleMintToken( - payload as Parameters[0], - activeConfig, - ); - case "revoke_token": - return handleRevokeToken( - payload as Parameters[0], - activeConfig, - ); - case "revoke_all_tokens": - return handleRevokeAllTokens(activeConfig); case "list_relay_agents": return handleListRelayAgents(); case "list_personas": @@ -4893,10 +4664,6 @@ export function maybeInstallE2eTauriMocks() { return handleDeleteManagedAgent( payload as Parameters[0], ); - case "mint_managed_agent_token": - return handleMintManagedAgentToken( - payload as Parameters[0], - ); case "get_managed_agent_log": return handleGetManagedAgentLog( payload as Parameters[0], diff --git a/desktop/tests/e2e/tokens.spec.ts b/desktop/tests/e2e/tokens.spec.ts deleted file mode 100644 index fa7380334..000000000 --- a/desktop/tests/e2e/tokens.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { installMockBridge } from "../helpers/bridge"; -import { openSettings } from "../helpers/settings"; - -const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -const DESIGN_CHANNEL_ID = "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"; - -test("creates a channel-scoped token from settings and can revoke it", async ({ - page, -}) => { - await installMockBridge(page); - await page.goto("/"); - - await openSettings(page, "tokens"); - - const tokenCard = page.getByTestId("settings-tokens"); - await tokenCard.getByRole("button", { name: "Create token" }).click(); - - const dialog = page.getByTestId("create-token-dialog"); - await expect(dialog).toBeVisible(); - - await page.getByTestId("token-name-input").fill("qa-selected-channels"); - await page.getByTestId("token-scope-messages-read").click(); - await page.getByTestId("token-scope-channels-read").click(); - await page.getByTestId("token-channel-access-selected").click(); - - await expect( - dialog.getByText( - /Only channels where you are a member can be added to a scoped token\.( \d+ accessible channels? hidden because you are not a member\.)?/, - ), - ).toBeVisible(); - await expect( - page.getByTestId(`token-channel-${GENERAL_CHANNEL_ID}`), - ).toBeVisible(); - await expect( - page.getByTestId(`token-channel-${DESIGN_CHANNEL_ID}`), - ).toHaveCount(0); - - await page.getByTestId(`token-channel-${GENERAL_CHANNEL_ID}`).click(); - await page.getByTestId("token-expiry-7").click(); - await page.getByTestId("confirm-create-token").click(); - - const createdDialog = page.getByTestId("token-created-dialog"); - await expect(createdDialog).toBeVisible(); - await expect(createdDialog).toContainText("Token created"); - await expect(createdDialog).toContainText("spr_tok_mock_"); - await page.getByTestId("token-created-done").click(); - - await expect(tokenCard).toContainText("qa-selected-channels"); - await expect(tokenCard).toContainText("Scoped to 1 channel"); - await expect(tokenCard).toContainText("general"); - await tokenCard.locator('[data-testid^="revoke-token-"]').click(); - await expect(tokenCard).toContainText("revoked"); -}); - -test("surfaces token mint errors in the dialog", async ({ page }) => { - await installMockBridge(page, { - mintTokenError: - "relay returned 403 Forbidden: not a member of channel: 8f321c1d-f77e-4952-881c-f6e7bfb94c6b", - }); - await page.goto("/"); - - await openSettings(page, "tokens"); - - await page - .getByTestId("settings-tokens") - .getByRole("button", { name: "Create token" }) - .click(); - - await page.getByTestId("token-name-input").fill("qa-failing-token"); - await page.getByTestId("token-scope-messages-read").click(); - await page.getByTestId("confirm-create-token").click(); - - const dialog = page.getByTestId("create-token-dialog"); - await expect(dialog).toBeVisible(); - await expect(dialog).toContainText( - "relay returned 403 Forbidden: not a member of channel: 8f321c1d-f77e-4952-881c-f6e7bfb94c6b", - ); - await expect(page.getByTestId("confirm-create-token")).toHaveText( - "Create token", - ); -}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 52f4c2091..4bc6089bd 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -55,21 +55,9 @@ type MockBridgeOptions = { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; }; - mintTokenError?: string; profileReadDelayMs?: number; profileReadError?: string; profileUpdateError?: string; - seededTokens?: Array<{ - id: string; - name: string; - scopes: string[]; - channel_ids: string[]; - created_at: string; - expires_at: string | null; - last_used_at: string | null; - revoked_at: string | null; - token?: string; - }>; }; type BridgeOptions = { diff --git a/justfile b/justfile index 3d7d17105..39323cfb6 100644 --- a/justfile +++ b/justfile @@ -280,7 +280,7 @@ check-compile: # ─── Agent Harness ──────────────────────────────────────────────────────────── # Run a goose agent connected to a Sprout relay (foreground) -goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY" token="$SPROUT_ACP_API_TOKEN": +goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY": #!/usr/bin/env bash set -euo pipefail cargo build --release -p sprout-acp -p sprout-mcp @@ -293,7 +293,6 @@ goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROU SPROUT_ACP_AGENTS="{{agents}}" GOOSE_MODE=auto ) - [[ -n "{{token}}" ]] && env_args+=(SPROUT_ACP_API_TOKEN="{{token}}") [[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}") if [[ "{{heartbeat}}" != "0" ]]; then env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}}) @@ -301,7 +300,7 @@ goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROU exec env "${env_args[@]}" ./target/release/sprout-acp # Run a goose agent in the background (screen session named 'goose-agent-N') -goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY" token="$SPROUT_ACP_API_TOKEN": +goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY": #!/usr/bin/env bash set -euo pipefail cargo build --release -p sprout-acp -p sprout-mcp @@ -314,7 +313,6 @@ goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SP SPROUT_ACP_AGENTS="{{agents}}" GOOSE_MODE=auto ) - [[ -n "{{token}}" ]] && env_args+=(SPROUT_ACP_API_TOKEN="{{token}}") [[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}") if [[ "{{heartbeat}}" != "0" ]]; then env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}}) diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index d38c798e6..7f782378b 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -160,7 +160,7 @@ void main() { final nsec = nostr.Nip19.encodePrivkey(keychain.private); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( @@ -226,7 +226,7 @@ void main() { final nsec = nostr.Nip19.encodePrivkey(keychain.private); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( @@ -294,7 +294,7 @@ void main() { final nsec = nostr.Nip19.encodePrivkey(keychain.private); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response('bad upload', 401); @@ -329,7 +329,7 @@ void main() { final nsec = nostr.Nip19.encodePrivkey(keychain.private); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, pickGalleryVideo: () async => null, pickGalleryImage: () async => @@ -366,7 +366,7 @@ void main() { final nsec = nostr.Nip19.encodePrivkey(keychain.private); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, pickGalleryVideo: () async => null, pickGalleryImage: () async => @@ -422,7 +422,7 @@ void main() { try { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 18c529519..9c78277c8 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -258,7 +258,7 @@ void main() { final service = MediaUploadService( baseUrl: 'https://relay.example:8443', - apiToken: 'sprout_test_token', + apiToken: null, nsec: nsec, httpClient: client, pickGalleryVideo: () async => null, @@ -277,7 +277,6 @@ void main() { 'https://relay.example:8443/media/upload', ); expect(capturedRequest!.headers['Content-Type'], 'image/png'); - expect(capturedRequest!.headers['X-Auth-Token'], 'sprout_test_token'); expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty); expect(capturedRequest!.bodyBytes, _pngBytes); From 5ee0555439b255464df77cdf1a88aae30e556951 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 7 May 2026 11:51:18 -0700 Subject: [PATCH 2/2] chore: remove dead Okta/JWT/OIDC references from docs and config The Okta OIDC integration (PR #293) was never merged. Remove all residual documentation, config, and dependencies: - Remove OKTA_* env vars from .env.example and README - Remove Nip42Okta, JWKS caching, and Okta JWT auth from ARCHITECTURE.md - Remove unused jsonwebtoken crate from workspace Cargo.toml - Delete dead scripts/setup-keycloak.sh - Clean stale JWT/OIDC terminology from NOSTR.md, VISION.md, CONTRIBUTING.md, and config.rs comments Schema (okta_user_id column) and docker-compose Keycloak service deferred to relay auth follow-up PR. Co-Authored-By: Claude Opus 4.6 --- .env.example | 28 --- ARCHITECTURE.md | 19 +-- CONTRIBUTING.md | 2 +- Cargo.toml | 4 - NOSTR.md | 10 +- README.md | 11 +- VISION.md | 2 +- crates/sprout-relay/src/config.rs | 6 +- scripts/setup-keycloak.sh | 273 ------------------------------ 9 files changed, 21 insertions(+), 334 deletions(-) delete mode 100755 scripts/setup-keycloak.sh diff --git a/.env.example b/.env.example index 60541caff..24d20acc3 100644 --- a/.env.example +++ b/.env.example @@ -51,34 +51,6 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # SPROUT_WEB_DIR=./web/dist -# ----------------------------------------------------------------------------- -# Auth -# ----------------------------------------------------------------------------- -# JWKS endpoint for verifying JWT access tokens. -# Claim that carries the user's Nostr public key (hex, 32 bytes). -OKTA_PUBKEY_CLAIM=nostr_pubkey - -# ── Keycloak (local OAuth testing — stands in for Okta in prod) ────────────── -# Keycloak is NOT a production dependency. It lets you test the full OAuth -# flow locally without needing an Okta tenant. Run `docker compose up -d` -# then `./scripts/setup-keycloak.sh` to create the realm, client, and users. -# -# Admin UI: http://localhost:8180 (admin / admin) -# Get a token: -# curl -s -X POST http://localhost:8180/realms/sprout/protocol/openid-connect/token \ -# -d 'client_id=sprout-desktop&grant_type=password&username=tyler&password=password123' \ -# | jq -r .access_token -OKTA_JWKS_URI=http://localhost:8180/realms/sprout/protocol/openid-connect/certs -OKTA_ISSUER=http://localhost:8180/realms/sprout -OKTA_AUDIENCE=sprout-desktop - -# ── Okta (production / staging) ────────────────────────────────────────────── -# Uncomment and fill in when deploying against a real Okta tenant. -# OKTA_JWKS_URI=https://dev-example.okta.com/oauth2/default/v1/keys -# OKTA_ISSUER=https://dev-example.okta.com/oauth2/default -# OKTA_AUDIENCE=sprout-api -# OKTA_PUBKEY_CLAIM=nostr_pubkey - # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) # ----------------------------------------------------------------------------- diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d3361691d..77c9ee2c4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ Sprout is a Rust monorepo (~72K LOC across 17 crates), licensed Apache 2.0 under sprout-core (zero I/O — types, verification, filter matching, kind registry) │ ├── sprout-db (Postgres: events, channels, tokens, workflows, audit) - ├── sprout-auth (NIP-42, Okta JWT, API tokens, scopes, rate limiting) + ├── sprout-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting) ├── sprout-pubsub (Redis pub/sub, presence, typing indicators) ├── sprout-search (Typesense: index, query, delete) ├── sprout-audit (hash-chain tamper-evident log) @@ -171,12 +171,11 @@ The relay immediately sends `["AUTH", ""]`. The challenge is a random ### Step 3: Authentication -The client must respond with `["AUTH", ]` before submitting events or subscriptions. Four authentication paths: +The client must respond with `["AUTH", ]` before submitting events or subscriptions. Authentication paths: | Path | Mechanism | Use Case | |------|-----------|---------| -| NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay | -| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta | +| NIP-42 | Signed challenge, pubkey verified | WebSocket connections | | NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients | On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`. @@ -346,21 +345,20 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) ### sprout-auth — Authentication and Authorization -**2,310 LOC.** Handles all four authentication paths, JWKS caching, scope enforcement, and token operations. +**2,310 LOC.** Handles authentication paths, scope enforcement, and token operations. -**Four auth paths:** +**Auth paths:** | Path | Entry Point | Notes | |------|-------------|-------| -| NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) | -| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated | +| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) | | NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event | **Key types:** ```rust pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec, pub auth_method: AuthMethod } -pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip98 } +pub enum AuthMethod { Nip42, Nip98 } pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite, AdminChannels, UsersRead, UsersWrite, AdminUsers, JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite, @@ -370,7 +368,6 @@ pub trait RateLimiter: Send + Sync { ... } ``` **Security details:** -- JWKS double-checked locking: two read-lock checks before fetching, HTTP fetch with no lock held, write-lock re-check after. Cache TTL: 300 seconds. - NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags. - NIP-42 timestamp tolerance: ±60 seconds. - Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments. @@ -804,7 +801,6 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic | Concern | Mechanism | |---------|-----------| -| JWKS cache | Double-checked locking; HTTP fetch with no lock held (prevents global DoS) | | NIP-42 timestamp | ±60 second tolerance — prevents replay attacks | | AUTH events | Never stored in Postgres, never logged in audit chain | | NIP-98 HTTP Auth | Schnorr-signed `kind:27235` events — URL and method verification | @@ -863,7 +859,6 @@ Docker Compose provides the full local development stack. All services include h | Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) | | Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index | | Adminer | `adminer` | 8082 | DB web UI (dev only) | -| Keycloak | `quay.io/keycloak/keycloak:26` | 8180 | Local OAuth/OIDC stand-in for Okta | | MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) | | Prometheus | `prom/prometheus` | 9090 | Metrics collection | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 680a64024..76f95ef15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,7 +286,7 @@ The short version: sprout-relay ← WebSocket server, REST API, event ingestion sprout-core ← Shared types, event verification, filter matching sprout-db ← Postgres access layer (sqlx) -sprout-auth ← NIP-42 + OIDC JWT + API token scopes +sprout-auth ← NIP-42 + NIP-98 + API token scopes sprout-pubsub ← Redis fan-out sprout-search ← Typesense full-text search sprout-audit ← Tamper-evident hash-chain audit log diff --git a/Cargo.toml b/Cargo.toml index 47f2ef724..8c6757cf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,10 +91,6 @@ zeroize = "1.8" dashmap = "6" moka = { version = "0.12", features = ["sync"] } -# JWT validation (Okta JWKS) -jsonwebtoken = "9" - - # Async stream utilities futures-util = "0.3" diff --git a/NOSTR.md b/NOSTR.md index daf98b088..9f87f3903 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -10,7 +10,7 @@ third-party Nostr clients to connect: **Direct** is simpler — no extra process, no translation layer. Use it when your client speaks NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28 -clients and don't have company Okta credentials. +clients and don't have company credentials. Both paths require NIP-42 authentication. @@ -83,10 +83,10 @@ PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \ ### Pubkey Allowlist When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey -(no JWT) are checked against the `pubkey_allowlist` table. This lets you open the -relay to specific external Nostr identities without granting full Okta access. +(no API token) are checked against the `pubkey_allowlist` table. This lets you open the +relay to specific external Nostr identities without granting full access. -- Users with valid **Okta JWTs** bypass the allowlist. +- Users with valid **API tokens** bypass the allowlist. - **Fail-closed:** if the DB lookup fails, the connection is denied. - Default: `false` (all authenticated pubkeys accepted). - Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message). @@ -488,7 +488,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 ### Direct Path - **Pubkey allowlist is fail-closed.** DB errors deny the connection. -- **Okta JWT users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. +- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. - **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected. - **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed). - **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005). diff --git a/README.md b/README.md index f4918b23b..3e17817ad 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ just build ``` `just setup` does the heavy lifting: -- Starts Docker services (Postgres, Redis, Typesense, Adminer, Keycloak, MinIO, Prometheus) +- Starts Docker services (Postgres, Redis, Typesense, Adminer, MinIO, Prometheus) - Waits for core services (Postgres, Redis, Typesense) to be healthy - Runs database migrations - Installs desktop dependencies (`pnpm install`) @@ -67,7 +67,7 @@ That's it — you're running Sprout locally. | ✅ | **ACP agent harness** — AI agents connect out of the box via `sprout-acp` | | ✅ | **Tamper-evident audit log** — hash-chain, SOX-grade compliance | | ✅ | **Permission-aware full-text search** — Typesense, respects channel membership | -| ✅ | **Enterprise SSO bridge** — NIP-42 authentication with OIDC | +| ✅ | **NIP-42 + NIP-98 authentication** — Schnorr signatures for WebSocket and REST | | ✅ | **Pure Rust backend** — memory safe, no GC pauses | ## Supported NIPs @@ -136,7 +136,7 @@ That's it — you're running Sprout locally. | Crate | Role | |-------|------| | `sprout-db` | Postgres access layer — events, channels, users, DMs, threads, reactions, workflows, tokens, feed (sqlx) | -| `sprout-auth` | NIP-42 challenge/response + Okta OIDC JWT validation + NIP-98 HTTP Auth + token scopes + rate limiting | +| `sprout-auth` | NIP-42 challenge/response + NIP-98 HTTP Auth + token scopes + rate limiting | | `sprout-pubsub` | Redis pub/sub fan-out, presence tracking, typing indicators, and rate limiting | | `sprout-search` | Typesense indexing and query — full-text search over event content | | `sprout-audit` | Append-only audit log with SHA-256 hash chain for tamper detection | @@ -213,8 +213,6 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `SPROUT_BIND_ADDR` | `0.0.0.0:3000` | Relay bind address (host:port) | | `RELAY_URL` | `ws://localhost:3000` | Public URL (used in NIP-42 challenges) | | `SPROUT_RELAY_PRIVATE_KEY` | auto-generated | Relay keypair for signing system messages | -| `OKTA_ISSUER` | — | Okta OIDC issuer URL (optional) | -| `OKTA_AUDIENCE` | — | Expected JWT audience (optional) | | `RUST_LOG` | `sprout_relay=info` | Log filter (tracing env-filter syntax) | | `SPROUT_PROXY_BIND_ADDR` | `0.0.0.0:4869` | Proxy bind address (see [NOSTR.md](NOSTR.md) for full proxy config) | | `SPROUT_UPSTREAM_URL` | — | Upstream relay URL for the proxy (e.g., `ws://localhost:3000`) | @@ -234,10 +232,9 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `SPROUT_S3_SECRET_KEY` | `sprout_dev_secret` | S3 secret key | | `SPROUT_S3_BUCKET` | `sprout-media` | S3 bucket name for media uploads | | `SPROUT_METRICS_PORT` | `9102` | Port for Prometheus metrics endpoint | -| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`); Okta JWT auth bypasses | +| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`) | | `SPROUT_SEND_BUFFER` | `1000` | WebSocket send buffer size | | `SPROUT_UDS_PATH` | — | Unix domain socket path (alternative to TCP) | -| `OKTA_JWKS_URI` | — | Okta JWKS endpoint URI for JWT verification | | `SPROUT_TOOLSETS` | `default` | MCP toolsets to enable (comma-separated: `default`, `channel_admin`, `dms`, `canvas`, `workflow_admin`, `identity`, `forums`, `all`, `none`; append `:ro` for read-only) | | `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` | diff --git a/VISION.md b/VISION.md index 1e786ed77..0623a408a 100644 --- a/VISION.md +++ b/VISION.md @@ -78,7 +78,7 @@ Humans and agents get the same thing: - secp256k1 keypair (Nostr-native) - `alice@example.com` NIP-05 handle -- Okta SSO → keypair bridge (humans) or NIP-98 Schnorr auth (agents) +- NIP-42 Schnorr auth (humans) or NIP-98 Schnorr auth (agents) - Bot role on agent channel membership. Visual badges are next. Auth is simple — authenticated or not. Channel membership gates content visibility. diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index ee0dcd696..855676e31 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -57,16 +57,16 @@ pub struct Config { /// TCP port for the Prometheus metrics exporter (`GET /metrics`). pub metrics_port: u16, - /// When true, NIP-42 pubkey-only authentication (no JWT or API token) is + /// When true, NIP-42 pubkey-only authentication (no API token) is /// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid - /// API tokens or Okta JWTs bypass the allowlist entirely. + /// API tokens bypass the allowlist entirely. /// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`. pub pubkey_allowlist_enabled: bool, /// When true, every authenticated request must also pass a relay-level /// membership check against the `relay_members` table. /// When false (default), the check is a no-op and all authenticated callers - /// are permitted regardless of auth method (API token, JWT, NIP-42). + /// are permitted regardless of auth method (API token, NIP-42). pub require_relay_membership: bool, /// Optional hex-encoded pubkey of the relay owner. diff --git a/scripts/setup-keycloak.sh b/scripts/setup-keycloak.sh deleted file mode 100755 index daf3e2e85..000000000 --- a/scripts/setup-keycloak.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# setup-keycloak.sh — Configure Keycloak for local OAuth testing -# ============================================================================= -# Usage: ./scripts/setup-keycloak.sh -# -# Creates the `sprout` realm with: -# - sprout-desktop client (public, direct access grants) -# - Test users: tyler, alice, bob, charlie (password: password123) -# - nostr_pubkey custom attribute on each user -# - Protocol mapper: nostr_pubkey → JWT access token claim -# -# Keycloak is a LOCAL DEV STAND-IN for Okta/generic OIDC providers. -# It is NOT a production dependency. -# -# Prerequisites: -# - Keycloak running at http://localhost:8180 (docker compose up -d) -# - curl and jq installed -# ============================================================================= -set -euo pipefail - -KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8180}" -ADMIN_USER="${KEYCLOAK_ADMIN:-admin}" -ADMIN_PASS="${KEYCLOAK_ADMIN_PASSWORD:-admin}" -REALM="sprout" -CLIENT_ID="sprout-desktop" -TIMEOUT=120 # seconds to wait for Keycloak - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -log() { echo -e "${BLUE}[keycloak-setup]${NC} $*"; } -success(){ echo -e "${GREEN}[keycloak-setup]${NC} ✅ $*"; } -warn() { echo -e "${YELLOW}[keycloak-setup]${NC} ⚠️ $*"; } -error() { echo -e "${RED}[keycloak-setup]${NC} ❌ $*" >&2; } - -# ---- Preflight -------------------------------------------------------------- - -for cmd in curl jq; do - if ! command -v "$cmd" &>/dev/null; then - error "Required tool not found: $cmd" - exit 1 - fi -done - -# ---- Wait for Keycloak ------------------------------------------------------ - -log "Waiting for Keycloak at ${KEYCLOAK_URL}..." -elapsed=0 -interval=5 -until curl -sf "${KEYCLOAK_URL}/health/ready" -o /dev/null 2>/dev/null; do - if [[ ${elapsed} -ge ${TIMEOUT} ]]; then - error "Timed out waiting for Keycloak (${TIMEOUT}s). Is it running?" - error " docker compose up -d keycloak" - exit 1 - fi - echo -n "." - sleep "${interval}" - elapsed=$((elapsed + interval)) -done -echo "" -success "Keycloak is ready" - -# ---- Get admin token -------------------------------------------------------- - -log "Authenticating as admin..." -ADMIN_TOKEN=$(curl -sf \ - -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "client_id=admin-cli" \ - -d "username=${ADMIN_USER}" \ - -d "password=${ADMIN_PASS}" \ - -d "grant_type=password" \ - | jq -r '.access_token') - -if [[ -z "${ADMIN_TOKEN}" || "${ADMIN_TOKEN}" == "null" ]]; then - error "Failed to get admin token. Check KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD." - exit 1 -fi -success "Admin token obtained" - -# Helper: authenticated API call -kc() { - local method="$1"; shift - local path="$1"; shift - curl -sf \ - -X "${method}" \ - "${KEYCLOAK_URL}/admin/realms${path}" \ - -H "Authorization: Bearer ${ADMIN_TOKEN}" \ - -H "Content-Type: application/json" \ - "$@" -} - -kc_root() { - local method="$1"; shift - local path="$1"; shift - curl -sf \ - -X "${method}" \ - "${KEYCLOAK_URL}/admin${path}" \ - -H "Authorization: Bearer ${ADMIN_TOKEN}" \ - -H "Content-Type: application/json" \ - "$@" -} - -# ---- Create realm ----------------------------------------------------------- - -log "Checking for realm '${REALM}'..." -REALM_EXISTS=$(curl -sf \ - "${KEYCLOAK_URL}/admin/realms/${REALM}" \ - -H "Authorization: Bearer ${ADMIN_TOKEN}" \ - -o /dev/null -w "%{http_code}" 2>/dev/null || true) - -if [[ "${REALM_EXISTS}" == "200" ]]; then - warn "Realm '${REALM}' already exists — skipping creation" -else - log "Creating realm '${REALM}'..." - curl -sf \ - -X POST "${KEYCLOAK_URL}/admin/realms" \ - -H "Authorization: Bearer ${ADMIN_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "realm": "'"${REALM}"'", - "displayName": "Sprout", - "enabled": true, - "registrationAllowed": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "bruteForceProtected": false - }' - success "Realm '${REALM}' created" -fi - -# ---- Create client ---------------------------------------------------------- - -log "Checking for client '${CLIENT_ID}'..." -EXISTING_CLIENT=$(kc GET "/${REALM}/clients?clientId=${CLIENT_ID}" | jq -r '.[0].id // empty') - -if [[ -n "${EXISTING_CLIENT}" ]]; then - warn "Client '${CLIENT_ID}' already exists (id: ${EXISTING_CLIENT}) — skipping creation" - CLIENT_UUID="${EXISTING_CLIENT}" -else - log "Creating client '${CLIENT_ID}'..." - kc POST "/${REALM}/clients" -d '{ - "clientId": "'"${CLIENT_ID}"'", - "name": "Sprout Desktop", - "enabled": true, - "publicClient": true, - "directAccessGrantsEnabled": true, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "serviceAccountsEnabled": false, - "redirectUris": [ - "http://localhost:*", - "sprout://*" - ], - "webOrigins": ["*"], - "protocol": "openid-connect" - }' - - CLIENT_UUID=$(kc GET "/${REALM}/clients?clientId=${CLIENT_ID}" | jq -r '.[0].id') - success "Client '${CLIENT_ID}' created (id: ${CLIENT_UUID})" -fi - -# ---- Add nostr_pubkey protocol mapper --------------------------------------- - -log "Checking for nostr_pubkey protocol mapper..." -MAPPER_EXISTS=$(kc GET "/${REALM}/clients/${CLIENT_UUID}/protocol-mappers/models" \ - | jq -r '.[] | select(.name == "nostr_pubkey") | .id // empty') - -if [[ -n "${MAPPER_EXISTS}" ]]; then - warn "Protocol mapper 'nostr_pubkey' already exists — skipping" -else - log "Creating nostr_pubkey → JWT claim mapper..." - kc POST "/${REALM}/clients/${CLIENT_UUID}/protocol-mappers/models" -d '{ - "name": "nostr_pubkey", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "nostr_pubkey", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "nostr_pubkey", - "jsonType.label": "String" - } - }' - success "Protocol mapper 'nostr_pubkey' created" -fi - -# ---- Create users ----------------------------------------------------------- - -# Format: "username:nostr_pubkey" -declare -a USERS=( - "tyler:e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34" - "alice:953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f" - "bob:bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260" - "charlie:554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea" -) - -for entry in "${USERS[@]}"; do - username="${entry%%:*}" - pubkey="${entry##*:}" - - log "Checking for user '${username}'..." - EXISTING_USER=$(kc GET "/${REALM}/users?username=${username}&exact=true" | jq -r '.[0].id // empty') - - if [[ -n "${EXISTING_USER}" ]]; then - warn "User '${username}' already exists (id: ${EXISTING_USER}) — updating nostr_pubkey attribute" - kc PUT "/${REALM}/users/${EXISTING_USER}" -d '{ - "attributes": { - "nostr_pubkey": ["'"${pubkey}"'"] - } - }' - success "User '${username}' updated" - else - log "Creating user '${username}'..." - kc POST "/${REALM}/users" -d '{ - "username": "'"${username}"'", - "email": "'"${username}"'@sprout.local", - "firstName": "'"${username^}"'", - "lastName": "Test", - "enabled": true, - "emailVerified": true, - "credentials": [{ - "type": "password", - "value": "password123", - "temporary": false - }], - "attributes": { - "nostr_pubkey": ["'"${pubkey}"'"] - } - }' - success "User '${username}' created (nostr_pubkey: ${pubkey:0:16}...)" - fi -done - -# ---- Summary ---------------------------------------------------------------- - -echo "" -echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}" -echo -e "${GREEN} Keycloak realm setup complete! 🔑${NC}" -echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}" -echo "" -echo -e " ${BLUE}Admin UI${NC} http://localhost:8180 (admin / admin)" -echo -e " ${BLUE}Realm${NC} ${REALM}" -echo -e " ${BLUE}Client${NC} ${CLIENT_ID} (public, direct access grants)" -echo "" -echo -e " ${BLUE}Test users${NC} (password: password123)" -echo -e " tyler e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34" -echo -e " alice 953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f" -echo -e " bob bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260" -echo -e " charlie 554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea" -echo "" -echo -e " ${YELLOW}Relay env vars for Keycloak:${NC}" -echo -e " OKTA_JWKS_URI=http://localhost:8180/realms/sprout/protocol/openid-connect/certs" -echo -e " OKTA_ISSUER=http://localhost:8180/realms/sprout" -echo -e " OKTA_AUDIENCE=sprout-desktop" -echo -e " OKTA_PUBKEY_CLAIM=nostr_pubkey" -echo "" -echo -e " ${YELLOW}Get a token (direct grant):${NC}" -echo -e " curl -s -X POST http://localhost:8180/realms/sprout/protocol/openid-connect/token \\" -echo -e " -d 'client_id=sprout-desktop&grant_type=password&username=tyler&password=password123' \\" -echo -e " | jq -r .access_token" -echo "" - -exit 0