Attach Blossom auth token in clients

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-01 15:38:49 -04:00
parent 9f2a11b338
commit 5cc7e4493e
20 changed files with 221 additions and 21 deletions
+12 -1
View File
@@ -116,6 +116,8 @@ pub struct BuzzClient {
auth_tag: Option<Tag>,
/// Raw JSON of the auth tag for the `x-auth-tag` HTTP header.
auth_tag_json: Option<String>,
/// API token attached as `X-Auth-Token` for Blossom media uploads.
api_token: Option<String>,
}
impl BuzzClient {
@@ -124,6 +126,7 @@ impl BuzzClient {
keys: Keys,
auth_tag: Option<Tag>,
auth_tag_json: Option<String>,
api_token: Option<String>,
) -> Result<Self, CliError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
@@ -136,6 +139,11 @@ impl BuzzClient {
keys,
auth_tag,
auth_tag_json,
api_token: api_token
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
.map(str::to_string),
})
}
@@ -375,13 +383,16 @@ impl BuzzClient {
Duration::from_secs(120)
};
let url = format!("{}/media/upload", self.relay_url);
let req = self
let mut req = self
.http
.put(&url)
.timeout(upload_timeout)
.header("Authorization", &auth_header)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);
if let Some(token) = self.api_token.as_deref() {
req = req.header("X-Auth-Token", token);
}
let resp = self.with_auth_tag(req).body(bytes).send().await?;
if !resp.status().is_success() {
+1 -1
View File
@@ -842,7 +842,7 @@ mod tests {
let keys =
nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid test key");
BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None)
BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None, None)
.expect("client construction should not fail")
}
+1 -1
View File
@@ -786,7 +786,7 @@ mod tests {
// common quick-check inputs.
fn test_client(keys: nostr::Keys) -> BuzzClient {
BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None).unwrap()
BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None, None).unwrap()
}
#[test]
+29 -2
View File
@@ -56,6 +56,7 @@ Configuration (flags override env vars):
BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000]
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required]
BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional]
BUZZ_API_TOKEN API token for Blossom media upload [optional]
The 'pack' subcommand runs locally and does not require a relay connection.
@@ -75,6 +76,10 @@ struct Cli {
#[arg(long, env = "BUZZ_AUTH_TAG")]
auth_tag: Option<String>,
/// API token attached as X-Auth-Token for Blossom media uploads.
#[arg(long, env = "BUZZ_API_TOKEN")]
api_token: Option<String>,
/// Output format: 'json' (default, full fields) or 'compact' (reduced fields).
#[arg(long, value_enum, default_value = "json")]
format: OutputFormat,
@@ -1420,7 +1425,9 @@ async fn run(cli: Cli) -> Result<(), CliError> {
}
// Auth: private key is required for all relay operations.
// The keypair IS the identity — no tokens, no other auth.
// The keypair is the identity for Nostr/WebSocket auth. `BUZZ_API_TOKEN`,
// when present, is only attached to Blossom media uploads as an additional
// relay-side scope credential.
let private_key_str = cli.private_key.ok_or_else(|| {
CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into())
})?;
@@ -1443,7 +1450,13 @@ async fn run(cli: Cli) -> Result<(), CliError> {
_ => (None, None),
};
let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
let api_token = cli
.api_token
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
.map(str::to_string);
let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json, api_token)?;
match cli.command {
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
@@ -1523,6 +1536,20 @@ mod tests {
);
}
#[test]
fn api_token_flag_is_available() {
let cmd = Cli::command();
let api_token = cmd
.get_arguments()
.find(|arg| arg.get_id() == "api_token")
.expect("global api_token argument should exist");
assert_eq!(api_token.get_long(), Some("api-token"));
assert!(api_token
.get_env()
.is_some_and(|env| env == "BUZZ_API_TOKEN"));
}
#[test]
fn subcommand_names_are_stable() {
fn names(cmd: &clap::Command, group: &str) -> Vec<String> {
+2 -2
View File
@@ -15,9 +15,8 @@ use crate::managed_agents::ManagedAgentProcess;
pub struct AppState {
pub keys: Mutex<Keys>,
pub http_client: reqwest::Client,
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
/// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex<Option<String>>,
pub auth_token_override: Mutex<Option<String>>,
pub managed_agents_store_lock: Mutex<()>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
@@ -93,6 +92,7 @@ pub fn build_app_state() -> AppState {
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
relay_url_override: Mutex::new(None),
auth_token_override: Mutex::new(None),
managed_agents_store_lock: Mutex::new(()),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
+5 -2
View File
@@ -7,7 +7,7 @@ use tauri::State;
use crate::app_state::AppState;
use crate::relay::{
classify_request_error, parse_json_response, relay_api_base_url_with_override,
relay_error_message,
relay_error_message, workspace_auth_token,
};
use super::media_transcode::{
@@ -216,12 +216,15 @@ async fn do_upload(
"Nostr {}",
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
let req = state
let mut req = state
.http_client
.put(format!("{base_url}/media/upload"))
.header("Authorization", &auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);
if let Some(token) = workspace_auth_token(state) {
req = req.header("X-Auth-Token", token);
}
// With a progress channel, stream the body in chunks and emit a
// `media-upload-progress` event as each chunk is handed to the socket,
+7 -2
View File
@@ -15,7 +15,9 @@ use tokio_util::sync::CancellationToken;
use zeroize::Zeroizing;
use crate::app_state::AppState;
use crate::relay::{relay_api_base_url_with_override, relay_ws_url_with_override};
use crate::relay::{
relay_api_base_url_with_override, relay_ws_url_with_override, workspace_auth_token,
};
#[derive(Serialize, Clone)]
struct PairingSasPayload {
@@ -107,11 +109,14 @@ pub async fn start_pairing(
let (session, qr_payload) = PairingSession::new_source(qr_relay_url);
let qr_uri = encode_qr(&qr_payload);
let payload_json = serde_json::json!({
let mut payload_json = serde_json::json!({
"relayUrl": http_url,
"pubkey": pubkey_hex,
"nsec": nsec,
});
if let Some(token) = workspace_auth_token(&state) {
payload_json["token"] = serde_json::Value::String(token);
}
{
let mut s = pairing.session.lock().await;
@@ -60,6 +60,7 @@ pub fn validate_repos_dir(dir: String) -> Result<(), String> {
pub fn apply_workspace(
relay_url: String,
nsec: Option<String>,
token: Option<String>,
repos_dir: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
@@ -97,6 +98,17 @@ pub fn apply_workspace(
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
*override_guard = Some(relay_url);
}
{
let mut token_guard = state
.auth_token_override
.lock()
.map_err(|e| e.to_string())?;
*token_guard = token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
}
if let Some(keys) = parsed_keys {
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
+12
View File
@@ -39,6 +39,18 @@ fn workspace_relay_override(state: &AppState) -> Option<String> {
.and_then(|guard| guard.clone())
}
/// Read the workspace API/Blossom auth token override, if set. Returns `None`
/// when no trimmed token is active or when the mutex is poisoned (best-effort).
pub fn workspace_auth_token(state: &AppState) -> Option<String> {
state.auth_token_override.lock().ok().and_then(|guard| {
guard
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
/// Returns the relay WebSocket URL, checking the workspace override first.
/// Precedence: workspace override > env vars > build-time vars > default.
pub fn relay_ws_url_with_override(state: &AppState) -> String {
@@ -420,6 +420,7 @@ class PairingNotifier extends Notifier<PairingState> {
final relayUrl = data['relayUrl'] as String?;
final pubkey = data['pubkey'] as String?;
final nsec = data['nsec'] as String?;
final token = data['token'] as String?;
if (relayUrl == null) {
throw const FormatException('Missing relayUrl in payload');
@@ -440,6 +441,7 @@ class PairingNotifier extends Notifier<PairingState> {
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
token: token,
);
await ref
.read(authProvider.notifier)
@@ -616,6 +618,7 @@ class PairingNotifier extends Notifier<PairingState> {
relayUrl: relayUrl,
pubkey: decoded['pubkey'] as String?,
nsec: decoded['nsec'] as String?,
token: decoded['token'] as String?,
);
}
+2 -2
View File
@@ -139,7 +139,7 @@ class MediaUploadService {
DateTime Function()? now,
http.Client? httpClient,
}) : _baseUrl = baseUrl,
_apiToken = apiToken,
_apiToken = apiToken?.trim(),
_nsec = nsec,
_pickGalleryImage = pickGalleryImage,
_pickGalleryVideo = pickGalleryVideo,
@@ -641,7 +641,7 @@ final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
final picker = ImagePicker();
final service = MediaUploadService(
baseUrl: config.baseUrl,
apiToken: null,
apiToken: config.apiToken,
nsec: config.nsec,
pickGalleryImage: () => picker.pickImage(
source: ImageSource.gallery,
+12 -5
View File
@@ -15,7 +15,10 @@ class RelayConfig {
/// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH.
final String? nsec;
const RelayConfig({required this.baseUrl, this.nsec});
/// API token attached as X-Auth-Token for Blossom media upload.
final String? apiToken;
const RelayConfig({required this.baseUrl, this.nsec, this.apiToken});
/// Derive the websocket URL from the HTTP base URL.
String get wsUrl {
@@ -46,15 +49,19 @@ class RelayConfigNotifier extends Notifier<RelayConfig> {
final activeAsync = ref.watch(activeWorkspaceProvider);
final active = activeAsync.value;
if (active != null) {
return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec);
return RelayConfig(
baseUrl: active.relayUrl,
nsec: active.nsec,
apiToken: active.token,
);
}
// Fallback to compile-time env config (dev mode).
return const RelayConfig(baseUrl: Env.relayUrl);
return const RelayConfig(baseUrl: Env.relayUrl, apiToken: null);
}
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
void update({required String baseUrl, String? nsec, String? apiToken}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec, apiToken: apiToken);
}
}
@@ -9,6 +9,7 @@ class Workspace {
final String relayUrl;
final String? pubkey;
final String? nsec;
final String? token;
final DateTime addedAt;
const Workspace({
@@ -17,6 +18,7 @@ class Workspace {
required this.relayUrl,
this.pubkey,
this.nsec,
this.token,
required this.addedAt,
});
@@ -25,6 +27,7 @@ class Workspace {
required String relayUrl,
String? pubkey,
String? nsec,
String? token,
}) {
return Workspace(
id: _uuid.v4(),
@@ -32,6 +35,7 @@ class Workspace {
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
token: _normalizeToken(token),
addedAt: DateTime.now(),
);
}
@@ -41,6 +45,7 @@ class Workspace {
String? relayUrl,
Object? pubkey = _sentinel,
Object? nsec = _sentinel,
Object? token = _sentinel,
}) {
return Workspace(
id: id,
@@ -48,6 +53,9 @@ class Workspace {
relayUrl: relayUrl ?? this.relayUrl,
pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?,
nsec: nsec == _sentinel ? this.nsec : nsec as String?,
token: token == _sentinel
? this.token
: _normalizeToken(token as String?),
addedAt: addedAt,
);
}
@@ -58,6 +66,7 @@ class Workspace {
'relayUrl': relayUrl,
if (pubkey != null) 'pubkey': pubkey,
if (nsec != null) 'nsec': nsec,
if (token != null) 'token': token,
'addedAt': addedAt.toIso8601String(),
};
@@ -67,9 +76,15 @@ class Workspace {
relayUrl: json['relayUrl'] as String,
pubkey: json['pubkey'] as String?,
nsec: json['nsec'] as String?,
token: _normalizeToken(json['token'] as String?),
addedAt: DateTime.parse(json['addedAt'] as String),
);
static String? _normalizeToken(String? token) {
final trimmed = token?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
/// Derive a human-friendly workspace name from a relay URL.
static String nameFromUrl(String url) {
try {
@@ -31,6 +31,7 @@ class WorkspaceListNotifier extends AsyncNotifier<List<Workspace>> {
final updated = existing.copyWith(
pubkey: workspace.pubkey,
nsec: workspace.nsec,
token: workspace.token ?? existing.token,
);
await storage.save(updated);
final updatedList = [...current];
@@ -43,6 +43,7 @@ class WorkspaceStorage {
relayUrl: legacyUrl,
pubkey: legacyPubkey,
nsec: legacyNsec,
token: legacyToken,
);
await _saveList([workspace]);
@@ -355,11 +355,18 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier {
_FakeRelayConfigNotifier({required String? nsec}) : _nsec = nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec);
RelayConfig build() => RelayConfig(
baseUrl: 'http://localhost:3000',
nsec: _nsec,
apiToken: null,
);
void setNsec(String? nsec) {
_nsec = nsec;
state = RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec);
state = RelayConfig(
baseUrl: 'http://localhost:3000',
nsec: _nsec,
apiToken: null,
);
}
}
@@ -157,6 +157,7 @@ String _encodePairingCode({
String relayUrl = 'http://test:3000',
String? pubkey,
String? nsec,
String? token,
}) {
final json = <String, dynamic>{
'relayUrl': relayUrl,
@@ -164,6 +165,8 @@ String _encodePairingCode({
if (pubkey != null) 'pubkey': pubkey,
// ignore: use_null_aware_elements
if (nsec != null) 'nsec': nsec,
// ignore: use_null_aware_elements
if (token != null) 'token': token,
};
return base64Url.encode(utf8.encode(jsonEncode(json)));
}
@@ -278,6 +278,7 @@ void main() {
);
expect(capturedRequest!.headers['Content-Type'], 'image/png');
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
expect(capturedRequest!.headers['X-Auth-Token'], isNull);
expect(capturedRequest!.bodyBytes, _pngBytes);
final authHeader = capturedRequest!.headers['Authorization'];
@@ -308,6 +309,41 @@ void main() {
);
});
test('attaches configured API token to uploads', () async {
final keychain = nostr.Keys.generate();
http.Request? capturedRequest;
final client = http_testing.MockClient((request) async {
capturedRequest = request;
return http.Response(
jsonEncode({
'url': 'https://relay.example/media/test.png',
'sha256':
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'size': 16,
'type': 'image/png',
'uploaded': 1,
}),
200,
);
});
final service = MediaUploadService(
baseUrl: 'https://relay.example',
apiToken: 'buzz_media_token',
nsec: keychain.nsec,
httpClient: client,
pickGalleryVideo: () async => null,
pickGalleryImage: () async =>
XFile.fromData(_pngBytes, name: 'tiny.png'),
);
await service.pickAndUploadImage();
expect(capturedRequest, isNotNull);
expect(capturedRequest!.headers['X-Auth-Token'], 'buzz_media_token');
});
test('returns null when the gallery picker is cancelled', () async {
final service = MediaUploadService(
baseUrl: 'https://relay.example',
@@ -101,6 +101,62 @@ void main() {
final activeId = await workspaceStorage.loadActiveId();
expect(activeId, ws2.id);
});
test(
'updates duplicate workspace without clearing existing token',
() async {
container = createContainer();
await container.read(workspaceListProvider.future);
final original = Workspace.create(
name: 'Test',
relayUrl: 'https://test.example.com',
pubkey: 'old-pubkey',
nsec: 'old-nsec',
token: 'buzz_existing',
);
final duplicate = Workspace.create(
name: 'Test',
relayUrl: 'https://test.example.com',
pubkey: 'new-pubkey',
nsec: 'new-nsec',
);
final notifier = container.read(workspaceListProvider.notifier);
await notifier.addWorkspace(original);
await notifier.addWorkspace(duplicate);
final workspaces = await container.read(workspaceListProvider.future);
expect(workspaces, hasLength(1));
expect(workspaces.first.pubkey, 'new-pubkey');
expect(workspaces.first.nsec, 'new-nsec');
expect(workspaces.first.token, 'buzz_existing');
},
);
test('duplicate workspace updates token when provided', () async {
container = createContainer();
await container.read(workspaceListProvider.future);
final original = Workspace.create(
name: 'Test',
relayUrl: 'https://test.example.com',
token: 'buzz_existing',
);
final duplicate = Workspace.create(
name: 'Test',
relayUrl: 'https://test.example.com',
token: 'buzz_new',
);
final notifier = container.read(workspaceListProvider.notifier);
await notifier.addWorkspace(original);
await notifier.addWorkspace(duplicate);
final workspaces = await container.read(workspaceListProvider.future);
expect(workspaces, hasLength(1));
expect(workspaces.first.token, 'buzz_new');
});
});
group('activeWorkspaceProvider', () {
@@ -176,6 +176,7 @@ void main() {
expect(loaded.first.relayUrl, 'https://legacy.example.com');
expect(loaded.first.pubkey, 'legacy_pub');
expect(loaded.first.nsec, 'legacy_nsec');
expect(loaded.first.token, 'legacy_token');
expect(loaded.first.name, isNotEmpty);
// Legacy keys should be deleted.