diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 82fdd2840b..989e20d354 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -236,6 +236,8 @@ export function createBuilderlab({ }) { let credential; let account; + let capabilitiesCheckedAt; + let capabilityRead; let pending = false; let loginError; let linking = Promise.resolve(); @@ -246,6 +248,7 @@ export function createBuilderlab({ revision, pending, auth: account ?? null, + capabilitiesCheckedAt, ...(loginError ? { error: loginError } : {}), }); const notify = () => { @@ -290,7 +293,7 @@ export function createBuilderlab({ } throw new Error(`Builderlab request failed (HTTP ${response.status}).`); }; - const me = async (session, signal) => { + const me = async (session, signal, capabilitiesOnly = false) => { const started = generation; const response = await fetch(`${api}/v1/auth/me`, { headers: { "X-BB-Session-Credential": session }, @@ -300,6 +303,7 @@ export function createBuilderlab({ if (!response.ok) { if ( response.status === 401 && + !capabilitiesOnly && started === generation && credential === session ) @@ -308,6 +312,28 @@ export function createBuilderlab({ `Builderlab session check failed with HTTP ${response.status}`, ); } + if (capabilitiesOnly) { + const value = await response.json(); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Builderlab returned invalid capabilities"); + const { capabilities } = value; + if ( + capabilities !== undefined && + (!capabilities || + typeof capabilities !== "object" || + Array.isArray(capabilities)) + ) + throw new Error("Builderlab returned invalid capabilities"); + return { + capabilities: { + can_use_remote_agents: capabilities?.can_use_remote_agents === true, + can_use_builderlab_decisions: + capabilities?.can_use_builderlab_decisions === true, + can_delete_buzz_communities: + capabilities?.can_delete_buzz_communities === true, + }, + }; + } const { subject, email, name, expires_at, capabilities } = await response.json(); if ( @@ -341,6 +367,8 @@ export function createBuilderlab({ pending = false; loginError = undefined; account = undefined; + capabilitiesCheckedAt = undefined; + capabilityRead = undefined; session?.abort(); session = new AbortController(); credential = undefined; @@ -360,6 +388,7 @@ export function createBuilderlab({ }; const publishAccount = (value) => { account = value; + capabilitiesCheckedAt = Date.now(); notify(); }; const broker = { @@ -368,6 +397,28 @@ export function createBuilderlab({ listeners.add(listener); return () => listeners.delete(listener); }, + async capabilities(expected) { + current(expected); + if (capabilityRead) return capabilityRead; + const started = generation; + const checkedAt = Date.now(); + capabilityRead = (async () => { + try { + const result = await me(credential, session.signal, true); + if (started !== generation) + throw new Error("Builderlab session changed"); + // Restored pages retain refreshed flags without changing the sign-in profile. + account = { ...account, capabilities: result.capabilities }; + return { revision, ...result }; + } finally { + if (started === generation) { + capabilitiesCheckedAt = checkedAt; + capabilityRead = undefined; + } + } + })(); + return capabilityRead; + }, async auth() { const current = credential; if (!current) return null; diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 7ace78e925..33acf897a1 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -105,12 +105,16 @@ function account(responses = {}) { }; } async function signIn(h) { + const previous = h.opened(); // Observe rejection before the callback's network round trip; still propagate it to the caller. const pending = h.builderlab.login(new AbortController().signal).then( (auth) => ({ auth }), (error) => ({ error }), ); - while (!h.opened()) await new Promise((resolve) => setTimeout(resolve, 5)); + await vi.waitFor(() => { + expect(h.opened()).toBeDefined(); + expect(h.opened()).not.toBe(previous); + }); const returnTo = h.opened().searchParams.get("redirect_uri"); expect(new URL(returnTo).hostname).toBe("127.0.0.1"); await globalThis.fetch( @@ -924,3 +928,190 @@ it("redacts the login credential before publishing a shared failure or returning h.builderlab.signOut(); } }); + +it("keeps the broker sign-in and account projection when a capability-only check is refused", async () => { + let status = 200; + let invalid = false; + const h = account({ + "/v1/auth/me": () => + Response.json( + invalid + ? [] + : { + subject: "user", + email: "a@example.org", + expires_at: "2030", + capabilities: { can_use_remote_agents: true }, + }, + { status }, + ), + }); + await signIn(h); + const previous = h.builderlab.snapshot(); + status = 401; + await expect(h.builderlab.capabilities(previous.revision)).rejects.toThrow( + "HTTP 401", + ); + expect(h.builderlab.snapshot()).toEqual({ + ...previous, + capabilitiesCheckedAt: expect.any(Number), + }); + status = 200; + invalid = true; + await expect(h.builderlab.capabilities(previous.revision)).rejects.toThrow( + "invalid capabilities", + ); + expect(h.builderlab.snapshot()).toEqual({ + ...previous, + capabilitiesCheckedAt: expect.any(Number), + }); + invalid = false; + await expect(h.builderlab.capabilities(previous.revision)).resolves.toEqual({ + revision: previous.revision, + capabilities: { + can_use_remote_agents: true, + can_use_builderlab_decisions: false, + can_delete_buzz_communities: false, + }, + }); + expect(h.builderlab.snapshot()).toEqual({ + ...previous, + capabilitiesCheckedAt: expect.any(Number), + }); + h.builderlab.signOut(); +}); + +it("retains refreshed flags and their age for reloaded pages without changing the sign-in profile", async () => { + let now = Date.now(); + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + let granted = false; + const held = Promise.withResolvers(); + const started = Promise.withResolvers(); + let pending; + let restored; + const h = account({ + "/v1/auth/me": async () => { + if (granted) { + started.resolve(); + await held.promise; + } + return Response.json({ + subject: granted ? "replacement-profile" : "original", + email: granted ? "changed@example.org" : "original@example.org", + name: granted ? "Changed" : "Original", + expires_at: "2030", + capabilities: { can_use_remote_agents: granted }, + }); + }, + }); + try { + await signIn(h); + const before = h.builderlab.snapshot(); + granted = true; + now += 60_000; + const checkedAt = now; + pending = h.builderlab.capabilities(before.revision); + await started.promise; + // A page restoring mid-read must see an expired cache and join this request. + expect(h.builderlab.snapshot()).toEqual(before); + restored = h.builderlab.capabilities(before.revision); + now += 1_000; + held.resolve(); + await Promise.all([pending, restored]); + expect( + h.requests.filter((request) => request.path === "/v1/auth/me"), + ).toHaveLength(2); + expect(h.builderlab.snapshot()).toMatchObject({ + revision: before.revision, + capabilitiesCheckedAt: checkedAt, + auth: { + subject: "original", + email: "original@example.org", + name: "Original", + expiresAt: "2030", + capabilities: { can_use_remote_agents: true }, + }, + }); + } finally { + held.resolve(); + await Promise.allSettled([pending, restored]); + h.builderlab.signOut(); + clock.mockRestore(); + } +}); + +it("rejects a stale page before joining a replacement account's capability read and fences old cleanup", async () => { + let now = Date.now(); + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + let subject = "A"; + let held; + const first = { + started: Promise.withResolvers(), + reply: Promise.withResolvers(), + }; + const second = { + started: Promise.withResolvers(), + reply: Promise.withResolvers(), + }; + const h = account({ + "/v1/auth/me": () => { + if (held) { + const read = held; + held = undefined; + read.started.resolve(); + return read.reply.promise; + } + return Response.json({ subject, expires_at: "2030" }); + }, + }); + const pending = []; + try { + await signIn(h); + const oldRevision = h.builderlab.snapshot().revision; + now += 60_000; + held = first; + const old = h.builderlab.capabilities(oldRevision); + const rejected = expect(old).rejects.toThrow("session changed"); + pending.push(rejected); + await first.started.promise; + subject = "B"; + now += 1_000; + await signIn(h); + const before = h.builderlab.snapshot(); + now += 60_000; + held = second; + const current = h.builderlab.capabilities(before.revision); + pending.push(current); + await second.started.promise; + const stale = h.builderlab.capabilities(oldRevision).then( + () => "accepted", + (error) => error.message, + ); + pending.push(stale); + first.reply.resolve( + Response.json({ capabilities: { can_use_remote_agents: false } }), + ); + await rejected; + expect(h.builderlab.snapshot()).toEqual(before); + const joined = h.builderlab.capabilities(before.revision); + pending.push(joined); + second.reply.resolve( + Response.json({ capabilities: { can_use_remote_agents: true } }), + ); + await Promise.all([current, joined]); + await expect(stale).resolves.toBe("Builderlab session changed"); + expect( + h.requests.filter((request) => request.path === "/v1/auth/me"), + ).toHaveLength(4); + expect(h.builderlab.snapshot()).toMatchObject({ + revision: before.revision, + auth: { subject: "B", capabilities: { can_use_remote_agents: true } }, + }); + } finally { + first.reply.resolve(Response.json({})); + second.reply.resolve(Response.json({})); + await Promise.allSettled(pending); + h.builderlab.signOut(); + clock.mockRestore(); + } +}); diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index aef364d5a1..6eba014c42 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -4101,6 +4101,7 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat }); const instructions = "\\".repeat(20_000); let received; + let accountReads = 0; const nativeCalls = []; const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { open: async (url) => openLogin(new URL(url)), @@ -4111,12 +4112,14 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat session_credential: "fixture-only", expires_at: "2030", }); - if (path.endsWith("/v1/auth/me")) + if (path.endsWith("/v1/auth/me")) { + accountReads += 1; return Response.json({ subject: "fixture-account", expires_at: "2030", capabilities: { can_use_remote_agents: true }, }); + } if (path.endsWith("/nostr-identities/challenge")) return Response.json({ error: { code: "missing_mapping", setup_needed: true }, @@ -4165,6 +4168,29 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat const { revision } = JSON.parse( event.slice("data: ".length).split("\n\n")[0], ); + const capabilityCheck = await fetch( + `${h.base}/api/builderlab/capabilities?revision=${encodeURIComponent(revision)}`, + ); + expect(capabilityCheck.status).toBe(200); + expect(await capabilityCheck.json()).toEqual({ + revision, + capabilities: { + can_use_remote_agents: true, + can_use_builderlab_decisions: false, + can_delete_buzz_communities: false, + }, + }); + const reads = accountReads; + for (const query of ["", "?revision=previous"]) { + const refused = await fetch( + `${h.base}/api/builderlab/capabilities${query}`, + ); + expect(refused.status).toBe(502); + expect(await refused.json()).toEqual({ + error: "Builderlab session changed", + }); + } + expect(accountReads).toBe(reads); const body = JSON.stringify({ agent_pubkey: "a".repeat(64), agent_instructions: instructions, diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 3f552422bc..c9e7a27ad2 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -994,6 +994,14 @@ export function relayBrokerPlugin({ send(builderlab.snapshot()); return; } + if (action === "capabilities" && req.method === "GET") + return json( + res, + 200, + await builderlab.capabilities( + url.searchParams.get("revision"), + ), + ); if (action === "auth" && req.method === "GET") return json(res, 200, { auth: await builderlab.auth() }); if (req.method !== "POST") diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 0d3af642dd..dba1597b48 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -745,6 +745,12 @@ available rather than silently discarding it. depend on the type. Do not assume a provider is configured or signed in. Builderlab shows **Unavailable for this account** when signed in without remote-agent access. Manage its account in **Settings → Builderlab**. + Opening Builderlab settings or checking a gated feature refreshes permissions + at most once a minute; a failed check keeps the previous permissions. + Decisions API settings, decision requests, and Attention classifier readiness + checks also discover newly granted Decisions access, even while Builderlab is + absent from the provider list. Requests and Attention skip discovery while + your explicitly chosen provider is still listed; Settings always checks. Claude runs with an Anthropic API key pasted under **Anthropic API key** there, once for every Claude agent. When creating a Claude agent or editing one that runs on this computer, click **Add key** beside **Harness** to open diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index f0c2340a0f..3e081516ac 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -2663,6 +2663,21 @@ export function apply(ctx: Context) { } ``` +A plugin whose provider depends on changing account permissions can register +`ctx.decisions.registerProviderDiscovery({ id, refresh })` while that provider is +absent. The host calls `refresh(): Promise` when Decisions Settings opens +and before choosing a provider for a request, within that request's deadline. +Request routing skips discovery while the explicitly chosen provider is registered. +The callback receives no decision text. It may register or withdraw its provider; +cache/coalesce its reads and retain previous availability on failure. The host +isolates discovery failures and preserves the saved provider choice. Discovery +uses the same active-plugin ownership and removal rules as provider registration; +the returned function can remove it early. No checks run without a discovery +registration, and no polling is implied. Consumers that gate work on `status()` +can first await `ctx.decisions.refreshProviders({ forRouting: true })` to discover +newly available providers. Attention does this before deciding whether a watch +can be classified. + The host owns everything around the call, so every provider is held to the same rules: it checks the request against the limits before any provider sees it, stops waiting at the deadline, turns a throw into `service_error`, and checks diff --git a/src/app/DecisionsSettings.test.tsx b/src/app/DecisionsSettings.test.tsx index 913e3b970f..07cc6f18e0 100644 --- a/src/app/DecisionsSettings.test.tsx +++ b/src/app/DecisionsSettings.test.tsx @@ -55,6 +55,7 @@ function fake( providers.find((provider) => provider.key === (chosen ?? TYPESAFE))?.key; return { providers: () => providers, + refreshProviders: async () => {}, backend, subscribe: (listener: () => void) => { listeners.add(listener); diff --git a/src/app/DecisionsSettings.tsx b/src/app/DecisionsSettings.tsx index 6aca0ccaea..8176442ef6 100644 --- a/src/app/DecisionsSettings.tsx +++ b/src/app/DecisionsSettings.tsx @@ -29,11 +29,14 @@ export function DecisionsSettings({ }: { decisions: Pick< DecisionsService, - "backend" | "subscribe" | "setBackend" | "providers" + "backend" | "subscribe" | "setBackend" | "providers" | "refreshProviders" >; active(): boolean; onSection?: ((section: string) => void) | undefined; }) { + useEffect(() => { + if (active()) void decisions.refreshProviders(); + }, [decisions, active]); const backend = useSyncExternalStore( decisions.subscribe, decisions.backend, diff --git a/src/app/Settings.test.tsx b/src/app/Settings.test.tsx index 08ba53e9c7..532b7e00c8 100644 --- a/src/app/Settings.test.tsx +++ b/src/app/Settings.test.tsx @@ -1302,6 +1302,7 @@ it("opens Manage plugins from the Decisions API hint", async () => { providers: () => providers, subscribe: () => () => {}, setBackend: vi.fn(), + refreshProviders: async () => {}, } as unknown as DecisionsService; const onSection = vi.fn(); const view = (section: string) => ( diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index 3bd13759d9..0484afbd1b 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -175,6 +175,9 @@ agents are not migrated into this type. Its sign-out lives in Identity → Organization sign-in, which `login/` links to, because community sign-in shares the session. In live development it projects the development broker's sign-in instead, through `brokerApi`. +- `capabilities.ts` shares a demand-driven, one-minute permission refresh across + capability gates. Concurrent checks join one request; failed attempts keep + cached values and wait a minute before another attempt. No polling is added. - `oauth/` names the build's Builderlab API (`oauthTarget`). `oauth/first-run.ts` lets the app's first-launch welcome screen run the same native NIP-FI sign-in before the plugin's JavaScript starts; the app's later calls go @@ -237,16 +240,22 @@ The login snapshot retains `capabilities.can_use_remote_agents`; only explicit `true` enables remote agents. Otherwise, Harness defaults and Builderlab Settings show “Unavailable for this account”; the provider dialog explains the same restriction. The Harness defaults row has no action. Account management stays in -Settings → Builderlab, and no Beekeeper requests are sent. Sign out and back in -to refresh this capability. +Settings → Builderlab, and no Beekeeper requests are sent. Permission checks +refresh capabilities from `/v1/auth/me` with a one-minute cache shared by the +plugin; failed checks retain the previous values. Only capabilities change; +account identity and display details remain those read at sign-in. The plugin also offers Builderlab as a Decisions API provider. It reads `capabilities.can_use_builderlab_decisions` the same way, but only explicit `true` registers the provider, and only while the user is signed in. Otherwise Builderlab is not listed in Settings > Decisions API and no decision request goes to it. While it is registered, it answers unless the user chose another -provider; when it goes away, TypeSafe answers. Sign out and back in to refresh -this capability. The plugin names the NIP-FI session by its revision, and +provider; when it goes away, TypeSafe answers. It uses the same capability refresh +cache. Opening Decisions API settings checks for newly granted access while +Builderlab is absent from the provider list. Requests and Attention readiness +also discover access unless an explicitly chosen provider is still registered. +Live development retains refreshed flags and their cache age across page reloads. +The plugin names the NIP-FI session by its revision, and native `decide` posts to the identity origin's API with that session as `Authorization: Bearer`, as for the build's other Builderlab calls; the token never reaches the webview. A 401 there refuses only that call, and diff --git a/src/bundled/builderlab/adapter-type/ModelSettings.tsx b/src/bundled/builderlab/adapter-type/ModelSettings.tsx index 6288294890..2b0f820725 100644 --- a/src/bundled/builderlab/adapter-type/ModelSettings.tsx +++ b/src/bundled/builderlab/adapter-type/ModelSettings.tsx @@ -1,11 +1,5 @@ -import { - useEffect, - useId, - useRef, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; +import { useSessionWithCapabilityRefresh } from "../capabilities"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AgentSaveButton } from "../../../features/agents/AgentSaveButton"; import { ModelFamilyMenu } from "../../../features/agents/ModelFamilyMenu"; @@ -65,7 +59,7 @@ export function ModelSettings({ | { agent: RemoteAgent; subject: string; email?: string } | { pending: boolean; error: unknown; retry(): void }; }) { - const login = useSyncExternalStore(session.subscribe, session.snapshot); + const login = useSessionWithCapabilityRefresh(session); const [retrySignIn, setRetrySignIn] = useState< "switching" | "waiting" | "ready" >(); diff --git a/src/bundled/builderlab/agents/RemoteAgents.tsx b/src/bundled/builderlab/agents/RemoteAgents.tsx index c34f10c296..bbec319d40 100644 --- a/src/bundled/builderlab/agents/RemoteAgents.tsx +++ b/src/bundled/builderlab/agents/RemoteAgents.tsx @@ -1,9 +1,5 @@ -import { - useEffect, - useEffectEvent, - useState, - useSyncExternalStore, -} from "react"; +import { useSessionWithCapabilityRefresh } from "../capabilities"; +import { useEffect, useEffectEvent, useState } from "react"; import { ArrowsClockwiseIcon, RobotIcon, @@ -24,7 +20,7 @@ export function RemoteAgents({ session: BuilderlabSession; active(): boolean; }) { - const login = useSyncExternalStore(session.subscribe, session.snapshot); + const login = useSessionWithCapabilityRefresh(session); const [revision, setRevision] = useState(0); if (login.status === "signed-in" && !login.account?.canUseRemoteAgents) return ( diff --git a/src/bundled/builderlab/agents/client.test.ts b/src/bundled/builderlab/agents/client.test.ts index b42c9f4ec7..04a25e1f6b 100644 --- a/src/bundled/builderlab/agents/client.test.ts +++ b/src/bundled/builderlab/agents/client.test.ts @@ -38,6 +38,7 @@ async function fixture(subject = "user", canUseRemoteAgents = true) { email: "a@example.com", canUseRemoteAgents, canUseBuilderlabDecisions: false, + canDeleteBuzzCommunities: false, }, }), undefined, @@ -207,22 +208,33 @@ it.each(["sign-out", "new-login", "cancel", "dispose"])( async (action) => { const h = await fixture(); const held = deferred(); - vi.mocked(h.host.request).mockReturnValue(held.promise); + const started = deferred(); + vi.mocked(h.host.request).mockImplementation(() => { + started.resolve(); + return held.promise; + }); const controller = new AbortController(); const pending = h.client.list(controller.signal); - if (action === "cancel") controller.abort(); - else if (action === "dispose") h.session.dispose(); - else { - h.session.signOut(); - if (action === "new-login") await h.session.signIn(); + const rejected = expect(pending).rejects.toMatchObject({ + name: "AbortError", + }); + await started.promise; + try { + if (action === "cancel") controller.abort(); + else if (action === "dispose") h.session.dispose(); + else { + h.session.signOut(); + if (action === "new-login") await h.session.signIn(); + } + } finally { + held.resolve( + response( + { status: 1, agents: [row] }, + action === "new-login" ? 401 : 200, + ), + ); } - held.resolve( - response( - { status: 1, agents: [row] }, - action === "new-login" ? 401 : 200, - ), - ); - await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await rejected; if (action === "new-login") expect(h.session.snapshot().status).toBe("signed-in"); }, diff --git a/src/bundled/builderlab/agents/client.ts b/src/bundled/builderlab/agents/client.ts index 9bb4048da6..b4edb7710f 100644 --- a/src/bundled/builderlab/agents/client.ts +++ b/src/bundled/builderlab/agents/client.ts @@ -160,7 +160,12 @@ export function createAgentClient( signal: AbortSignal, ): Promise> { signal.throwIfAborted(); - const credential = remoteCredential(); + const credential = session.credential(); + const refresh = session.refreshCapabilities(); + // Stop is urgent; refresh its cache without delaying cancellation. + if (path !== "cancel-agent-turn") await refresh; + check(credential, signal); + remoteCredential(); let response: HostResponse; try { response = await session.request(credential, { @@ -202,7 +207,8 @@ export function createAgentClient( body: unknown, signal: AbortSignal, ) { - const credential = remoteCredential(); + signal.throwIfAborted(); + const credential = session.credential(); return retryRead( (signal) => { check(credential, signal); @@ -583,7 +589,10 @@ export function createAgentClient( signal.throwIfAborted(); const agentName = name.trim(); if (!AGENT_NAME.test(agentName)) throw new Error(INVALID_NAME); - const credential = remoteCredential(); + const credential = session.credential(); + await session.refreshCapabilities(); + check(credential, signal); + remoteCredential(); const { account } = credential; const intent = registrationIntent( oauthTarget(), diff --git a/src/bundled/builderlab/agents/turn-status.ts b/src/bundled/builderlab/agents/turn-status.ts index fedcadd55b..35e9b7d567 100644 --- a/src/bundled/builderlab/agents/turn-status.ts +++ b/src/bundled/builderlab/agents/turn-status.ts @@ -30,6 +30,7 @@ export function startTurnStatusRecovery({ function attach() { const connection = relay.snapshot(); const signedIn = session.snapshot(); + if (signedIn.status === "signed-in") void session.refreshCapabilities(); const next = connection.status === "ready" && !connection.cached && diff --git a/src/bundled/builderlab/capabilities.ts b/src/bundled/builderlab/capabilities.ts new file mode 100644 index 0000000000..1b19f136f5 --- /dev/null +++ b/src/bundled/builderlab/capabilities.ts @@ -0,0 +1,74 @@ +import { useEffect, useSyncExternalStore } from "react"; +import type { BuilderlabSession } from "./session"; + +export type Capabilities = Readonly<{ + canUseRemoteAgents: boolean; + canUseBuilderlabDecisions: boolean; + canDeleteBuzzCommunities?: boolean; +}>; + +/** Refresh only permissions. Failed attempts also cool down for one minute. */ +export function createCapabilityRefresh( + previous: Capabilities, + read: () => Promise, + update: (capabilities: Capabilities) => void, + checkedAt = Date.now(), +) { + let pending: Promise | undefined; + return function refresh(): Promise { + if (pending) return pending; + if (Date.now() - checkedAt < 60_000) return Promise.resolve(); + checkedAt = Date.now(); + pending = (async () => { + try { + const value = await read(); + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const capabilities = (value as { capabilities?: unknown }).capabilities; + if ( + capabilities !== undefined && + (!capabilities || + typeof capabilities !== "object" || + Array.isArray(capabilities)) + ) + return; + const flags = capabilities as Record | undefined; + const next: Capabilities = { + canUseRemoteAgents: flags?.can_use_remote_agents === true, + canUseBuilderlabDecisions: + flags?.can_use_builderlab_decisions === true, + canDeleteBuzzCommunities: flags?.can_delete_buzz_communities === true, + }; + if ( + next.canUseRemoteAgents === previous.canUseRemoteAgents && + next.canUseBuilderlabDecisions === + previous.canUseBuilderlabDecisions && + next.canDeleteBuzzCommunities === previous.canDeleteBuzzCommunities + ) + return; + previous = next; + update(next); + } catch { + // A failed capability check does not end sign-in or erase permissions. + } finally { + pending = undefined; + } + })(); + return pending; + }; +} + +const signedOut = Object.freeze({ status: "signed-out" as const }); +const noSubscribe = () => () => {}; +const noSnapshot = () => signedOut; + +/** Return the full session snapshot and refresh permissions on gated surfaces. */ +export function useSessionWithCapabilityRefresh(session?: BuilderlabSession) { + const state = useSyncExternalStore( + session?.subscribe ?? noSubscribe, + session?.snapshot ?? noSnapshot, + ); + useEffect(() => { + if (state.status === "signed-in") void session?.refreshCapabilities(); + }, [session, state]); + return state; +} diff --git a/src/bundled/builderlab/communities/HostedCommunities.tsx b/src/bundled/builderlab/communities/HostedCommunities.tsx index 933d1ddf19..b02183c09e 100644 --- a/src/bundled/builderlab/communities/HostedCommunities.tsx +++ b/src/bundled/builderlab/communities/HostedCommunities.tsx @@ -1,3 +1,5 @@ +import type { BuilderlabSession } from "../session"; +import { useSessionWithCapabilityRefresh } from "../capabilities"; import { SettingsGroup } from "../../../shared/design-system/ui/SettingsGroup"; import { type ReactNode, @@ -70,12 +72,15 @@ export function HostedCommunities({ api, active, sharedSignIn = false, + session, }: { api: Api; active(): boolean; /** The surrounding page owns sign-in, sign-out and the account row. */ sharedSignIn?: boolean; + session?: BuilderlabSession; }) { + const login = useSessionWithCapabilityRefresh(session); const activeRef = useRef(active); activeRef.current = active; const [auth, setAuth] = useState(); @@ -382,6 +387,7 @@ export function HostedCommunities({ // Acting requires this device's key to be known and to match the account's. const ready = bound !== null && bound === local; const deletionEnabled = + login.account?.canDeleteBuzzCommunities ?? auth?.capabilities?.can_delete_buzz_communities === true; // A handoff belongs to the bound identity: whenever it changes, by a local // action or a refresh, drop the handoff and any clipboard result in flight. @@ -636,7 +642,12 @@ export function HostedCommunities({ variant="ghost" size="sm" disabled={busy} - onClick={() => void run("refresh", () => load(true))} + onClick={() => + void run("refresh", async () => { + await session?.refreshCapabilities(); + return load(true); + }) + } >