From 1de10e61ba7d0d142af1b2f38cebe7bce3dcc881 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Mon, 18 May 2026 10:07:02 +0200 Subject: [PATCH 1/2] Add authorization management polish --- api/src/api/http/routes.rs | 4 + api/src/api/http/teams.rs | 293 +++++++++++++++++- api/src/api/types.rs | 2 + core/src/database.rs | 12 +- core/src/types/authorization.rs | 14 +- .../0003_add_authorization_name.sql | 2 + .../lib/components/AuthorizationCard.svelte | 38 ++- web/src/lib/components/SignInMenu.svelte | 42 ++- web/src/lib/nostr.ts | 25 +- web/src/lib/types.ts | 1 + web/src/lib/utils/auth.ts | 10 +- web/src/lib/utils/nostr.test.ts | 13 + .../teams/[id]/keys/[pubkey]/+page.svelte | 31 +- .../[pubkey]/authorizations/new/+page.svelte | 13 + 14 files changed, 459 insertions(+), 41 deletions(-) create mode 100644 database/migrations/0003_add_authorization_name.sql diff --git a/api/src/api/http/routes.rs b/api/src/api/http/routes.rs index 1c78d96..a6a5c51 100644 --- a/api/src/api/http/routes.rs +++ b/api/src/api/http/routes.rs @@ -34,6 +34,10 @@ fn protected_routes(pool: SqlitePool) -> Router { "/teams/:id/keys/:pubkey/authorizations", post(teams::add_authorization), ) + .route( + "/teams/:id/keys/:pubkey/authorizations/:authorization_id", + delete(teams::remove_authorization), + ) .route("/teams/:id/policies", post(teams::add_policy)) .layer(middleware::from_fn(auth_middleware)) .with_state(pool) diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index 213d324..ecdee0b 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -564,6 +564,7 @@ pub async fn add_authorization( Json(request): Json, ) -> ApiResult> { verify_admin(&pool, &event.pubkey, team_id).await?; + let authorization_name = normalized_authorization_name(request.name.as_deref()); let stored_key_public_key = PublicKey::from_hex(&pubkey).map_err(|e| ApiError::bad_request(e.to_string()))?; @@ -612,12 +613,13 @@ pub async fn add_authorization( // Create authorization let authorization = sqlx::query_as::query_as::<_, Authorization>( r#" - INSERT INTO authorizations (stored_key_id, policy_id, secret, bunker_public_key, bunker_secret, relays, max_uses, expires_at, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now')) + INSERT INTO authorizations (stored_key_id, name, policy_id, secret, bunker_public_key, bunker_secret, relays, max_uses, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'), datetime('now')) RETURNING * "#, ) .bind(stored_key.id) + .bind(authorization_name) .bind(request.policy_id) .bind(secret) .bind(bunker_keys.public_key().to_hex()) @@ -633,6 +635,59 @@ pub async fn add_authorization( Ok(Json(authorization)) } +pub async fn remove_authorization( + State(pool): State, + AuthEvent(event): AuthEvent, + Path((team_id, pubkey, authorization_id)): Path<(u32, String, u32)>, +) -> ApiResult { + verify_admin(&pool, &event.pubkey, team_id).await?; + + let stored_key_public_key = + PublicKey::from_hex(&pubkey).map_err(|e| ApiError::bad_request(e.to_string()))?; + + let mut tx = pool.begin().await?; + + let existing_authorization_id: Option = sqlx::query_scalar::query_scalar( + r#" + SELECT a.id + FROM authorizations a + JOIN stored_keys sk ON sk.id = a.stored_key_id + WHERE a.id = ?1 + AND sk.team_id = ?2 + AND sk.public_key = ?3 + "#, + ) + .bind(authorization_id) + .bind(team_id) + .bind(stored_key_public_key.to_hex()) + .fetch_optional(&mut *tx) + .await?; + + if existing_authorization_id.is_none() { + return Err(ApiError::not_found("Authorization not found")); + } + + sqlx::query::query("DELETE FROM user_authorizations WHERE authorization_id = ?1") + .bind(authorization_id) + .execute(&mut *tx) + .await?; + + sqlx::query::query("DELETE FROM authorizations WHERE id = ?1") + .bind(authorization_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(StatusCode::NO_CONTENT) +} + +fn normalized_authorization_name(name: Option<&str>) -> Option { + name.map(str::trim) + .filter(|trimmed| !trimmed.is_empty()) + .map(ToOwned::to_owned) +} + pub async fn add_policy( State(pool): State, AuthEvent(event): AuthEvent, @@ -718,10 +773,26 @@ pub async fn verify_admin<'a>( #[cfg(test)] mod tests { use super::*; + use crate::state::{KeycastState, KEYCAST_STATE}; use axum::extract::Path; + use keycast_core::encryption::{KeyManager, KeyManagerError}; use keycast_core::types::user::TeamUserRole; use sqlx::raw_sql::raw_sql; use sqlx_sqlite::SqlitePoolOptions; + use std::sync::Arc; + + struct TestKeyManager; + + #[async_trait::async_trait] + impl KeyManager for TestKeyManager { + async fn encrypt(&self, plaintext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(plaintext_bytes.to_vec()) + } + + async fn decrypt(&self, ciphertext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(ciphertext_bytes.to_vec()) + } + } async fn setup_test_db() -> SqlitePool { let pool = SqlitePoolOptions::new() @@ -740,6 +811,17 @@ mod tests { .execute(&pool) .await .unwrap(); + raw_sql(include_str!( + "../../../../database/migrations/0003_add_authorization_name.sql" + )) + .execute(&pool) + .await + .unwrap(); + + let _ = KEYCAST_STATE.set(Arc::new(KeycastState { + db: pool.clone(), + key_manager: Box::new(TestKeyManager), + })); pool } @@ -901,6 +983,7 @@ mod tests { AuthEvent(auth_event(&admin)), Path((first_team.team.id, stored_key.public_key().to_hex())), Json(AddAuthorizationRequest { + name: Some("Other team policy".to_string()), policy_id: second_team.policies[0].policy.id, relays: vec!["wss://relay.example".to_string()], max_uses: None, @@ -914,6 +997,212 @@ mod tests { assert_eq!(count_rows(&pool, "authorizations").await, 0); } + #[tokio::test] + async fn add_authorization_persists_trimmed_name() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + let stored_key = Keys::generate(); + + sqlx::query::query( + "INSERT INTO stored_keys (name, team_id, public_key, secret_key, created_at, updated_at) + VALUES ('key', ?1, ?2, ?3, datetime('now'), datetime('now'))", + ) + .bind(team.team.id) + .bind(stored_key.public_key().to_hex()) + .bind(vec![1_u8, 2, 3]) + .execute(&pool) + .await + .unwrap(); + + let authorization = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some(" Alice tablet ".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + + assert_eq!(authorization.name.as_deref(), Some("Alice tablet")); + + let stored_name: Option = + sqlx::query_scalar::query_scalar("SELECT name FROM authorizations WHERE id = ?1") + .bind(authorization.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_name.as_deref(), Some("Alice tablet")); + } + + #[tokio::test] + async fn remove_authorization_deletes_selected_authorization_and_redemptions_only() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + let stored_key = Keys::generate(); + let auth_user = Keys::generate(); + + sqlx::query::query( + "INSERT INTO stored_keys (name, team_id, public_key, secret_key, created_at, updated_at) + VALUES ('key', ?1, ?2, ?3, datetime('now'), datetime('now'))", + ) + .bind(team.team.id) + .bind(stored_key.public_key().to_hex()) + .bind(vec![1_u8, 2, 3]) + .execute(&pool) + .await + .unwrap(); + + let first = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some("Alice phone".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + let second = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some("Bob laptop".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + + sqlx::query::query( + "INSERT INTO users (public_key, created_at, updated_at) + VALUES (?1, datetime('now'), datetime('now'))", + ) + .bind(auth_user.public_key().to_hex()) + .execute(&pool) + .await + .unwrap(); + sqlx::query::query( + "INSERT INTO user_authorizations (user_public_key, authorization_id, created_at, updated_at) + VALUES (?1, ?2, datetime('now'), datetime('now'))", + ) + .bind(auth_user.public_key().to_hex()) + .bind(first.id) + .execute(&pool) + .await + .unwrap(); + + let status = remove_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex(), first.id)), + ) + .await + .unwrap(); + + assert_eq!(status, StatusCode::NO_CONTENT); + let first_count: i64 = + sqlx::query_scalar::query_scalar("SELECT COUNT(*) FROM authorizations WHERE id = ?1") + .bind(first.id) + .fetch_one(&pool) + .await + .unwrap(); + let second_count: i64 = + sqlx::query_scalar::query_scalar("SELECT COUNT(*) FROM authorizations WHERE id = ?1") + .bind(second.id) + .fetch_one(&pool) + .await + .unwrap(); + let redemption_count: i64 = sqlx::query_scalar::query_scalar( + "SELECT COUNT(*) FROM user_authorizations WHERE authorization_id = ?1", + ) + .bind(first.id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(first_count, 0); + assert_eq!(second_count, 1); + assert_eq!(redemption_count, 0); + } + + #[tokio::test] + async fn remove_authorization_rejects_authorization_from_another_key_path() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + let first_key = Keys::generate(); + let second_key = Keys::generate(); + + for key in [&first_key, &second_key] { + sqlx::query::query( + "INSERT INTO stored_keys (name, team_id, public_key, secret_key, created_at, updated_at) + VALUES ('key', ?1, ?2, ?3, datetime('now'), datetime('now'))", + ) + .bind(team.team.id) + .bind(key.public_key().to_hex()) + .bind(vec![1_u8, 2, 3]) + .execute(&pool) + .await + .unwrap(); + } + + let authorization = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, first_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some("Alice phone".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + + let err = remove_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path(( + team.team.id, + second_key.public_key().to_hex(), + authorization.id, + )), + ) + .await + .unwrap_err(); + + assert!(matches!(err, ApiError::NotFound(_))); + let authorization_count: i64 = + sqlx::query_scalar::query_scalar("SELECT COUNT(*) FROM authorizations WHERE id = ?1") + .bind(authorization.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(authorization_count, 1); + } + #[tokio::test] async fn delete_team_removes_related_rows_without_orphaning_permissions() { let pool = setup_test_db().await; diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 46c5eca..84090be 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -40,6 +40,8 @@ pub struct CreatePolicyRequest { #[derive(Debug, Deserialize)] pub struct AddAuthorizationRequest { + #[serde(default)] + pub name: Option, pub policy_id: u32, pub relays: Vec, pub max_uses: Option, diff --git a/core/src/database.rs b/core/src/database.rs index 5d9b96e..21cd2c2 100644 --- a/core/src/database.rs +++ b/core/src/database.rs @@ -158,6 +158,7 @@ mod tests { &migrations_dir, "0002_normalize_allowed_kinds_permissions.sql", ); + copy_migration(&migrations_dir, "0003_add_authorization_name.sql"); let database = Database::new(db_path.clone(), migrations_dir.clone()) .await @@ -169,7 +170,7 @@ mod tests { .fetch_all(pool) .await .expect("read migration versions"); - assert_eq!(migration_versions, vec![1, 2]); + assert_eq!(migration_versions, vec![1, 2, 3]); let migrated_config: serde_json::Value = query_scalar( "SELECT config FROM permissions @@ -195,8 +196,8 @@ mod tests { .expect("read current-shape config"); assert_eq!(current_shape_config, json!({ "allowed_kinds": [9735] })); - let existing_authorization: (String, i64, String) = query_as( - "SELECT secret, max_uses, bunker_public_key FROM authorizations WHERE secret = ?", + let existing_authorization: (String, i64, String, Option) = query_as( + "SELECT secret, max_uses, bunker_public_key, name FROM authorizations WHERE secret = ?", ) .bind("existing-secret") .fetch_one(pool) @@ -207,7 +208,8 @@ mod tests { ( "existing-secret".to_string(), 10, - "existing-bunker-pubkey".to_string() + "existing-bunker-pubkey".to_string(), + None ) ); @@ -227,7 +229,7 @@ mod tests { .fetch_all(&database.pool) .await .expect("read migration versions after second open"); - assert_eq!(migration_versions, vec![1, 2]); + assert_eq!(migration_versions, vec![1, 2, 3]); database.pool.close().await; let _ = fs::remove_dir_all(root); diff --git a/core/src/types/authorization.rs b/core/src/types/authorization.rs index 207557c..2c7c22b 100644 --- a/core/src/types/authorization.rs +++ b/core/src/types/authorization.rs @@ -71,6 +71,8 @@ pub struct Authorization { pub id: u32, /// The id of the stored key the authorization belongs to pub stored_key_id: u32, + /// A human-readable label for remembering what the authorization is for + pub name: Option, /// The generated secret connection uuid pub secret: String, /// The public key of the bunker nostr secret key @@ -102,6 +104,7 @@ impl<'r> FromRow<'r, SqliteRow> for Authorization { Ok(Self { id: row.try_get("id")?, stored_key_id: row.try_get("stored_key_id")?, + name: row.try_get("name")?, secret: row.try_get("secret")?, bunker_public_key: row.try_get("bunker_public_key")?, bunker_secret: row.try_get("bunker_secret")?, @@ -476,6 +479,12 @@ mod tests { .execute(&pool) .await .unwrap(); + raw_sql(include_str!( + "../../../database/migrations/0003_add_authorization_name.sql" + )) + .execute(&pool) + .await + .unwrap(); pool } @@ -520,12 +529,13 @@ mod tests { sqlx::query_as::query_as::<_, Authorization>( r#" INSERT INTO authorizations - (stored_key_id, secret, bunker_public_key, bunker_secret, relays, policy_id, max_uses, expires_at, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now')) + (stored_key_id, name, secret, bunker_public_key, bunker_secret, relays, policy_id, max_uses, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'), datetime('now')) RETURNING * "#, ) .bind(stored_key_id) + .bind(Option::::None) .bind(format!("test_secret_{}", uuid::Uuid::new_v4())) .bind(keys.public_key().to_hex()) .bind(keys.secret_key().to_secret_bytes().to_vec()) diff --git a/database/migrations/0003_add_authorization_name.sql b/database/migrations/0003_add_authorization_name.sql new file mode 100644 index 0000000..3190abf --- /dev/null +++ b/database/migrations/0003_add_authorization_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE authorizations +ADD COLUMN name TEXT; diff --git a/web/src/lib/components/AuthorizationCard.svelte b/web/src/lib/components/AuthorizationCard.svelte index c4ca546..125ea36 100644 --- a/web/src/lib/components/AuthorizationCard.svelte +++ b/web/src/lib/components/AuthorizationCard.svelte @@ -1,12 +1,19 @@
-

{authorization.authorization.secret}

+ {#if authorization.authorization.name} +
+

{authorization.authorization.name}

+

{authorization.authorization.secret}

+
+ {:else} +

{authorization.authorization.secret}

+ {/if} +
Redemptions: {authorization.users.length} / {authorization.authorization.max_uses || "∞"} diff --git a/web/src/lib/components/SignInMenu.svelte b/web/src/lib/components/SignInMenu.svelte index 16efd55..4c7eb80 100644 --- a/web/src/lib/components/SignInMenu.svelte +++ b/web/src/lib/components/SignInMenu.svelte @@ -3,6 +3,7 @@ import { createNostrConnectSigninSession, hasNip07Extension, isAmberSigninSupported, + type NostrConnectSigninOptions, type NostrConnectSigninSession, } from "$lib/nostr"; import { @@ -22,7 +23,12 @@ import { X, } from "phosphor-svelte"; -type BusyState = SigninMethod | "nostr-connect-link" | null; +type BusyState = SigninMethod | "nostr-connect-link" | "amber" | null; +type ConnectSessionStartOptions = { + autoOpen?: boolean; + busyState: Exclude; + signerKind?: NostrConnectSigninOptions["signerKind"]; +}; let open = $state(false); let bunkerUri = $state(""); @@ -72,15 +78,33 @@ async function submitBunker(event: SubmitEvent) { } async function startConnectLink() { + await startConnectSession({ busyState: "nostr-connect-link" }); +} + +async function startAmberConnect() { + await startConnectSession({ + autoOpen: true, + busyState: "amber", + signerKind: "amber", + }); +} + +async function startConnectSession(options: ConnectSessionStartOptions) { cancelConnectLink(); connectError = null; connectCopied = false; - connectSession = createNostrConnectSigninSession(); + connectSession = createNostrConnectSigninSession({ + signerKind: options.signerKind, + }); connectUri = connectSession.uri; const controller = new AbortController(); connectController = controller; - busy = "nostr-connect-link"; + busy = options.busyState; + + if (options.autoOpen) { + openConnectUri(connectUri); + } try { const user = await connectSession.waitForUser(controller.signal); @@ -101,6 +125,10 @@ async function startConnectLink() { } } +function openConnectUri(uri: string) { + window.location.href = uri; +} + function cancelConnectLink() { connectController?.abort(); connectSession?.cancel(); @@ -251,19 +279,19 @@ async function copyConnectUri() {
diff --git a/web/src/lib/nostr.ts b/web/src/lib/nostr.ts index 576e272..e126134 100644 --- a/web/src/lib/nostr.ts +++ b/web/src/lib/nostr.ts @@ -8,11 +8,7 @@ import { normalizeToPubkey, npubEncode } from "applesauce-core/helpers"; import { createEventLoaderForStore } from "applesauce-loaders/loaders"; import { RelayPool } from "applesauce-relay"; import type { ISigner } from "applesauce-signers"; -import { - AmberClipboardSigner, - ExtensionSigner, - NostrConnectSigner, -} from "applesauce-signers"; +import { ExtensionSigner, NostrConnectSigner } from "applesauce-signers"; import { catchError, filter, firstValueFrom, of, timeout } from "rxjs"; import { DEFAULT_NOSTR_READ_RELAYS, @@ -40,6 +36,10 @@ export type NostrConnectSigninSession = { waitForUser: (abort?: AbortSignal) => Promise; cancel: () => void; }; +export type NostrConnectSigninOptions = { + relays?: readonly string[]; + signerKind?: "nostr-connect" | "amber"; +}; const PROFILE_LOAD_TIMEOUT_MS = 5000; const CONTACTS_LOAD_TIMEOUT_MS = 5000; @@ -95,7 +95,10 @@ export function hasNip07Extension(): boolean { } export function isAmberSigninSupported(): boolean { - return Boolean(AmberClipboardSigner.SUPPORTED); + return ( + typeof navigator !== "undefined" && + /\bAndroid\b/i.test(navigator.userAgent) + ); } export function normalizeBunkerUri(uri: string): string { @@ -132,10 +135,6 @@ export async function getExtensionUser(): Promise { return userFromSigner(getExtensionSigner(), "extension"); } -export async function getAmberUser(): Promise { - return userFromSigner(new AmberClipboardSigner(), "amber"); -} - export async function connectNostrConnectBunker( bunkerUri: string, ): Promise { @@ -149,8 +148,10 @@ export async function connectNostrConnectBunker( } export function createNostrConnectSigninSession( - relays: readonly string[] = DEFAULT_NOSTR_CONNECT_RELAYS, + options: NostrConnectSigninOptions = {}, ): NostrConnectSigninSession { + const relays = options.relays ?? DEFAULT_NOSTR_CONNECT_RELAYS; + const signerKind = options.signerKind ?? "nostr-connect"; const signer = new NostrConnectSigner({ relays: [...relays], pool: relayPool, @@ -166,7 +167,7 @@ export function createNostrConnectSigninSession( uri, waitForUser: async (abort?: AbortSignal) => { await signer.waitForSigner(abort); - return userFromSigner(signer, "nostr-connect"); + return userFromSigner(signer, signerKind); }, cancel: () => { void signer.close(); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 39d0cdf..3b86b80 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -17,6 +17,7 @@ export type User = { export type Authorization = { id: number; stored_key_id: number; + name: string | null; secret: string; bunker_nsec: string; relays: string[]; diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index f6b7e29..5fd49a9 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -3,15 +3,13 @@ import { getCurrentUser, setCurrentUser } from "$lib/current_user.svelte"; import { clearActiveSigner, connectNostrConnectBunker, - getAmberUser, getExtensionUser, - isAmberSigninSupported, type NostrUser, } from "$lib/nostr"; import toast from "svelte-hot-french-toast"; import { checkPubkeyAllowed } from "./allowlist"; -export type SigninMethod = "extension" | "nip46-bunker" | "amber"; +export type SigninMethod = "extension" | "nip46-bunker"; export type SigninOptions = { bunkerUri?: string; }; @@ -72,12 +70,6 @@ async function userFromSigninMethod( return null; } return connectNostrConnectBunker(options.bunkerUri); - case "amber": - if (!isAmberSigninSupported()) { - toast.error("Amber sign-in is only available on supported Android browsers"); - return null; - } - return getAmberUser(); default: method satisfies never; return null; diff --git a/web/src/lib/utils/nostr.test.ts b/web/src/lib/utils/nostr.test.ts index 21ade76..e28e116 100644 --- a/web/src/lib/utils/nostr.test.ts +++ b/web/src/lib/utils/nostr.test.ts @@ -3,6 +3,7 @@ import type { EventTemplate, NostrEvent } from "applesauce-core/helpers"; import { buildNip46SigningPermissions, clearActiveSigner, + createNostrConnectSigninSession, getActiveSignerSummary, npubForPubkey, normalizeBunkerUri, @@ -65,6 +66,18 @@ describe("Nostr helper utilities", () => { ]); }); + test("creates Amber sign-in sessions through Nostr Connect", () => { + const session = createNostrConnectSigninSession({ signerKind: "amber" }); + const uri = new URL(session.uri); + + session.cancel(); + + expect(uri.protocol).toBe("nostrconnect:"); + expect(uri.searchParams.get("perms")).toBe( + "get_public_key,sign_event:27235", + ); + }); + test("signs events with the active signer", async () => { const template = authTemplate(); const signedEvent = signedAuthEvent(PUBKEY); diff --git a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte index 88257f4..6958995 100644 --- a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte +++ b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte @@ -98,6 +98,32 @@ async function removeKey() { toast.error("Failed to remove key"); }); } + +async function revokeAuthorization(authorization: AuthorizationWithRelations) { + if (!user?.pubkey) return; + if (!confirm("Revoke this authorization? Existing clients using it will lose access.")) + return; + + const authorizationId = authorization.authorization.id; + const endpoint = `/teams/${id}/keys/${pubkey}/authorizations/${authorizationId}`; + const authHeader = await api.buildAuthHeader(endpoint, "DELETE", user.pubkey); + + try { + await api.delete(endpoint, { + headers: { + Authorization: authHeader, + }, + }); + + authorizations = authorizations.filter( + (item) => item.authorization.id !== authorizationId, + ); + toast.success("Authorization revoked"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`Failed to revoke authorization: ${message}`); + } +} {#if isLoading} @@ -147,7 +173,10 @@ async function removeKey() { {:else}
{#each authorizations as authorization} - + {/each}
{/if} diff --git a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte index c8ecd18..3c4965d 100644 --- a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte +++ b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte @@ -23,6 +23,7 @@ const user = $derived(getCurrentUser()?.user); let isLoading = $state(true); let teamAuthHeader: string | null = $state(null); +let authorizationName = $state(""); let maxUses: number | null = $state(0); let expiresAt: Date | null = $state(null); let relaysString: string = $state(relayListForInput()); @@ -79,7 +80,9 @@ async function createAuthorization() { return; } + const name = authorizationName.trim(); const request = { + name: name || null, max_uses: maxUses === 0 ? null : maxUses, expires_at: expiresAt ? Math.floor(new Date(expiresAt).getTime() / 1000) @@ -121,6 +124,16 @@ async function createAuthorization() {
{ event.preventDefault(); createAuthorization(); }}> +
+ + +
+
From 24c8b75e2448380db49d8da84607f182fa003b93 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Mon, 18 May 2026 10:19:13 +0200 Subject: [PATCH 2/2] Address authorization review feedback --- web/src/lib/components/SignInMenu.svelte | 6 +++++- web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/SignInMenu.svelte b/web/src/lib/components/SignInMenu.svelte index 4c7eb80..ea4a178 100644 --- a/web/src/lib/components/SignInMenu.svelte +++ b/web/src/lib/components/SignInMenu.svelte @@ -118,7 +118,11 @@ async function startConnectSession(options: ConnectSessionStartOptions) { error instanceof Error ? error.message : "Unable to connect signer"; } } finally { - if (connectController === controller) { + const shouldClearConnectState = + connectController === controller || + (controller.signal.aborted && connectController === null); + + if (shouldClearConnectState) { busy = null; connectController = null; } diff --git a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte index 6958995..4862435 100644 --- a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte +++ b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte @@ -106,9 +106,10 @@ async function revokeAuthorization(authorization: AuthorizationWithRelations) { const authorizationId = authorization.authorization.id; const endpoint = `/teams/${id}/keys/${pubkey}/authorizations/${authorizationId}`; - const authHeader = await api.buildAuthHeader(endpoint, "DELETE", user.pubkey); try { + const authHeader = await api.buildAuthHeader(endpoint, "DELETE", user.pubkey); + await api.delete(endpoint, { headers: { Authorization: authHeader,