Add desktop API token management (#39)

This commit is contained in:
Wes
2026-03-12 10:21:11 -07:00
committed by GitHub
parent 550d6c520f
commit 6c8ed78048
12 changed files with 1575 additions and 36 deletions
+1
View File
@@ -33,6 +33,7 @@ export default defineConfig({
"**/stream.spec.ts",
"**/integration.spec.ts",
"**/profile.spec.ts",
"**/tokens.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+2
View File
@@ -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",
+2
View File
@@ -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"
+214 -5
View File
@@ -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<Keys>,
pub http_client: reqwest::Client,
pub configured_api_token: Option<String>,
pub session_token: Mutex<Option<String>>,
}
#[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<String>,
pub purpose: Option<String>,
@@ -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<String>,
pub topic_set_by: Option<String>,
@@ -170,6 +176,49 @@ struct SearchQueryParams<'a> {
limit: Option<u32>,
}
#[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<u32>,
}
#[derive(Serialize, Deserialize)]
pub struct MintTokenResponse {
pub id: String,
pub token: String,
pub name: String,
pub scopes: Vec<String>,
pub channel_ids: Vec<String>,
pub created_at: String,
pub expires_at: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct TokenInfo {
pub id: String,
pub name: String,
pub scopes: Vec<String>,
pub channel_ids: Vec<String>,
pub created_at: String,
pub expires_at: Option<String>,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ListTokensResponse {
pub tokens: Vec<TokenInfo>,
}
#[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<String, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<String>::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<reqwest::RequestBuilder, String> {
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<String, String> {
@@ -253,14 +314,68 @@ fn auth_pubkey_header(state: &AppState) -> Result<String, String> {
Ok(keys.public_key().to_hex())
}
fn session_api_token(state: &AppState) -> Result<Option<String>, 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<reqwest::RequestBuilder, String> {
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<String, String> {
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::<serde_json::Value>(&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<String, String> {
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<ListTokensResponse, String> {
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<String>,
channel_ids: Option<Vec<String>>,
expires_in_days: Option<u32>,
state: tauri::State<'_, AppState>,
) -> Result<MintTokenResponse, String> {
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<RevokeAllTokensResponse, String> {
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");
@@ -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}
/>
<TokenSettingsCard currentPubkey={currentPubkey} />
</div>
</div>
);
+85
View File
@@ -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<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 });
},
});
}
@@ -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 (
<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-green-700 dark:text-green-400",
status === "revoked" && "bg-muted text-muted-foreground",
status === "expired" &&
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
)}
>
{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 [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 (
<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">
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<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-yellow-700 dark:text-yellow-400">
<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">
{ALL_SCOPES.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-yellow-700 dark:text-yellow-400">
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="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
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-yellow-700 dark:text-yellow-400">
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>
);
}
+145 -28
View File
@@ -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<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> {
try {
return await tauriInvoke<T>(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<Identity> {
const identity = await invoke<RawIdentity>("get_identity");
const identity = await invokeTauri<RawIdentity>("get_identity");
return {
pubkey: identity.pubkey,
@@ -244,26 +309,26 @@ export async function getIdentity(): Promise<Identity> {
}
export async function getProfile(): Promise<Profile> {
const profile = await invoke<RawProfile>("get_profile");
const profile = await invokeTauri<RawProfile>("get_profile");
return fromRawProfile(profile);
}
export async function updateProfile(
input: UpdateProfileInput,
): Promise<Profile> {
const profile = await invoke<RawProfile>("update_profile", input);
const profile = await invokeTauri<RawProfile>("update_profile", input);
return fromRawProfile(profile);
}
export async function getUserProfile(pubkey?: string): Promise<Profile> {
const profile = await invoke<RawProfile>("get_user_profile", { pubkey });
const profile = await invokeTauri<RawProfile>("get_user_profile", { pubkey });
return fromRawProfile(profile);
}
export async function getUsersBatch(
pubkeys: string[],
): Promise<UsersBatchResponse> {
const response = await invoke<RawUsersBatchResponse>("get_users_batch", {
const response = await invokeTauri<RawUsersBatchResponse>("get_users_batch", {
pubkeys,
});
@@ -279,7 +344,7 @@ export async function getUsersBatch(
}
export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
const response = await invoke<RawPresenceLookup>("get_presence", {
const response = await invokeTauri<RawPresenceLookup>("get_presence", {
pubkeys,
});
@@ -294,7 +359,7 @@ export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
export async function setPresence(
status: PresenceStatus,
): Promise<SetPresenceResult> {
const response = await invoke<RawSetPresenceResult>("set_presence", {
const response = await invokeTauri<RawSetPresenceResult>("set_presence", {
status,
});
@@ -305,25 +370,25 @@ export async function setPresence(
}
export function getRelayWsUrl(): Promise<string> {
return invoke<string>("get_relay_ws_url");
return invokeTauri<string>("get_relay_ws_url");
}
export async function getChannels(): Promise<Channel[]> {
const channels = await invoke<RawChannel[]>("get_channels");
const channels = await invokeTauri<RawChannel[]>("get_channels");
return channels.map(fromRawChannel);
}
export async function createChannel(
input: CreateChannelInput,
): Promise<Channel> {
const channel = await invoke<RawChannel>("create_channel", input);
const channel = await invokeTauri<RawChannel>("create_channel", input);
return fromRawChannel(channel);
}
export async function getChannelDetails(
channelId: string,
): Promise<ChannelDetail> {
const channel = await invoke<RawChannelDetail>("get_channel_details", {
const channel = await invokeTauri<RawChannelDetail>("get_channel_details", {
channelId,
});
return fromRawChannelDetail(channel);
@@ -332,7 +397,7 @@ export async function getChannelDetails(
export async function getChannelMembers(
channelId: string,
): Promise<ChannelMember[]> {
const response = await invoke<RawChannelMembersResponse>(
const response = await invokeTauri<RawChannelMembersResponse>(
"get_channel_members",
{
channelId,
@@ -344,59 +409,59 @@ export async function getChannelMembers(
export async function updateChannel(
input: UpdateChannelInput,
): Promise<ChannelDetail> {
const channel = await invoke<RawChannelDetail>("update_channel", input);
const channel = await invokeTauri<RawChannelDetail>("update_channel", input);
return fromRawChannelDetail(channel);
}
export async function setChannelTopic(
input: SetChannelTopicInput,
): Promise<void> {
await invoke("set_channel_topic", input);
await invokeTauri("set_channel_topic", input);
}
export async function setChannelPurpose(
input: SetChannelPurposeInput,
): Promise<void> {
await invoke("set_channel_purpose", input);
await invokeTauri("set_channel_purpose", input);
}
export async function archiveChannel(channelId: string): Promise<void> {
await invoke("archive_channel", { channelId });
await invokeTauri("archive_channel", { channelId });
}
export async function unarchiveChannel(channelId: string): Promise<void> {
await invoke("unarchive_channel", { channelId });
await invokeTauri("unarchive_channel", { channelId });
}
export async function deleteChannel(channelId: string): Promise<void> {
await invoke("delete_channel", { channelId });
await invokeTauri("delete_channel", { channelId });
}
export async function addChannelMembers(
input: AddChannelMembersInput,
): Promise<AddChannelMembersResult> {
return invoke<RawAddChannelMembersResult>("add_channel_members", input);
return invokeTauri<RawAddChannelMembersResult>("add_channel_members", input);
}
export async function removeChannelMember(
channelId: string,
pubkey: string,
): Promise<void> {
await invoke("remove_channel_member", { channelId, pubkey });
await invokeTauri("remove_channel_member", { channelId, pubkey });
}
export async function joinChannel(channelId: string): Promise<void> {
await invoke("join_channel", { channelId });
await invokeTauri("join_channel", { channelId });
}
export async function leaveChannel(channelId: string): Promise<void> {
await invoke("leave_channel", { channelId });
await invokeTauri("leave_channel", { channelId });
}
export async function getHomeFeed(
input: GetHomeFeedInput = {},
): Promise<HomeFeedResponse> {
const response = await invoke<RawHomeFeedResponse>("get_feed", input);
const response = await invokeTauri<RawHomeFeedResponse>("get_feed", input);
return {
feed: {
@@ -416,7 +481,10 @@ export async function getHomeFeed(
export async function searchMessages(
input: SearchMessagesInput,
): Promise<SearchMessagesResponse> {
const response = await invoke<RawSearchResponse>("search_messages", input);
const response = await invokeTauri<RawSearchResponse>(
"search_messages",
input,
);
return {
hits: response.hits.map(fromRawSearchHit),
@@ -425,7 +493,7 @@ export async function searchMessages(
}
export async function getEventById(eventId: string): Promise<RelayEvent> {
const eventJson = await invoke<string>("get_event", { eventId });
const eventJson = await invokeTauri<string>("get_event", { eventId });
return JSON.parse(eventJson) as RelayEvent;
}
@@ -434,7 +502,7 @@ export async function signRelayEvent(input: {
content: string;
tags: string[][];
}): Promise<RelayEvent> {
const eventJson = await invoke<string>("sign_event", input);
const eventJson = await invokeTauri<string>("sign_event", input);
return JSON.parse(eventJson) as RelayEvent;
}
@@ -442,6 +510,55 @@ export async function createAuthEvent(input: {
challenge: string;
relayUrl: string;
}): Promise<RelayEvent> {
const eventJson = await invoke<string>("create_auth_event", input);
const eventJson = await invokeTauri<string>("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<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 };
}
+37
View File
@@ -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;
};
+197
View File
@@ -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<string, RelayEvent[]>();
const mockSockets = new Map<number, MockSocket>();
const realSockets = new Map<number, WebSocket>();
let mockTokens: MockToken[] = [];
let mockMintTokenError: string | null = null;
const mockProfiles = new Map<string, RawProfile>([
[
MOCK_IDENTITY_PUBKEY,
@@ -1536,6 +1610,114 @@ async function handleGetFeed(
return response.json();
}
async function handleListTokens(
config: E2eConfig | undefined,
): Promise<RawListTokensResponse> {
const identity = getIdentity(config);
if (!identity) {
return {
tokens: mockTokens.map(cloneToken),
};
}
return relayJsonRequest<RawListTokensResponse>(config, "/api/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);
}
return relayJsonRequest<RawMintTokenResponse>(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<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,
};
}
return relayJsonRequest<RawRevokeAllTokensResponse>(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<typeof handleGetFeed>[0]) ?? {},
activeConfig,
);
case "list_tokens":
return handleListTokens(activeConfig);
case "mint_token":
return handleMintToken(
payload as Parameters<typeof handleMintToken>[0],
activeConfig,
);
case "revoke_token":
return handleRevokeToken(
payload as Parameters<typeof handleRevokeToken>[0],
activeConfig,
);
case "revoke_all_tokens":
return handleRevokeAllTokens(activeConfig);
case "create_channel":
return handleCreateChannel(
payload as Parameters<typeof handleCreateChannel>[0],
+84
View File
@@ -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",
);
});
+21 -3
View File
@@ -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<string, unknown>;
}
).__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(