mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
39
.env.example
39
.env.example
@@ -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
|
||||
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
27
NOSTR.md
27
NOSTR.md
@@ -10,7 +10,7 @@ third-party Nostr clients to connect:
|
||||
|
||||
**Direct** is simpler — no extra process, no translation layer. Use it when your client speaks
|
||||
NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28
|
||||
clients and don't have company Okta/API-token credentials.
|
||||
clients and don't have company 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).
|
||||
|
||||
33
README.md
33
README.md
@@ -8,7 +8,7 @@ A Nostr relay built for the agentic era — agents and humans share the same pro
|
||||
|
||||
Sprout is a self-hosted WebSocket relay implementing a subset of the Nostr protocol, extended with
|
||||
structured channels, per-channel canvases, full-text search, and an MCP server so AI agents can
|
||||
participate in conversations natively. Authentication is NIP-42 + bearer token; all writes are
|
||||
participate in conversations natively. Authentication is NIP-42 + NIP-98 Schnorr signatures; all writes are
|
||||
append-only and audited.
|
||||
|
||||
## Quick Start
|
||||
@@ -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
|
||||
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@@ -48,10 +48,10 @@ challenge/response before writing events. The relay sends a random challenge;
|
||||
the client signs a `kind:22242` event containing the challenge and the relay
|
||||
URL, proving possession of the private key.
|
||||
|
||||
API tokens (bearer tokens minted by `sprout-admin`) are presented inside the
|
||||
NIP-42 signed event as an `auth_token` tag. The relay validates the token
|
||||
against the database before granting elevated scopes. Tokens are stored as
|
||||
SHA-256 hashes — the plaintext is shown once at mint time and never stored.
|
||||
REST endpoints authenticate via
|
||||
[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth —
|
||||
the client signs a `kind:27235` event containing the request URL and method.
|
||||
The relay verifies the Schnorr signature and extracts the pubkey.
|
||||
|
||||
### Authorization — Channel Membership as the Gate
|
||||
|
||||
@@ -64,13 +64,6 @@ Private channels are invisible to non-members: they do not appear in channel
|
||||
listings, and subscription filters for private channel events return nothing
|
||||
unless the subscriber is a member.
|
||||
|
||||
### Scope-Based Token Permissions
|
||||
|
||||
API tokens carry a set of scopes (e.g., `messages:read`, `channels:write`).
|
||||
The relay enforces scopes on every REST endpoint and WebSocket write. A token
|
||||
without `channels:write` cannot create channels, regardless of channel
|
||||
membership.
|
||||
|
||||
### Append-Only Audit Log
|
||||
|
||||
All events are written to a tamper-evident audit log (`sprout-audit`). Each
|
||||
|
||||
@@ -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
|
||||
|
||||
10
VISION.md
10
VISION.md
@@ -78,10 +78,10 @@ Humans and agents get the same thing:
|
||||
|
||||
- secp256k1 keypair (Nostr-native)
|
||||
- `alice@example.com` NIP-05 handle
|
||||
- Okta SSO → keypair bridge (humans) or API token (agents)
|
||||
- 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
6
justfile
6
justfile
@@ -280,7 +280,7 @@ check-compile:
|
||||
# ─── Agent Harness ────────────────────────────────────────────────────────────
|
||||
|
||||
# Run a goose agent connected to a Sprout relay (foreground)
|
||||
goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY" token="$SPROUT_ACP_API_TOKEN":
|
||||
goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cargo build --release -p sprout-acp -p sprout-mcp
|
||||
@@ -293,7 +293,6 @@ goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROU
|
||||
SPROUT_ACP_AGENTS="{{agents}}"
|
||||
GOOSE_MODE=auto
|
||||
)
|
||||
[[ -n "{{token}}" ]] && env_args+=(SPROUT_ACP_API_TOKEN="{{token}}")
|
||||
[[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}")
|
||||
if [[ "{{heartbeat}}" != "0" ]]; then
|
||||
env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}})
|
||||
@@ -301,7 +300,7 @@ goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROU
|
||||
exec env "${env_args[@]}" ./target/release/sprout-acp
|
||||
|
||||
# Run a goose agent in the background (screen session named 'goose-agent-N')
|
||||
goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY" token="$SPROUT_ACP_API_TOKEN":
|
||||
goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cargo build --release -p sprout-acp -p sprout-mcp
|
||||
@@ -314,7 +313,6 @@ goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SP
|
||||
SPROUT_ACP_AGENTS="{{agents}}"
|
||||
GOOSE_MODE=auto
|
||||
)
|
||||
[[ -n "{{token}}" ]] && env_args+=(SPROUT_ACP_API_TOKEN="{{token}}")
|
||||
[[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}")
|
||||
if [[ "{{heartbeat}}" != "0" ]]; then
|
||||
env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user