mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +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
|
# 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.
|
# to keep resolving to the original author across relay restarts.
|
||||||
# SPROUT_RELAY_PRIVATE_KEY=<32-byte hex private key>
|
# 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
|
# 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
|
# the web frontend at / for browser requests. Leave unset for local dev
|
||||||
# (use `just web` for Vite HMR instead).
|
# (use `just web` for Vite HMR instead).
|
||||||
# SPROUT_WEB_DIR=./web/dist
|
# 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)
|
# 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.
|
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
|
||||||
# SPROUT_PRIVATE_KEY=<32-byte hex or nsec1… private key>
|
# 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.
|
# Relay WebSocket URL the harness connects to.
|
||||||
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
|
# 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.
|
# 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
|
# These are accepted for backward compatibility but the canonical names above
|
||||||
# are preferred:
|
# are preferred:
|
||||||
# SPROUT_ACP_PRIVATE_KEY → SPROUT_PRIVATE_KEY
|
# 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
|
- name: Dependency policy
|
||||||
run: cargo-deny check
|
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:
|
server-cross-compile:
|
||||||
name: Server Cross-Compile
|
name: Server Cross-Compile
|
||||||
runs-on: ubuntu-latest
|
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-core (zero I/O — types, verification, filter matching, kind registry)
|
||||||
│
|
│
|
||||||
├── sprout-db (Postgres: events, channels, tokens, workflows, audit)
|
├── 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-pubsub (Redis pub/sub, presence, typing indicators)
|
||||||
├── sprout-search (Typesense: index, query, delete)
|
├── sprout-search (Typesense: index, query, delete)
|
||||||
├── sprout-audit (hash-chain tamper-evident log)
|
├── 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
|
### 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 |
|
| Path | Mechanism | Use Case |
|
||||||
|------|-----------|---------|
|
|------|-----------|---------|
|
||||||
| NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay |
|
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
|
||||||
| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta |
|
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
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: ..."]`.
|
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
|
### 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 |
|
| Path | Entry Point | Notes |
|
||||||
|------|-------------|-------|
|
|------|-------------|-------|
|
||||||
| NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) |
|
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
|
||||||
| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated |
|
| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event |
|
||||||
| 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` |
|
|
||||||
|
|
||||||
**Key types:**
|
**Key types:**
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec<Scope>, pub auth_method: AuthMethod }
|
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,
|
pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite,
|
||||||
AdminChannels, UsersRead, UsersWrite, AdminUsers,
|
AdminChannels, UsersRead, UsersWrite, AdminUsers,
|
||||||
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
|
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
|
||||||
@@ -372,10 +368,7 @@ pub trait RateLimiter: Send + Sync { ... }
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Security details:**
|
**Security details:**
|
||||||
- JWKS double-checked locking: two read-lock checks before fetching, HTTP fetch with no lock held, write-lock re-check after. Cache TTL: 300 seconds.
|
- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags.
|
||||||
- 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-42 timestamp tolerance: ±60 seconds.
|
- 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.
|
- 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` |
|
| `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 |
|
| `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens |
|
||||||
| `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables |
|
| `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 |
|
| `dm.rs` | DM channel management |
|
||||||
| `reaction.rs` | Reaction storage and retrieval |
|
| `reaction.rs` | Reaction storage and retrieval |
|
||||||
| `thread.rs` | Thread/reply tracking |
|
| `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.
|
- 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.
|
- 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)`.
|
- `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: `create_approval` receives the raw token and hashes it internally with SHA-256.
|
||||||
- Approval tokens: separate path — `create_approval` receives the raw token and hashes it internally.
|
|
||||||
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.
|
- 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.
|
**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/{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}/grant` | Approve by hash (🚧 unreachable — see WF-08) |
|
||||||
| POST | `/api/approvals/by-hash/{hash}/deny` | Deny 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 |
|
| GET | `/api/dms` | List DM channels |
|
||||||
| POST | `/api/dms` | Open a DM channel |
|
| POST | `/api/dms` | Open a DM channel |
|
||||||
| POST | `/api/dms/{channel_id}/members` | Add DM member |
|
| 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.
|
- Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically.
|
||||||
- Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr).
|
- Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr).
|
||||||
- Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect.
|
- 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).
|
- `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.
|
- `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID.
|
||||||
- UUID validation at tool boundary before any network call.
|
- 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 |
|
| 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 |
|
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
|
||||||
| AUTH events | Never stored in Postgres, never logged in audit chain |
|
| 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
|
### 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) |
|
| 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 |
|
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
|
||||||
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
|
| 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) |
|
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
|
||||||
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
|
| 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) |
|
| `workflows` | Workflow definitions (YAML stored as canonical JSON) |
|
||||||
| `workflow_runs` | Execution records with trigger context and trace |
|
| `workflow_runs` | Execution records with trigger context and trace |
|
||||||
| `workflow_approvals` | Approval gates (token stored as SHA-256 hash) |
|
| `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 |
|
| `audit_log` | Hash-chain audit entries |
|
||||||
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |
|
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ The short version:
|
|||||||
sprout-relay ← WebSocket server, REST API, event ingestion
|
sprout-relay ← WebSocket server, REST API, event ingestion
|
||||||
sprout-core ← Shared types, event verification, filter matching
|
sprout-core ← Shared types, event verification, filter matching
|
||||||
sprout-db ← Postgres access layer (sqlx)
|
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-pubsub ← Redis fan-out
|
||||||
sprout-search ← Typesense full-text search
|
sprout-search ← Typesense full-text search
|
||||||
sprout-audit ← Tamper-evident hash-chain audit log
|
sprout-audit ← Tamper-evident hash-chain audit log
|
||||||
|
|||||||
@@ -91,10 +91,6 @@ zeroize = "1.8"
|
|||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
moka = { version = "0.12", features = ["sync"] }
|
moka = { version = "0.12", features = ["sync"] }
|
||||||
|
|
||||||
# JWT validation (Okta JWKS)
|
|
||||||
jsonwebtoken = "9"
|
|
||||||
|
|
||||||
|
|
||||||
# Async stream utilities
|
# Async stream utilities
|
||||||
futures-util = "0.3"
|
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
|
**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
|
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.
|
Both paths require NIP-42 authentication.
|
||||||
|
|
||||||
@@ -83,10 +83,10 @@ PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \
|
|||||||
### Pubkey Allowlist
|
### Pubkey Allowlist
|
||||||
|
|
||||||
When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
|
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
|
(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.
|
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.
|
- **Fail-closed:** if the DB lookup fails, the connection is denied.
|
||||||
- Default: `false` (all authenticated pubkeys accepted).
|
- Default: `false` (all authenticated pubkeys accepted).
|
||||||
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
|
- 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)
|
export SPROUT_PROXY_SERVER_KEY=$(openssl rand -hex 32)
|
||||||
PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public)
|
PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public)
|
||||||
|
|
||||||
# 3. Mint a proxy API token
|
# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth)
|
||||||
cargo run -p sprout-admin -- mint-token \
|
export SPROUT_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \
|
||||||
--name "sprout-proxy" \
|
-H "Authorization: Nostr <base64-nip98-event>" \
|
||||||
--scopes "proxy:submit,channels:read,messages:read" \
|
-H "Content-Type: application/json" \
|
||||||
--pubkey $PROXY_PUBKEY
|
-d '{"name":"proxy"}' | jq -r .token)
|
||||||
|
|
||||||
# 4. Get the relay's public key (needed for attribution trust)
|
# 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
|
# 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
|
# 5. Start the proxy
|
||||||
export SPROUT_UPSTREAM_URL=ws://localhost:3000
|
export SPROUT_UPSTREAM_URL=ws://localhost:3000
|
||||||
export SPROUT_PROXY_SALT=$(openssl rand -hex 32)
|
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)
|
export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16)
|
||||||
cargo run -p sprout-proxy # proxy on :4869
|
cargo run -p sprout-proxy # proxy on :4869
|
||||||
|
|
||||||
@@ -312,7 +311,7 @@ curl -X DELETE http://localhost:4869/admin/guests \
|
|||||||
-d '{"pubkey": "<hex>"}'
|
-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.
|
> `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
|
> 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.
|
> 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 |
|
| Variable | Required | Default | Description |
|
||||||
|----------|:--------:|---------|-------------|
|
|----------|:--------:|---------|-------------|
|
||||||
| `SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay |
|
| `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_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_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_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_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. |
|
| `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_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_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
|
### Direct Path
|
||||||
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
|
- **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: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: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).
|
- **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
|
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
|
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.
|
append-only and audited.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -34,7 +34,7 @@ just build
|
|||||||
```
|
```
|
||||||
|
|
||||||
`just setup` does the heavy lifting:
|
`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
|
- Waits for core services (Postgres, Redis, Typesense) to be healthy
|
||||||
- Runs database migrations
|
- Runs database migrations
|
||||||
- Installs desktop dependencies (`pnpm install`)
|
- 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` |
|
| ✅ | **ACP agent harness** — AI agents connect out of the box via `sprout-acp` |
|
||||||
| ✅ | **Tamper-evident audit log** — hash-chain, SOX-grade compliance |
|
| ✅ | **Tamper-evident audit log** — hash-chain, SOX-grade compliance |
|
||||||
| ✅ | **Permission-aware full-text search** — Typesense, respects channel membership |
|
| ✅ | **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 |
|
| ✅ | **Pure Rust backend** — memory safe, no GC pauses |
|
||||||
|
|
||||||
## Supported NIPs
|
## 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-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-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-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
|
## Architecture
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ That's it — you're running Sprout locally.
|
|||||||
| Crate | Role |
|
| Crate | Role |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `sprout-db` | Postgres access layer — events, channels, users, DMs, threads, reactions, workflows, tokens, feed (sqlx) |
|
| `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-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-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 |
|
| `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
|
## 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)
|
### Launch an agent (MCP)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SPROUT_RELAY_URL=ws://localhost:3000 \
|
SPROUT_RELAY_URL=ws://localhost:3000 \
|
||||||
SPROUT_API_TOKEN=<token> \
|
|
||||||
SPROUT_PRIVATE_KEY=nsec1... \
|
SPROUT_PRIVATE_KEY=nsec1... \
|
||||||
goose run --no-profile \
|
goose run --no-profile \
|
||||||
--with-extension "cargo run -p sprout-mcp --bin sprout-mcp-server" \
|
--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 |
|
| `TYPESENSE_COLLECTION` | `events` | Typesense collection name |
|
||||||
| `SPROUT_BIND_ADDR` | `0.0.0.0:3000` | Relay bind address (host:port) |
|
| `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) |
|
| `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 |
|
| `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) |
|
| `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_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_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_SERVER_KEY` | — | Hex private key for the proxy server keypair |
|
||||||
| `SPROUT_PROXY_SALT` | — | Hex 32-byte salt for shadow key derivation |
|
| `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_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_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (unset = permissive) |
|
||||||
| `SPROUT_HEALTH_PORT` | `8080` | Port for health check endpoint (separate from main bind) |
|
| `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_SECRET_KEY` | `sprout_dev_secret` | S3 secret key |
|
||||||
| `SPROUT_S3_BUCKET` | `sprout-media` | S3 bucket name for media uploads |
|
| `SPROUT_S3_BUCKET` | `sprout-media` | S3 bucket name for media uploads |
|
||||||
| `SPROUT_METRICS_PORT` | `9102` | Port for Prometheus metrics endpoint |
|
| `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_SEND_BUFFER` | `1000` | WebSocket send buffer size |
|
||||||
| `SPROUT_UDS_PATH` | — | Unix domain socket path (alternative to TCP) |
|
| `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_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` |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## MCP Tools
|
## 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
|
the client signs a `kind:22242` event containing the challenge and the relay
|
||||||
URL, proving possession of the private key.
|
URL, proving possession of the private key.
|
||||||
|
|
||||||
API tokens (bearer tokens minted by `sprout-admin`) are presented inside the
|
REST endpoints authenticate via
|
||||||
NIP-42 signed event as an `auth_token` tag. The relay validates the token
|
[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth —
|
||||||
against the database before granting elevated scopes. Tokens are stored as
|
the client signs a `kind:27235` event containing the request URL and method.
|
||||||
SHA-256 hashes — the plaintext is shown once at mint time and never stored.
|
The relay verifies the Schnorr signature and extracts the pubkey.
|
||||||
|
|
||||||
### Authorization — Channel Membership as the Gate
|
### 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
|
listings, and subscription filters for private channel events return nothing
|
||||||
unless the subscriber is a member.
|
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
|
### Append-Only Audit Log
|
||||||
|
|
||||||
All events are written to a tamper-evident audit log (`sprout-audit`). Each
|
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
|
### 3. Generate Keys
|
||||||
|
|
||||||
Each agent needs a Nostr keypair. In dev mode (`SPROUT_REQUIRE_AUTH_TOKEN=false`),
|
Each agent needs a Nostr keypair. Authentication uses NIP-42 (WebSocket) and
|
||||||
the `X-Pubkey` header authenticates all REST calls — no tokens needed.
|
NIP-98 Schnorr signatures (REST).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Agent identity
|
# Agent identity
|
||||||
|
|||||||
10
VISION.md
10
VISION.md
@@ -78,10 +78,10 @@ Humans and agents get the same thing:
|
|||||||
|
|
||||||
- secp256k1 keypair (Nostr-native)
|
- secp256k1 keypair (Nostr-native)
|
||||||
- `alice@example.com` NIP-05 handle
|
- `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.
|
- 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
|
## 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 |
|
| ✅ | 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 |
|
| ✅ | 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 |
|
| ✅ | 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` |
|
| ✅ | 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 |
|
| ✅ | 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) |
|
| 🚧 | 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 |
|
| 🚧 | Huddles — LiveKit token minting in place; relay-side lifecycle events not yet wired |
|
||||||
|
|||||||
@@ -10,25 +10,21 @@ cargo install --path crates/sprout-cli
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Three modes, checked in order:
|
Two modes, checked in order:
|
||||||
|
|
||||||
| Priority | Env Var | Mode | Use Case |
|
| Priority | Env Var | Mode | Use Case |
|
||||||
|----------|---------|------|----------|
|
|----------|---------|------|----------|
|
||||||
| 1 | `SPROUT_API_TOKEN` | Bearer token | Production — fastest, no extra HTTP call |
|
| 1 | `SPROUT_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
|
||||||
| 2 | `SPROUT_PRIVATE_KEY` | Auto-mint short-lived token via NIP-98 | Agents with a keypair |
|
| 2 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development |
|
||||||
| 3 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Option 1: Pre-minted token
|
# Option 1: Private key (NIP-98 signed requests)
|
||||||
export SPROUT_API_TOKEN="sprout_tok_..."
|
|
||||||
sprout list-channels
|
|
||||||
|
|
||||||
# Option 2: Private key (auto-mints a 1-day token at startup)
|
|
||||||
export SPROUT_PRIVATE_KEY="nsec1..."
|
export SPROUT_PRIVATE_KEY="nsec1..."
|
||||||
sprout list-channels
|
sprout list-channels
|
||||||
|
|
||||||
# Option 3: Mint a long-lived token explicitly
|
# Option 2: Dev mode (no auth)
|
||||||
export SPROUT_API_TOKEN=$(SPROUT_PRIVATE_KEY=nsec1... sprout auth)
|
export SPROUT_PUBKEY="<hex>"
|
||||||
|
sprout list-channels
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -83,17 +79,11 @@ sprout vote-on-post --event <event-id> --direction up
|
|||||||
sprout get-canvas --channel <uuid>
|
sprout get-canvas --channel <uuid>
|
||||||
sprout set-canvas --channel <uuid> --content "# Welcome"
|
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
|
# Pipe to jq
|
||||||
sprout list-channels | jq '.[].name'
|
sprout list-channels | jq '.[].name'
|
||||||
```
|
```
|
||||||
|
|
||||||
## All 48 Commands
|
## All 44 Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
@@ -141,10 +131,6 @@ sprout list-channels | jq '.[].name'
|
|||||||
| `approve-step` | Approve/deny a workflow step |
|
| `approve-step` | Approve/deny a workflow step |
|
||||||
| `get-feed` | Get your activity feed |
|
| `get-feed` | Get your activity feed |
|
||||||
| `vote-on-post` | Vote on a forum post |
|
| `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
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -57,16 +57,16 @@ pub struct Config {
|
|||||||
/// TCP port for the Prometheus metrics exporter (`GET /metrics`).
|
/// TCP port for the Prometheus metrics exporter (`GET /metrics`).
|
||||||
pub metrics_port: u16,
|
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
|
/// 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`.
|
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
|
||||||
pub pubkey_allowlist_enabled: bool,
|
pub pubkey_allowlist_enabled: bool,
|
||||||
|
|
||||||
/// When true, every authenticated request must also pass a relay-level
|
/// When true, every authenticated request must also pass a relay-level
|
||||||
/// membership check against the `relay_members` table.
|
/// membership check against the `relay_members` table.
|
||||||
/// When false (default), the check is a no-op and all authenticated callers
|
/// 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,
|
pub require_relay_membership: bool,
|
||||||
|
|
||||||
/// Optional hex-encoded pubkey of the relay owner.
|
/// 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/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/settings/ui/SettingsView.tsx", 600],
|
||||||
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
|
["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/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/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
|
["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 { normalizePubkey } from "@/shared/lib/pubkey";
|
||||||
import {
|
import {
|
||||||
addChannelMembers,
|
addChannelMembers,
|
||||||
@@ -170,12 +169,6 @@ function pickPreferredManagedAgent(agents: ManagedAgent[]) {
|
|||||||
return rightRunningScore - leftRunningScore;
|
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);
|
return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
|
||||||
})[0];
|
})[0];
|
||||||
}
|
}
|
||||||
@@ -256,9 +249,6 @@ export async function ensureChannelAgentPresetInChannel(
|
|||||||
agentCommand: input.provider.command,
|
agentCommand: input.provider.command,
|
||||||
agentArgs: input.provider.defaultArgs,
|
agentArgs: input.provider.defaultArgs,
|
||||||
mcpCommand: "sprout-mcp-server",
|
mcpCommand: "sprout-mcp-server",
|
||||||
mintToken: true,
|
|
||||||
tokenName: `${expectedName} agent`,
|
|
||||||
tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
|
|
||||||
spawnAfterCreate: false,
|
spawnAfterCreate: false,
|
||||||
});
|
});
|
||||||
const attached = await attachManagedAgentToChannel(channelId, {
|
const attached = await attachManagedAgentToChannel(channelId, {
|
||||||
@@ -310,9 +300,6 @@ export async function createChannelManagedAgent(
|
|||||||
agentCommand: input.provider.command,
|
agentCommand: input.provider.command,
|
||||||
agentArgs: input.provider.defaultArgs,
|
agentArgs: input.provider.defaultArgs,
|
||||||
mcpCommand: "sprout-mcp-server",
|
mcpCommand: "sprout-mcp-server",
|
||||||
mintToken: true,
|
|
||||||
tokenName: `${trimmedName} agent`,
|
|
||||||
tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
|
|
||||||
personaId: input.personaId ?? undefined,
|
personaId: input.personaId ?? undefined,
|
||||||
systemPrompt: input.systemPrompt?.trim() || undefined,
|
systemPrompt: input.systemPrompt?.trim() || undefined,
|
||||||
avatarUrl: resolvedAvatarUrl,
|
avatarUrl: resolvedAvatarUrl,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
getManagedAgentLog,
|
getManagedAgentLog,
|
||||||
listManagedAgents,
|
listManagedAgents,
|
||||||
listRelayAgents,
|
listRelayAgents,
|
||||||
mintManagedAgentToken,
|
|
||||||
startManagedAgent,
|
startManagedAgent,
|
||||||
stopManagedAgent,
|
stopManagedAgent,
|
||||||
updateManagedAgent,
|
updateManagedAgent,
|
||||||
@@ -43,7 +42,6 @@ import type {
|
|||||||
CreatePersonaInput,
|
CreatePersonaInput,
|
||||||
CreateTeamInput,
|
CreateTeamInput,
|
||||||
ManagedAgent,
|
ManagedAgent,
|
||||||
MintManagedAgentTokenInput,
|
|
||||||
UpdateManagedAgentInput,
|
UpdateManagedAgentInput,
|
||||||
UpdatePersonaInput,
|
UpdatePersonaInput,
|
||||||
UpdateTeamInput,
|
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(
|
export function useAttachManagedAgentToChannelMutation(
|
||||||
channelId: string | null,
|
channelId: string | null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { TeamDialog } from "./TeamDialog";
|
|||||||
import { TeamImportDialog } from "./TeamImportDialog";
|
import { TeamImportDialog } from "./TeamImportDialog";
|
||||||
import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog";
|
import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog";
|
||||||
import { TeamsSection } from "./TeamsSection";
|
import { TeamsSection } from "./TeamsSection";
|
||||||
import { TokenRevealDialog } from "./TokenRevealDialog";
|
|
||||||
import { useManagedAgentActions } from "./useManagedAgentActions";
|
import { useManagedAgentActions } from "./useManagedAgentActions";
|
||||||
import { usePersonaActions } from "./usePersonaActions";
|
import { usePersonaActions } from "./usePersonaActions";
|
||||||
import { useTeamActions } from "./useTeamActions";
|
import { useTeamActions } from "./useTeamActions";
|
||||||
@@ -143,9 +142,6 @@ export function AgentsView() {
|
|||||||
onDelete={(pubkey) => {
|
onDelete={(pubkey) => {
|
||||||
void agents.handleDelete(pubkey);
|
void agents.handleDelete(pubkey);
|
||||||
}}
|
}}
|
||||||
onMintToken={(pubkey, name) => {
|
|
||||||
void agents.handleMintToken(pubkey, name);
|
|
||||||
}}
|
|
||||||
onSelectLogAgent={agents.setLogAgentPubkey}
|
onSelectLogAgent={agents.setLogAgentPubkey}
|
||||||
onStart={(pubkey) => {
|
onStart={(pubkey) => {
|
||||||
void agents.handleStart(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
|
<PersonaDialog
|
||||||
description={personas.personaDialogState?.description ?? ""}
|
description={personas.personaDialogState?.description ?? ""}
|
||||||
error={
|
error={
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import {
|
|||||||
useCreateManagedAgentMutation,
|
useCreateManagedAgentMutation,
|
||||||
useManagedAgentPrereqsQuery,
|
useManagedAgentPrereqsQuery,
|
||||||
} from "@/features/agents/hooks";
|
} from "@/features/agents/hooks";
|
||||||
import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions";
|
|
||||||
import { probeBackendProvider } from "@/shared/api/tauri";
|
import { probeBackendProvider } from "@/shared/api/tauri";
|
||||||
import type {
|
import type {
|
||||||
BackendProviderProbeResult,
|
BackendProviderProbeResult,
|
||||||
CreateManagedAgentInput,
|
CreateManagedAgentInput,
|
||||||
CreateManagedAgentResponse,
|
CreateManagedAgentResponse,
|
||||||
TokenScope,
|
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +26,6 @@ import {
|
|||||||
CreateAgentOptionToggles,
|
CreateAgentOptionToggles,
|
||||||
CreateAgentRuntimeProviderField,
|
CreateAgentRuntimeProviderField,
|
||||||
CreateAgentRuntimeFields,
|
CreateAgentRuntimeFields,
|
||||||
CreateAgentTokenSection,
|
|
||||||
} from "./CreateAgentDialogSections";
|
} from "./CreateAgentDialogSections";
|
||||||
import {
|
import {
|
||||||
coerceConfigValues,
|
coerceConfigValues,
|
||||||
@@ -59,13 +56,8 @@ export function CreateAgentDialog({
|
|||||||
const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand);
|
const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand);
|
||||||
const [name, setName] = React.useState("");
|
const [name, setName] = React.useState("");
|
||||||
const [relayUrl, setRelayUrl] = React.useState("");
|
const [relayUrl, setRelayUrl] = React.useState("");
|
||||||
const [mintToken, setMintToken] = React.useState(true);
|
|
||||||
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
|
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
|
||||||
const [startOnAppLaunch, setStartOnAppLaunch] = 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 [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320");
|
||||||
const [parallelism, setParallelism] = React.useState("3");
|
const [parallelism, setParallelism] = React.useState("3");
|
||||||
const [systemPrompt, setSystemPrompt] = React.useState("");
|
const [systemPrompt, setSystemPrompt] = React.useState("");
|
||||||
@@ -97,13 +89,9 @@ export function CreateAgentDialog({
|
|||||||
[backendProviders, runOn],
|
[backendProviders, runOn],
|
||||||
);
|
);
|
||||||
const isProviderMode = runOn !== "local";
|
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 =
|
const isSpawnSupported =
|
||||||
prereqs?.acp.available === true && prereqs?.mcp.available === true;
|
prereqs?.acp.available === true && prereqs?.mcp.available === true;
|
||||||
const mintToggleDisabled = prereqsQuery.isLoading;
|
|
||||||
const spawnToggleDisabled =
|
const spawnToggleDisabled =
|
||||||
prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported);
|
prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported);
|
||||||
const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading;
|
const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading;
|
||||||
@@ -207,11 +195,8 @@ export function CreateAgentDialog({
|
|||||||
function reset() {
|
function reset() {
|
||||||
setName("");
|
setName("");
|
||||||
setRelayUrl("");
|
setRelayUrl("");
|
||||||
setMintToken(true);
|
|
||||||
setSpawnAfterCreate(true);
|
setSpawnAfterCreate(true);
|
||||||
setStartOnAppLaunch(true);
|
setStartOnAppLaunch(true);
|
||||||
setTokenName("");
|
|
||||||
setSelectedScopes(new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES));
|
|
||||||
setAcpCommand("sprout-acp");
|
setAcpCommand("sprout-acp");
|
||||||
setAgentCommand("goose");
|
setAgentCommand("goose");
|
||||||
setAgentArgs("acp");
|
setAgentArgs("acp");
|
||||||
@@ -238,35 +223,6 @@ export function CreateAgentDialog({
|
|||||||
onOpenChange(next);
|
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) {
|
function handleProviderChange(nextProviderId: string) {
|
||||||
setSelectedProviderId(nextProviderId);
|
setSelectedProviderId(nextProviderId);
|
||||||
|
|
||||||
@@ -306,7 +262,6 @@ export function CreateAgentDialog({
|
|||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
name.trim().length > 0 &&
|
name.trim().length > 0 &&
|
||||||
(!effectiveMintToken || selectedScopes.size > 0) &&
|
|
||||||
!isDiscoveryPending &&
|
!isDiscoveryPending &&
|
||||||
!(
|
!(
|
||||||
!isProviderMode &&
|
!isProviderMode &&
|
||||||
@@ -335,9 +290,6 @@ export function CreateAgentDialog({
|
|||||||
? Number.parseInt(parallelism, 10)
|
? Number.parseInt(parallelism, 10)
|
||||||
: undefined,
|
: undefined,
|
||||||
systemPrompt: systemPrompt.trim() || undefined,
|
systemPrompt: systemPrompt.trim() || undefined,
|
||||||
mintToken: true, // Required: ownership established during mint
|
|
||||||
tokenName: tokenName.trim() || undefined,
|
|
||||||
tokenScopes: [...selectedScopes],
|
|
||||||
spawnAfterCreate: true,
|
spawnAfterCreate: true,
|
||||||
startOnAppLaunch: false, // Remote agents don't auto-start with the desktop
|
startOnAppLaunch: false, // Remote agents don't auto-start with the desktop
|
||||||
backend: {
|
backend: {
|
||||||
@@ -369,9 +321,6 @@ export function CreateAgentDialog({
|
|||||||
? Number.parseInt(parallelism, 10)
|
? Number.parseInt(parallelism, 10)
|
||||||
: undefined,
|
: undefined,
|
||||||
systemPrompt: systemPrompt.trim() || undefined,
|
systemPrompt: systemPrompt.trim() || undefined,
|
||||||
mintToken: effectiveMintToken,
|
|
||||||
tokenName: tokenName.trim() || undefined,
|
|
||||||
tokenScopes: [...selectedScopes],
|
|
||||||
spawnAfterCreate,
|
spawnAfterCreate,
|
||||||
startOnAppLaunch,
|
startOnAppLaunch,
|
||||||
backend: { type: "local" },
|
backend: { type: "local" },
|
||||||
@@ -393,8 +342,7 @@ export function CreateAgentDialog({
|
|||||||
<DialogTitle>Create agent</DialogTitle>
|
<DialogTitle>Create agent</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This creates a local agent identity, syncs its display name when
|
This creates a local agent identity, syncs its display name when
|
||||||
possible, optionally mints a relay token, and can spawn
|
possible, and can spawn `sprout-acp` immediately.
|
||||||
`sprout-acp` immediately.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -467,13 +415,6 @@ export function CreateAgentDialog({
|
|||||||
|
|
||||||
<CreateAgentOptionToggles
|
<CreateAgentOptionToggles
|
||||||
isSpawnSupported={isSpawnSupported}
|
isSpawnSupported={isSpawnSupported}
|
||||||
mintToken={effectiveMintToken}
|
|
||||||
mintToggleDisabled={isProviderMode || mintToggleDisabled}
|
|
||||||
onToggleMintToken={() => {
|
|
||||||
if (!mintToggleDisabled) {
|
|
||||||
setMintToken((current) => !current);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onToggleStartOnAppLaunch={() => {
|
onToggleStartOnAppLaunch={() => {
|
||||||
setStartOnAppLaunch((current) => !current);
|
setStartOnAppLaunch((current) => !current);
|
||||||
}}
|
}}
|
||||||
@@ -489,20 +430,6 @@ export function CreateAgentDialog({
|
|||||||
spawnToggleDisabled={isProviderMode || spawnToggleDisabled}
|
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">
|
<div className="rounded-2xl border border-border/70 bg-muted/20">
|
||||||
<button
|
<button
|
||||||
aria-expanded={showAdvanced}
|
aria-expanded={showAdvanced}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import type {
|
import type { AcpProvider, ManagedAgentPrereqs } from "@/shared/api/types";
|
||||||
AcpProvider,
|
|
||||||
ManagedAgentPrereqs,
|
|
||||||
TokenScope,
|
|
||||||
} from "@/shared/api/types";
|
|
||||||
import { MANAGED_AGENT_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
@@ -326,51 +321,26 @@ export function CreateAgentRuntimeFields({
|
|||||||
|
|
||||||
export function CreateAgentOptionToggles({
|
export function CreateAgentOptionToggles({
|
||||||
isSpawnSupported,
|
isSpawnSupported,
|
||||||
mintToken,
|
|
||||||
mintToggleDisabled,
|
|
||||||
prereqs,
|
prereqs,
|
||||||
startOnAppLaunch,
|
startOnAppLaunch,
|
||||||
startOnAppLaunchDisabled,
|
startOnAppLaunchDisabled,
|
||||||
spawnAfterCreate,
|
spawnAfterCreate,
|
||||||
spawnToggleDisabled,
|
spawnToggleDisabled,
|
||||||
onToggleMintToken,
|
|
||||||
onToggleStartOnAppLaunch,
|
onToggleStartOnAppLaunch,
|
||||||
onToggleSpawnAfterCreate,
|
onToggleSpawnAfterCreate,
|
||||||
}: {
|
}: {
|
||||||
isSpawnSupported: boolean;
|
isSpawnSupported: boolean;
|
||||||
mintToken: boolean;
|
|
||||||
mintToggleDisabled: boolean;
|
|
||||||
prereqs: ManagedAgentPrereqs | null;
|
prereqs: ManagedAgentPrereqs | null;
|
||||||
startOnAppLaunch: boolean;
|
startOnAppLaunch: boolean;
|
||||||
/** When true, the toggle is disabled (e.g. remote agents don't support auto-start). */
|
/** When true, the toggle is disabled (e.g. remote agents don't support auto-start). */
|
||||||
startOnAppLaunchDisabled?: boolean;
|
startOnAppLaunchDisabled?: boolean;
|
||||||
spawnAfterCreate: boolean;
|
spawnAfterCreate: boolean;
|
||||||
spawnToggleDisabled: boolean;
|
spawnToggleDisabled: boolean;
|
||||||
onToggleMintToken: () => void;
|
|
||||||
onToggleStartOnAppLaunch: () => void;
|
onToggleStartOnAppLaunch: () => void;
|
||||||
onToggleSpawnAfterCreate: () => void;
|
onToggleSpawnAfterCreate: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-pressed={startOnAppLaunch}
|
aria-pressed={startOnAppLaunch}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -419,74 +389,3 @@ export function CreateAgentOptionToggles({
|
|||||||
</div>
|
</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,
|
Clipboard,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
FileText,
|
FileText,
|
||||||
KeyRound,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Power,
|
Power,
|
||||||
@@ -49,7 +48,6 @@ export function ManagedAgentRow({
|
|||||||
presenceLookup,
|
presenceLookup,
|
||||||
onAddToChannel,
|
onAddToChannel,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMintToken,
|
|
||||||
onSelectLogAgent,
|
onSelectLogAgent,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -67,7 +65,6 @@ export function ManagedAgentRow({
|
|||||||
presenceLookup: PresenceLookup;
|
presenceLookup: PresenceLookup;
|
||||||
onAddToChannel: (agent: ManagedAgent) => void;
|
onAddToChannel: (agent: ManagedAgent) => void;
|
||||||
onDelete: (pubkey: string) => void;
|
onDelete: (pubkey: string) => void;
|
||||||
onMintToken: (pubkey: string, name: string) => void;
|
|
||||||
onSelectLogAgent: (pubkey: string | null) => void;
|
onSelectLogAgent: (pubkey: string | null) => void;
|
||||||
onStart: (pubkey: string) => void;
|
onStart: (pubkey: string) => void;
|
||||||
onStop: (pubkey: string) => void;
|
onStop: (pubkey: string) => void;
|
||||||
@@ -158,7 +155,6 @@ export function ManagedAgentRow({
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onAddToChannel={onAddToChannel}
|
onAddToChannel={onAddToChannel}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onMintToken={onMintToken}
|
|
||||||
onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)}
|
onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
@@ -307,7 +303,6 @@ function AgentActionsMenu({
|
|||||||
isActive,
|
isActive,
|
||||||
onAddToChannel,
|
onAddToChannel,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMintToken,
|
|
||||||
onOpenLogs,
|
onOpenLogs,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -318,7 +313,6 @@ function AgentActionsMenu({
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onAddToChannel: (agent: ManagedAgent) => void;
|
onAddToChannel: (agent: ManagedAgent) => void;
|
||||||
onDelete: (pubkey: string) => void;
|
onDelete: (pubkey: string) => void;
|
||||||
onMintToken: (pubkey: string, name: string) => void;
|
|
||||||
onOpenLogs: (pubkey: string) => void;
|
onOpenLogs: (pubkey: string) => void;
|
||||||
onStart: (pubkey: string) => void;
|
onStart: (pubkey: string) => void;
|
||||||
onStop: (pubkey: string) => void;
|
onStop: (pubkey: string) => void;
|
||||||
@@ -391,14 +385,6 @@ function AgentActionsMenu({
|
|||||||
Add to channel
|
Add to channel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={isActionPending}
|
|
||||||
onClick={() => onMintToken(agent.pubkey, agent.name)}
|
|
||||||
>
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
Mint token
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(agent.pubkey);
|
await navigator.clipboard.writeText(agent.pubkey);
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export function ManagedAgentsSection({
|
|||||||
onBulkStopRunning,
|
onBulkStopRunning,
|
||||||
onCreate,
|
onCreate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMintToken,
|
|
||||||
onSelectLogAgent,
|
onSelectLogAgent,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -59,7 +58,6 @@ export function ManagedAgentsSection({
|
|||||||
onBulkStopRunning: () => void;
|
onBulkStopRunning: () => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onDelete: (pubkey: string) => void;
|
onDelete: (pubkey: string) => void;
|
||||||
onMintToken: (pubkey: string, name: string) => void;
|
|
||||||
onSelectLogAgent: (pubkey: string | null) => void;
|
onSelectLogAgent: (pubkey: string | null) => void;
|
||||||
onStart: (pubkey: string) => void;
|
onStart: (pubkey: string) => void;
|
||||||
onStop: (pubkey: string) => void;
|
onStop: (pubkey: string) => void;
|
||||||
@@ -176,7 +174,6 @@ export function ManagedAgentsSection({
|
|||||||
presenceLookup={presenceLookup}
|
presenceLookup={presenceLookup}
|
||||||
onAddToChannel={onAddToChannel}
|
onAddToChannel={onAddToChannel}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onMintToken={onMintToken}
|
|
||||||
onSelectLogAgent={onSelectLogAgent}
|
onSelectLogAgent={onSelectLogAgent}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export function SecretRevealDialog({
|
|||||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||||
<DialogTitle>Agent created</DialogTitle>
|
<DialogTitle>Agent created</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Save the private key and token now. The app can keep running the
|
Save the private key now. The app can keep running the harness
|
||||||
harness locally, but these secrets are only revealed here.
|
locally, but this secret is only revealed here.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -51,26 +51,6 @@ export function SecretRevealDialog({
|
|||||||
</code>
|
</code>
|
||||||
</div>
|
</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 ? (
|
{created.profileSyncError ? (
|
||||||
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
|
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
|
||||||
{created.profileSyncError}
|
{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,
|
type AttachManagedAgentToChannelResult,
|
||||||
useManagedAgentLogQuery,
|
useManagedAgentLogQuery,
|
||||||
useManagedAgentsQuery,
|
useManagedAgentsQuery,
|
||||||
useMintManagedAgentTokenMutation,
|
|
||||||
useRelayAgentsQuery,
|
useRelayAgentsQuery,
|
||||||
useSetManagedAgentStartOnAppLaunchMutation,
|
useSetManagedAgentStartOnAppLaunchMutation,
|
||||||
useStartManagedAgentMutation,
|
useStartManagedAgentMutation,
|
||||||
@@ -36,17 +35,11 @@ export function useManagedAgentActions() {
|
|||||||
const stopMutation = useStopManagedAgentMutation();
|
const stopMutation = useStopManagedAgentMutation();
|
||||||
const deleteMutation = useDeleteManagedAgentMutation();
|
const deleteMutation = useDeleteManagedAgentMutation();
|
||||||
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
|
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
|
||||||
const mintTokenMutation = useMintManagedAgentTokenMutation();
|
|
||||||
|
|
||||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||||
const [agentToAddToChannel, setAgentToAddToChannel] =
|
const [agentToAddToChannel, setAgentToAddToChannel] =
|
||||||
React.useState<ManagedAgent | null>(null);
|
React.useState<ManagedAgent | null>(null);
|
||||||
const [createdAgent, setCreatedAgent] =
|
const [createdAgent, setCreatedAgent] =
|
||||||
React.useState<CreateManagedAgentResponse | null>(null);
|
React.useState<CreateManagedAgentResponse | null>(null);
|
||||||
const [revealedToken, setRevealedToken] = React.useState<{
|
|
||||||
name: string;
|
|
||||||
token: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
|
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
|
||||||
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(
|
function handleAddedToChannel(
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
result: AttachManagedAgentToChannelResult,
|
result: AttachManagedAgentToChannelResult,
|
||||||
@@ -318,8 +296,7 @@ export function useManagedAgentActions() {
|
|||||||
startMutation.isPending ||
|
startMutation.isPending ||
|
||||||
stopMutation.isPending ||
|
stopMutation.isPending ||
|
||||||
startOnLaunchMutation.isPending ||
|
startOnLaunchMutation.isPending ||
|
||||||
deleteMutation.isPending ||
|
deleteMutation.isPending;
|
||||||
mintTokenMutation.isPending;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Queries
|
// Queries
|
||||||
@@ -339,8 +316,6 @@ export function useManagedAgentActions() {
|
|||||||
setAgentToAddToChannel,
|
setAgentToAddToChannel,
|
||||||
createdAgent,
|
createdAgent,
|
||||||
setCreatedAgent,
|
setCreatedAgent,
|
||||||
revealedToken,
|
|
||||||
setRevealedToken,
|
|
||||||
logAgentPubkey,
|
logAgentPubkey,
|
||||||
setLogAgentPubkey,
|
setLogAgentPubkey,
|
||||||
actionNoticeMessage,
|
actionNoticeMessage,
|
||||||
@@ -352,7 +327,6 @@ export function useManagedAgentActions() {
|
|||||||
handleStop,
|
handleStop,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleToggleStartOnAppLaunch,
|
handleToggleStartOnAppLaunch,
|
||||||
handleMintToken,
|
|
||||||
handleAddedToChannel,
|
handleAddedToChannel,
|
||||||
handleBulkStopRunning,
|
handleBulkStopRunning,
|
||||||
handleBulkRemoveStopped,
|
handleBulkRemoveStopped,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyRound,
|
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MonitorCog,
|
MonitorCog,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -21,7 +20,6 @@ import type {
|
|||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
} from "@/features/notifications/hooks";
|
} from "@/features/notifications/hooks";
|
||||||
import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard";
|
import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard";
|
||||||
import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
|
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
|
||||||
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
|
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
|
||||||
@@ -39,7 +37,6 @@ export type SettingsSection =
|
|||||||
| "agents"
|
| "agents"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "tokens"
|
|
||||||
| "relay-members"
|
| "relay-members"
|
||||||
| "mobile"
|
| "mobile"
|
||||||
| "updates"
|
| "updates"
|
||||||
@@ -92,11 +89,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
|||||||
label: "Shortcuts",
|
label: "Shortcuts",
|
||||||
icon: Keyboard,
|
icon: Keyboard,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "tokens",
|
|
||||||
label: "Tokens",
|
|
||||||
icon: KeyRound,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "relay-members",
|
value: "relay-members",
|
||||||
label: "Relay Access",
|
label: "Relay Access",
|
||||||
@@ -270,8 +262,6 @@ export function renderSettingsSection(
|
|||||||
return <ThemeSettingsCard />;
|
return <ThemeSettingsCard />;
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
return <KeyboardShortcutsCard />;
|
return <KeyboardShortcutsCard />;
|
||||||
case "tokens":
|
|
||||||
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
|
|
||||||
case "relay-members":
|
case "relay-members":
|
||||||
return <RelayMembersSettingsCard currentPubkey={props.currentPubkey} />;
|
return <RelayMembersSettingsCard currentPubkey={props.currentPubkey} />;
|
||||||
case "mobile":
|
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,
|
GetHomeFeedInput,
|
||||||
HomeFeedResponse,
|
HomeFeedResponse,
|
||||||
Identity,
|
Identity,
|
||||||
MintTokenInput,
|
|
||||||
MintTokenResponse,
|
|
||||||
MintManagedAgentTokenInput,
|
|
||||||
ManagedAgent,
|
ManagedAgent,
|
||||||
ManagedAgentBackend,
|
ManagedAgentBackend,
|
||||||
RelayAgent,
|
RelayAgent,
|
||||||
@@ -34,8 +31,6 @@ import type {
|
|||||||
SetPresenceResult,
|
SetPresenceResult,
|
||||||
SetChannelPurposeInput,
|
SetChannelPurposeInput,
|
||||||
SetChannelTopicInput,
|
SetChannelTopicInput,
|
||||||
Token,
|
|
||||||
TokenScope,
|
|
||||||
UpdateProfileInput,
|
UpdateProfileInput,
|
||||||
UpdateChannelInput,
|
UpdateChannelInput,
|
||||||
UserProfileSummary,
|
UserProfileSummary,
|
||||||
@@ -193,31 +188,6 @@ type RawSendChannelMessageResult = {
|
|||||||
created_at: number;
|
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 = {
|
type RawRelayAgent = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -244,7 +214,6 @@ export type RawManagedAgent = {
|
|||||||
system_prompt: string | null;
|
system_prompt: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
mcp_toolsets: string | null;
|
mcp_toolsets: string | null;
|
||||||
has_api_token: boolean;
|
|
||||||
status: ManagedAgent["status"];
|
status: ManagedAgent["status"];
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -262,16 +231,10 @@ export type RawManagedAgent = {
|
|||||||
type RawCreateManagedAgentResponse = {
|
type RawCreateManagedAgentResponse = {
|
||||||
agent: RawManagedAgent;
|
agent: RawManagedAgent;
|
||||||
private_key_nsec: string;
|
private_key_nsec: string;
|
||||||
api_token: string | null;
|
|
||||||
profile_sync_error: string | null;
|
profile_sync_error: string | null;
|
||||||
spawn_error: string | null;
|
spawn_error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RawMintManagedAgentTokenResponse = {
|
|
||||||
agent: RawManagedAgent;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawManagedAgentLog = {
|
type RawManagedAgentLog = {
|
||||||
content: string;
|
content: string;
|
||||||
log_path: string;
|
log_path: string;
|
||||||
@@ -828,19 +791,6 @@ export async function createAuthEvent(input: {
|
|||||||
return JSON.parse(eventJson) as RelayEvent;
|
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 {
|
function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent {
|
||||||
return {
|
return {
|
||||||
pubkey: agent.pubkey,
|
pubkey: agent.pubkey,
|
||||||
@@ -870,7 +820,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
|||||||
systemPrompt: agent.system_prompt,
|
systemPrompt: agent.system_prompt,
|
||||||
model: agent.model,
|
model: agent.model,
|
||||||
mcpToolsets: agent.mcp_toolsets,
|
mcpToolsets: agent.mcp_toolsets,
|
||||||
hasApiToken: agent.has_api_token,
|
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
pid: agent.pid,
|
pid: agent.pid,
|
||||||
createdAt: agent.created_at,
|
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 ────────────────────────────────────────────────────────────
|
// ── Relay Members ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function fromRawRelayMember(raw: RawRelayMember): RelayMember {
|
function fromRawRelayMember(raw: RawRelayMember): RelayMember {
|
||||||
@@ -1027,9 +940,6 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
|
|||||||
systemPrompt: input.systemPrompt,
|
systemPrompt: input.systemPrompt,
|
||||||
avatarUrl: input.avatarUrl,
|
avatarUrl: input.avatarUrl,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
mintToken: input.mintToken,
|
|
||||||
tokenScopes: input.tokenScopes,
|
|
||||||
tokenName: input.tokenName,
|
|
||||||
spawnAfterCreate: input.spawnAfterCreate,
|
spawnAfterCreate: input.spawnAfterCreate,
|
||||||
startOnAppLaunch: input.startOnAppLaunch,
|
startOnAppLaunch: input.startOnAppLaunch,
|
||||||
backend: input.backend,
|
backend: input.backend,
|
||||||
@@ -1040,7 +950,6 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
|
|||||||
return {
|
return {
|
||||||
agent: fromRawManagedAgent(response.agent),
|
agent: fromRawManagedAgent(response.agent),
|
||||||
privateKeyNsec: response.private_key_nsec,
|
privateKeyNsec: response.private_key_nsec,
|
||||||
apiToken: response.api_token,
|
|
||||||
profileSyncError: response.profile_sync_error,
|
profileSyncError: response.profile_sync_error,
|
||||||
spawnError: response.spawn_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) {
|
export async function getManagedAgentLog(pubkey: string, lineCount?: number) {
|
||||||
const response = await invokeTauri<RawManagedAgentLog>(
|
const response = await invokeTauri<RawManagedAgentLog>(
|
||||||
"get_managed_agent_log",
|
"get_managed_agent_log",
|
||||||
|
|||||||
@@ -248,44 +248,6 @@ export type RelayMember = {
|
|||||||
createdAt: string;
|
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 = {
|
export type RelayAgent = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -316,7 +278,6 @@ export type ManagedAgent = {
|
|||||||
systemPrompt: string | null;
|
systemPrompt: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
mcpToolsets: string | null;
|
mcpToolsets: string | null;
|
||||||
hasApiToken: boolean;
|
|
||||||
status: "running" | "stopped" | "deployed" | "not_deployed";
|
status: "running" | "stopped" | "deployed" | "not_deployed";
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -360,9 +321,6 @@ export type CreateManagedAgentInput = {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
mcpToolsets?: string;
|
mcpToolsets?: string;
|
||||||
mintToken?: boolean;
|
|
||||||
tokenScopes?: TokenScope[];
|
|
||||||
tokenName?: string;
|
|
||||||
spawnAfterCreate?: boolean;
|
spawnAfterCreate?: boolean;
|
||||||
startOnAppLaunch?: boolean;
|
startOnAppLaunch?: boolean;
|
||||||
backend?: ManagedAgentBackend;
|
backend?: ManagedAgentBackend;
|
||||||
@@ -371,22 +329,10 @@ export type CreateManagedAgentInput = {
|
|||||||
export type CreateManagedAgentResponse = {
|
export type CreateManagedAgentResponse = {
|
||||||
agent: ManagedAgent;
|
agent: ManagedAgent;
|
||||||
privateKeyNsec: string;
|
privateKeyNsec: string;
|
||||||
apiToken: string | null;
|
|
||||||
profileSyncError: string | null;
|
profileSyncError: string | null;
|
||||||
spawnError: string | null;
|
spawnError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MintManagedAgentTokenInput = {
|
|
||||||
pubkey: string;
|
|
||||||
tokenName?: string;
|
|
||||||
scopes?: TokenScope[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MintManagedAgentTokenResponse = {
|
|
||||||
agent: ManagedAgent;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ManagedAgentLog = {
|
export type ManagedAgentLog = {
|
||||||
content: string;
|
content: string;
|
||||||
logPath: string;
|
logPath: string;
|
||||||
|
|||||||
@@ -33,29 +33,15 @@ type E2eConfig = {
|
|||||||
acp?: MockCommandAvailability;
|
acp?: MockCommandAvailability;
|
||||||
mcp?: MockCommandAvailability;
|
mcp?: MockCommandAvailability;
|
||||||
};
|
};
|
||||||
mintTokenError?: string;
|
|
||||||
profileReadDelayMs?: number;
|
profileReadDelayMs?: number;
|
||||||
profileReadError?: string;
|
profileReadError?: string;
|
||||||
profileUpdateError?: string;
|
profileUpdateError?: string;
|
||||||
seededTokens?: RawMockTokenSeed[];
|
|
||||||
};
|
};
|
||||||
relayHttpUrl?: string;
|
relayHttpUrl?: string;
|
||||||
relayWsUrl?: string;
|
relayWsUrl?: string;
|
||||||
identity?: TestIdentity;
|
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 = {
|
type RawRelayMember = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
role: "owner" | "admin" | "member";
|
role: "owner" | "admin" | "member";
|
||||||
@@ -274,29 +260,6 @@ type RawSendChannelMessageResponse = {
|
|||||||
created_at: number;
|
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 = {
|
type RawRelayAgent = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -322,7 +285,6 @@ type RawManagedAgent = {
|
|||||||
parallelism: number;
|
parallelism: number;
|
||||||
system_prompt: string | null;
|
system_prompt: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
has_api_token: boolean;
|
|
||||||
status: "running" | "stopped" | "deployed" | "not_deployed";
|
status: "running" | "stopped" | "deployed" | "not_deployed";
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -342,16 +304,10 @@ type RawManagedAgent = {
|
|||||||
type RawCreateManagedAgentResponse = {
|
type RawCreateManagedAgentResponse = {
|
||||||
agent: RawManagedAgent;
|
agent: RawManagedAgent;
|
||||||
private_key_nsec: string;
|
private_key_nsec: string;
|
||||||
api_token: string | null;
|
|
||||||
profile_sync_error: string | null;
|
profile_sync_error: string | null;
|
||||||
spawn_error: string | null;
|
spawn_error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RawMintManagedAgentTokenResponse = {
|
|
||||||
agent: RawManagedAgent;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawManagedAgentLog = {
|
type RawManagedAgentLog = {
|
||||||
content: string;
|
content: string;
|
||||||
log_path: string;
|
log_path: string;
|
||||||
@@ -397,13 +353,8 @@ type RawTeam = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MockToken = RawToken & {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MockManagedAgent = RawManagedAgent & {
|
type MockManagedAgent = RawManagedAgent & {
|
||||||
private_key_nsec: string;
|
private_key_nsec: string;
|
||||||
api_token: string | null;
|
|
||||||
log_lines: string[];
|
log_lines: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -682,21 +633,6 @@ function cloneProfile(profile: RawProfile): RawProfile {
|
|||||||
return { ...profile };
|
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 {
|
function cloneRelayAgent(agent: RawRelayAgent): RawRelayAgent {
|
||||||
return {
|
return {
|
||||||
...agent,
|
...agent,
|
||||||
@@ -722,7 +658,6 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
|
|||||||
parallelism: agent.parallelism,
|
parallelism: agent.parallelism,
|
||||||
system_prompt: agent.system_prompt,
|
system_prompt: agent.system_prompt,
|
||||||
model: agent.model,
|
model: agent.model,
|
||||||
has_api_token: agent.has_api_token,
|
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
pid: agent.pid,
|
pid: agent.pid,
|
||||||
created_at: agent.created_at,
|
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) {
|
function resetMockRelayMembers(config: E2eConfig | undefined) {
|
||||||
const pubkey = getMockMemberPubkey(config);
|
const pubkey = getMockMemberPubkey(config);
|
||||||
mockRelayMembers = [
|
mockRelayMembers = [
|
||||||
@@ -1150,8 +1071,6 @@ const mockMessages = new Map<string, RelayEvent[]>();
|
|||||||
let mockRelayMembers: RawRelayMember[] = [];
|
let mockRelayMembers: RawRelayMember[] = [];
|
||||||
const mockSockets = new Map<number, MockSocket>();
|
const mockSockets = new Map<number, MockSocket>();
|
||||||
const realSockets = new Map<number, WebSocket>();
|
const realSockets = new Map<number, WebSocket>();
|
||||||
let mockTokens: MockToken[] = [];
|
|
||||||
let mockMintTokenError: string | null = null;
|
|
||||||
let mockManagedAgents: MockManagedAgent[] = [];
|
let mockManagedAgents: MockManagedAgent[] = [];
|
||||||
let mockPersonas: RawPersona[] = [];
|
let mockPersonas: RawPersona[] = [];
|
||||||
let mockTeams: RawTeam[] = [];
|
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[]> {
|
async function handleListRelayAgents(): Promise<RawRelayAgent[]> {
|
||||||
syncMockRelayAgentsFromManagedAgents();
|
syncMockRelayAgentsFromManagedAgents();
|
||||||
return mockRelayAgents.map(cloneRelayAgent);
|
return mockRelayAgents.map(cloneRelayAgent);
|
||||||
@@ -3961,9 +3779,6 @@ async function handleCreateManagedAgent(args: {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
mintToken?: boolean;
|
|
||||||
tokenScopes?: string[];
|
|
||||||
tokenName?: string;
|
|
||||||
spawnAfterCreate?: boolean;
|
spawnAfterCreate?: boolean;
|
||||||
startOnAppLaunch?: boolean;
|
startOnAppLaunch?: boolean;
|
||||||
backend?:
|
backend?:
|
||||||
@@ -3981,10 +3796,6 @@ async function handleCreateManagedAgent(args: {
|
|||||||
.replace(/-/g, "")
|
.replace(/-/g, "")
|
||||||
.padEnd(64, "0")
|
.padEnd(64, "0")
|
||||||
.slice(0, 64);
|
.slice(0, 64);
|
||||||
const token =
|
|
||||||
args.input.mintToken === false
|
|
||||||
? null
|
|
||||||
: `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
||||||
const managedAgent: MockManagedAgent = {
|
const managedAgent: MockManagedAgent = {
|
||||||
pubkey,
|
pubkey,
|
||||||
name,
|
name,
|
||||||
@@ -4003,7 +3814,6 @@ async function handleCreateManagedAgent(args: {
|
|||||||
parallelism: args.input.parallelism ?? 1,
|
parallelism: args.input.parallelism ?? 1,
|
||||||
system_prompt: args.input.systemPrompt?.trim() || null,
|
system_prompt: args.input.systemPrompt?.trim() || null,
|
||||||
model: args.input.model?.trim() || null,
|
model: args.input.model?.trim() || null,
|
||||||
has_api_token: token !== null,
|
|
||||||
status: args.input.spawnAfterCreate ? "running" : "stopped",
|
status: args.input.spawnAfterCreate ? "running" : "stopped",
|
||||||
pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null,
|
pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -4017,7 +3827,6 @@ async function handleCreateManagedAgent(args: {
|
|||||||
backend: args.input.backend ?? { type: "local" as const },
|
backend: args.input.backend ?? { type: "local" as const },
|
||||||
backend_agent_id: null,
|
backend_agent_id: null,
|
||||||
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
|
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
|
||||||
api_token: token,
|
|
||||||
log_lines: [
|
log_lines: [
|
||||||
`sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey} parallelism=${args.input.parallelism ?? 1}`,
|
`sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey} parallelism=${args.input.parallelism ?? 1}`,
|
||||||
args.input.systemPrompt?.trim()
|
args.input.systemPrompt?.trim()
|
||||||
@@ -4035,7 +3844,6 @@ async function handleCreateManagedAgent(args: {
|
|||||||
return {
|
return {
|
||||||
agent: cloneManagedAgent(managedAgent),
|
agent: cloneManagedAgent(managedAgent),
|
||||||
private_key_nsec: managedAgent.private_key_nsec,
|
private_key_nsec: managedAgent.private_key_nsec,
|
||||||
api_token: managedAgent.api_token,
|
|
||||||
profile_sync_error: null,
|
profile_sync_error: null,
|
||||||
spawn_error: null,
|
spawn_error: null,
|
||||||
};
|
};
|
||||||
@@ -4114,28 +3922,6 @@ async function handleSetManagedAgentStartOnAppLaunch(args: {
|
|||||||
return cloneManagedAgent(agent);
|
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: {
|
async function handleGetManagedAgentLog(args: {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
lineCount?: number;
|
lineCount?: number;
|
||||||
@@ -4679,7 +4465,6 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetMockTokens(config);
|
|
||||||
resetMockRelayMembers(config);
|
resetMockRelayMembers(config);
|
||||||
resetMockManagedAgents();
|
resetMockManagedAgents();
|
||||||
resetMockPersonas();
|
resetMockPersonas();
|
||||||
@@ -4811,20 +4596,6 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
|
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
|
||||||
activeConfig,
|
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":
|
case "list_relay_agents":
|
||||||
return handleListRelayAgents();
|
return handleListRelayAgents();
|
||||||
case "list_personas":
|
case "list_personas":
|
||||||
@@ -4893,10 +4664,6 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
return handleDeleteManagedAgent(
|
return handleDeleteManagedAgent(
|
||||||
payload as Parameters<typeof handleDeleteManagedAgent>[0],
|
payload as Parameters<typeof handleDeleteManagedAgent>[0],
|
||||||
);
|
);
|
||||||
case "mint_managed_agent_token":
|
|
||||||
return handleMintManagedAgentToken(
|
|
||||||
payload as Parameters<typeof handleMintManagedAgentToken>[0],
|
|
||||||
);
|
|
||||||
case "get_managed_agent_log":
|
case "get_managed_agent_log":
|
||||||
return handleGetManagedAgentLog(
|
return handleGetManagedAgentLog(
|
||||||
payload as Parameters<typeof handleGetManagedAgentLog>[0],
|
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;
|
acp?: MockCommandAvailability;
|
||||||
mcp?: MockCommandAvailability;
|
mcp?: MockCommandAvailability;
|
||||||
};
|
};
|
||||||
mintTokenError?: string;
|
|
||||||
profileReadDelayMs?: number;
|
profileReadDelayMs?: number;
|
||||||
profileReadError?: string;
|
profileReadError?: string;
|
||||||
profileUpdateError?: 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 = {
|
type BridgeOptions = {
|
||||||
|
|||||||
6
justfile
6
justfile
@@ -280,7 +280,7 @@ check-compile:
|
|||||||
# ─── Agent Harness ────────────────────────────────────────────────────────────
|
# ─── Agent Harness ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Run a goose agent connected to a Sprout relay (foreground)
|
# 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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cargo build --release -p sprout-acp -p sprout-mcp
|
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}}"
|
SPROUT_ACP_AGENTS="{{agents}}"
|
||||||
GOOSE_MODE=auto
|
GOOSE_MODE=auto
|
||||||
)
|
)
|
||||||
[[ -n "{{token}}" ]] && env_args+=(SPROUT_ACP_API_TOKEN="{{token}}")
|
|
||||||
[[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}")
|
[[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}")
|
||||||
if [[ "{{heartbeat}}" != "0" ]]; then
|
if [[ "{{heartbeat}}" != "0" ]]; then
|
||||||
env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}})
|
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
|
exec env "${env_args[@]}" ./target/release/sprout-acp
|
||||||
|
|
||||||
# Run a goose agent in the background (screen session named 'goose-agent-N')
|
# 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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cargo build --release -p sprout-acp -p sprout-mcp
|
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}}"
|
SPROUT_ACP_AGENTS="{{agents}}"
|
||||||
GOOSE_MODE=auto
|
GOOSE_MODE=auto
|
||||||
)
|
)
|
||||||
[[ -n "{{token}}" ]] && env_args+=(SPROUT_ACP_API_TOKEN="{{token}}")
|
|
||||||
[[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}")
|
[[ -n "{{prompt}}" ]] && env_args+=(SPROUT_ACP_SYSTEM_PROMPT="{{prompt}}")
|
||||||
if [[ "{{heartbeat}}" != "0" ]]; then
|
if [[ "{{heartbeat}}" != "0" ]]; then
|
||||||
env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}})
|
env_args+=(SPROUT_ACP_HEARTBEAT_INTERVAL={{heartbeat}})
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ void main() {
|
|||||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||||
final uploadService = MediaUploadService(
|
final uploadService = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example',
|
baseUrl: 'https://relay.example',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
httpClient: http_testing.MockClient((request) async {
|
httpClient: http_testing.MockClient((request) async {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
@@ -226,7 +226,7 @@ void main() {
|
|||||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||||
final uploadService = MediaUploadService(
|
final uploadService = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example',
|
baseUrl: 'https://relay.example',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
httpClient: http_testing.MockClient((request) async {
|
httpClient: http_testing.MockClient((request) async {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
@@ -294,7 +294,7 @@ void main() {
|
|||||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||||
final uploadService = MediaUploadService(
|
final uploadService = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example',
|
baseUrl: 'https://relay.example',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
httpClient: http_testing.MockClient((request) async {
|
httpClient: http_testing.MockClient((request) async {
|
||||||
return http.Response('bad upload', 401);
|
return http.Response('bad upload', 401);
|
||||||
@@ -329,7 +329,7 @@ void main() {
|
|||||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||||
final uploadService = MediaUploadService(
|
final uploadService = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example',
|
baseUrl: 'https://relay.example',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
pickGalleryVideo: () async => null,
|
pickGalleryVideo: () async => null,
|
||||||
pickGalleryImage: () async =>
|
pickGalleryImage: () async =>
|
||||||
@@ -366,7 +366,7 @@ void main() {
|
|||||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||||
final uploadService = MediaUploadService(
|
final uploadService = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example',
|
baseUrl: 'https://relay.example',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
pickGalleryVideo: () async => null,
|
pickGalleryVideo: () async => null,
|
||||||
pickGalleryImage: () async =>
|
pickGalleryImage: () async =>
|
||||||
@@ -422,7 +422,7 @@ void main() {
|
|||||||
try {
|
try {
|
||||||
final uploadService = MediaUploadService(
|
final uploadService = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example',
|
baseUrl: 'https://relay.example',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
httpClient: http_testing.MockClient((request) async {
|
httpClient: http_testing.MockClient((request) async {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ void main() {
|
|||||||
|
|
||||||
final service = MediaUploadService(
|
final service = MediaUploadService(
|
||||||
baseUrl: 'https://relay.example:8443',
|
baseUrl: 'https://relay.example:8443',
|
||||||
apiToken: 'sprout_test_token',
|
apiToken: null,
|
||||||
nsec: nsec,
|
nsec: nsec,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
pickGalleryVideo: () async => null,
|
pickGalleryVideo: () async => null,
|
||||||
@@ -277,7 +277,6 @@ void main() {
|
|||||||
'https://relay.example:8443/media/upload',
|
'https://relay.example:8443/media/upload',
|
||||||
);
|
);
|
||||||
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
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!.headers['X-SHA-256'], isNotEmpty);
|
||||||
expect(capturedRequest!.bodyBytes, _pngBytes);
|
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