chore: remove dead API token references from GUI, docs, and config (#498)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-07 12:07:44 -07:00
committed by GitHub
parent 27bcd45cc9
commit 0dcfa86f84
36 changed files with 92 additions and 2191 deletions

View File

@@ -46,46 +46,11 @@ 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).
# SPROUT_WEB_DIR=./web/dist
# -----------------------------------------------------------------------------
# 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
# ── 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)
# -----------------------------------------------------------------------------
@@ -126,9 +91,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 +186,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

View File

@@ -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

View File

@@ -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,14 +171,12 @@ The relay immediately sends `["AUTH", "<challenge>"]`. The challenge is a random
### Step 3: Authentication
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Four authentication paths:
The client must respond with `["AUTH", <signed-event>]` 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 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts |
| HTTP Bearer JWT | `Authorization: Bearer <jwt>` header on REST endpoints | REST API clients |
| 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: ..."]`.
@@ -347,22 +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 + 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-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<Scope>, pub auth_method: AuthMethod }
pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip42ApiToken }
pub enum AuthMethod { Nip42, Nip98 }
pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite,
AdminChannels, UsersRead, UsersWrite, AdminUsers,
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
@@ -372,10 +368,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 +389,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 +407,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 +655,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 +704,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 <token>` when `SPROUT_API_TOKEN` is set; falls back to `X-Pubkey: <hex>` in dev mode.
- REST calls use NIP-98 Schnorr-signed auth when `SPROUT_PRIVATE_KEY` is set; falls back to `X-Pubkey: <hex>` 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 +801,9 @@ 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
@@ -873,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 |
@@ -887,7 +872,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) |

View File

@@ -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

View File

@@ -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"

View File

@@ -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 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 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 **API tokens** (`sprout_*`) or **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).
@@ -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 <base64-nip98-event>" \
-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=<relay-hex-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=<token from step 3>
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": "<hex>"}'
```
> **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.
- **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).

View File

@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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=<token> \
SPROUT_PRIVATE_KEY=nsec1... \
goose run --no-profile \
--with-extension "cargo run -p sprout-mcp --bin sprout-mcp-server" \
@@ -225,16 +212,12 @@ 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) |
| `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`) |
| `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 +232,11 @@ 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`) |
| `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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
- 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. 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 |

View File

@@ -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="<hex>"
sprout list-channels
```
## Usage
@@ -83,17 +79,11 @@ sprout vote-on-post --event <event-id> --direction up
sprout get-canvas --channel <uuid>
sprout set-canvas --channel <uuid> --content "# Welcome"
# Tokens
sprout auth # mint token, print to stdout
sprout list-tokens
sprout delete-token --id <uuid>
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

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
) {

View File

@@ -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() {
}
}}
/>
<TokenRevealDialog
name={agents.revealedToken?.name ?? null}
onOpenChange={(open) => {
if (!open) {
agents.setRevealedToken(null);
}
}}
token={agents.revealedToken?.token ?? null}
/>
<PersonaDialog
description={personas.personaDialogState?.description ?? ""}
error={

View File

@@ -7,13 +7,11 @@ import {
useCreateManagedAgentMutation,
useManagedAgentPrereqsQuery,
} from "@/features/agents/hooks";
import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions";
import { probeBackendProvider } from "@/shared/api/tauri";
import type {
BackendProviderProbeResult,
CreateManagedAgentInput,
CreateManagedAgentResponse,
TokenScope,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
@@ -28,7 +26,6 @@ import {
CreateAgentOptionToggles,
CreateAgentRuntimeProviderField,
CreateAgentRuntimeFields,
CreateAgentTokenSection,
} from "./CreateAgentDialogSections";
import {
coerceConfigValues,
@@ -59,13 +56,8 @@ export function CreateAgentDialog({
const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand);
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [mintToken, setMintToken] = React.useState(true);
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true);
const [tokenName, setTokenName] = React.useState("");
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
() => new Set<TokenScope>(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<TokenScope>(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({
<DialogTitle>Create agent</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -467,13 +415,6 @@ export function CreateAgentDialog({
<CreateAgentOptionToggles
isSpawnSupported={isSpawnSupported}
mintToken={effectiveMintToken}
mintToggleDisabled={isProviderMode || mintToggleDisabled}
onToggleMintToken={() => {
if (!mintToggleDisabled) {
setMintToken((current) => !current);
}
}}
onToggleStartOnAppLaunch={() => {
setStartOnAppLaunch((current) => !current);
}}
@@ -489,20 +430,6 @@ export function CreateAgentDialog({
spawnToggleDisabled={isProviderMode || spawnToggleDisabled}
/>
{effectiveMintToken ? (
<CreateAgentTokenSection
lockedScopes={
isProviderMode
? new Set<TokenScope>(PROVIDER_REQUIRED_SCOPES)
: undefined
}
onScopeToggle={toggleScope}
onTokenNameChange={setTokenName}
selectedScopes={selectedScopes}
tokenName={tokenName}
/>
) : null}
<div className="rounded-2xl border border-border/70 bg-muted/20">
<button
aria-expanded={showAdvanced}

View File

@@ -1,9 +1,4 @@
import type {
AcpProvider,
ManagedAgentPrereqs,
TokenScope,
} from "@/shared/api/types";
import { MANAGED_AGENT_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
import type { AcpProvider, ManagedAgentPrereqs } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
@@ -326,51 +321,26 @@ export function CreateAgentRuntimeFields({
export function CreateAgentOptionToggles({
isSpawnSupported,
mintToken,
mintToggleDisabled,
prereqs,
startOnAppLaunch,
startOnAppLaunchDisabled,
spawnAfterCreate,
spawnToggleDisabled,
onToggleMintToken,
onToggleStartOnAppLaunch,
onToggleSpawnAfterCreate,
}: {
isSpawnSupported: boolean;
mintToken: boolean;
mintToggleDisabled: boolean;
prereqs: ManagedAgentPrereqs | null;
startOnAppLaunch: boolean;
/** When true, the toggle is disabled (e.g. remote agents don't support auto-start). */
startOnAppLaunchDisabled?: boolean;
spawnAfterCreate: boolean;
spawnToggleDisabled: boolean;
onToggleMintToken: () => void;
onToggleStartOnAppLaunch: () => void;
onToggleSpawnAfterCreate: () => void;
}) {
return (
<div className="grid gap-3 md:grid-cols-3">
<button
aria-pressed={mintToken}
className={cn(
"rounded-2xl border px-4 py-3 text-left transition-colors",
mintToggleDisabled && "cursor-not-allowed opacity-60",
mintToken
? "border-primary bg-primary/10"
: "border-border/70 bg-background/70",
)}
disabled={mintToggleDisabled}
onClick={onToggleMintToken}
type="button"
>
<p className="text-sm font-semibold tracking-tight">Mint token</p>
<p className="mt-1 text-sm text-foreground/70">
Mint a relay API token for this agent.
</p>
</button>
<div className="grid gap-3 md:grid-cols-2">
<button
aria-pressed={startOnAppLaunch}
className={cn(
@@ -419,74 +389,3 @@ export function CreateAgentOptionToggles({
</div>
);
}
export function CreateAgentTokenSection({
selectedScopes,
tokenName,
lockedScopes,
onScopeToggle,
onTokenNameChange,
}: {
selectedScopes: Set<TokenScope>;
tokenName: string;
/** Scopes that cannot be removed (e.g. required for remote agent controllability). */
lockedScopes?: Set<TokenScope>;
onScopeToggle: (scope: TokenScope) => void;
onTokenNameChange: (value: string) => void;
}) {
return (
<div className="space-y-4 rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-token-name">
Token name
</label>
<Input
id="agent-token-name"
onChange={(event) => onTokenNameChange(event.target.value)}
placeholder="Leave blank to reuse the agent name"
value={tokenName}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Scopes</p>
<div className="grid grid-cols-2 gap-2">
{MANAGED_AGENT_SCOPE_OPTIONS.map((scope) => {
const selected = selectedScopes.has(scope.value);
const locked = lockedScopes?.has(scope.value) && selected;
return (
<button
className={cn(
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
locked && "cursor-not-allowed opacity-70",
)}
disabled={locked}
key={scope.value}
onClick={() => onScopeToggle(scope.value)}
title={
locked
? "Required for remote agent controllability"
: undefined
}
type="button"
>
{scope.label}
{locked ? (
<span className="ml-1 text-xs text-muted-foreground">
(required)
</span>
) : null}
<span className="block text-xs text-foreground/60">
{scope.description}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -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
</DropdownMenuItem>
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onMintToken(agent.pubkey, agent.name)}
>
<KeyRound className="h-4 w-4" />
Mint token
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await navigator.clipboard.writeText(agent.pubkey);

View File

@@ -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}

View File

@@ -23,8 +23,8 @@ export function SecretRevealDialog({
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Agent created</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -51,26 +51,6 @@ export function SecretRevealDialog({
</code>
</div>
{created.apiToken ? (
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold tracking-tight">
API token
</p>
<p className="text-sm text-muted-foreground">
Optional for local dev, required when the relay
enforces bearer auth.
</p>
</div>
<CopyButton label="Copy token" value={created.apiToken} />
</div>
<code className="mt-3 block break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
{created.apiToken}
</code>
</div>
) : null}
{created.profileSyncError ? (
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
{created.profileSyncError}

View File

@@ -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 (
<Dialog onOpenChange={onOpenChange} open={token !== null}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Agent token minted</DialogTitle>
<DialogDescription>
Save this token now. Restart the harness if you want the running
agent to pick it up immediately.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-5">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold tracking-tight">{name}</p>
<p className="text-sm text-muted-foreground">
Token shown once only.
</p>
</div>
{token ? <CopyButton label="Copy token" value={token} /> : null}
</div>
{token ? (
<code className="mt-3 block break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
{token}
</code>
) : null}
</div>
</div>
<div className="flex justify-end border-t border-border/60 px-6 py-4">
<Button
onClick={() => onOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Done
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<ManagedAgent | null>(null);
const [createdAgent, setCreatedAgent] =
React.useState<CreateManagedAgentResponse | null>(null);
const [revealedToken, setRevealedToken] = React.useState<{
name: string;
token: string;
} | null>(null);
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
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,

View File

@@ -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 <ThemeSettingsCard />;
case "shortcuts":
return <KeyboardShortcutsCard />;
case "tokens":
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
case "relay-members":
return <RelayMembersSettingsCard currentPubkey={props.currentPubkey} />;
case "mobile":

View File

@@ -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<Token[]>(tokensQueryKey);
queryClient.setQueryData<Token[]>(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<Token[]>(tokensQueryKey);
const now = new Date().toISOString();
queryClient.setQueryData<Token[]>(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 });
},
});
}

View File

@@ -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",
];

View File

@@ -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 (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
status === "active" && "bg-green-500/10 text-status-added",
status === "revoked" && "bg-muted text-muted-foreground",
status === "expired" && "bg-yellow-500/10 text-warning",
)}
>
{status}
</span>
);
}
function ScopeBadge({ scope }: { scope: string }) {
return (
<span className="inline-flex items-center rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{scope}
</span>
);
}
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<string, Channel>) {
return (
channelsById.get(channelId)?.name ?? `Channel ${channelId.slice(0, 8)}`
);
}
function TokenRow({
channelsById,
token,
onRevoke,
isRevoking,
}: {
channelsById: Map<string, Channel>;
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 (
<div
className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/60 px-3 py-2.5"
data-testid={`token-row-${token.id}`}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{token.name}</span>
<StatusBadge status={status} />
</div>
<div className="flex flex-wrap gap-1">
{token.scopes.map((scope) => (
<ScopeBadge key={scope} scope={scope} />
))}
</div>
<p className="text-xs text-muted-foreground">
Created {formatRelativeDate(token.createdAt)}
{token.lastUsedAt
? ` · Last used ${formatRelativeDate(token.lastUsedAt)}`
: " · Never used"}
{token.expiresAt ? ` · Expires ${formatDate(token.expiresAt)}` : ""}
</p>
<p className="text-xs text-muted-foreground">
{token.channelIds.length === 0
? "All accessible channels"
: `Scoped to ${token.channelIds.length} channel${token.channelIds.length === 1 ? "" : "s"}`}
</p>
{visibleChannelIds.length > 0 ? (
<div className="flex flex-wrap gap-1">
{visibleChannelIds.map((channelId) => (
<ScopeBadge
key={channelId}
scope={channelLabel(channelId, channelsById)}
/>
))}
{hiddenChannelCount > 0 ? (
<ScopeBadge scope={`+${hiddenChannelCount} more`} />
) : null}
</div>
) : null}
</div>
{status === "active" ? (
<Button
data-testid={`revoke-token-${token.id}`}
disabled={isRevoking}
onClick={() => onRevoke(token.id)}
size="sm"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Revoke</span>
</Button>
) : null}
</div>
);
}
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<Set<TokenScope>>(
new Set(),
);
const [channelAccessMode, setChannelAccessMode] = React.useState<
"all" | "selected"
>("all");
const [selectedChannelIds, setSelectedChannelIds] = React.useState<
Set<string>
>(new Set());
const [expiryDays, setExpiryDays] = React.useState<number>(30);
const [mintedToken, setMintedToken] = React.useState<string | null>(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 (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
className="max-w-lg overflow-hidden p-0"
data-testid="token-created-dialog"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy this token now. You will not be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
{mintedToken}
</code>
<Button onClick={handleCopy} size="sm" variant="outline">
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>
This is the only time this token will be shown. Store it
securely.
</span>
</div>
</div>
</div>
<div className="flex justify-end border-t border-border/60 bg-background/95 px-6 py-4">
<Button
data-testid="token-created-done"
onClick={() => handleOpenChange(false)}
size="sm"
variant="outline"
>
Done
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
className="max-w-lg overflow-hidden p-0"
data-testid="create-token-dialog"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Create API token</DialogTitle>
<DialogDescription>
Tokens allow agents and scripts to authenticate with the relay on
your behalf.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="token-name">
Name
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
data-testid="token-name-input"
id="token-name"
maxLength={100}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. my-agent-bot"
spellCheck={false}
value={name}
/>
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium">Scopes</p>
<div className="grid grid-cols-2 gap-2">
{TOKEN_SCOPE_OPTIONS.map(({ value, label }) => {
const isSelected = selectedScopes.has(value);
return (
<button
className={cn(
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
)}
data-testid={`token-scope-${value.replace(/:/g, "-")}`}
key={value}
onClick={() => toggleScope(value)}
type="button"
>
{label}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Channel access</p>
<span className="text-xs text-muted-foreground">
{channelAccessMode === "all"
? "All accessible channels"
: `${selectedChannelIds.size} selected`}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{[
{
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 (
<button
className={cn(
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
)}
data-testid={`token-channel-access-${option.value}`}
key={option.value}
onClick={() => setChannelAccessMode(option.value)}
type="button"
>
<p className="font-medium">{option.label}</p>
<p className="mt-1 text-xs text-muted-foreground">
{option.description}
</p>
</button>
);
})}
</div>
{channelAccessMode === "selected" ? (
isLoadingChannels ? (
<p className="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
Loading channels...
</p>
) : channelsError ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{channelsError.message}
</p>
) : channels.length > 0 ? (
<div className="max-h-52 space-y-2 overflow-y-auto rounded-xl border border-border/60 bg-muted/20 p-2">
{channels.map((channel) => {
const isSelected = selectedChannelIds.has(channel.id);
return (
<button
className={cn(
"flex w-full items-start justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
isSelected
? "border-primary bg-primary/10"
: "border-border/60 bg-background/70 hover:bg-accent",
)}
data-testid={`token-channel-${channel.id}`}
key={channel.id}
onClick={() => toggleChannel(channel.id)}
type="button"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{channel.name}
</p>
<p className="text-xs text-muted-foreground">
{channel.visibility} {channel.channelType}
</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isSelected
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{isSelected ? "Selected" : "Select"}
</span>
</button>
);
})}
</div>
) : (
<p className="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
No accessible channels available for scoping yet.
</p>
)
) : null}
<p className="text-xs text-muted-foreground">
Use channel-scoped tokens for guests and single-purpose
agents.
</p>
{currentPubkey ? (
<p className="text-xs text-muted-foreground">
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.`
: ""}
</p>
) : (
<p className="text-xs text-muted-foreground">
Your identity is still loading, so channel membership cannot
be checked yet.
</p>
)}
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium">Expiry</p>
<div className="flex flex-wrap gap-2">
{EXPIRY_OPTIONS.map(({ value, label }) => (
<button
className={cn(
"rounded-lg border px-3 py-1.5 text-sm transition-colors",
expiryDays === value
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
)}
data-testid={`token-expiry-${value}`}
key={value}
onClick={() => setExpiryDays(value)}
type="button"
>
{label}
</button>
))}
</div>
</div>
{activeTokenCount >= MAX_ACTIVE_TOKENS ? (
<p className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">
You already have {MAX_ACTIVE_TOKENS} active tokens. Revoke one
before creating another.
</p>
) : null}
{mintMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mintMutation.error.message}
</p>
) : null}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border/60 bg-background/95 px-6 py-4">
<Button
data-testid="cancel-create-token"
onClick={() => handleOpenChange(false)}
size="sm"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="confirm-create-token"
disabled={!canCreate}
onClick={() => void handleCreate()}
size="sm"
>
{mintMutation.isPending ? "Creating..." : "Create token"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function RevokeAllDialog({
open,
onOpenChange,
onConfirm,
isPending,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending: boolean;
}) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Revoke all tokens?</DialogTitle>
<DialogDescription>
This will immediately revoke every active token. Agents using these
tokens will lose access.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button
onClick={() => onOpenChange(false)}
size="sm"
variant="outline"
>
Cancel
</Button>
<Button
disabled={isPending}
onClick={onConfirm}
size="sm"
variant="destructive"
>
{isPending ? "Revoking..." : "Revoke all"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
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 (
<section className="min-w-0" data-testid="settings-tokens">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold tracking-tight">API Tokens</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Create tokens for agents, guests, and integrations to access the
relay. {activeTokens.length}/{MAX_ACTIVE_TOKENS} active.
</p>
</div>
<div className="flex shrink-0 gap-2">
{activeTokens.length > 0 ? (
<Button
onClick={() => setRevokeAllOpen(true)}
size="sm"
variant="outline"
>
Revoke all
</Button>
) : null}
<Button
disabled={hasReachedTokenLimit}
onClick={() => setCreateOpen(true)}
size="sm"
>
<Plus className="h-3.5 w-3.5" />
Create token
</Button>
</div>
</div>
{hasReachedTokenLimit ? (
<p className="mt-3 rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">
You've reached the active token limit. Revoke an existing token to
mint another.
</p>
) : null}
{tokensQuery.error instanceof Error ? (
<p className="mt-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{tokensQuery.error.message}
</p>
) : null}
{tokens.length > 0 ? (
<div className="mt-4 space-y-2">
{tokens.map((token) => (
<TokenRow
channelsById={channelsById}
isRevoking={revokeTokenMutation.isPending}
key={token.id}
onRevoke={(id) => revokeTokenMutation.mutate(id)}
token={token}
/>
))}
</div>
) : tokensQuery.isSuccess ? (
<p className="mt-4 text-sm text-muted-foreground">
No tokens yet. Create one to get started.
</p>
) : null}
<CreateTokenDialog
activeTokenCount={activeTokens.length}
channels={scopeableChannels}
channelsError={
scopeableChannelsQuery.error instanceof Error
? scopeableChannelsQuery.error
: channelsQuery.error instanceof Error
? channelsQuery.error
: null
}
currentPubkey={currentPubkey}
hiddenChannelsCount={hiddenChannelsCount}
isLoadingChannels={
channelsQuery.isLoading || scopeableChannelsQuery.isLoading
}
onOpenChange={setCreateOpen}
open={createOpen}
/>
<RevokeAllDialog
isPending={revokeAllMutation.isPending}
onConfirm={() => {
revokeAllMutation.mutate(undefined, {
onSuccess: () => setRevokeAllOpen(false),
});
}}
onOpenChange={setRevokeAllOpen}
open={revokeAllOpen}
/>
</section>
);
}

View File

@@ -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<Token[]> {
const response = await invokeTauri<RawListTokensResponse>("list_tokens");
return response.tokens.map(fromRawToken);
}
export async function mintToken(
input: MintTokenInput,
): Promise<MintTokenResponse> {
const response = await invokeTauri<RawMintTokenResponse>("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<void> {
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<RawMintManagedAgentTokenResponse>(
"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<RawManagedAgentLog>(
"get_managed_agent_log",

View File

@@ -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;

View File

@@ -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<string, RelayEvent[]>();
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map<number, MockSocket>();
const realSockets = new Map<number, WebSocket>();
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<RawListTokensResponse> {
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<RawMintTokenResponse> {
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<RawRevokeAllTokensResponse> {
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<RawRelayAgent[]> {
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<RawMintManagedAgentTokenResponse> {
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<typeof handleGetFeed>[0]) ?? {},
activeConfig,
);
case "list_tokens":
return handleListTokens(activeConfig);
case "mint_token":
return handleMintToken(
payload as Parameters<typeof handleMintToken>[0],
activeConfig,
);
case "revoke_token":
return handleRevokeToken(
payload as Parameters<typeof handleRevokeToken>[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<typeof handleDeleteManagedAgent>[0],
);
case "mint_managed_agent_token":
return handleMintManagedAgentToken(
payload as Parameters<typeof handleMintManagedAgentToken>[0],
);
case "get_managed_agent_log":
return handleGetManagedAgentLog(
payload as Parameters<typeof handleGetManagedAgentLog>[0],

View File

@@ -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",
);
});

View File

@@ -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 = {

View File

@@ -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}})

View File

@@ -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(

View File

@@ -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);

View File

@@ -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