From 3632fad1cb1c004d15f1d6a931c89992c743c406 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 17 May 2026 11:52:00 +0200 Subject: [PATCH 1/3] Enable Amber and remote signer login, fix team member read access --- README.md | 6 +- api/src/api/http/teams.rs | 62 +++- web/AGENTS.md | 6 +- web/README.md | 10 +- web/src/lib/components/Header.svelte | 60 +++- web/src/lib/nostr.ts | 339 ++++++++++++++++++- web/src/lib/utils/auth.ts | 41 ++- web/src/lib/utils/signer_session.test.ts | 68 ++++ web/src/lib/utils/signer_session.ts | 72 ++++ web/src/routes/(app)/teams/[id]/+page.svelte | 74 ++-- 10 files changed, 687 insertions(+), 51 deletions(-) create mode 100644 web/src/lib/utils/signer_session.test.ts create mode 100644 web/src/lib/utils/signer_session.ts diff --git a/README.md b/README.md index 99ef273..0a8cc9f 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,11 @@ Socket organization policy. ## Runtime Flow -1. The web app asks the raw NIP-07 browser extension API for the active Nostr pubkey. +1. The web app signs in through a selected external signer: NIP-07 browser extension, Amber/NIP-55 + clipboard signing on Android, or a `bunker://` NIP-46 remote signer. 2. API calls include a NIP-98 HTTP auth event in the `Authorization` header. -3. The API verifies the event, extracts the pubkey, and checks team admin rights for most routes. +3. The API verifies the event, extracts the pubkey, and checks team membership for team reads and + admin rights for mutations or authorization-secret-bearing routes. 4. Stored keys and per-authorization bunker keys are encrypted with the root `master.key`. 5. The signer manager watches authorization rows and starts a `signer_daemon` for each one. 6. Each signer daemon decrypts the stored key and bunker key, listens on configured relays, and calls diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index 213d324..cc5b8c2 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -140,7 +140,7 @@ pub async fn get_team( AuthEvent(event): AuthEvent, Path(team_id): Path, ) -> ApiResult> { - verify_admin(&pool, &event.pubkey, team_id).await?; + verify_teammate(&pool, &event.pubkey, team_id).await?; let team_with_relations = Team::find_with_relations(&pool, team_id).await?; @@ -715,6 +715,20 @@ pub async fn verify_admin<'a>( } } +pub async fn verify_teammate<'a>( + pool: &'a SqlitePool, + pubkey: &'a PublicKey, + team_id: u32, +) -> ApiResult<()> { + match User::is_team_teammate(pool, pubkey, team_id).await { + Ok(true) => Ok(()), + Ok(false) => Err(ApiError::forbidden( + "You are not authorized to access this team", + )), + Err(_) => Err(ApiError::auth("Failed to verify team membership")), + } +} + #[cfg(test)] mod tests { use super::*; @@ -877,6 +891,52 @@ mod tests { assert_eq!(policy.permissions.len(), 2); } + #[tokio::test] + async fn team_members_can_read_team_but_not_admin_routes() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let member = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + + let _ = add_user( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path(team.team.id), + Json(AddTeammateRequest { + user_public_key: member.public_key().to_hex(), + role: TeamUserRole::Member, + }), + ) + .await + .unwrap(); + + let team_response = get_team( + State(pool.clone()), + AuthEvent(auth_event(&member)), + Path(team.team.id), + ) + .await + .unwrap() + .0; + + assert_eq!(team_response.team.id, team.team.id); + assert_eq!(team_response.team_users.len(), 2); + + let err = add_key( + State(pool.clone()), + AuthEvent(auth_event(&member)), + Path(team.team.id), + Json(AddKeyRequest { + name: "member key".to_string(), + secret_key: Keys::generate().secret_key().to_secret_hex(), + }), + ) + .await + .unwrap_err(); + + assert!(matches!(err, ApiError::Forbidden(_))); + } + #[tokio::test] async fn add_authorization_rejects_policy_from_another_team() { let pool = setup_test_db().await; diff --git a/web/AGENTS.md b/web/AGENTS.md index e25abde..caab339 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -15,8 +15,10 @@ This file inherits the root `AGENTS.md` guidance. It applies to `web/`. - Keep NIP-98 body hashes in exact sync with the JSON sent to the API. - Permission form data must match the Rust structs exactly. - Do not store private keys in localStorage or sessionStorage. -- Do not restore `nostr-login`; sign-in should stay on the raw NIP-07 browser extension API unless - there is a fresh security review. +- Do not restore `nostr-login`; sign-in should stay on the explicit signer-session paths in + `src/lib/nostr.ts` unless there is a fresh security review. +- Remote signer session storage may contain NIP-46 client material, but never the user's Nostr + private key. - Submit handlers should prevent default browser form submission before doing async signing. - For security-sensitive UI changes, run both typecheck and build. diff --git a/web/README.md b/web/README.md index 2ace18b..1b4a579 100644 --- a/web/README.md +++ b/web/README.md @@ -31,13 +31,13 @@ The repo-level dev command runs this app through `bun run dev:web`. ## Auth Flow -Sign-in uses the raw NIP-07 browser extension API to fetch the active pubkey. The app then uses -Applesauce's extension signer for NIP-98 event signing. `nostr-login` and NDK are no longer part of -the auth path. +Sign-in uses an explicit signer session selected by the user: raw NIP-07 browser extension, +Amber/NIP-55 clipboard signing on Android, or a `bunker://` NIP-46 remote signer. `nostr-login` and +NDK are no longer part of the auth path. `src/lib/keycast_api.svelte.ts` builds NIP-98 events with `u`, `method`, and optional `payload` tags, -signs them with NIP-07 through Applesauce, and sends the base64-encoded event in the -`Authorization` header. `src/lib/nostr.ts` owns browser signer access and public profile/contact +signs them through the active signer, and sends the base64-encoded event in the `Authorization` +header. `src/lib/nostr.ts` owns signer access, signer-session storage, and public profile/contact reads through Applesauce's event store and relay pool. The cookie named `keycastUserPubkey` is only a UI route hint. It is not proof of authorization. API diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index b38b345..911e22c 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -1,11 +1,18 @@ @@ -33,13 +40,50 @@ const activePage = $derived($page.url.pathname); Sign out {:else} - +
+ + {#if signInMenuOpen} +
+ {#if hasNip07Extension()} + + {/if} + {#if hasAmberSignerSupport()} + + {/if} + +
+ {/if} +
{/if} + + diff --git a/web/src/lib/nostr.ts b/web/src/lib/nostr.ts index 94bde6c..be2433e 100644 --- a/web/src/lib/nostr.ts +++ b/web/src/lib/nostr.ts @@ -4,11 +4,28 @@ import type { NostrEvent, ProfileContent, } from "applesauce-core/helpers"; +import { + bytesToHex, + getEventHash, + hexToBytes, + verifyEvent, +} from "applesauce-core/helpers/event"; import { normalizeToPubkey, npubEncode } from "applesauce-core/helpers"; import { createEventLoaderForStore } from "applesauce-loaders/loaders"; import { RelayPool } from "applesauce-relay"; -import { ExtensionSigner } from "applesauce-signers"; +import { + AmberClipboardSigner, + ExtensionSigner, + NostrConnectSigner, + PrivateKeySigner, + type ISigner, +} from "applesauce-signers"; import { catchError, filter, firstValueFrom, of, timeout } from "rxjs"; +import { + parseStoredSignerSession, + serializeStoredSignerSession, + type StoredSignerSession, +} from "$lib/utils/signer_session"; import { DEFAULT_NOSTR_READ_RELAYS, DEFAULT_OUTBOX_RELAYS, @@ -23,9 +40,17 @@ export type NostrProfile = ProfileContent; const PROFILE_LOAD_TIMEOUT_MS = 5000; const CONTACTS_LOAD_TIMEOUT_MS = 5000; +const REMOTE_SIGNER_TIMEOUT_MS = 30000; +const NIP_98_HTTP_AUTH_KIND = 27235; +const SIGNER_SESSION_STORAGE_KEY = "keycastSignerSession"; +const HEX_SIGNATURE = /^[0-9a-f]{128}$/i; export const eventStore = new EventStore(); export const relayPool = new RelayPool(); +NostrConnectSigner.pool = relayPool; + +let activeSigner: ISigner | null = null; +let activeSignerSession: StoredSignerSession | null = null; const loaderRelays = Array.from( new Set([...DEFAULT_NOSTR_READ_RELAYS, ...DEFAULT_OUTBOX_RELAYS]), @@ -78,20 +103,58 @@ export function getExtensionSigner(): ExtensionSigner { } export async function getExtensionPubkey(): Promise { - const pubkey = normalizePubkey(await getExtensionSigner().getPublicKey()); + const signer = getExtensionSigner(); + const pubkey = normalizePubkey(await signer.getPublicKey()); if (!pubkey) { throw new Error("The NIP-07 extension did not return a valid pubkey"); } + rememberSigner({ kind: "extension", pubkey }, signer); return pubkey; } +export function hasAmberSignerSupport(): boolean { + return ( + typeof navigator !== "undefined" && + /Android/i.test(navigator.userAgent) + ); +} + +export async function getAmberPubkey(): Promise { + const signer = new ManualAmberSigner(); + const pubkey = normalizePubkey(await signer.getPublicKey()); + if (!pubkey) { + throw new Error("Amber did not return a valid pubkey"); + } + + rememberSigner({ kind: "amber", pubkey }, signer); + return pubkey; +} + +export async function getRemoteSignerPubkey(bunkerUri: string): Promise { + const { signer, session } = await connectRemoteSigner(bunkerUri); + rememberSigner(session, signer); + return session.pubkey; +} + +export function clearSignerSession() { + activeSigner = null; + activeSignerSession = null; + browserStorage()?.removeItem(SIGNER_SESSION_STORAGE_KEY); +} + export async function signNostrEvent( template: EventTemplate, expectedPubkey?: string, ): Promise { - const signer = getExtensionSigner(); - const signedEvent = await signer.signEvent(template); + const signer = await signerForRequest(expectedPubkey); + const signedEvent = + signer instanceof ManualAmberSigner && expectedPubkey + ? await signer.signEvent({ + ...template, + pubkey: expectedPubkey, + } as EventTemplate & { pubkey: string }) + : await signer.signEvent(template); const normalizedExpectedPubkey = expectedPubkey ? normalizePubkey(expectedPubkey) : null; @@ -100,12 +163,278 @@ export async function signNostrEvent( normalizedExpectedPubkey && normalizePubkey(signedEvent.pubkey) !== normalizedExpectedPubkey ) { - throw new Error("The NIP-07 extension signed with a different pubkey"); + throw new Error("The signer signed with a different pubkey"); } return signedEvent; } +function browserStorage(): Storage | null { + if (typeof window === "undefined") return null; + return window.localStorage; +} + +function readStoredSignerSession(): StoredSignerSession | null { + return parseStoredSignerSession( + browserStorage()?.getItem(SIGNER_SESSION_STORAGE_KEY), + ); +} + +function rememberSigner(session: StoredSignerSession, signer: ISigner) { + activeSignerSession = session; + activeSigner = signer; + browserStorage()?.setItem( + SIGNER_SESSION_STORAGE_KEY, + serializeStoredSignerSession(session), + ); +} + +async function signerForRequest(expectedPubkey?: string): Promise { + const normalizedExpectedPubkey = expectedPubkey + ? normalizePubkey(expectedPubkey) + : null; + const storedSession = readStoredSignerSession(); + + if ( + activeSigner && + activeSignerSession && + (!normalizedExpectedPubkey || + activeSignerSession.pubkey === normalizedExpectedPubkey) + ) { + return activeSigner; + } + + if (storedSession) { + if ( + normalizedExpectedPubkey && + storedSession.pubkey !== normalizedExpectedPubkey + ) { + throw new Error("Stored signer session does not match the signed-in user"); + } + + const signer = await signerFromSession(storedSession); + activeSigner = signer; + activeSignerSession = storedSession; + return signer; + } + + return getExtensionSigner(); +} + +async function signerFromSession(session: StoredSignerSession): Promise { + if (session.kind === "extension") { + return getExtensionSigner(); + } + + if (session.kind === "amber") { + return new ManualAmberSigner(session.pubkey); + } + + return (await connectRemoteSigner( + session.bunkerUri, + session.clientSecretHex, + )).signer; +} + +async function connectRemoteSigner( + bunkerUri: string, + clientSecretHex?: string, +): Promise<{ signer: NostrConnectSigner; session: StoredSignerSession }> { + const parsed = NostrConnectSigner.parseBunkerURI(bunkerUri); + const clientSigner = clientSecretHex + ? new PrivateKeySigner(hexToBytes(clientSecretHex)) + : new PrivateKeySigner(); + const signer = new NostrConnectSigner({ + relays: parsed.relays, + remote: parsed.remote, + signer: clientSigner, + pool: relayPool, + onAuth: async (url) => { + window.open(url, "auth", "width=400,height=600"); + }, + }); + + try { + await withTimeout( + signer.connect( + parsed.secret, + NostrConnectSigner.buildSigningPermissions([ + NIP_98_HTTP_AUTH_KIND, + ]), + ), + REMOTE_SIGNER_TIMEOUT_MS, + "Remote signer connection timed out", + ); + + const pubkey = normalizePubkey( + await withTimeout( + signer.getPublicKey(), + REMOTE_SIGNER_TIMEOUT_MS, + "Remote signer did not return a pubkey", + ), + ); + if (!pubkey) throw new Error("Remote signer returned an invalid pubkey"); + + return { + signer, + session: { + kind: "remote", + pubkey, + bunkerUri, + clientSecretHex: bytesToHex(clientSigner.key), + }, + }; + } catch (error) { + await signer.close(); + throw error; + } +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +class ManualAmberSigner implements ISigner { + constructor(public pubkey?: string) {} + + async getPublicKey(): Promise { + if (!hasAmberSignerSupport()) { + throw new Error("Amber signing is only available on Android"); + } + + if (this.pubkey) return this.pubkey; + + const result = await requestAmberResult( + AmberClipboardSigner.createGetPublicKeyIntent(), + "public key", + false, + ); + const pubkey = normalizePubkey(result); + if (!pubkey) throw new Error("Expected Amber to return a pubkey"); + + this.pubkey = pubkey; + return pubkey; + } + + async signEvent( + template: EventTemplate & { pubkey?: string }, + ): Promise { + if (!hasAmberSignerSupport()) { + throw new Error("Amber signing is only available on Android"); + } + + const signerPubkey = template.pubkey ?? this.pubkey; + const pubkey = signerPubkey ? normalizePubkey(signerPubkey) : null; + if (!pubkey) throw new Error("Unknown Amber signer pubkey"); + + const draftWithPubkey = { ...template, pubkey }; + const draftWithId = { + ...draftWithPubkey, + id: getEventHash(draftWithPubkey), + }; + const result = await requestAmberResult( + AmberClipboardSigner.createSignEventIntent(draftWithId), + "signature", + ); + const signature = result.trim(); + if (!HEX_SIGNATURE.test(signature)) { + throw new Error("Expected Amber to return a hex signature"); + } + + const event = { ...draftWithId, sig: signature }; + if (!verifyEvent(event)) { + throw new Error("Amber returned an invalid signature"); + } + + return event; + } +} + +async function requestAmberResult( + intent: string, + label: string, + readClipboardOnReturn = true, +): Promise { + if (typeof window === "undefined" || typeof document === "undefined") { + throw new Error("Amber signing requires a browser"); + } + + window.open(intent, "_blank"); + const returnedFromAmber = await waitForBrowserToReturn(); + + if (returnedFromAmber && readClipboardOnReturn) { + const clipboardResult = await readClipboardText(); + if (clipboardResult.trim()) return clipboardResult.trim(); + } + + const manualResult = window.prompt(`Paste the Amber ${label} result`); + if (manualResult?.trim()) return manualResult.trim(); + + throw new Error(`Amber ${label} result was not provided`); +} + +async function waitForBrowserToReturn(): Promise { + return new Promise((resolve) => { + let sawHidden = document.visibilityState === "hidden"; + let settled = false; + + const done = (returnedFromAmber: boolean) => { + if (settled) return; + settled = true; + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("focus", onFocus); + resolve(returnedFromAmber); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + sawHidden = true; + } else if (sawHidden) { + setTimeout(() => done(true), 250); + } + }; + + const onFocus = () => { + if (sawHidden) { + setTimeout(() => done(true), 250); + } + }; + + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("focus", onFocus); + setTimeout(() => { + if (!sawHidden && document.visibilityState === "visible") { + done(false); + } + }, 1500); + }); +} + +async function readClipboardText(): Promise { + try { + if (navigator.clipboard?.readText) { + return await navigator.clipboard.readText(); + } + } catch { + // Android browsers often require a user gesture before granting clipboard access. + } + + return ""; +} + export async function loadProfile( pubkey: string | null | undefined, ): Promise { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 6ce2fbb..4ceb159 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,10 @@ import { goto } from "$app/navigation"; import { getCurrentUser, setCurrentUser } from "$lib/current_user.svelte"; import { + clearSignerSession, + getAmberPubkey, getExtensionPubkey, + getRemoteSignerPubkey, hasNip07Extension, userFromPubkey, type NostrUser, @@ -9,12 +12,16 @@ import { import toast from "svelte-hot-french-toast"; import { checkPubkeyAllowed } from "./allowlist"; +export type SigninMethod = "extension" | "amber" | "remote"; + async function isAllowedPubkey(pubkey: string) { return checkPubkeyAllowed(pubkey); } -export async function signin(): Promise { - const signedInUser = await userFromNip07(); +export async function signin( + method: SigninMethod = "extension", +): Promise { + const signedInUser = await userFromSigner(method); if (signedInUser) { let allowed = false; @@ -42,20 +49,20 @@ export async function signin(): Promise { } /** - * Retrieves a user object using the raw NIP-07 browser extension API. + * Retrieves a user object using the selected external signer. * @async - * @returns A Promise that resolves to a Nostr user if a NIP-07 extension is available, or null otherwise. + * @returns A Promise that resolves to a Nostr user if the signer returns a valid pubkey, or null otherwise. */ -async function userFromNip07(): Promise { - if (!hasNip07Extension()) { +async function userFromSigner(method: SigninMethod): Promise { + if (method === "extension" && !hasNip07Extension()) { toast.error("Install or enable a NIP-07 browser extension to sign in"); return null; } try { - const user = userFromPubkey(await getExtensionPubkey()); + const user = userFromPubkey(await getPubkeyForMethod(method)); if (!user) { - toast.error("The NIP-07 extension did not return a valid pubkey"); + toast.error("The signer did not return a valid pubkey"); return null; } @@ -68,11 +75,29 @@ async function userFromNip07(): Promise { } } +async function getPubkeyForMethod(method: SigninMethod): Promise { + if (method === "extension") { + return getExtensionPubkey(); + } + + if (method === "amber") { + return getAmberPubkey(); + } + + const bunkerUri = window.prompt("Paste your bunker:// remote signer URI"); + if (!bunkerUri) { + throw new Error("Remote signer URI was not provided"); + } + + return getRemoteSignerPubkey(bunkerUri.trim()); +} + /** * Signs the user out. */ export function signout() { setCurrentUser(null); + clearSignerSession(); document.cookie = "keycastUserPubkey="; toast.success("Signed out"); goto("/"); diff --git a/web/src/lib/utils/signer_session.test.ts b/web/src/lib/utils/signer_session.test.ts new file mode 100644 index 0000000..2741bf7 --- /dev/null +++ b/web/src/lib/utils/signer_session.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { + parseStoredSignerSession, + serializeStoredSignerSession, +} from "./signer_session"; + +const PUBKEY = "6f".repeat(32); +const CLIENT_SECRET = "a1".repeat(32); + +describe("signer session storage", () => { + test("round-trips extension and Amber signer sessions", () => { + expect( + parseStoredSignerSession( + serializeStoredSignerSession({ + kind: "extension", + pubkey: PUBKEY.toUpperCase(), + }), + ), + ).toEqual({ kind: "extension", pubkey: PUBKEY }); + + expect( + parseStoredSignerSession( + serializeStoredSignerSession({ + kind: "amber", + pubkey: PUBKEY, + }), + ), + ).toEqual({ kind: "amber", pubkey: PUBKEY }); + }); + + test("round-trips remote signer session metadata", () => { + expect( + parseStoredSignerSession( + serializeStoredSignerSession({ + kind: "remote", + pubkey: PUBKEY, + bunkerUri: "bunker://abc?relay=wss://relay.example", + clientSecretHex: CLIENT_SECRET.toUpperCase(), + }), + ), + ).toEqual({ + kind: "remote", + pubkey: PUBKEY, + bunkerUri: "bunker://abc?relay=wss://relay.example", + clientSecretHex: CLIENT_SECRET, + }); + }); + + test("fails closed for malformed sessions", () => { + expect(parseStoredSignerSession("{")).toBeNull(); + expect(parseStoredSignerSession("{}")).toBeNull(); + expect( + parseStoredSignerSession( + JSON.stringify({ kind: "amber", pubkey: "not-a-key" }), + ), + ).toBeNull(); + expect( + parseStoredSignerSession( + JSON.stringify({ + kind: "remote", + pubkey: PUBKEY, + bunkerUri: "nostrconnect://abc", + clientSecretHex: CLIENT_SECRET, + }), + ), + ).toBeNull(); + }); +}); diff --git a/web/src/lib/utils/signer_session.ts b/web/src/lib/utils/signer_session.ts new file mode 100644 index 0000000..2ef9d3a --- /dev/null +++ b/web/src/lib/utils/signer_session.ts @@ -0,0 +1,72 @@ +export type SignerKind = "extension" | "amber" | "remote"; + +export type StoredSignerSession = + | { + kind: "extension"; + pubkey: string; + } + | { + kind: "amber"; + pubkey: string; + } + | { + kind: "remote"; + pubkey: string; + bunkerUri: string; + clientSecretHex: string; + }; + +const HEX_64 = /^[0-9a-f]{64}$/i; + +export function isHexPubkey(value: unknown): value is string { + return typeof value === "string" && HEX_64.test(value); +} + +export function parseStoredSignerSession( + raw: string | null | undefined, +): StoredSignerSession | null { + if (!raw) return null; + + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return null; + } + + if (!value || typeof value !== "object") return null; + + const session = value as Record; + if (!isHexPubkey(session.pubkey)) return null; + + if (session.kind === "extension" || session.kind === "amber") { + return { + kind: session.kind, + pubkey: session.pubkey.toLowerCase(), + }; + } + + if ( + session.kind === "remote" && + typeof session.bunkerUri === "string" && + session.bunkerUri.startsWith("bunker://") && + typeof session.clientSecretHex === "string" && + /^[0-9a-f]+$/i.test(session.clientSecretHex) && + session.clientSecretHex.length === 64 + ) { + return { + kind: "remote", + pubkey: session.pubkey.toLowerCase(), + bunkerUri: session.bunkerUri, + clientSecretHex: session.clientSecretHex.toLowerCase(), + }; + } + + return null; +} + +export function serializeStoredSignerSession( + session: StoredSignerSession, +): string { + return JSON.stringify(session); +} diff --git a/web/src/routes/(app)/teams/[id]/+page.svelte b/web/src/routes/(app)/teams/[id]/+page.svelte index 745b326..86aa55b 100644 --- a/web/src/routes/(app)/teams/[id]/+page.svelte +++ b/web/src/routes/(app)/teams/[id]/+page.svelte @@ -29,6 +29,13 @@ let team: TeamWithRelations | null = $state(null); let users: User[] = $state([]); let storedKeys: StoredKey[] = $state([]); let policies: PolicyWithPermissions[] = $state([]); +let isAdmin = $derived( + users.some( + (team_user) => + team_user.user_public_key === user?.pubkey && + team_user.role === "Admin", + ), +); $effect(() => { if (user?.pubkey && !teamAuthHeader) { @@ -128,14 +135,18 @@ async function removeUser(userToRemove: User) { - - + {#if isAdmin} + + + {/if} {/each} - Add Member + {#if isAdmin} + Add Member + {/if} @@ -146,26 +157,47 @@ async function removeUser(userToRemove: User) { {:else}
{#each storedKeys as key} - - -
- - {key.name} - -
- - + {#if isAdmin} + + +
+ + {key.name} - - ({truncatedNpubForPubkey(key.public_key)}…) +
+ + + + + ({truncatedNpubForPubkey(key.public_key)}…) + +
+
+
+ {:else} +
+ +
+ + {key.name} +
+ + + + + ({truncatedNpubForPubkey(key.public_key)}…) + +
- + {/if} {/each}
{/if} - Add Key + {#if isAdmin} + Add Key + {/if}
@@ -180,11 +212,13 @@ async function removeUser(userToRemove: User) { {/each}
{/if} - Add Policy + {#if isAdmin} + Add Policy + {/if} - {#if users && users.some((team_user) => team_user.user_public_key === user?.pubkey && team_user.role === "Admin")} + {#if isAdmin} From 07dc2b63ff4f5ca50740efc137e09766860bf2be Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 17 May 2026 12:53:24 +0200 Subject: [PATCH 2/3] Add pull request CI workflow --- .github/workflows/ci.yml | 78 ++++++++++++++++++++++++++++++++++++++++ api/src/api/http/mod.rs | 2 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4cde1b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Rust + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + + - name: Generate test master key + run: | + set -euo pipefail + openssl rand 32 | base64 > master.key + chmod 600 master.key + + - name: Check Rust formatting + run: cargo fmt --all --check + + - name: Check Rust workspace + run: cargo check --workspace --locked + + - name: Build Rust workspace + run: cargo build --workspace --locked + + - name: Test Rust workspace + run: cargo test --workspace --locked + + web: + name: Web + runs-on: ubuntu-latest + + defaults: + run: + working-directory: web + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install web dependencies + run: bun install --frozen-lockfile + + - name: Test web package + run: bun test + + - name: Check Svelte package + run: bun run check + + - name: Build web package + run: bun run build diff --git a/api/src/api/http/mod.rs b/api/src/api/http/mod.rs index d0fe356..c18fac4 100644 --- a/api/src/api/http/mod.rs +++ b/api/src/api/http/mod.rs @@ -444,7 +444,7 @@ mod tests { #[test] fn rejects_future_timestamps() { let req = request(Method::GET, "/teams"); - let future = chrono::Utc::now().timestamp() + AUTH_EVENT_MAX_FUTURE_SKEW_SECONDS + 1; + let future = chrono::Utc::now().timestamp() + AUTH_EVENT_MAX_FUTURE_SKEW_SECONDS + 60; let event = auth_event( standard_tags("GET", "https://example.com/api/teams"), future, From 28419d8c0f6850523e14afa148d3c16519f76c47 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 17 May 2026 12:55:40 +0200 Subject: [PATCH 3/3] Sync SvelteKit before CI web tests --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cde1b0..5f38ad3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,11 +68,11 @@ jobs: - name: Install web dependencies run: bun install --frozen-lockfile - - name: Test web package - run: bun test - - name: Check Svelte package run: bun run check + - name: Test web package + run: bun test + - name: Build web package run: bun run build