diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 22513aa91..45dbaa7a8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ "**/stream.spec.ts", "**/integration.spec.ts", "**/profile.spec.ts", + "**/tokens.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 099f99271..e2c54e831 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4017,10 +4017,12 @@ dependencies = [ name = "sprout" version = "0.1.0" dependencies = [ + "base64 0.22.1", "nostr 0.37.0", "reqwest 0.12.28", "serde", "serde_json", + "sha2", "sprout-core", "tauri", "tauri-build", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index e428ccb7f..fe9bd458a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -27,3 +27,5 @@ serde_json = "1" nostr = "0.37" reqwest = { version = "0.12", features = ["json"] } sprout-core = { path = "../../crates/sprout-core" } +base64 = "0.22" +sha2 = "0.10" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 02b8bcc8d..aa85035a1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,14 +1,18 @@ use std::{collections::HashMap, sync::Mutex}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32}; use reqwest::Method; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; use sprout_core::PresenceStatus; use tauri_plugin_window_state::StateFlags; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, + pub configured_api_token: Option, + pub session_token: Mutex>, } #[derive(Serialize)] @@ -50,6 +54,7 @@ pub struct ChannelInfo { pub name: String, pub channel_type: String, pub visibility: String, + #[serde(deserialize_with = "deserialize_null_string_as_empty")] pub description: String, pub topic: Option, pub purpose: Option, @@ -66,6 +71,7 @@ pub struct ChannelDetailInfo { pub name: String, pub channel_type: String, pub visibility: String, + #[serde(deserialize_with = "deserialize_null_string_as_empty")] pub description: String, pub topic: Option, pub topic_set_by: Option, @@ -170,6 +176,49 @@ struct SearchQueryParams<'a> { limit: Option, } +#[derive(Serialize)] +struct MintTokenBody<'a> { + name: &'a str, + scopes: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + channel_ids: Option<&'a [String]>, + #[serde(skip_serializing_if = "Option::is_none")] + expires_in_days: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct MintTokenResponse { + pub id: String, + pub token: String, + pub name: String, + pub scopes: Vec, + pub channel_ids: Vec, + pub created_at: String, + pub expires_at: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct TokenInfo { + pub id: String, + pub name: String, + pub scopes: Vec, + pub channel_ids: Vec, + pub created_at: String, + pub expires_at: Option, + pub last_used_at: Option, + pub revoked_at: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct ListTokensResponse { + pub tokens: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct RevokeAllTokensResponse { + pub revoked_count: u64, +} + #[derive(Serialize, Deserialize)] pub struct FeedItemInfo { pub id: String, @@ -222,6 +271,13 @@ pub struct SearchResponse { pub found: u64, } +fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + fn relay_ws_url() -> String { std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) } @@ -242,10 +298,15 @@ fn build_authed_request( path: &str, state: &AppState, ) -> Result { - let pubkey_hex = auth_pubkey_header(state)?; let url = format!("{}{}", relay_api_base_url(), path); + let request = client.request(method, url); - Ok(client.request(method, url).header("X-Pubkey", pubkey_hex)) + if let Some(token) = state.configured_api_token.as_deref() { + return Ok(request.header("Authorization", format!("Bearer {token}"))); + } + + let pubkey_hex = auth_pubkey_header(state)?; + Ok(request.header("X-Pubkey", pubkey_hex)) } fn auth_pubkey_header(state: &AppState) -> Result { @@ -253,14 +314,68 @@ fn auth_pubkey_header(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } +fn session_api_token(state: &AppState) -> Result, String> { + let token = state.session_token.lock().map_err(|e| e.to_string())?; + Ok(token.clone()) +} + +fn build_token_management_request( + client: &reqwest::Client, + method: Method, + path: &str, + state: &AppState, +) -> Result { + let url = format!("{}{}", relay_api_base_url(), path); + let request = client.request(method, url); + + if let Some(token) = state.configured_api_token.as_deref() { + return Ok(request.header("Authorization", format!("Bearer {token}"))); + } + + if let Some(token) = session_api_token(state)? { + return Ok(request.header("Authorization", format!("Bearer {token}"))); + } + + let pubkey_hex = auth_pubkey_header(state)?; + Ok(request.header("X-Pubkey", pubkey_hex)) +} + +fn build_nip98_auth_header( + method: &Method, + url: &str, + body: &[u8], + state: &AppState, +) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let payload_hash = format!("{:x}", Sha256::digest(body)); + let tags = vec![ + Tag::parse(vec!["u", url]).map_err(|e| format!("url tag failed: {e}"))?, + Tag::parse(vec!["method", method.as_str()]) + .map_err(|e| format!("method tag failed: {e}"))?, + Tag::parse(vec!["payload", &payload_hash]) + .map_err(|e| format!("payload tag failed: {e}"))?, + ]; + + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(&keys) + .map_err(|e| format!("sign failed: {e}"))?; + + Ok(format!("Nostr {}", BASE64.encode(event.as_json().as_bytes()))) +} + async fn relay_error_message(response: reqwest::Response) -> String { let status = response.status(); let body = response.text().await.unwrap_or_default(); if let Ok(value) = serde_json::from_str::(&body) { - if let Some(message) = value.get("error").and_then(serde_json::Value::as_str) { + if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) { return format!("relay returned {status}: {message}"); } + + if let Some(error) = value.get("error").and_then(serde_json::Value::as_str) { + return format!("relay returned {status}: {error}"); + } } format!("relay returned {status}: {body}") @@ -454,12 +569,19 @@ fn create_auth_event( ) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; - let tags = vec![ + let mut tags = vec![ Tag::parse(vec!["relay", &relay_url]).map_err(|e| format!("relay tag failed: {e}"))?, Tag::parse(vec!["challenge", &challenge]) .map_err(|e| format!("challenge tag failed: {e}"))?, ]; + if let Some(token) = state.configured_api_token.as_deref() { + tags.push( + Tag::parse(vec!["auth_token", token]) + .map_err(|e| format!("auth token tag failed: {e}"))?, + ); + } + let event = EventBuilder::new(Kind::Custom(22242), "") .tags(tags) .sign_with_keys(&keys) @@ -684,6 +806,78 @@ async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Resul response.text().await.map_err(|e| format!("parse failed: {e}")) } +#[tauri::command] +async fn list_tokens(state: tauri::State<'_, AppState>) -> Result { + let request = + build_token_management_request(&state.http_client, Method::GET, "/api/tokens", &state)?; + send_json_request(request).await +} + +#[tauri::command] +async fn mint_token( + name: String, + scopes: Vec, + channel_ids: Option>, + expires_in_days: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let body = MintTokenBody { + name: &name, + scopes: &scopes, + channel_ids: channel_ids.as_deref(), + expires_in_days, + }; + let request = if state.configured_api_token.is_some() { + build_authed_request(&state.http_client, Method::POST, "/api/tokens", &state)?.json(&body) + } else { + let url = format!("{}{}", relay_api_base_url(), "/api/tokens"); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("serialize failed: {e}"))?; + let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, &state)?; + let forwarded_proto = if url.starts_with("http://") { + "http" + } else { + "https" + }; + + state + .http_client + .request(Method::POST, url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .header("X-Forwarded-Proto", forwarded_proto) + .body(body_bytes) + }; + let response: MintTokenResponse = send_json_request(request).await?; + + if state.configured_api_token.is_none() { + let mut token = state.session_token.lock().map_err(|e| e.to_string())?; + *token = Some(response.token.clone()); + } + + Ok(response) +} + +#[tauri::command] +async fn revoke_token( + token_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/tokens/{token_id}"); + let request = + build_token_management_request(&state.http_client, Method::DELETE, &path, &state)?; + send_empty_request(request).await +} + +#[tauri::command] +async fn revoke_all_tokens( + state: tauri::State<'_, AppState>, +) -> Result { + let request = + build_token_management_request(&state.http_client, Method::DELETE, "/api/tokens", &state)?; + send_json_request(request).await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // GUI app: warn on bad key but don't crash — fall back to ephemeral. @@ -708,9 +902,20 @@ pub fn run() { keys.public_key().to_hex() ); + let api_token = match std::env::var("SPROUT_API_TOKEN") { + Ok(token) if !token.trim().is_empty() => Some(token), + Ok(_) | Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + eprintln!("sprout-desktop: SPROUT_API_TOKEN contains invalid UTF-8"); + None + } + }; + let app_state = AppState { keys: Mutex::new(keys), http_client: reqwest::Client::new(), + configured_api_token: api_token, + session_token: Mutex::new(None), }; tauri::Builder::default() @@ -752,6 +957,10 @@ pub fn run() { get_feed, search_messages, get_event, + list_tokens, + mint_token, + revoke_token, + revoke_all_tokens, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 6cf8c23e9..ae594d716 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -15,6 +15,7 @@ import { useProfileQuery, useUpdateProfileMutation, } from "@/features/profile/hooks"; +import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceBadge, @@ -542,6 +543,7 @@ export function SettingsView({ currentPubkey={currentPubkey} fallbackDisplayName={fallbackDisplayName} /> + ); diff --git a/desktop/src/features/tokens/hooks.ts b/desktop/src/features/tokens/hooks.ts new file mode 100644 index 000000000..a0794cf5c --- /dev/null +++ b/desktop/src/features/tokens/hooks.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + listTokens, + mintToken, + revokeAllTokens, + revokeToken, +} from "@/shared/api/tauri"; +import type { MintTokenInput, Token } from "@/shared/api/types"; + +export const tokensQueryKey = ["tokens"] as const; + +export function useTokensQuery() { + return useQuery({ + queryKey: tokensQueryKey, + queryFn: listTokens, + staleTime: 30_000, + }); +} + +export function useMintTokenMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: MintTokenInput) => mintToken(input), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: tokensQueryKey }); + }, + }); +} + +export function useRevokeTokenMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (tokenId: string) => revokeToken(tokenId), + onMutate: async (tokenId) => { + await queryClient.cancelQueries({ queryKey: tokensQueryKey }); + const previous = queryClient.getQueryData(tokensQueryKey); + + queryClient.setQueryData(tokensQueryKey, (old) => + old?.map((t) => + t.id === tokenId ? { ...t, revokedAt: new Date().toISOString() } : t, + ), + ); + + return { previous }; + }, + onError: (_err, _tokenId, context) => { + if (context?.previous) { + queryClient.setQueryData(tokensQueryKey, context.previous); + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: tokensQueryKey }); + }, + }); +} + +export function useRevokeAllTokensMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => revokeAllTokens(), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: tokensQueryKey }); + const previous = queryClient.getQueryData(tokensQueryKey); + const now = new Date().toISOString(); + + queryClient.setQueryData(tokensQueryKey, (old) => + old?.map((t) => (t.revokedAt ? t : { ...t, revokedAt: now })), + ); + + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(tokensQueryKey, context.previous); + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: tokensQueryKey }); + }, + }); +} diff --git a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx new file mode 100644 index 000000000..538d63de2 --- /dev/null +++ b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx @@ -0,0 +1,785 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Check, + Copy, + KeyRound, + Plus, + Trash2, + TriangleAlert, +} from "lucide-react"; +import * as React from "react"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useMintTokenMutation, + useRevokeAllTokensMutation, + useRevokeTokenMutation, + useTokensQuery, +} from "@/features/tokens/hooks"; +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 ALL_SCOPES: { value: TokenScope; label: string }[] = [ + { value: "messages:read", label: "Messages: Read" }, + { value: "messages:write", label: "Messages: Write" }, + { value: "channels:read", label: "Channels: Read" }, + { value: "channels:write", label: "Channels: Write" }, + { value: "users:read", label: "Users: Read" }, + { value: "files:read", label: "Files: Read" }, + { value: "files:write", label: "Files: Write" }, +]; + +const EXPIRY_OPTIONS = [ + { value: 7, label: "7 days" }, + { value: 30, label: "30 days" }, + { value: 90, label: "90 days" }, + { value: 365, label: "1 year" }, + { value: 0, label: "No expiry" }, +] as const; + +const MAX_ACTIVE_TOKENS = 10; + +function tokenStatus(token: Token): "active" | "revoked" | "expired" { + if (token.revokedAt) return "revoked"; + if (token.expiresAt && new Date(token.expiresAt) < new Date()) + return "expired"; + return "active"; +} + +function StatusBadge({ status }: { status: "active" | "revoked" | "expired" }) { + return ( + + {status} + + ); +} + +function ScopeBadge({ scope }: { scope: string }) { + return ( + + {scope} + + ); +} + +function formatRelativeDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function channelLabel(channelId: string, channelsById: Map) { + return ( + channelsById.get(channelId)?.name ?? `Channel ${channelId.slice(0, 8)}` + ); +} + +function TokenRow({ + channelsById, + token, + onRevoke, + isRevoking, +}: { + channelsById: Map; + token: Token; + onRevoke: (id: string) => void; + isRevoking: boolean; +}) { + const status = tokenStatus(token); + const visibleChannelIds = token.channelIds.slice(0, 4); + const hiddenChannelCount = token.channelIds.length - visibleChannelIds.length; + + return ( +
+
+
+ {token.name} + +
+
+ {token.scopes.map((scope) => ( + + ))} +
+

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

+

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

+ {visibleChannelIds.length > 0 ? ( +
+ {visibleChannelIds.map((channelId) => ( + + ))} + {hiddenChannelCount > 0 ? ( + + ) : null} +
+ ) : null} +
+ {status === "active" ? ( + + ) : null} +
+ ); +} + +function CreateTokenDialog({ + activeTokenCount, + currentPubkey, + channels, + hiddenChannelsCount, + channelsError, + isLoadingChannels, + open, + onOpenChange, +}: { + activeTokenCount: number; + currentPubkey?: string; + channels: Channel[]; + hiddenChannelsCount: number; + channelsError: Error | null; + isLoadingChannels: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const mintMutation = useMintTokenMutation(); + const [name, setName] = React.useState(""); + const [selectedScopes, setSelectedScopes] = React.useState>( + new Set(), + ); + const [channelAccessMode, setChannelAccessMode] = React.useState< + "all" | "selected" + >("all"); + const [selectedChannelIds, setSelectedChannelIds] = React.useState< + Set + >(new Set()); + const [expiryDays, setExpiryDays] = React.useState(30); + const [mintedToken, setMintedToken] = React.useState(null); + const [copied, setCopied] = React.useState(false); + + 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); + setCopied(false); + 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); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (mintedToken) { + return ( + + +
+ + Token created + + Copy this token now. You will not be able to see it again. + + + +
+
+
+ + {mintedToken} + + +
+
+ + + This is the only time this token will be shown. Store it + securely. + +
+
+
+ +
+ +
+
+
+
+ ); + } + + return ( + + +
+ + Create API token + + Tokens allow agents and scripts to authenticate with the relay on + your behalf. + + + +
+
+
+ + setName(e.target.value)} + placeholder="e.g. my-agent-bot" + spellCheck={false} + value={name} + /> +
+ +
+

Scopes

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

Channel access

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

+ Loading channels... +

+ ) : channelsError ? ( +

+ {channelsError.message} +

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

+ No accessible channels available for scoping yet. +

+ ) + ) : null} + +

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

+ {currentPubkey ? ( +

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

+ ) : ( +

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

+ )} +
+ +
+

Expiry

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

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

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

+ {mintMutation.error.message} +

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

API Tokens

+
+

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

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

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

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

+ {tokensQuery.error.message} +

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

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

+ ) : null} + + + { + revokeAllMutation.mutate(undefined, { + onSuccess: () => setRevokeAllOpen(false), + }); + }} + onOpenChange={setRevokeAllOpen} + open={revokeAllOpen} + /> +
+ ); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 877b88165..21579b2f4 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import type { AddChannelMembersInput, @@ -11,6 +11,8 @@ import type { GetHomeFeedInput, HomeFeedResponse, Identity, + MintTokenInput, + MintTokenResponse, PresenceLookup, PresenceStatus, Profile, @@ -20,6 +22,8 @@ import type { SetPresenceResult, SetChannelPurposeInput, SetChannelTopicInput, + Token, + TokenScope, UpdateProfileInput, UpdateChannelInput, UserProfileSummary, @@ -146,6 +150,67 @@ type RawSearchResponse = { found: 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; +}; + +function toTauriError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return new Error(error.message); + } + + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error("Unknown Tauri error"); + } +} + +async function invokeTauri( + command: string, + args?: Record, +): Promise { + try { + return await tauriInvoke(command, args); + } catch (error) { + throw toTauriError(error); + } +} + function fromRawChannel(channel: RawChannel): Channel { return { id: channel.id, @@ -235,7 +300,7 @@ function fromRawUserProfileSummary( } export async function getIdentity(): Promise { - const identity = await invoke("get_identity"); + const identity = await invokeTauri("get_identity"); return { pubkey: identity.pubkey, @@ -244,26 +309,26 @@ export async function getIdentity(): Promise { } export async function getProfile(): Promise { - const profile = await invoke("get_profile"); + const profile = await invokeTauri("get_profile"); return fromRawProfile(profile); } export async function updateProfile( input: UpdateProfileInput, ): Promise { - const profile = await invoke("update_profile", input); + const profile = await invokeTauri("update_profile", input); return fromRawProfile(profile); } export async function getUserProfile(pubkey?: string): Promise { - const profile = await invoke("get_user_profile", { pubkey }); + const profile = await invokeTauri("get_user_profile", { pubkey }); return fromRawProfile(profile); } export async function getUsersBatch( pubkeys: string[], ): Promise { - const response = await invoke("get_users_batch", { + const response = await invokeTauri("get_users_batch", { pubkeys, }); @@ -279,7 +344,7 @@ export async function getUsersBatch( } export async function getPresence(pubkeys: string[]): Promise { - const response = await invoke("get_presence", { + const response = await invokeTauri("get_presence", { pubkeys, }); @@ -294,7 +359,7 @@ export async function getPresence(pubkeys: string[]): Promise { export async function setPresence( status: PresenceStatus, ): Promise { - const response = await invoke("set_presence", { + const response = await invokeTauri("set_presence", { status, }); @@ -305,25 +370,25 @@ export async function setPresence( } export function getRelayWsUrl(): Promise { - return invoke("get_relay_ws_url"); + return invokeTauri("get_relay_ws_url"); } export async function getChannels(): Promise { - const channels = await invoke("get_channels"); + const channels = await invokeTauri("get_channels"); return channels.map(fromRawChannel); } export async function createChannel( input: CreateChannelInput, ): Promise { - const channel = await invoke("create_channel", input); + const channel = await invokeTauri("create_channel", input); return fromRawChannel(channel); } export async function getChannelDetails( channelId: string, ): Promise { - const channel = await invoke("get_channel_details", { + const channel = await invokeTauri("get_channel_details", { channelId, }); return fromRawChannelDetail(channel); @@ -332,7 +397,7 @@ export async function getChannelDetails( export async function getChannelMembers( channelId: string, ): Promise { - const response = await invoke( + const response = await invokeTauri( "get_channel_members", { channelId, @@ -344,59 +409,59 @@ export async function getChannelMembers( export async function updateChannel( input: UpdateChannelInput, ): Promise { - const channel = await invoke("update_channel", input); + const channel = await invokeTauri("update_channel", input); return fromRawChannelDetail(channel); } export async function setChannelTopic( input: SetChannelTopicInput, ): Promise { - await invoke("set_channel_topic", input); + await invokeTauri("set_channel_topic", input); } export async function setChannelPurpose( input: SetChannelPurposeInput, ): Promise { - await invoke("set_channel_purpose", input); + await invokeTauri("set_channel_purpose", input); } export async function archiveChannel(channelId: string): Promise { - await invoke("archive_channel", { channelId }); + await invokeTauri("archive_channel", { channelId }); } export async function unarchiveChannel(channelId: string): Promise { - await invoke("unarchive_channel", { channelId }); + await invokeTauri("unarchive_channel", { channelId }); } export async function deleteChannel(channelId: string): Promise { - await invoke("delete_channel", { channelId }); + await invokeTauri("delete_channel", { channelId }); } export async function addChannelMembers( input: AddChannelMembersInput, ): Promise { - return invoke("add_channel_members", input); + return invokeTauri("add_channel_members", input); } export async function removeChannelMember( channelId: string, pubkey: string, ): Promise { - await invoke("remove_channel_member", { channelId, pubkey }); + await invokeTauri("remove_channel_member", { channelId, pubkey }); } export async function joinChannel(channelId: string): Promise { - await invoke("join_channel", { channelId }); + await invokeTauri("join_channel", { channelId }); } export async function leaveChannel(channelId: string): Promise { - await invoke("leave_channel", { channelId }); + await invokeTauri("leave_channel", { channelId }); } export async function getHomeFeed( input: GetHomeFeedInput = {}, ): Promise { - const response = await invoke("get_feed", input); + const response = await invokeTauri("get_feed", input); return { feed: { @@ -416,7 +481,10 @@ export async function getHomeFeed( export async function searchMessages( input: SearchMessagesInput, ): Promise { - const response = await invoke("search_messages", input); + const response = await invokeTauri( + "search_messages", + input, + ); return { hits: response.hits.map(fromRawSearchHit), @@ -425,7 +493,7 @@ export async function searchMessages( } export async function getEventById(eventId: string): Promise { - const eventJson = await invoke("get_event", { eventId }); + const eventJson = await invokeTauri("get_event", { eventId }); return JSON.parse(eventJson) as RelayEvent; } @@ -434,7 +502,7 @@ export async function signRelayEvent(input: { content: string; tags: string[][]; }): Promise { - const eventJson = await invoke("sign_event", input); + const eventJson = await invokeTauri("sign_event", input); return JSON.parse(eventJson) as RelayEvent; } @@ -442,6 +510,55 @@ export async function createAuthEvent(input: { challenge: string; relayUrl: string; }): Promise { - const eventJson = await invoke("create_auth_event", input); + const eventJson = await invokeTauri("create_auth_event", input); return JSON.parse(eventJson) as RelayEvent; } + +function fromRawToken(token: RawToken): Token { + return { + id: token.id, + name: token.name, + scopes: token.scopes, + channelIds: token.channel_ids, + createdAt: token.created_at, + expiresAt: token.expires_at, + lastUsedAt: token.last_used_at, + revokedAt: token.revoked_at, + }; +} + +export async function listTokens(): Promise { + const response = await invokeTauri("list_tokens"); + return response.tokens.map(fromRawToken); +} + +export async function mintToken( + input: MintTokenInput, +): Promise { + const response = await invokeTauri("mint_token", { + name: input.name, + scopes: input.scopes, + channelIds: input.channelIds, + expiresInDays: input.expiresInDays, + }); + return { + id: response.id, + token: response.token, + name: response.name, + scopes: response.scopes, + channelIds: response.channel_ids, + createdAt: response.created_at, + expiresAt: response.expires_at, + }; +} + +export async function revokeToken(tokenId: string): Promise { + await invokeTauri("revoke_token", { tokenId }); +} + +export async function revokeAllTokens(): Promise<{ revokedCount: number }> { + const response = await invokeTauri<{ revoked_count: number }>( + "revoke_all_tokens", + ); + return { revokedCount: response.revoked_count }; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index a7a339199..2123be031 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -186,3 +186,40 @@ export type SearchMessagesResponse = { hits: SearchHit[]; found: number; }; + +export type TokenScope = + | "messages:read" + | "messages:write" + | "channels:read" + | "channels:write" + | "users:read" + | "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; +}; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 62cc1ea7a..ba6473a9c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12,11 +12,27 @@ type TestIdentity = { type E2eConfig = { mode?: "mock" | "relay"; + mock?: { + mintTokenError?: string; + seededTokens?: RawMockTokenSeed[]; + }; relayHttpUrl?: string; relayWsUrl?: string; identity?: TestIdentity; }; +type RawMockTokenSeed = { + id: string; + name: string; + scopes: string[]; + channel_ids: string[]; + created_at: string; + expires_at: string | null; + last_used_at: string | null; + revoked_at: string | null; + token?: string; +}; + type RawProfile = { pubkey: string; display_name: string | null; @@ -138,6 +154,33 @@ type RawSearchResponse = { found: 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 MockToken = RawToken & { + token: string; +}; + type WsHandler = (message: unknown) => void; type MockSocket = { @@ -301,6 +344,35 @@ function cloneProfile(profile: RawProfile): RawProfile { return { ...profile }; } +function cloneToken(token: RawToken): RawToken { + return { + ...token, + channel_ids: [...token.channel_ids], + scopes: [...token.scopes], + }; +} + +function cloneMintedToken(token: MockToken): RawMintTokenResponse { + return { + ...cloneToken(token), + token: token.token, + }; +} + +function 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 getMockProfileByPubkey(pubkey: string): RawProfile | null { const normalizedPubkey = pubkey.toLowerCase(); const existing = mockProfiles.get(normalizedPubkey); @@ -566,6 +638,8 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockSockets = new Map(); const realSockets = new Map(); +let mockTokens: MockToken[] = []; +let mockMintTokenError: string | null = null; const mockProfiles = new Map([ [ MOCK_IDENTITY_PUBKEY, @@ -1536,6 +1610,114 @@ async function handleGetFeed( return response.json(); } +async function handleListTokens( + config: E2eConfig | undefined, +): Promise { + const identity = getIdentity(config); + if (!identity) { + return { + tokens: mockTokens.map(cloneToken), + }; + } + + return relayJsonRequest(config, "/api/tokens"); +} + +async function handleMintToken( + args: { + name: string; + scopes: string[]; + channelIds?: string[]; + expiresInDays?: number; + }, + config: E2eConfig | undefined, +): Promise { + const identity = getIdentity(config); + if (!identity) { + if (mockMintTokenError) { + throw mockMintTokenError; + } + + const now = new Date(); + const token: MockToken = { + id: crypto.randomUUID(), + name: args.name, + scopes: [...args.scopes], + channel_ids: [...(args.channelIds ?? [])], + created_at: now.toISOString(), + expires_at: + typeof args.expiresInDays === "number" + ? new Date( + now.getTime() + args.expiresInDays * 24 * 60 * 60 * 1_000, + ).toISOString() + : null, + last_used_at: null, + revoked_at: null, + token: `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`, + }; + + mockTokens.unshift(token); + return cloneMintedToken(token); + } + + return relayJsonRequest(config, "/api/tokens", { + method: "POST", + body: JSON.stringify({ + name: args.name, + scopes: args.scopes, + channel_ids: args.channelIds, + expires_in_days: args.expiresInDays, + }), + }); +} + +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; + } + + await relayEmptyRequest(config, `/api/tokens/${args.tokenId}`, { + method: "DELETE", + }); +} + +async function handleRevokeAllTokens( + config: E2eConfig | undefined, +): Promise { + const identity = getIdentity(config); + if (!identity) { + const now = new Date().toISOString(); + let revokedCount = 0; + + for (const token of mockTokens) { + if (token.revoked_at) { + continue; + } + + token.revoked_at = now; + revokedCount += 1; + } + + return { + revoked_count: revokedCount, + }; + } + + return relayJsonRequest(config, "/api/tokens", { + method: "DELETE", + }); +} + async function handleSearchMessages( args: { q: string; @@ -1831,6 +2013,7 @@ export function maybeInstallE2eTauriMocks() { return; } + resetMockTokens(config); mockWindows("main"); window.__SPROUT_E2E_COMMANDS__ = []; window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, content }) => { @@ -1896,6 +2079,20 @@ export function maybeInstallE2eTauriMocks() { (payload as Parameters[0]) ?? {}, activeConfig, ); + case "list_tokens": + return handleListTokens(activeConfig); + case "mint_token": + return handleMintToken( + payload as Parameters[0], + activeConfig, + ); + case "revoke_token": + return handleRevokeToken( + payload as Parameters[0], + activeConfig, + ); + case "revoke_all_tokens": + return handleRevokeAllTokens(activeConfig); case "create_channel": return handleCreateChannel( payload as Parameters[0], diff --git a/desktop/tests/e2e/tokens.spec.ts b/desktop/tests/e2e/tokens.spec.ts new file mode 100644 index 000000000..976ebc291 --- /dev/null +++ b/desktop/tests/e2e/tokens.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; + +test("creates a channel-scoped token from settings and can revoke it", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("open-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + + 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. 3 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-${RANDOM_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 page.getByTestId("open-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + + await page + .getByTestId("settings-tokens") + .getByRole("button", { name: "Create token" }) + .click(); + + await page.getByTestId("token-name-input").fill("qa-failing-token"); + await page.getByTestId("token-scope-messages-read").click(); + await page.getByTestId("confirm-create-token").click(); + + const dialog = page.getByTestId("create-token-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText( + "relay returned 403 Forbidden: not a member of channel: 8f321c1d-f77e-4952-881c-f6e7bfb94c6b", + ); + await expect(page.getByTestId("confirm-create-token")).toHaveText( + "Create token", + ); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index b544703d9..9138a2ff1 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -35,8 +35,24 @@ export const TEST_IDENTITIES = { type BridgeMode = "mock" | "relay"; +type MockBridgeOptions = { + mintTokenError?: 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 = { mode: BridgeMode; + mock?: MockBridgeOptions; relayHttpUrl?: string; relayWsUrl?: string; user?: keyof typeof TEST_IDENTITIES; @@ -49,13 +65,14 @@ export async function installBridge(page: Page, options: BridgeOptions) { : undefined; await page.addInitScript( - ({ identity: bridgeIdentity, mode, relayHttpUrl, relayWsUrl }) => { + ({ identity: bridgeIdentity, mock, mode, relayHttpUrl, relayWsUrl }) => { ( window as Window & { __SPROUT_E2E__?: Record; } ).__SPROUT_E2E__ = { identity: bridgeIdentity, + mock, mode, relayHttpUrl, relayWsUrl, @@ -63,6 +80,7 @@ export async function installBridge(page: Page, options: BridgeOptions) { }, { identity, + mock: options.mock, mode: options.mode, relayHttpUrl: options.relayHttpUrl, relayWsUrl: options.relayWsUrl, @@ -70,8 +88,8 @@ export async function installBridge(page: Page, options: BridgeOptions) { ); } -export async function installMockBridge(page: Page) { - await installBridge(page, { mode: "mock" }); +export async function installMockBridge(page: Page, mock?: MockBridgeOptions) { + await installBridge(page, { mode: "mock", mock }); } export async function installRelayBridge(