From 408ac348cce7fd9bca7b526a0990def018bacbee Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Wed, 28 Oct 2026 15:41:18 -0700 Subject: [PATCH 1/3] feat(builderlab): one Builderlab settings page and sign-in for communities and agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the Hosted communities plugin into the Builderlab plugin. Settings → Plugins → Builderlab (Block logo) now holds the account, hosted communities and remote agents. Desktop communities use the plugin's shared sign-in and the configured BUZZ_BUILDERLAB_URL instead of a separate production login, and read the account once sign-in's automatic identity link settles. Live development with a pinned viewer keeps the broker. Create community links open the new page. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- crates/plugin-manager/src/lib.rs | 5 - docs/identity.md | 2 +- docs/input-correction-audit.md | 2 +- docs/plugin-architecture.md | 28 +- docs/shell-design.md | 2 +- docs/status.md | 2 +- src-tauri/src/host_request.rs | 23 -- src/app/App.tsx | 2 +- src/app/pages.integration.test.mjs | 16 +- src/app/settings-sections.test.ts | 12 +- src/bundled/builderlab/README.md | 15 +- .../builderlab/agents/RemoteAgents.tsx | 48 ++-- .../communities}/HostedCommunities.test.tsx | 0 .../communities}/HostedCommunities.tsx | 150 +++++----- .../communities}/api.test.ts | 0 .../communities}/api.ts | 0 .../communities}/native.test.ts | 269 ++++++++---------- .../communities}/native.ts | 209 +++++--------- src/bundled/builderlab/index.test.tsx | 45 ++- src/bundled/builderlab/index.tsx | 64 ++++- src/bundled/builderlab/manifest.json | 2 +- src/bundled/hosted-communities/README.md | 9 - src/bundled/hosted-communities/icon.png | Bin 58298 -> 0 bytes src/bundled/hosted-communities/index.test.tsx | 186 ------------ src/bundled/hosted-communities/index.tsx | 24 -- src/bundled/hosted-communities/manifest.json | 10 - src/bundled/index.ts | 7 - src/features/communities/CommunityLanding.tsx | 2 +- .../communities/CommunityPicker.test.tsx | 4 +- src/features/communities/CommunityPicker.tsx | 4 +- src/shared/design-system/icons/BlockLogo.tsx | 35 +++ src/shared/design-system/icons/index.ts | 8 + 32 files changed, 467 insertions(+), 718 deletions(-) rename src/bundled/{hosted-communities => builderlab/communities}/HostedCommunities.test.tsx (100%) rename src/bundled/{hosted-communities => builderlab/communities}/HostedCommunities.tsx (92%) rename src/bundled/{hosted-communities => builderlab/communities}/api.test.ts (100%) rename src/bundled/{hosted-communities => builderlab/communities}/api.ts (100%) rename src/bundled/{hosted-communities => builderlab/communities}/native.test.ts (63%) rename src/bundled/{hosted-communities => builderlab/communities}/native.ts (50%) delete mode 100644 src/bundled/hosted-communities/README.md delete mode 100644 src/bundled/hosted-communities/icon.png delete mode 100644 src/bundled/hosted-communities/index.test.tsx delete mode 100644 src/bundled/hosted-communities/index.tsx delete mode 100644 src/bundled/hosted-communities/manifest.json create mode 100644 src/shared/design-system/icons/BlockLogo.tsx diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 9662d06499..4f8ce00daf 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -318,10 +318,6 @@ pub fn bundled_manifests() -> Vec { .expect("workflows manifest"), serde_json::from_str(include_str!("../../../src/bundled/feedback/manifest.json")) .expect("feedback manifest"), - serde_json::from_str(include_str!( - "../../../src/bundled/hosted-communities/manifest.json" - )) - .expect("hosted communities manifest"), serde_json::from_str(include_str!( "../../../src/bundled/moderation/manifest.json" )) @@ -364,7 +360,6 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.codex" | "buzz.goose" | "buzz.workflows" - | "block.hosted-communities" | "block.builderlab" | "buzz.moderation" | "buzz.relay-staff" diff --git a/docs/identity.md b/docs/identity.md index 3a3680f96a..1d262281b5 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -85,7 +85,7 @@ the signing key across rebuilds therefore extends trust to those programs. Desktop builds configured with `BUZZ_NSEC_BACKUP_SERVICE_URL` and `BUZZ_NSEC_BACKUP_NAMESPACE` back up a local identity only after an explicit saved -automatic-backup choice, Builderlab or Hosted communities login, and matching key +automatic-backup choice, Builderlab login, and matching key binding. Unset, self-managed, corrupt or mismatched consent prevents all backup requests, including retries. These are paired native build-process inputs; unset leaves the feature disabled. The service URL diff --git a/docs/input-correction-audit.md b/docs/input-correction-audit.md index b45a0ca274..378897ae77 100644 --- a/docs/input-correction-audit.md +++ b/docs/input-correction-audit.md @@ -26,7 +26,7 @@ capitalization, but do not explicitly disable autocorrect. | `features/communities/CommunityDialog.tsx` | Relay URL and invite code | | `features/browser/BrowserHostView.tsx` | Address bar | | `features/profiles/AvatarEditor.tsx` | Picture URL and emoji | -| `bundled/hosted-communities/HostedCommunities.tsx` | Domain slug, transfer recipient npub | +| `bundled/builderlab/communities/HostedCommunities.tsx` | Domain slug, transfer recipient npub | | `bundled/channels/CreateChannelDialog.tsx` | Channel name | | `bundled/channels/ChannelLifecycleDialog.tsx` | Exact channel-name confirmation | | `bundled/workflows/WorkflowEditor.tsx` | Workflow identifiers and YAML | diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 099924e12e..e61c2c72d1 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -509,7 +509,7 @@ contracts with their own layout and local navigation. **Channels is the only required bundled plugin.** Bestie, Todos and Usage are off by default. Feedback, Diff viewer, Identity Naming, Agent Activity, Terminal, Links, GitHub, Inbox, -Repositories, Agents, Claude, Codex, Workflows, Builderlab, Hosted communities, +Repositories, Agents, Claude, Codex, Workflows, Builderlab, Membership, and Moderation are on by default, but optional. Both browser and native catalogs declare that policy. @@ -523,7 +523,7 @@ without a saved flag use the current default, so changing a default also changes those existing profiles. There is no migration or forced reset: a browser profile that previously saved its full plugin snapshot can retain Bestie enabled. Native profiles store per-plugin overrides. Default-on does not promise platform support: Terminal contributes UI -only on macOS/Linux desktop; Hosted communities needs either the desktop app or +only on macOS/Linux desktop; Builderlab's hosted communities need either the desktop app or the live development broker. Disabling Membership removes its Invite to community shortcut. Membership and Moderation share app-owned access controllers composed in @@ -593,9 +593,10 @@ Create resolves its agents and its teams' members to those that are the owner's agents in this community and sends them with the Canvas as ordinary channel setup, which the session applies and retries as before. -Hosted communities (`block.hosted-communities`) is a Block-specific bundled plugin -under Settings → Plugins → Builderlab hosting. It manages Block-hosted relays through a Builderlab -account: browser sign-in, binding the local Buzz identity (a locally signed kind +Hosted communities are part of the Block-specific Builderlab plugin +(`block.builderlab`, `communities/`), shown under Settings → Plugins → Builderlab +beside remote agents. They manage Block-hosted relays through the plugin's one +Builderlab sign-in: binding the local Buzz identity (a locally signed kind 24243 challenge), and create/archive/unarchive/transfer. A server-declared, default-off capability also exposes owner deletion for archived communities. The card persists the bound four-field request before admission. A fresh request can @@ -610,14 +611,15 @@ re-read and verified before dispatch; browser local storage has no atomic compar so exactly simultaneous contexts remain a documented client-side race; it never signs deletion or infers acceptance from a missing list row. Joining stays in the existing Join a community dialog; the card only copies the new relay address. Its -account backend follows the same split as `HostService`: live development uses the -broker's `/api/builderlab/*` routes (`browser-host/builderlab.mjs`), which keep the session -credential and signing key in Node. Desktop builds use `native.ts`, the same routes -over the shared `oauth_callback` sign-in, host HTTP to the manifest's single -`https://app.builderlab.xyz` grant, and `identity_sign_builderlab_binding`, which -validates the challenge and signs only the fixed kind 24243 binding. The desktop -credential lives in plugin memory for the plugin's lifetime and is never persisted, -so restarting the app signs out. Other browser builds show the unavailable notice. +account backend follows the same split as `HostService`: live development with a +pinned viewer uses the broker's `/api/builderlab/*` routes (`browser-host/builderlab.mjs`), +which keep their own session credential and signing key in Node. Desktop builds use +`native.ts`, the same routes with the plugin's shared session credential, host HTTP to +the configured `BUZZ_BUILDERLAB_URL` grant, and `identity_sign_builderlab_binding`, which +validates the challenge and signs only the fixed kind 24243 binding. They read the +account after sign-in's automatic identity link settles. The desktop credential lives in +plugin memory for the plugin's lifetime and is never persisted, so restarting the app +signs out. Builds without a configured Builderlab URL show the unavailable notice. ### Composer accessories diff --git a/docs/shell-design.md b/docs/shell-design.md index 0412782c17..48f47b03f6 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -65,7 +65,7 @@ semantic tokens, UI authoring rules and the local component reference. a shared Popover containing NavigationItem rows with their existing context menus; this richer picker retains community actions as well as switching. Arrow keys, Home and End move focus. Join and Create community appear at the bottom; creation - opens the registered Hosted communities settings flow. Dialogs return focus to + opens the registered Builderlab settings flow. Dialogs return focus to the surviving community trigger. Switching pages preserves the desktop sidebar’s current visibility. The manual collapse toggle remains available across destinations. diff --git a/docs/status.md b/docs/status.md index f60eed138c..fc3fd134bd 100644 --- a/docs/status.md +++ b/docs/status.md @@ -55,7 +55,7 @@ and [contributing](contributing.md) for maintained commands and test organizatio | Gate | What is still needed | | --- | --- | | Native lifecycle acceptance | Agree app-data **and keyring** isolation, then exercise actual CLI install/update → running-app observation, on-disk rollback/backup, disable/recovery/safe mode and process restart. Browser fixture storage is not native IPC/filesystem evidence. | -| Native identity and networking | External plugins can declare exact commands and HTTPS origins for bounded native calls. Packaged real-data acceptance and per-user login/session behavior remain open. Hosted communities' Builderlab sign-in and identity binding run natively in desktop builds; the session lasts until the app restarts. The declarations are disclosure, not plugin isolation; private keys stay outside plugin JavaScript. Do not widen CSP to unrestricted networking. | +| Native identity and networking | External plugins can declare exact commands and HTTPS origins for bounded native calls. Packaged real-data acceptance and per-user login/session behavior remain open. The Builderlab sign-in, shared by hosted communities, remote agents and apps, and its identity binding run natively in desktop builds; the session lasts until the app restarts. The declarations are disclosure, not plugin isolation; private keys stay outside plugin JavaScript. Do not widen CSP to unrestricted networking. | | Supported author contract | The conversation preview adds generated type-only `@buzz/author`, scaffold consumption and external Composer/Message reuse. Final validation, archived cross-host compatibility and native acceptance remain open; no stable SDK promise. | | Independent plugin acceptance | Independently build a real-data page and a non-GitHub panel without private host imports, copied contracts or bespoke host patches. In a packaged app: install disabled → enable/use → A/B switching → update/remount → disable → rollback → failed-start recovery. Include revocation, unknown/rejected writes and stalled cleanup. | | Distribution | PR CI is wired in `.github/workflows/ci.yml`; decide release signing, updating, provenance and compatibility support. Record packaged artifacts and platform-specific results. Native test targets at the recorded gate contain zero cases; compilation is not GUI acceptance. | diff --git a/src-tauri/src/host_request.rs b/src-tauri/src/host_request.rs index c7453b400e..0968c96f1f 100644 --- a/src-tauri/src/host_request.rs +++ b/src-tauri/src/host_request.rs @@ -499,29 +499,6 @@ mod tests { assert!(!allows_origin(&grants, &url)); } - #[test] - fn bundled_hosted_communities_reach_only_the_builderlab_origin() { - let temp = tempfile::tempdir().unwrap(); - let manager = - buzzodz_plugins::Manager::open(Some(temp.path().into()), "test", false).unwrap(); - let grants = manager - .host_grants("block.hosted-communities", "bundled") - .unwrap(); - assert_eq!(grants.network_origins, ["https://app.builderlab.xyz"]); - assert!(grants.commands.is_empty()); - for (url, allowed) in [ - ("https://app.builderlab.xyz/api/goose/v1/auth/me", true), - ("https://builderlab.xyz/api/goose/v1/auth/me", false), - ( - "https://app.builderlab.xyz:8443/api/goose/v1/auth/me", - false, - ), - ] { - let (url, _, _) = validate_request(&request(url)).unwrap(); - assert_eq!(allows_origin(&grants, &url), allowed, "{url}"); - } - } - #[test] fn uploads_only_an_archive_file_in_home() { let url = reqwest::Url::parse("https://apps.example.test/deploy").unwrap(); diff --git a/src/app/App.tsx b/src/app/App.tsx index 9d98db9a67..008c16734c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -299,7 +299,7 @@ function ConnectedApp({ services }: { services: AppServices }) { void services.navigation.open({ version: 1, kind: "settings", - section: "block.hosted-communities/hosted", + section: "block.builderlab/login", }) } /> diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 5070072d89..8431eede7f 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -135,13 +135,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .snapshot() .map((card) => card.pluginId) .sort(), - [ - "block.builderlab", - "block.hosted-communities", - "buzz.channels", - "buzz.emoji", - "buzz.pairing", - ], + ["block.builderlab", "buzz.channels", "buzz.emoji", "buzz.pairing"], ); // The device-local template library remains reachable without a selected community. assert.equal( @@ -207,13 +201,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .snapshot() .map((card) => card.pluginId) .sort(), - [ - "block.builderlab", - "block.hosted-communities", - "buzz.channels", - "buzz.emoji", - "buzz.pairing", - ], + ["block.builderlab", "buzz.channels", "buzz.emoji", "buzz.pairing"], ); await vi.waitFor(() => diff --git a/src/app/settings-sections.test.ts b/src/app/settings-sections.test.ts index 367424177c..d88a38fae4 100644 --- a/src/app/settings-sections.test.ts +++ b/src/app/settings-sections.test.ts @@ -32,13 +32,9 @@ const cards = [ card("other/groups"), card("example/membership", { section: "administration" }), card("example/hosted", { group: "Communities" }), - card("block.hosted-communities/hosted", { - group: "Plugins", - title: "Builderlab hosting", - }), card("block.builderlab/login", { group: "Plugins", - title: "Builderlab agents", + title: "Builderlab", }), ]; const primary = { id: "primary", name: "Primary" }; @@ -53,11 +49,7 @@ const app = [ "updates", ...(developerMode ? ["developer"] : []), ]; -const plugins = [ - "plugins", - "block.builderlab/login", - "block.hosted-communities/hosted", -]; +const plugins = ["plugins", "block.builderlab/login"]; const outline = (model: ReturnType) => model.groups.map((group) => [ group.id, diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index b42cf5598f..ca3e04ea2d 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -1,13 +1,14 @@ # Builderlab -Create agents that Builderlab hosts for you, so they keep working even when Buzz is closed. +One Builderlab account for communities and agents that Builderlab hosts for you, so they keep working even when Buzz is closed. ## Getting started -1. Go to **Settings → Plugins → Builderlab agents** and choose **Sign in with Builderlab**. -2. In **Agents**, choose **New agent** and pick **Builderlab (hosted)** as the **Harness**. -3. Choose a model and reasoning effort, optionally add instructions, then create the agent. -4. Edit instructions later in **Identity**, and model settings in **Settings**. +1. Go to **Settings → Plugins → Builderlab** and choose **Sign in with Builderlab**. +2. To host a community, choose **Create community** under **Communities**, then **Copy address**, open the community picker and choose **Join a community**. +3. To host an agent, in **Agents** choose **New agent** and pick **Builderlab (hosted)** as the **Harness**. +4. Choose a model and reasoning effort, optionally add instructions, then create the agent. +5. Edit instructions later in **Identity**, and model settings in **Settings**. --- @@ -43,7 +44,9 @@ SSO is a separate integration. ## Remote agents -The bundled `block.builderlab` plugin adds Settings → Plugins → Builderlab agents. +The bundled `block.builderlab` plugin adds Settings → Plugins → Builderlab: one +sign-in, then hosted communities (`communities/`, see +[plugin architecture](../../../docs/plugin-architecture.md)) and remote agents. With a valid `BUZZ_BUILDERLAB_URL` build input, it also registers `block.builderlab/builderlab` in Agents' new-agent type picker. `adapter-type/` connects this hosted type to Beekeeper: provision registers the diff --git a/src/bundled/builderlab/agents/RemoteAgents.tsx b/src/bundled/builderlab/agents/RemoteAgents.tsx index f33526815a..a1205a865a 100644 --- a/src/bundled/builderlab/agents/RemoteAgents.tsx +++ b/src/bundled/builderlab/agents/RemoteAgents.tsx @@ -4,10 +4,14 @@ import { useState, useSyncExternalStore, } from "react"; -import { RobotIcon } from "../../../shared/design-system/icons"; +import { + ArrowsClockwiseIcon, + RobotIcon, +} from "../../../shared/design-system/icons"; import { Button } from "../../../shared/design-system/ui/Button"; import { EmptyState } from "../../../shared/design-system/ui/EmptyState"; import { InlineHeader } from "../../../shared/design-system/ui/Header"; +import { SettingsGroup } from "../../../shared/design-system/ui/SettingsGroup"; import type { OAuthSession } from "../oauth/session"; import type { AgentClient, RemoteAgent } from "./client"; @@ -82,12 +86,18 @@ function AgentList({ return (
+
); } diff --git a/src/bundled/hosted-communities/HostedCommunities.test.tsx b/src/bundled/builderlab/communities/HostedCommunities.test.tsx similarity index 100% rename from src/bundled/hosted-communities/HostedCommunities.test.tsx rename to src/bundled/builderlab/communities/HostedCommunities.test.tsx diff --git a/src/bundled/hosted-communities/HostedCommunities.tsx b/src/bundled/builderlab/communities/HostedCommunities.tsx similarity index 92% rename from src/bundled/hosted-communities/HostedCommunities.tsx rename to src/bundled/builderlab/communities/HostedCommunities.tsx index e159fbc96f..8207fd8e6f 100644 --- a/src/bundled/hosted-communities/HostedCommunities.tsx +++ b/src/bundled/builderlab/communities/HostedCommunities.tsx @@ -1,4 +1,4 @@ -import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { SettingsGroup } from "../../../shared/design-system/ui/SettingsGroup"; import { type ReactNode, useCallback, @@ -9,14 +9,14 @@ import { useState, } from "react"; import { npubEncode } from "nostr-tools/nip19"; -import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; -import { Button } from "../../shared/design-system/ui/Button"; -import { Checkbox } from "../../shared/design-system/ui/Checkbox"; -import { Dialog } from "../../shared/design-system/ui/Dialog"; -import { EmptyState } from "../../shared/design-system/ui/EmptyState"; -import { Header, InlineHeader } from "../../shared/design-system/ui/Header"; -import { Field } from "../../shared/design-system/ui/Field"; -import { Input } from "../../shared/design-system/ui/Input"; +import { AlertDialog } from "../../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../../shared/design-system/ui/Button"; +import { Checkbox } from "../../../shared/design-system/ui/Checkbox"; +import { Dialog } from "../../../shared/design-system/ui/Dialog"; +import { EmptyState } from "../../../shared/design-system/ui/EmptyState"; +import { InlineHeader } from "../../../shared/design-system/ui/Header"; +import { Field } from "../../../shared/design-system/ui/Field"; +import { Input } from "../../../shared/design-system/ui/Input"; import { ArchiveIcon, ArrowSquareOutIcon, @@ -30,7 +30,7 @@ import { SignOutIcon, TrashIcon, WarningCircleIcon, -} from "../../shared/design-system/icons"; +} from "../../../shared/design-system/icons"; import { ApiFailure, boundKey, @@ -69,9 +69,12 @@ type Confirm = { export function HostedCommunities({ api, active, + sharedSignIn = false, }: { api: Api; active(): boolean; + /** The surrounding page owns sign-in, sign-out and the account row. */ + sharedSignIn?: boolean; }) { const activeRef = useRef(active); activeRef.current = active; @@ -417,11 +420,14 @@ export function HostedCommunities({ }; return ( -
-
+ {failure} {unsupported ? ( @@ -441,67 +447,71 @@ export function HostedCommunities({ Checking sign-in…

) : !auth ? ( - } - title="Sign in to manage hosted communities" - description="Sign in through your browser to manage Block hosting. The rest of Buzz works without a Builderlab account." - action={ - <> - - {action === "login" && ( - - )} - - } - /> + {action === "login" && ( + + )} + + } + /> + ) ) : ( <> - + {!sharedSignIn && } -
-
-

- {auth.name || auth.email || "Builderlab account"} -

- {auth.name && auth.email && ( -

{auth.email}

- )} + {!sharedSignIn && ( +
+
+

+ {auth.name || auth.email || "Builderlab account"} +

+ {auth.name && auth.email && ( +

{auth.email}

+ )} +
+
- -
+ )} {!identity ? ( identityLoadFailed ? null : (
diff --git a/src/bundled/hosted-communities/api.test.ts b/src/bundled/builderlab/communities/api.test.ts similarity index 100% rename from src/bundled/hosted-communities/api.test.ts rename to src/bundled/builderlab/communities/api.test.ts diff --git a/src/bundled/hosted-communities/api.ts b/src/bundled/builderlab/communities/api.ts similarity index 100% rename from src/bundled/hosted-communities/api.ts rename to src/bundled/builderlab/communities/api.ts diff --git a/src/bundled/hosted-communities/native.test.ts b/src/bundled/builderlab/communities/native.test.ts similarity index 63% rename from src/bundled/hosted-communities/native.test.ts rename to src/bundled/builderlab/communities/native.test.ts index ff616c3c05..da0400e5de 100644 --- a/src/bundled/hosted-communities/native.test.ts +++ b/src/bundled/builderlab/communities/native.test.ts @@ -1,5 +1,7 @@ -import { expect, it, vi } from "vitest"; -import type { Host, HostRequest } from "../../features/host/service"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Host, HostRequest } from "../../../features/host/service"; +import type { Credential } from "../oauth/browser"; +import { createOAuthSession } from "../oauth/session"; import { createNativeBackend } from "./native"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); @@ -7,6 +9,21 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); const API = "https://app.builderlab.xyz/api/goose"; type Reply = { status?: number; body: unknown }; +beforeEach(() => + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"), +); +afterEach(() => vi.unstubAllEnvs()); + +const credential = (value: string): Credential => + Object.freeze({ + value, + account: Object.freeze({ + subject: "user", + email: "a@example.com", + canUseRemoteAgents: false, + }), + }); + function setup( replies: Record Reply | Promise)>, commands: Record) => unknown> = {}, @@ -31,10 +48,6 @@ function setup( const invoke = vi.fn( async (name: string, input?: Record) => { const handlers: typeof commands = { - oauth_callback_begin: () => ({ id: "attempt" }), - oauth_callback_wait: () => ({ parameters: [["code", "one-time"]] }), - oauth_callback_cancel: () => undefined, - nsec_backup_session: () => undefined, nsec_backup_retry: () => undefined, ...commands, }; @@ -43,15 +56,22 @@ function setup( return handler(input); }, ); - const native = createNativeBackend(host, { + let token = "token"; + let link = Promise.resolve(); + const session = createOAuthSession(async () => credential(token)); + const backend = createNativeBackend(host, session, () => link, { invoke: invoke as (n: string, a?: Record) => Promise, }); - return { ...native, requests, invoke }; + const signIn = async (next = "token") => { + token = next; + await session.signIn(); + }; + const holdLink = (held: Promise) => { + link = held; + }; + return { backend, session, signIn, holdLink, requests, invoke }; } const signedIn = { - "/v1/auth/login/exchange": { - body: { session_credential: "token", expires_at: "2030" }, - }, "/v1/auth/me": { body: { email: "a@example.com", @@ -60,8 +80,6 @@ const signedIn = { }, }, }; -const login = (backend: ReturnType["backend"]) => - backend.send("login", {}, new AbortController().signal); it("reports no account before sign-in and refuses account calls", async () => { const { backend, requests } = setup({}); @@ -72,12 +90,14 @@ it("reports no account before sign-in and refuses account calls", async () => { expect(requests).toEqual([]); }); -it("keeps the credential out of results and forwards only allowlisted fields", async () => { - const { backend, requests } = setup({ - ...signedIn, - "/v1/buzz/communities/archive": { body: { community: { id: "c" } } }, - }); - const { value } = await login(backend); +it("signs in through the shared session and keeps its credential out of results", async () => { + const { backend, session } = setup(signedIn); + const { value } = await backend.send( + "login", + {}, + new AbortController().signal, + ); + expect(session.snapshot().status).toBe("signed-in"); expect(value).toEqual({ auth: { email: "a@example.com", @@ -86,6 +106,21 @@ it("keeps the credential out of results and forwards only allowlisted fields", a }, }); expect(JSON.stringify(value)).not.toContain("token"); + await expect(backend.send("sign-out")).resolves.toEqual({ + status: 200, + value: {}, + }); + expect(session.snapshot().status).toBe("signed-out"); + await expect(backend.auth()).resolves.toBeNull(); +}); + +it("forwards only allowlisted fields to the configured Builderlab", async () => { + const { backend, signIn, requests } = setup({ + ...signedIn, + "/v1/buzz/communities/archive": { body: { community: { id: "c" } } }, + }); + await signIn(); + expect(backend.origin).toBe("https://app.builderlab.xyz"); await backend.send("archive", { community_id: "c", extra: "dropped" }); expect(requests.at(-1)).toEqual({ url: `${API}/v1/buzz/communities/archive`, @@ -105,65 +140,16 @@ it("keeps the credential out of results and forwards only allowlisted fields", a ); }); -it("rejects a code exchange whose expiry does not match the session", async () => { - const { backend } = setup({ - ...signedIn, - "/v1/auth/me": { body: { email: "a@example.com", expires_at: "2031" } }, - }); - await expect(login(backend)).rejects.toThrow( - "Builderlab session expiry did not match code exchange", - ); - await expect(backend.auth()).resolves.toBeNull(); -}); - -it("surfaces a provider callback error and always releases the listener", async () => { - const { backend, invoke } = setup(signedIn, { - oauth_callback_wait: () => ({ - parameters: [ - ["error", "access_denied"], - ["error_description", "Denied"], - ], - }), - }); - await expect(login(backend)).rejects.toThrow("Denied"); - expect(invoke).toHaveBeenCalledWith("oauth_callback_cancel", { - id: "attempt", - }); -}); - -it("cancels the native listener when sign-in is aborted", async () => { - let release: () => void = () => {}; - const { backend, invoke } = setup(signedIn, { - oauth_callback_wait: () => - new Promise((_, reject) => { - release = () => reject("canceled"); - }), - oauth_callback_cancel: () => release(), - }); - const abort = new AbortController(); - const pending = backend.send("login", {}, abort.signal); - await vi.waitFor(() => - expect(invoke).toHaveBeenCalledWith("oauth_callback_wait", { - id: "attempt", - }), - ); - abort.abort(); - await expect(pending).rejects.toThrow("Builderlab authentication canceled"); - expect(invoke).toHaveBeenCalledWith("oauth_callback_cancel", { - id: "attempt", - }); -}); - it("passes structured errors through with their status and rejects others", async () => { let reply: Reply = { status: 409, body: { error: { code: "deletion_conflict" } }, }; - const { backend } = setup({ + const { backend, signIn } = setup({ ...signedIn, "/v1/buzz/communities/list": () => reply, }); - await login(backend); + await signIn(); await expect(backend.send("list")).resolves.toEqual({ status: 409, value: { error: { code: "deletion_conflict" } }, @@ -187,85 +173,63 @@ it("passes structured errors through with their status and rejects others", asyn }); it("turns a host transport rejection into an error", async () => { - const { backend } = setup(signedIn); - await login(backend); + const { backend, signIn } = setup(signedIn); + await signIn(); await expect(backend.send("list")).rejects.toThrow("Host request failed"); }); -it("clears the session when it can no longer be verified", async () => { +it("signs the shared session out when Builderlab rejects it", async () => { let me: Reply = signedIn["/v1/auth/me"]; - const { backend } = setup({ ...signedIn, "/v1/auth/me": () => me }); - await login(backend); - me = { status: 401, body: {} }; + const { backend, session, signIn } = setup({ + ...signedIn, + "/v1/auth/me": () => me, + }); + await signIn(); + me = { status: 500, body: {} }; await expect(backend.auth()).rejects.toThrow( - "Builderlab session check failed with HTTP 401", + "Builderlab session check failed with HTTP 500", ); + expect(session.snapshot().status).toBe("signed-in"); + me = { status: 401, body: {} }; await expect(backend.auth()).resolves.toBeNull(); + expect(session.snapshot().status).toBe("signed-out"); }); -it("forgets the credential on sign-out and plugin disposal", async () => { - const { backend, dispose, invoke } = setup(signedIn); - await login(backend); - const firstHandoff = invoke.mock.calls.find( - ([name, input]) => - name === "nsec_backup_session" && input?.credential === "token", - )?.[1]; - expect(firstHandoff).toMatchObject({ source: "hosted", credential: "token" }); - await expect(backend.send("sign-out")).resolves.toEqual({ - status: 200, - value: {}, - }); - await expect(backend.auth()).resolves.toBeNull(); - expect(invoke).toHaveBeenLastCalledWith("nsec_backup_session", { - source: "hosted", - generation: expect.any(Number), - credential: null, - }); - await login(backend); - const replacement = invoke.mock.calls - .filter( - ([name, input]) => - name === "nsec_backup_session" && input?.credential === "token", - ) - .at(-1)?.[1]; - expect(replacement?.generation).toBeGreaterThan( - Number(firstHandoff?.generation), +it("reads the account only after sign-in's identity link settles", async () => { + const { backend, signIn, holdLink, requests } = setup(signedIn); + let finish: () => void = () => {}; + holdLink( + new Promise((resolve) => { + finish = resolve; + }), ); - dispose(); - await expect(backend.auth()).resolves.toBeNull(); - expect(invoke).toHaveBeenLastCalledWith("nsec_backup_session", { - source: "hosted", - generation: expect.any(Number), - credential: null, - }); + await signIn(); + const checking = backend.auth(); + await Promise.resolve(); + expect(requests).toEqual([]); + finish(); + await expect(checking).resolves.toMatchObject({ email: "a@example.com" }); + expect(requests.map(({ url }) => url)).toEqual([`${API}/v1/auth/me`]); }); -it("finishes login while the silent backup handoff is still pending", async () => { - let release!: () => void; - const pending = new Promise((resolve) => { - release = resolve; - }); - const { backend, invoke } = setup(signedIn, { - nsec_backup_session: (input) => (input?.credential ? pending : undefined), +it("does not describe a session that ended during its check", async () => { + let answer: (reply: Reply) => void = () => {}; + const { backend, session, signIn } = setup({ + "/v1/auth/me": () => + new Promise((resolve) => { + answer = resolve; + }), }); - try { - await expect(login(backend)).resolves.toMatchObject({ status: 200 }); - expect(invoke).toHaveBeenCalledWith("nsec_backup_session", { - source: "hosted", - generation: expect.any(Number), - credential: "token", - }); - await expect(backend.auth()).resolves.toMatchObject({ - email: "a@example.com", - }); - } finally { - release(); - } + await signIn(); + const checking = backend.auth(); + session.signOut(); + answer(signedIn["/v1/auth/me"]); + await expect(checking).rejects.toThrow("Builderlab session changed"); }); it("returns a challenge error without signing", async () => { const sign = vi.fn(); - const { backend } = setup( + const { backend, signIn } = setup( { ...signedIn, "/v1/buzz/nostr-identities/challenge": { @@ -275,7 +239,7 @@ it("returns a challenge error without signing", async () => { }, { identity_sign_builderlab_binding: sign }, ); - await login(backend); + await signIn(); await expect(backend.send("bind")).resolves.toMatchObject({ value: { error: { code: "unauthorized" } }, }); @@ -285,7 +249,7 @@ it("returns a challenge error without signing", async () => { it("does not sign a challenge issued to a session that has since ended", async () => { let issue: (reply: Reply) => void = () => {}; const sign = vi.fn(); - const { backend, requests } = setup( + const { backend, session, signIn, requests } = setup( { ...signedIn, "/v1/buzz/nostr-identities/challenge": () => @@ -295,21 +259,21 @@ it("does not sign a challenge issued to a session that has since ended", async ( }, { identity_sign_builderlab_binding: sign }, ); - await login(backend); + await signIn(); const binding = backend.send("bind"); await vi.waitFor(() => expect(requests.at(-1)?.url).toBe( `${API}/v1/buzz/nostr-identities/challenge`, ), ); - await backend.send("sign-out"); + session.signOut(); issue({ body: { challenge_id: "x" } }); await expect(binding).rejects.toThrow("Builderlab session changed"); expect(sign).not.toHaveBeenCalled(); }); it("forwards a native signing refusal as an error", async () => { - const { backend, requests } = setup( + const { backend, signIn, requests } = setup( { ...signedIn, "/v1/buzz/nostr-identities/challenge": { @@ -322,7 +286,7 @@ it("forwards a native signing refusal as an error", async () => { }, }, ); - await login(backend); + await signIn(); await expect(backend.send("bind")).rejects.toThrow( "Invalid Nostr identity challenge", ); @@ -373,7 +337,7 @@ function bindingWithHeldSignature() { }, ); const start = async () => { - await login(native.backend); + await native.signIn(); const binding = native.backend.send("bind"); await vi.waitFor(() => expect(native.invoke).toHaveBeenCalledWith( @@ -389,8 +353,13 @@ function bindingWithHeldSignature() { } it("verifies a signed binding with the session that requested it", async () => { - const { start, verified, invoke } = bindingWithHeldSignature(); + const { start, verified, invoke, requests } = bindingWithHeldSignature(); const { binding, release } = await start(); + expect( + JSON.parse( + String(requests.find(({ url }) => url.endsWith("/challenge"))?.body), + ), + ).toEqual({ origin: "https://app.builderlab.xyz" }); release(); await expect(binding).resolves.toEqual({ status: 200, @@ -406,7 +375,7 @@ it("verifies a signed binding with the session that requested it", async () => { }), }); expect(invoke).toHaveBeenCalledWith("nsec_backup_retry", { - source: "hosted", + source: "builderlab", }); }); @@ -414,16 +383,19 @@ it.each([ [ "sign-out", (native: ReturnType) => - native.backend.send("sign-out"), + native.session.signOut(), ], [ "a replacement sign-in", - (native: ReturnType) => - login(native.backend), + async (native: ReturnType) => { + native.session.signOut(); + await native.signIn("replacement"); + }, ], [ "plugin disposal", - (native: ReturnType) => native.dispose(), + (native: ReturnType) => + native.session.dispose(), ], ])("does not verify a signature completed after %s", async (_, end) => { const native = bindingWithHeldSignature(); @@ -432,7 +404,8 @@ it.each([ release(); await expect(binding).rejects.toThrow("Builderlab session changed"); expect(native.verified()).toEqual([]); - expect(native.invoke).not.toHaveBeenCalledWith("nsec_backup_retry", { - source: "hosted", - }); + expect(native.invoke).not.toHaveBeenCalledWith( + "nsec_backup_retry", + expect.anything(), + ); }); diff --git a/src/bundled/hosted-communities/native.ts b/src/bundled/builderlab/communities/native.ts similarity index 50% rename from src/bundled/hosted-communities/native.ts rename to src/bundled/builderlab/communities/native.ts index e8afec6536..49ec52a436 100644 --- a/src/bundled/hosted-communities/native.ts +++ b/src/bundled/builderlab/communities/native.ts @@ -1,13 +1,12 @@ // Packaged-desktop Builderlab account backend, mirroring browser-host/builderlab.mjs route for -// route. The session credential lives only in this plugin's memory; native code owns -// the browser callback, host HTTP, and the kind 24243 binding signature. +// route. It uses the plugin's shared Builderlab sign-in; native code owns host HTTP +// and the kind 24243 binding signature. import { invoke } from "@tauri-apps/api/core"; -import type { Host, HostRequest } from "../../features/host/service"; +import type { Host, HostRequest } from "../../../features/host/service"; +import { oauthTarget } from "../oauth/browser"; +import type { OAuthSession } from "../oauth/session"; import { boundKey, type Account, type Backend } from "./api"; -import { handoffBackupSession } from "../../features/identity/backup"; -export const BUILDERLAB_ORIGIN = "https://app.builderlab.xyz"; -const API = `${BUILDERLAB_ORIGIN}/api/goose`; const MAX_RESPONSE_BYTES = 64 * 1024; /** Account routes forwarded with allowlisted string fields only. */ @@ -47,47 +46,15 @@ const command = ( throw asError(error); }); -/** Waits for one loopback redirect carrying `code`, through the native listener. */ -async function awaitCode(bridge: Native, signal: AbortSignal) { - signal.throwIfAborted(); - const login = new URL(`${API}/v1/auth/login`); - login.search = new URLSearchParams({ - type: "cli", - product: "buzz", - }).toString(); - const { id } = await command<{ id: string }>(bridge, "oauth_callback_begin", { - authorizationUrl: login.href, - callbackPath: `/callback/${crypto.randomUUID().replaceAll("-", "")}`, - useState: true, - }); - const cancel = () => - void bridge.invoke("oauth_callback_cancel", { id }).catch(() => {}); - try { - signal.throwIfAborted(); - signal.addEventListener("abort", cancel, { once: true }); - const { parameters } = await command<{ - parameters: readonly (readonly [string, string])[]; - }>(bridge, "oauth_callback_wait", { id }); - signal.throwIfAborted(); - const query = new URLSearchParams(parameters.map(([k, v]) => [k, v])); - const code = query.get("code"); - if (!code) - throw new Error( - query.get("error_description") ?? - query.get("error") ?? - "Authentication callback did not include a code", - ); - return code; - } catch (error) { - if (signal.aborted) throw new Error("Builderlab authentication canceled"); - throw error; - } finally { - signal.removeEventListener("abort", cancel); - cancel(); - } -} - -export function createNativeBackend(host: Host, bridge: Native = native) { +export function createNativeBackend( + host: Host, + session: OAuthSession, + /** Settles once sign-in's automatic identity link has finished. */ + linked: () => Promise, + bridge: Native = native, +): Backend { + const api = oauthTarget(); + const origin = new URL(api).origin; const http = (input: HostRequest) => host.request(input).catch((error: unknown) => { throw asError(error); @@ -101,23 +68,30 @@ export function createNativeBackend(host: Host, bridge: Native = native) { throw new Error("Builderlab returned an invalid response"); } }; - let credential: string | undefined; + // Results from an earlier sign-in never describe or act for the current one. + const ensureCurrent = (credential: object) => { + if ( + session.snapshot().status !== "signed-in" || + session.credential() !== credential + ) + throw new Error("Builderlab session changed"); + }; const request = async ( path: string, body: object, - session = credential, + credential = session.credential(), ): Promise<{ status: number; value: object }> => { - if (!session) throw new Error("Sign in to Builderlab first"); const response = await http({ - url: `${API}${path}`, + url: `${api}${path}`, method: "POST", headers: { "Content-Type": "application/json", - "X-BB-Session-Credential": session, - Origin: BUILDERLAB_ORIGIN, + "X-BB-Session-Credential": credential.value, + Origin: origin, }, body: JSON.stringify(body), }); + ensureCurrent(credential); const value = parse(response.body); const ok = response.status >= 200 && response.status < 300; // Structured `{ error: { code, ... } }` bodies pass through for friendly UI messages. @@ -130,12 +104,22 @@ export function createNativeBackend(host: Host, bridge: Native = native) { return { status: response.status, value }; throw new Error(`Builderlab request failed (HTTP ${response.status}).`); }; - const me = async (session: string, signal: AbortSignal): Promise => { + const auth = async (): Promise => { + if (session.snapshot().status !== "signed-in") return null; + const credential = session.credential(); + // Read the account after sign-in links this device's key, so the + // identity it then shows is settled. + await linked(); + ensureCurrent(credential); const response = await http({ - url: `${API}/v1/auth/me`, - headers: { "X-BB-Session-Credential": session }, + url: `${api}/v1/auth/me`, + headers: { "X-BB-Session-Credential": credential.value }, }); - signal.throwIfAborted(); + ensureCurrent(credential); + if (response.status === 401) { + session.signOut(); + return null; + } if (response.status < 200 || response.status >= 300) throw new Error( `Builderlab session check failed with HTTP ${response.status}`, @@ -159,70 +143,23 @@ export function createNativeBackend(host: Host, bridge: Native = native) { }, }; }; - // Sign-in and sign-out bump the generation; late results from an older one never - // write, clear or describe the current session. - let generation = 0; - const backupSession = (current: string | null) => - handoffBackupSession("hosted", current, bridge.invoke); - let session = new AbortController(); - const revoke = () => { - generation += 1; - session.abort(); - session = new AbortController(); - credential = undefined; - backupSession(null); - }; - const login = async (signal: AbortSignal) => { - revoke(); - const started = generation; - const attempt = AbortSignal.any([signal, session.signal]); - const code = await awaitCode(bridge, attempt); - const response = await http({ - url: `${API}/v1/auth/login/exchange`, - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), - }); - attempt.throwIfAborted(); - if (response.status < 200 || response.status >= 300) - throw new Error( - `Builderlab code exchange failed with HTTP ${response.status}`, - ); - const exchanged = (parse(response.body) ?? {}) as { - session_credential?: unknown; - expires_at?: unknown; - }; - if ( - typeof exchanged.session_credential !== "string" || - !exchanged.session_credential - ) - throw new Error("Builderlab code exchange returned an empty credential"); - const account = await me(exchanged.session_credential, attempt); - if (account.expiresAt !== exchanged.expires_at) - throw new Error("Builderlab session expiry did not match code exchange"); - if (attempt.aborted || started !== generation) - throw new Error("Builderlab authentication canceled"); - credential = exchanged.session_credential; - backupSession(credential); - return account; - }; const bind = async () => { - const current = credential; - const started = generation; + // A sign-out or new sign-in while the challenge or native signature is + // pending must not let the old credential claim this device's key. + const credential = session.credential(); const challenge = await request( "/v1/buzz/nostr-identities/challenge", - { origin: BUILDERLAB_ORIGIN }, - current, + { origin }, + credential, ); if ((challenge.value as { error?: unknown }).error) return challenge; - // A sign-out, new sign-in or disposal while the challenge or native signature - // is pending must not let the old credential claim this device's key. - const ensureCurrent = () => { - if (started !== generation) throw new Error("Builderlab session changed"); - }; - ensureCurrent(); - const { challenge_id, nonce, verification_code, origin, expires_at } = - challenge.value as Record; + const { + challenge_id, + nonce, + verification_code, + origin: challengeOrigin, + expires_at, + } = challenge.value as Record; const event = await command( bridge, "identity_sign_builderlab_binding", @@ -231,25 +168,24 @@ export function createNativeBackend(host: Host, bridge: Native = native) { challenge_id, nonce, verification_code, - origin, + origin: challengeOrigin, expires_at, }, }, ); - ensureCurrent(); + ensureCurrent(credential); const verified = await request( "/v1/buzz/nostr-identities/verify", { challenge_id, nonce, signed_payload: JSON.stringify(event) }, - current, + credential, ); - ensureCurrent(); if ( verified.status >= 200 && verified.status < 300 && !(verified.value as { error?: unknown }).error ) void bridge - .invoke("nsec_backup_retry", { source: "hosted" }) + .invoke("nsec_backup_retry", { source: "builderlab" }) .catch(() => {}); return verified; }; @@ -271,30 +207,16 @@ export function createNativeBackend(host: Host, bridge: Native = native) { } return request(path, body); }; - const backend: Backend = { - origin: BUILDERLAB_ORIGIN, - async auth() { - const current = credential; - if (!current) return null; - const started = generation; - try { - const account = await me(current, session.signal); - if (started !== generation) - throw new Error("Builderlab session changed"); - return account; - } catch (error) { - if (started === generation) revoke(); - throw error; - } - }, + return { + origin, + auth, async send(action, body, signal) { - if (action === "login") - return { - status: 200, - value: { auth: await login(signal ?? session.signal) }, - }; + if (action === "login") { + await session.ensureSignedIn(signal ?? new AbortController().signal); + return { status: 200, value: { auth: await auth() } }; + } if (action === "sign-out") { - revoke(); + session.signOut(); return { status: 200, value: {} }; } if (action === "bind") return bind(); @@ -309,5 +231,4 @@ export function createNativeBackend(host: Host, bridge: Native = native) { } }, }; - return { backend, dispose: revoke }; } diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index b62076a82d..3cd793a3ab 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -318,6 +318,11 @@ it.each([ "desktop login respects identity ownership in VITE_BUZZ_LIVE=%s with remote agents=%s", async (live, canUseRemoteAgents) => { vi.stubEnv("VITE_BUZZ_LIVE", live); + // Without the development broker, its communities report unavailable. + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ error: "missing" }, { status: 404 })), + ); const viewer = "01".repeat(32); const linkFinished = deferred(); native.invoke.mockImplementation(async (command, input) => { @@ -341,9 +346,12 @@ it.each([ else if (url.endsWith("/me")) result = { subject: "synthetic-account", + expires_at: "2030", capabilities: { can_use_remote_agents: canUseRemoteAgents }, }; else if (url.endsWith("/list-agents")) result = { status: 1, agents: [] }; + else if (url.endsWith("/v1/buzz/communities/list")) + result = { communities: [], can_create: true }; else if (url.endsWith("/nostr-identities/current")) { result = { identity: { pubkey_hex: viewer } }; linkFinished.resolve(); @@ -392,6 +400,20 @@ it.each([ canUseRemoteAgents ? "No remote agents yet." : "Coming soon", ), ).toBeInTheDocument(); + if (live === "1") + // Live development keeps communities on the broker. + expect(fetch).toHaveBeenCalledWith("/api/builderlab/auth"); + else { + // Communities share the one sign-in and its identity link. + expect( + await screen.findByText("No hosted communities yet"), + ).toBeInTheDocument(); + expect(screen.getByText(/Buzz identity connected/)).toBeVisible(); + expect( + screen.getAllByRole("button", { name: "Sign out" }), + ).toHaveLength(1); + expect(fetch).not.toHaveBeenCalled(); + } if (live === "0") { await linkFinished.promise; await waitFor(() => @@ -486,6 +508,7 @@ it.each([ result = { subject: "user", email: "a@example.com", + expires_at: "2030", capabilities: { can_use_remote_agents: true }, }; } else if (url.endsWith("/list-agents")) { @@ -541,6 +564,8 @@ it.each([ result = { status: 1 }; } else if (url.endsWith("/known-communities/list")) { result = { communities: [] }; + } else if (url.endsWith("/v1/buzz/communities/list")) { + result = { communities: [], can_create: true }; } else { throw new Error(`Unexpected Builderlab request ${url}`); } @@ -611,8 +636,8 @@ it.each([ expect( await screen.findByText("No remote agents yet."), ).toBeInTheDocument(); - expect(screen.getByRole("status")).toHaveTextContent( - "Signed in as a@example.com.", + expect(screen.getByText("Signed in as a@example.com.")).toHaveRole( + "status", ); expect(document.body).not.toHaveTextContent("private-token"); expect(native.invoke).toHaveBeenCalledWith("oauth_callback_wait", { @@ -620,14 +645,24 @@ it.each([ }); // Sign-in also checks the account's linked Buzz identity, and the // account's community list follows through the same wiring: the complete - // list is read under the session credential. + // list is read under the session credential. The card's Communities + // section reads the account, its identity and its hosted communities. await waitFor(() => expect(knownCommunities.apply).toHaveBeenCalled()); await waitFor(() => expect( hostRequests() .map((input) => input.request.url.split("/").at(-1)) .sort(), - ).toEqual(["current", "exchange", "list", "list-agents", "me"]), + ).toEqual([ + "current", + "current", + "exchange", + "list", + "list", + "list-agents", + "me", + "me", + ]), ); const requests = hostRequests(); const [account, sync] = [ @@ -636,7 +671,7 @@ it.each([ input.request.url.includes("/v1/buzz/known-communities/"), ), ]; - expect(account).toHaveLength(3); + expect(account).toHaveLength(4); expect( requests.every( (input) => diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index 1115d7bb0f..e22ffef04f 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -16,6 +16,11 @@ import { createKnownCommunitiesClient } from "./known-communities/client"; import { startKnownCommunitiesSync } from "./known-communities/sync"; import type { AgentProviderSnapshot } from "../../features/agents/service"; import { Header, InlineHeader } from "../../shared/design-system/ui/Header"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { BlockLogoIcon } from "../../shared/design-system/icons"; +import { HostedCommunities } from "./communities/HostedCommunities"; +import { brokerApi, createApi } from "./communities/api"; +import { createNativeBackend } from "./communities/native"; import { hostedAgentSettings } from "./service"; import { applyApps } from "./apps"; @@ -135,33 +140,64 @@ export const apply: PluginModule["apply"] = (ctx) => { // Link the Buzz key at sign-in. Best effort: activation links again and // reports why when it cannot. let linked: unknown; + let linking = Promise.resolve(); ctx.effect(() => session.subscribe(() => { if (session.snapshot().status !== "signed-in") return; const credential = session.credential(); if (linked === credential) return; linked = credential; - void agents.link(new AbortController().signal).catch(() => {}); + linking = agents.link(new AbortController().signal).catch(() => {}); }), ); + // Desktop communities use this sign-in. Live development with a pinned + // viewer keeps the broker, which signs in and signs bindings on its own. + const communities = !nativeIdentityEnabled() + ? brokerApi + : loginAvailable + ? createApi(createNativeBackend(ctx.host, session, () => linking)) + : null; + const Communities = ({ active }: { active(): boolean }) => { + const state = useSyncExternalStore(session.subscribe, session.snapshot); + if (!communities) return null; + if (communities === brokerApi) + return ; + return state.status === "signed-in" ? ( + + ) : null; + }; ctx.settingsCards.register({ id: "login", - title: "Builderlab agents", + title: "Builderlab", + icon: BlockLogoIcon, group: "Plugins", component: ({ active }) => ( -
-
- +
+ + + + +
), diff --git a/src/bundled/builderlab/manifest.json b/src/bundled/builderlab/manifest.json index ee055c620e..587b083f23 100644 --- a/src/bundled/builderlab/manifest.json +++ b/src/bundled/builderlab/manifest.json @@ -2,7 +2,7 @@ "id": "block.builderlab", "name": "Builderlab", "apiVersion": 1, - "description": "Connect Buzz to Builderlab-hosted agents.", + "description": "Host communities and agents with your Builderlab account.", "discovery": { "category": "agents", "tags": ["Essentials", "builderlab"] diff --git a/src/bundled/hosted-communities/README.md b/src/bundled/hosted-communities/README.md deleted file mode 100644 index f81d7d5583..0000000000 --- a/src/bundled/hosted-communities/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Hosted communities - -Create and manage your own communities, hosted by Builderlab. - -## Getting started - -1. Go to **Settings → Communities → Hosted communities** and choose **Sign in with Builderlab**. -2. Choose **Create community**, then **Copy address**. -3. Open the community picker and choose **Join a community**. diff --git a/src/bundled/hosted-communities/icon.png b/src/bundled/hosted-communities/icon.png deleted file mode 100644 index 6f2f7abb21816f6e7745e26f26b808cfd62994f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58298 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 MQp%E5;>Lmh1B})QS^xk5 diff --git a/src/bundled/hosted-communities/index.test.tsx b/src/bundled/hosted-communities/index.test.tsx deleted file mode 100644 index 4c79a7e7c2..0000000000 --- a/src/bundled/hosted-communities/index.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { Context } from "@deepseek-ai/cordis"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HostService } from "../../features/host/service"; -import { SettingsCardsService } from "../../features/settings/service"; -import { PluginRuntime } from "../../plugins/runtime"; -import * as hosted from "./index"; -import manifest from "./manifest.json"; - -const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); -vi.mock("@tauri-apps/api/core", () => native); - -const local = "a".repeat(64); -const API = "https://app.builderlab.xyz/api/goose"; -const UNSUPPORTED = - "Hosted communities need the Buzz development broker and are unavailable in this build."; - -beforeEach(() => { - vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - vi.stubGlobal( - "fetch", - vi.fn(async () => Response.json({ error: "missing" }, { status: 404 })), - ); -}); -afterEach(() => { - cleanup(); - native.invoke.mockReset(); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); -}); - -async function mountCard() { - const root = new Context(); - const runtime = new PluginRuntime(root, async () => hosted); - new HostService(root); - const cards = new SettingsCardsService(root); - runtime.reconcile([ - { - manifest: { ...manifest, apiVersion: 1 }, - source: "bundled", - revision: "bundled", - enabled: true, - previous: null, - reloadable: false, - error: null, - }, - ]); - await waitFor(() => expect(cards.snapshot()).toHaveLength(1)); - const Card = cards.snapshot()[0]?.component; - if (!Card) throw new Error("Missing Hosted communities card"); - render( true} />); - return async () => { - cleanup(); - await runtime.dispose(); - await root.fiber.dispose(); - }; -} - -it("keeps live development on the broker", async () => { - vi.stubEnv("VITE_BUZZ_LIVE", "1"); - const dispose = await mountCard(); - try { - expect(await screen.findByText(UNSUPPORTED)).toBeInTheDocument(); - expect(fetch).toHaveBeenCalledWith("/api/builderlab/auth"); - expect(native.invoke).not.toHaveBeenCalledWith( - "plugin_host_request", - expect.anything(), - ); - } finally { - await dispose(); - } -}); - -it("signs in and connects the identity natively in a desktop build", async () => { - vi.stubEnv("VITE_BUZZ_LIVE", ""); - let bound = false; - const event = { id: "e".repeat(64), kind: 24243 }; - const challenge = { - challenge_id: "550e8400-e29b-41d4-a716-446655440000", - nonce: "n".repeat(43), - verification_code: "123456", - origin: "https://app.builderlab.xyz", - expires_at: "2999-01-01T00:00:00Z", - }; - const replies: Record unknown> = { - "/v1/auth/login/exchange": () => ({ - session_credential: "private-token", - expires_at: "2030", - }), - "/v1/auth/me": () => ({ email: "a@example.com", expires_at: "2030" }), - "/v1/buzz/nostr-identities/current": () => - bound - ? { identity: { pubkey_hex: local } } - : { error: { code: "missing_mapping", setup_needed: true } }, - "/v1/buzz/communities/list": () => ({ - communities: [], - quota_used: 0, - quota_limit: 5, - can_create: true, - }), - "/v1/buzz/nostr-identities/challenge": () => challenge, - "/v1/buzz/nostr-identities/verify": () => { - bound = true; - return { identity: { pubkey_hex: local } }; - }, - }; - native.invoke.mockImplementation(async (command, input) => { - if (command === "identity_restore") return local; - if (command === "oauth_callback_begin") return { id: "attempt" }; - if (command === "oauth_callback_wait") - return { parameters: [["code", "one-time"]] }; - if (command === "oauth_callback_cancel") return; - if (command === "identity_sign_builderlab_binding") return event; - if (command === "plugin_host_request") { - const reply = replies[input.request.url.slice(API.length)]; - if (!reply) throw new Error(`Unexpected ${input.request.url}`); - const status = - bound || !input.request.url.endsWith("/current") ? 200 : 404; - return { status, headers: {}, body: JSON.stringify(reply()) }; - } - throw new Error(`Unexpected command ${command}`); - }); - const dispose = await mountCard(); - try { - const user = userEvent.setup(); - await user.click( - await screen.findByRole("button", { name: "Sign in with Builderlab" }), - ); - await user.click( - await screen.findByRole("button", { name: "Connect Buzz identity" }), - ); - await waitFor(() => - expect( - screen.queryByRole("button", { name: "Connect Buzz identity" }), - ).toBeNull(), - ); - expect(screen.queryByText(UNSUPPORTED)).toBeNull(); - expect(document.body).not.toHaveTextContent("private-token"); - expect(fetch).not.toHaveBeenCalled(); - - const begin = native.invoke.mock.calls.find( - ([command]) => command === "oauth_callback_begin", - )?.[1]; - const login = new URL(begin.authorizationUrl); - expect(`${login.origin}${login.pathname}`).toBe(`${API}/v1/auth/login`); - expect([...login.searchParams]).toEqual([ - ["type", "cli"], - ["product", "buzz"], - ]); - expect(begin).toMatchObject({ useState: true }); - expect(native.invoke).toHaveBeenCalledWith("oauth_callback_cancel", { - id: "attempt", - }); - expect(native.invoke).toHaveBeenCalledWith( - "identity_sign_builderlab_binding", - { challenge }, - ); - const requests = native.invoke.mock.calls - .filter(([command]) => command === "plugin_host_request") - .map(([, input]) => input); - for (const input of requests) - expect(input).toMatchObject({ - id: "block.hosted-communities", - revision: "bundled", - }); - const verify = requests.find(({ request }) => - request.url.endsWith("/verify"), - )?.request; - expect(verify.headers).toEqual({ - "Content-Type": "application/json", - "X-BB-Session-Credential": "private-token", - Origin: "https://app.builderlab.xyz", - }); - expect(JSON.parse(verify.body)).toEqual({ - challenge_id: challenge.challenge_id, - nonce: challenge.nonce, - signed_payload: JSON.stringify(event), - }); - } finally { - await dispose(); - } -}); diff --git a/src/bundled/hosted-communities/index.tsx b/src/bundled/hosted-communities/index.tsx deleted file mode 100644 index d876e585bd..0000000000 --- a/src/bundled/hosted-communities/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { PluginModule } from "../../plugins/api"; -import { nativeIdentityEnabled } from "../../features/identity/service"; -import { HostedCommunities } from "./HostedCommunities"; -import { brokerApi, createApi } from "./api"; -import { createNativeBackend } from "./native"; -import { GlobeIcon } from "../../shared/design-system/icons"; - -export const inject = ["host", "settingsCards"]; -export const apply: PluginModule["apply"] = (ctx) => { - // Same split as HostService: live development keeps the broker; desktop builds go native. - let api = brokerApi; - if (nativeIdentityEnabled()) { - const native = createNativeBackend(ctx.host); - ctx.effect(() => native.dispose); - api = createApi(native.backend); - } - ctx.settingsCards.register({ - id: "hosted", - title: "Builderlab hosting", - icon: GlobeIcon, - group: "Plugins", - component: ({ active }) => , - }); -}; diff --git a/src/bundled/hosted-communities/manifest.json b/src/bundled/hosted-communities/manifest.json deleted file mode 100644 index fca919d7c8..0000000000 --- a/src/bundled/hosted-communities/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "block.hosted-communities", - "name": "Hosted communities", - "apiVersion": 1, - "description": "Create and manage Builderlab-hosted communities.", - "discovery": { "category": "people-and-communities", "tags": ["builderlab"] }, - "host": { - "networkOrigins": ["https://app.builderlab.xyz"] - } -} diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 43b2432c15..f9c14ab70e 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -53,8 +53,6 @@ import * as workflows from "./workflows"; import type { BundledPlugin } from "../plugins/manager"; import linksManifest from "./links/manifest.json"; import * as links from "./links"; -import hostedManifest from "./hosted-communities/manifest.json"; -import * as hosted from "./hosted-communities"; import moderationManifest from "./moderation/manifest.json"; import * as moderation from "./moderation"; import relayStaffManifest from "./relay-staff/manifest.json"; @@ -206,11 +204,6 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: workflows, enabledByDefault: true, }, - { - manifest: { ...hostedManifest, apiVersion: 1 }, - module: hosted, - enabledByDefault: true, - }, { manifest: { ...moderationManifest, apiVersion: 1 }, module: moderation, diff --git a/src/features/communities/CommunityLanding.tsx b/src/features/communities/CommunityLanding.tsx index 63f20cef22..daea8369bf 100644 --- a/src/features/communities/CommunityLanding.tsx +++ b/src/features/communities/CommunityLanding.tsx @@ -28,7 +28,7 @@ export function CommunityLanding({ const [error, setError] = useState(""); const [joining, setJoining] = useState(); const hostedAvailable = useSyncExternalStore(settingsCards.subscribe, () => - settingsCards.has("block.hosted-communities/hosted"), + settingsCards.has("block.builderlab/login"), ); const opened = invite ?? joining; return ( diff --git a/src/features/communities/CommunityPicker.test.tsx b/src/features/communities/CommunityPicker.test.tsx index 8d1bb08c50..81c19afdbc 100644 --- a/src/features/communities/CommunityPicker.test.tsx +++ b/src/features/communities/CommunityPicker.test.tsx @@ -1237,7 +1237,7 @@ it("shows only the active community until opened and routes creation through Hos onOpenTarget={onOpenTarget} settingsCards={{ subscribe: () => () => {}, - has: (key) => key === "block.hosted-communities/hosted", + has: (key) => key === "block.builderlab/login", }} />, { wrapper: ToastProvider }, @@ -1265,7 +1265,7 @@ it("shows only the active community until opened and routes creation through Hos expect(onOpenTarget).toHaveBeenCalledWith({ version: 1, kind: "settings", - section: "block.hosted-communities/hosted", + section: "block.builderlab/login", }); expect(h.select).not.toHaveBeenCalled(); await waitFor(() => diff --git a/src/features/communities/CommunityPicker.tsx b/src/features/communities/CommunityPicker.tsx index b7f2426668..5b079a4e7a 100644 --- a/src/features/communities/CommunityPicker.tsx +++ b/src/features/communities/CommunityPicker.tsx @@ -117,7 +117,7 @@ export function CommunityPicker({ ); const hostedAvailable = useSyncExternalStore( settingsCards?.subscribe ?? noSubscription, - () => settingsCards?.has("block.hosted-communities/hosted") ?? false, + () => settingsCards?.has("block.builderlab/login") ?? false, ); const invites = !!onOpenTarget && membershipAvailable; const role = useCommunityRole(invites ? session : undefined, client.viewer); @@ -326,7 +326,7 @@ export function CommunityPicker({ onOpenTarget({ version: 1, kind: "settings", - section: "block.hosted-communities/hosted", + section: "block.builderlab/login", }); }} /> diff --git a/src/shared/design-system/icons/BlockLogo.tsx b/src/shared/design-system/icons/BlockLogo.tsx new file mode 100644 index 0000000000..9854ce0737 --- /dev/null +++ b/src/shared/design-system/icons/BlockLogo.tsx @@ -0,0 +1,35 @@ +import { forwardRef, type SVGProps } from "react"; + +const cells = [0, 3.28, 6.56]; + +/** The Block, Inc. mark: a three-by-three grid of rounded squares. */ +export const BlockLogoArtwork = forwardRef< + SVGSVGElement, + SVGProps & { size?: number | string } +>(function BlockLogoArtwork({ size = "1em", ...props }, ref) { + return ( + // biome-ignore lint/a11y/noSvgWithoutTitle: the public gateway supplies accessibility semantics. + + {cells.flatMap((y) => + cells.map((x) => ( + + )), + )} + + ); +}); diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts index e64f208da4..18931ddbf6 100644 --- a/src/shared/design-system/icons/index.ts +++ b/src/shared/design-system/icons/index.ts @@ -1,6 +1,7 @@ import { defineIcon } from "./createDecorativeIcon"; export type { DefinedIcon } from "./createDecorativeIcon"; import { BestieMarkArtwork } from "./BestieMark"; +import { BlockLogoArtwork } from "./BlockLogo"; import { GitHubIssueArtwork } from "./GitHubIssue"; import { OneDriveLogoArtwork } from "./OneDriveLogo"; import { @@ -333,6 +334,13 @@ export const OneDriveLogoIcon = defineIcon("custom", OneDriveLogoArtwork, { "Custom current-color outline recreation of the Microsoft OneDrive cloud mark.", intendedSizes: [{ width: 14, height: 14 }], }); +export const BlockLogoIcon = defineIcon("custom", BlockLogoArtwork, { + meaning: "Builderlab settings", + category: "Custom brand mark", + provenance: + "Block, Inc. corporate mark, current-color grid from the Builderlab Buzz site's block-logo.svg.", + intendedSizes: [{ width: 18, height: 18 }], +}); export const GitHubIssueIcon = defineIcon("custom", GitHubIssueArtwork, { meaning: "Open GitHub issue", category: "Product mark", From 1e5b155b54e02f5daae8a3197f18f376514b9d54 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 16:55:37 +1100 Subject: [PATCH 2/3] fix(builderlab): end the session on a community 401 and point journeys at the one page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the PR #1863 review: - A 401 from a community route now signs the shared Builderlab session out, as the account check and the remote agents path already do. Before, Account stayed signed in while Communities asked the user to sign in again with no way to do it. The current-credential check still runs first, so a stale 401 cannot sign out a newer session. - The Apps sign-in error now sends owners to Settings → Plugins → Builderlab. The Builderlab agents section no longer exists. - Browser journeys open the single Builderlab section in place of Builderlab hosting and Builderlab agents. The plugin-defaults journey checks Builderlab in place of the removed Hosted communities entry. Their behavioural assertions are unchanged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/builderlab/apps/client.test.ts | 2 +- src/bundled/builderlab/apps/client.ts | 2 +- .../builderlab/communities/native.test.ts | 25 +++++++++++++++++++ src/bundled/builderlab/communities/native.ts | 4 +++ tests/browser/plugin-defaults.spec.mjs | 2 +- tests/browser/settings-layout.spec.mjs | 2 +- tests/browser/settings.spec.mjs | 9 +++---- 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/bundled/builderlab/apps/client.test.ts b/src/bundled/builderlab/apps/client.test.ts index c42361911e..d643825cb6 100644 --- a/src/bundled/builderlab/apps/client.test.ts +++ b/src/bundled/builderlab/apps/client.test.ts @@ -298,7 +298,7 @@ it("uploads the artifact to the app's deploy endpoint", async () => { it("points the agent at Settings when signed out or refused", async () => { const signedOut = await setup([], false); await expect(signedOut.client.list()).rejects.toThrow( - /not signed in.*Settings → Plugins → Builderlab agents/, + /not signed in.*Settings → Plugins → Builderlab, then/, ); expect(signedOut.requests).toEqual([]); const refused = await setup([json(401, {})]); diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 1ad082e3a0..42fc4c6199 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -9,7 +9,7 @@ import type { OAuthSession } from "../oauth/session"; const PROTOCOL = "0.2.0"; export type RuntimeProfile = "static" | "fetch-js"; export const SIGN_IN = - "Ask your owner to sign in at Settings → Plugins → Builderlab agents, then try again."; + "Ask your owner to sign in at Settings → Plugins → Builderlab, then try again."; type Json = Record; const object = (value: unknown): Json => diff --git a/src/bundled/builderlab/communities/native.test.ts b/src/bundled/builderlab/communities/native.test.ts index da0400e5de..1a32ff7034 100644 --- a/src/bundled/builderlab/communities/native.test.ts +++ b/src/bundled/builderlab/communities/native.test.ts @@ -195,6 +195,31 @@ it("signs the shared session out when Builderlab rejects it", async () => { expect(session.snapshot().status).toBe("signed-out"); }); +it("ends only the current session when a community route returns 401", async () => { + let answer: (reply: Reply) => void = () => {}; + const { backend, session, signIn, requests } = setup({ + "/v1/buzz/communities/list": () => + new Promise((resolve) => { + answer = resolve; + }), + }); + await signIn(); + const stale = backend.send("list"); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + session.signOut(); + await signIn("replacement"); + answer({ status: 401, body: {} }); + await expect(stale).rejects.toThrow("Builderlab session changed"); + expect(session.snapshot().status).toBe("signed-in"); + const current = backend.send("list"); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + answer({ status: 401, body: { error: { code: "unauthorized" } } }); + await expect(current).rejects.toThrow( + "Your Builderlab session expired. Sign in again.", + ); + expect(session.snapshot().status).toBe("signed-out"); +}); + it("reads the account only after sign-in's identity link settles", async () => { const { backend, signIn, holdLink, requests } = setup(signedIn); let finish: () => void = () => {}; diff --git a/src/bundled/builderlab/communities/native.ts b/src/bundled/builderlab/communities/native.ts index 49ec52a436..3bc63d1792 100644 --- a/src/bundled/builderlab/communities/native.ts +++ b/src/bundled/builderlab/communities/native.ts @@ -92,6 +92,10 @@ export function createNativeBackend( body: JSON.stringify(body), }); ensureCurrent(credential); + if (response.status === 401) { + session.signOut(); + throw new Error("Your Builderlab session expired. Sign in again."); + } const value = parse(response.body); const ok = response.status >= 200 && response.status < 300; // Structured `{ error: { code, ... } }` bodies pass through for friendly UI messages. diff --git a/tests/browser/plugin-defaults.spec.mjs b/tests/browser/plugin-defaults.spec.mjs index dfdbdd53d6..b83b470e86 100644 --- a/tests/browser/plugin-defaults.spec.mjs +++ b/tests/browser/plugin-defaults.spec.mjs @@ -31,7 +31,7 @@ test("Bestie opts in without changing the other bundled defaults and survives re "GitHub", "Terminal", "Workflows", - "Hosted communities", + "Builderlab", "Agent Attention UI", ]) await expect(pluginAction(page, name)).toHaveAccessibleName(/^Disable /); diff --git a/tests/browser/settings-layout.spec.mjs b/tests/browser/settings-layout.spec.mjs index 7d5deec684..ac51958eed 100644 --- a/tests/browser/settings-layout.spec.mjs +++ b/tests/browser/settings-layout.spec.mjs @@ -427,7 +427,7 @@ test("hosted settings empty and setup cards fit without covering their actions", await chooseColorMode(page, mode === "light" ? "Light" : "Dark"); for (const width of [1440, 800, 390]) { await page.setViewportSize({ width, height: 1000 }); - await selectSettingsSection(page, "Builderlab hosting"); + await selectSettingsSection(page, "Builderlab"); const card = page.locator(".buzz-empty-state").filter({ has: page.getByRole("heading", { name: signedIn diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index a334cca4e8..723185f7eb 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -592,8 +592,7 @@ confirmedPresence( "Channel templates", "Software updates", "Manage plugins", - "Builderlab agents", - "Builderlab hosting", + "Builderlab", ]) { await tab(); await expect( @@ -693,7 +692,7 @@ test("hosted deletion reload keeps the UUID until an enabled manual replay", asy await page.reload(); await button(page, "Your profile").click(); await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); - await button(page, "Builderlab hosting").click(); + await button(page, "Builderlab").click(); await expect( page.getByText("Deletion status is unknown", { exact: true }), ).toBeVisible(); @@ -712,7 +711,7 @@ test("hosted deletion reload keeps the UUID until an enabled manual replay", asy // Reload restores the open Settings modal over its workspace. await page.reload(); await expect(settingsDialog(page)).toBeVisible(); - await button(page, "Builderlab hosting").click(); + await button(page, "Builderlab").click(); await expect(button(page, "Check deletion status")).toBeEnabled(); await button(page, "Check deletion status").click(); await expect( @@ -776,7 +775,7 @@ test("hosted deletion keeps a foreign owner's full npub inside the dialog and no await page.goto(app.origin); await button(page, "Your profile").click(); await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); - await button(page, "Builderlab hosting").click(); + await button(page, "Builderlab").click(); await button(page, "Delete").click(); const dialog = page.getByRole("dialog", { name: /Permanently delete north/ }); await dialog From ead0883cc789bebfa4af02f1b02ea9fa37f31707 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Thu, 29 Oct 2026 10:57:43 -0700 Subject: [PATCH 3/3] fix(builderlab): retry a failed community account check in place with the shared sign-in, a failed /v1/auth/me read (network error or 5xx) left communities with only an alert: auth was null, the sign-in empty state is owned by the page, and refresh only renders when signed in. render a try again action that reruns getAuth() and an ordinary load() under the same generation and active fences. a 401 still ends the shared session through the backend, which unmounts the card. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- .../communities/HostedCommunities.test.tsx | 66 +++++++++++++++++++ .../communities/HostedCommunities.tsx | 21 +++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/bundled/builderlab/communities/HostedCommunities.test.tsx b/src/bundled/builderlab/communities/HostedCommunities.test.tsx index ce11a7dd2e..97274a65d0 100644 --- a/src/bundled/builderlab/communities/HostedCommunities.test.tsx +++ b/src/bundled/builderlab/communities/HostedCommunities.test.tsx @@ -371,6 +371,72 @@ function hold() { return { answer: () => answer, release }; } +const renderShared = () => + render( + + true} sharedSignIn /> + , + ); + +it("retries a failed shared-session account check in place", async () => { + routes["/api/builderlab/auth"] = () => + Response.json({ error: "Builderlab is down" }, { status: 500 }); + renderShared(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Builderlab is down", + ); + expect(calls.filter(([url]) => url === "/api/builderlab/list")).toHaveLength( + 0, + ); + routes["/api/builderlab/auth"] = () => ({ + auth: { email: "a@example.com", expiresAt: "2030" }, + }); + routes["/api/builderlab/list"] = () => ({ + communities: [ + { + id: "c1", + name: "north", + normalized_host: "north.communities.buzz.xyz", + }, + ], + }); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByText("north")).toBeVisible(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); +}); + +it("drops a held account retry when the session replaces the card", async () => { + const held = hold(); + const getAuth = vi + .fn() + .mockRejectedValueOnce(new Error("Builderlab is down")) + .mockReturnValueOnce(held.answer() as ReturnType); + const view = render( + true} + sharedSignIn + />, + ); + await screen.findByRole("alert"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + try { + await waitFor(() => expect(getAuth).toHaveBeenCalledTimes(2)); + // index.tsx keys the card by account, so a new session remounts it. + view.unmount(); + } finally { + held.release({ email: "a@example.com", expiresAt: "2030" }); + } + // The retry's continuation runs before this one, so any load has started. + await act(async () => { + await held.answer(); + }); + expect(calls.filter(([url]) => url === "/api/builderlab/list")).toHaveLength( + 0, + ); +}); + it("keeps create and copy paused until this device's key is read and matches", async () => { const device = hold(); routes["/api/relay/identity"] = device.answer as Handler; diff --git a/src/bundled/builderlab/communities/HostedCommunities.tsx b/src/bundled/builderlab/communities/HostedCommunities.tsx index 8207fd8e6f..bb17ee9c4c 100644 --- a/src/bundled/builderlab/communities/HostedCommunities.tsx +++ b/src/bundled/builderlab/communities/HostedCommunities.tsx @@ -352,6 +352,15 @@ export function HostedCommunities({ api.admitDeletion(pending.request, "recovery"), ); }); + /** Retries a failed account check; the surrounding page keeps the shared session. */ + const retryAuth = () => + run("retry", async () => { + const at = generation.current; + const next = await api.getAuth(); + if (at !== generation.current || !activeRef.current()) return; + setAuth(next); + if (next) await load(); + }); const busy = action !== null; // Repeated inside open dialogs, whose modal backdrop hides the page copy. const failure = error && ( @@ -447,7 +456,17 @@ export function HostedCommunities({ Checking sign-in…

) : !auth ? ( - sharedSignIn ? null : ( + sharedSignIn ? ( +
+ +
+ ) : ( } title="Sign in to manage hosted communities"