From 3d948584a5838320b42be02599a60d082a0d4558 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 16:33:05 -0400 Subject: [PATCH 1/5] fix(builderlab): replace apps_share with visibility and community tools Compose removes workspace grants (squareup/blockplat-compose#496), so apps_share's sharing/workspaces route stops existing once Compose is bumped. Under the one-workspace model an app is open to its workspace or only its owner, plus any Buzz communities. - apps_visibility switches workspace/only_you through PUT .../visibility, as `bl apps visibility` does, and says who can open the app now. - apps_community grants or revokes a community at the grant revision it just read, as `bl apps community` does. - An app with no live version now says to deploy it first instead of "This app has no active sharing to change". BOT-2440 Co-authored-by: Dylan G <69953f73c9f5149d56f2155107338a5435823b938a6c430c63e779f9a8a446f9@remote-agent-pilot.communities.buzz.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/README.md | 5 ++ src/bundled/builderlab/apps/client.test.ts | 78 ++++++++++++++------ src/bundled/builderlab/apps/client.ts | 65 +++++++++++++---- src/bundled/builderlab/apps/index.test.ts | 57 ++++++++++++++- src/bundled/builderlab/apps/index.ts | 84 ++++++++++++++++++---- 5 files changed, 239 insertions(+), 50 deletions(-) diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index a6225ceec6..53b93ef25b 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -23,6 +23,11 @@ tenant's runtimes and schema location. Creation checks that contract and support initialization. The executable profile returns the required asset manifest and bindings, even when its handler serves all content itself. +Sharing follows the one-workspace model: `apps_visibility` switches an app +between everyone in the owner's workspace and only the owner, and +`apps_community` grants or revokes a Buzz community at the grant revision it just +read. Both keep existing viewers and community grants. + BBBlock is the employee pilot. BBPublic uses the same authenticated control-plane boundary: the server must grant Builderlab access to the current workspace/account. Existing workspace early-preview enablement can provide that access; these tools diff --git a/src/bundled/builderlab/apps/client.test.ts b/src/bundled/builderlab/apps/client.test.ts index d643825cb6..bce7880b27 100644 --- a/src/bundled/builderlab/apps/client.test.ts +++ b/src/bundled/builderlab/apps/client.test.ts @@ -31,7 +31,7 @@ function deferred() { return { promise, resolve }; } -it.each(["create", "share"] as const)( +it.each(["create", "community"] as const)( "stops %s after the owner changes during its first request", async (operation) => { let owner = "A"; @@ -50,7 +50,7 @@ it.each(["create", "share"] as const)( const pending = operation === "create" ? client.create("Demo") - : client.share("demo", "team"); + : client.community("demo", "buzz-alpha", "grant"); const rejected = expect(pending).rejects.toThrow(/sign-in changed/); session.signOut(); owner = "B"; @@ -58,11 +58,7 @@ it.each(["create", "share"] as const)( held.resolve( operation === "create" ? contract() - : json(200, { - visibility: "restricted", - lifecycle_id: "life", - workspace_grant_revision: 1, - }), + : json(200, { lifecycle_id: "life", community_grant_revision: 1 }), ); await rejected; expect(request).toHaveBeenCalledTimes(1); @@ -323,27 +319,65 @@ it("reports the server's error without the session", async () => { ); }); -it("shares only a restricted app, at its current grant revision", async () => { +it("switches visibility without sending viewers", async () => { const { client, requests } = await setup([ - json(200, { - visibility: "restricted", - lifecycle_id: "life-1", - workspace_grant_revision: 3, - }), - json(200, { ok: true }), + json(200, { ok: true, app_id: "demo", visibility: "restricted" }), + json(200, { ok: true, app_id: "demo", visibility: "restricted" }), ]); - await client.share("demo", "team/one"); - expect(requests[1]).toMatchObject({ - url: `${BASE}/v1/agent/apps/demo/sharing/workspaces/team%2Fone`, - method: "POST", - body: JSON.stringify({ lifecycle_id: "life-1", expected_revision: 3 }), + await client.visibility("demo", "only_you"); + expect(requests[0]).toMatchObject({ + url: `${BASE}/v1/agent/apps/demo/visibility`, + method: "PUT", + body: JSON.stringify({ visibility: "restricted" }), }); - const open = await setup([json(200, { visibility: "organization" })]); - await expect(open.client.share("demo", "team")).rejects.toThrow( - /restricted visibility/, + await expect(client.visibility("demo", "workspace")).rejects.toThrow( + /reported demo as restricted, not demo as organization/, ); }); +it.each([ + ["grant", "POST"], + ["revoke", "DELETE"], +] as const)( + "%ss a community at the grant revision it just read", + async (action, method) => { + const { client, requests } = await setup([ + json(200, { lifecycle_id: "life-1", community_grant_revision: 7 }), + json(200, { ok: true, community_grant_revision: 8 }), + ]); + await client.community("demo", "buzz/alpha", action); + const path = `${BASE}/v1/agent/apps/demo/sharing/communities/buzz%2Falpha`; + expect(requests).toMatchObject([ + { url: path, method: "GET" }, + { + url: path, + method, + body: JSON.stringify({ lifecycle_id: "life-1", expected_revision: 7 }), + }, + ]); + }, +); + +it("tells the agent to deploy before sharing an app with no live version", async () => { + for (const response of [ + json(409, { + error: { + code: "community_grant_conflict", + message: "app sharing state changed; read access and retry", + }, + }), + json(200, { community_grant_revision: 0 }), + ]) { + const { client, requests } = await setup([response]); + await expect( + client.community("demo", "buzz-alpha", "grant"), + ).rejects.toThrow( + "This app has no live version to share yet. Deploy it with apps_deploy, then try again.", + ); + expect(requests).toHaveLength(1); + } +}); + it("deploys to the sign-in's own tenant", () => { expect(controlPlane("https://app.builderlabstaging.xyz")).toBe(BASE); expect(controlPlane("https://block.builderlab.xyz/")).toBe( diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 42fc4c6199..5a7fe009e1 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -8,6 +8,12 @@ import type { OAuthSession } from "../oauth/session"; /** The Apps protocol `bl` speaks, which the control plane checks. */ const PROTOCOL = "0.2.0"; export type RuntimeProfile = "static" | "fetch-js"; +/** Who can open an app besides the communities it is shared with. */ +export type Audience = "workspace" | "only_you"; +const VISIBILITY: Record = { + workspace: "organization", + only_you: "restricted", +}; export const SIGN_IN = "Ask your owner to sign in at Settings → Plugins → Builderlab, then try again."; @@ -74,7 +80,7 @@ export function createAppsClient( return JSON.parse(JSON.stringify(body).replaceAll(secret, "[redacted]")); }; const call = async ( - method: "GET" | "POST", + method: "GET" | "POST" | "PUT" | "DELETE", path: string, what: string, body?: Json, @@ -219,22 +225,53 @@ export function createAppsClient( `${app(id, "ready")}?${new URLSearchParams({ version_id: version, ...(deployment ? { deployment_id: deployment } : {}), ...(rollout ? { rollout_id: rollout } : {}) })}`, "Checking readiness", ), - /** Lets a Builderlab workspace's members view the app, as `bl apps share - * grant` does. */ - async share(id: string, workspace: string) { - const access = await call("GET", app(id, "access"), "Reading access"); - if (access.visibility !== "restricted") + /** Switches who can open the app, as `bl apps visibility` does. Viewers + * and community grants are kept. */ + async visibility(id: string, audience: Audience) { + const visibility = VISIBILITY[audience]; + const updated = await call( + "PUT", + app(id, "visibility"), + "Changing visibility", + { + visibility, + }, + ); + if (updated.app_id !== id || updated.visibility !== visibility) throw new Error( - `Sharing with a workspace needs restricted visibility; this app is ${String(access.visibility)}`, + `Apps Platform reported ${String(updated.app_id)} as ${String(updated.visibility)}, not ${id} as ${visibility}; read the app's access before retrying`, ); - const lifecycle = text(access.lifecycle_id); - const revision = access.workspace_grant_revision; - if (!lifecycle || typeof revision !== "number") - throw new Error("This app has no active sharing to change"); + return updated; + }, + /** Shares the app with a Buzz community or stops sharing it, as `bl apps + * community` does, at the grant revision it just read. */ + async community(id: string, community: string, action: "grant" | "revoke") { + const path = app( + id, + `sharing/communities/${encodeURIComponent(community)}`, + ); + const undeployed = new Error( + "This app has no live version to share yet. Deploy it with apps_deploy, then try again.", + ); + // Compose answers this read with 409 only when no version is live. + const current = await call( + "GET", + path, + "Reading community sharing", + ).catch((error: unknown) => { + throw error instanceof Error && error.message.includes("(HTTP 409)") + ? undeployed + : error; + }); + const lifecycle = text(current.lifecycle_id); + const revision = current.community_grant_revision; + if (!lifecycle || typeof revision !== "number") throw undeployed; return call( - "POST", - app(id, `sharing/workspaces/${encodeURIComponent(workspace)}`), - "Sharing", + action === "grant" ? "POST" : "DELETE", + path, + action === "grant" + ? "Sharing with the community" + : "Revoking the community", { lifecycle_id: lifecycle, expected_revision: revision }, ); }, diff --git a/src/bundled/builderlab/apps/index.test.ts b/src/bundled/builderlab/apps/index.test.ts index fdd1203446..88eaf10ebc 100644 --- a/src/bundled/builderlab/apps/index.test.ts +++ b/src/bundled/builderlab/apps/index.test.ts @@ -108,7 +108,8 @@ it("adds the app tools only for a tenant with an Apps control plane", async () = "apps_deploy", "apps_status", "apps_list", - "apps_share", + "apps_visibility", + "apps_community", "apps_open", ]); const other = await setup("https://builderlab.example"); @@ -289,3 +290,57 @@ it.each([ call("apps_open", { url: "https://example.org/" }, { channel: "c1" }), ).rejects.toThrow(/not a Builderlab app URL/); }); + +it("says who can open an app after a visibility switch", async () => { + const { call, responses } = await setup(); + responses.push( + json({ + ok: true, + app_id: "demo", + owner: "auth0|owner", + visibility: "restricted", + effective_viewers: ["auth0|owner", "auth0|earlier"], + shared_community_ids: ["buzz-alpha"], + }), + ); + expect( + JSON.parse( + String( + await call("apps_visibility", { app_id: "demo", audience: "only_you" }), + ), + ), + ).toEqual({ + app_id: "demo", + audience: "only_you", + shared_community_ids: ["buzz-alpha"], + who_can_open: + "Only your owner. 1 other person added earlier can also open it. So can members of the Buzz community it is shared with.", + }); + await expect( + call("apps_visibility", { app_id: "demo", audience: "private" }), + ).rejects.toThrow(/workspace or only_you/); +}); + +it("reports whether the community now shares the app", async () => { + const { call, responses } = await setup(); + responses.push( + json({ lifecycle_id: "life-1", community_grant_revision: 2 }), + json({ ok: true, shared_community_ids: [], community_grant_revision: 3 }), + ); + expect( + JSON.parse( + String( + await call("apps_community", { + app_id: "demo", + community_id: "buzz-alpha", + action: "revoke", + }), + ), + ), + ).toEqual({ + app_id: "demo", + community_id: "buzz-alpha", + shared: false, + shared_community_ids: [], + }); +}); diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index fddcd273f5..0b4e7428e0 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -5,7 +5,7 @@ import { createElement, useSyncExternalStore } from "react"; import type { PluginModule } from "../../../plugins/api"; import type { PanelProps } from "../../../features/panels/service"; import type { OAuthSession } from "../oauth/session"; -import { createAppsClient, type RuntimeProfile } from "./client"; +import { createAppsClient, type Audience, type RuntimeProfile } from "./client"; import { appName, controlPlane, @@ -284,29 +284,87 @@ export function applyApps( }, call: async () => reply(await apps().list()), }); + const communities = (access: Record) => + Array.isArray(access.shared_community_ids) + ? access.shared_community_ids.filter((id) => typeof id === "string") + : []; ctx.agents.addTool({ - name: "apps_share", + name: "apps_visibility", description: - "Let the members of a Builderlab workspace view an app. Only when asked to share it.", + "Choose who can open an app: everyone in your owner's workspace (the default for new apps) or only your owner. Communities it is shared with keep access either way. Only when asked.", inputSchema: { type: "object", properties: { app_id: { type: "string" }, - workspace_id: { + audience: { type: "string", enum: ["workspace", "only_you"] }, + }, + required: ["app_id", "audience"], + additionalProperties: false, + }, + async call(_client, _context, args) { + const audience = string(args, "audience") as Audience; + if (audience !== "workspace" && audience !== "only_you") + throw new Error("audience must be workspace or only_you"); + const id = string(args, "app_id"); + const access = await apps().visibility(id, audience); + const shared = communities(access); + const others = Array.isArray(access.effective_viewers) + ? access.effective_viewers.filter((viewer) => viewer !== access.owner) + .length + : 0; + return reply({ + app_id: id, + audience, + shared_community_ids: shared, + who_can_open: [ + audience === "workspace" + ? "Everyone in your owner's workspace." + : "Only your owner.", + ...(audience === "only_you" && others + ? [ + `${others} ${others === 1 ? "other person" : "other people"} added earlier can also open it.`, + ] + : []), + ...(shared.length + ? [ + `So can members of the ${shared.length === 1 ? "Buzz community" : `${shared.length} Buzz communities`} it is shared with.`, + ] + : []), + ].join(" "), + }); + }, + }); + ctx.agents.addTool({ + name: "apps_community", + description: + "Share an app with a Buzz community your owner belongs to, or stop sharing it. Its members can then open the app whatever its visibility. Only when asked.", + inputSchema: { + type: "object", + properties: { + app_id: { type: "string" }, + community_id: { type: "string", - description: "The Builderlab workspace's identifier.", + description: "The Buzz community's Builderlab ID.", }, + action: { type: "string", enum: ["grant", "revoke"] }, }, - required: ["app_id", "workspace_id"], + required: ["app_id", "community_id", "action"], additionalProperties: false, }, - call: async (_client, _context, args) => - reply( - await apps().share( - string(args, "app_id"), - string(args, "workspace_id"), - ), - ), + async call(_client, _context, args) { + const action = string(args, "action"); + if (action !== "grant" && action !== "revoke") + throw new Error("action must be grant or revoke"); + const id = string(args, "app_id"); + const community = string(args, "community_id"); + const shared = communities(await apps().community(id, community, action)); + return reply({ + app_id: id, + community_id: community, + shared: shared.includes(community), + shared_community_ids: shared, + }); + }, }); ctx.agents.addTool({ name: "apps_open", From 3db2016f6510bf06bbcd1dd8f3e33d5f582415d4 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 16:37:29 -0400 Subject: [PATCH 2/5] fix(builderlab): tell malformed sharing reads apart from undeployed apps Address review on #1934: a 200 sharing read without a valid lifecycle or non-negative integer revision now says nothing was changed instead of asking for a redeploy, and a mismatched visibility answer points at Share & manage rather than an access read no tool offers. Adds tests for the app_id mismatch, POST/DELETE 409 passthrough, tool-level grant and unknown action, and plural wording. Co-authored-by: Dylan G <69953f73c9f5149d56f2155107338a5435823b938a6c430c63e779f9a8a446f9@remote-agent-pilot.communities.buzz.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/client.test.ts | 39 +++++++++++++++- src/bundled/builderlab/apps/client.ts | 12 ++++- src/bundled/builderlab/apps/index.test.ts | 53 ++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/bundled/builderlab/apps/client.test.ts b/src/bundled/builderlab/apps/client.test.ts index bce7880b27..4733d384cc 100644 --- a/src/bundled/builderlab/apps/client.test.ts +++ b/src/bundled/builderlab/apps/client.test.ts @@ -333,6 +333,12 @@ it("switches visibility without sending viewers", async () => { await expect(client.visibility("demo", "workspace")).rejects.toThrow( /reported demo as restricted, not demo as organization/, ); + const other = await setup([ + json(200, { ok: true, app_id: "other", visibility: "organization" }), + ]); + await expect(other.client.visibility("demo", "workspace")).rejects.toThrow( + /reported other as organization, not demo as organization/, + ); }); it.each([ @@ -366,7 +372,6 @@ it("tells the agent to deploy before sharing an app with no live version", async message: "app sharing state changed; read access and retry", }, }), - json(200, { community_grant_revision: 0 }), ]) { const { client, requests } = await setup([response]); await expect( @@ -378,6 +383,38 @@ it("tells the agent to deploy before sharing an app with no live version", async } }); +it("changes nothing when the sharing read has no valid revision", async () => { + for (const body of [ + { community_grant_revision: 0 }, + { lifecycle_id: "life-1" }, + { lifecycle_id: "life-1", community_grant_revision: -1 }, + { lifecycle_id: "life-1", community_grant_revision: 1.5 }, + ]) { + const { client, requests } = await setup([json(200, body)]); + await expect( + client.community("demo", "buzz-alpha", "grant"), + ).rejects.toThrow(/no valid sharing revision.*nothing was changed/); + expect(requests).toHaveLength(1); + } +}); + +it("passes a conflicting community change through unchanged", async () => { + const { client } = await setup([ + json(200, { lifecycle_id: "life-1", community_grant_revision: 7 }), + json(409, { + error: { + code: "community_grant_conflict", + message: "app sharing state changed; read access and retry", + }, + }), + ]); + await expect( + client.community("demo", "buzz-alpha", "revoke"), + ).rejects.toThrow( + "Revoking the community failed (HTTP 409): community_grant_conflict: app sharing state changed; read access and retry", + ); +}); + it("deploys to the sign-in's own tenant", () => { expect(controlPlane("https://app.builderlabstaging.xyz")).toBe(BASE); expect(controlPlane("https://block.builderlab.xyz/")).toBe( diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 5a7fe009e1..299fcc57c2 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -239,7 +239,7 @@ export function createAppsClient( ); if (updated.app_id !== id || updated.visibility !== visibility) throw new Error( - `Apps Platform reported ${String(updated.app_id)} as ${String(updated.visibility)}, not ${id} as ${visibility}; read the app's access before retrying`, + `Apps Platform reported ${String(updated.app_id)} as ${String(updated.visibility)}, not ${id} as ${visibility}. Ask your owner to check who can open it in Share & manage before retrying`, ); return updated; }, @@ -265,7 +265,15 @@ export function createAppsClient( }); const lifecycle = text(current.lifecycle_id); const revision = current.community_grant_revision; - if (!lifecycle || typeof revision !== "number") throw undeployed; + if ( + !lifecycle || + typeof revision !== "number" || + !Number.isInteger(revision) || + revision < 0 + ) + throw new Error( + "Apps Platform returned no valid sharing revision for this app; nothing was changed", + ); return call( action === "grant" ? "POST" : "DELETE", path, diff --git a/src/bundled/builderlab/apps/index.test.ts b/src/bundled/builderlab/apps/index.test.ts index 88eaf10ebc..84c5536636 100644 --- a/src/bundled/builderlab/apps/index.test.ts +++ b/src/bundled/builderlab/apps/index.test.ts @@ -344,3 +344,56 @@ it("reports whether the community now shares the app", async () => { shared_community_ids: [], }); }); + +it("reports a granted community and refuses an unknown action", async () => { + const { call, responses } = await setup(); + responses.push( + json({ lifecycle_id: "life-1", community_grant_revision: 2 }), + json({ + ok: true, + shared_community_ids: ["buzz-alpha", "buzz-beta"], + community_grant_revision: 3, + }), + ); + expect( + JSON.parse( + String( + await call("apps_community", { + app_id: "demo", + community_id: "buzz-alpha", + action: "grant", + }), + ), + ), + ).toMatchObject({ shared: true }); + await expect( + call("apps_community", { + app_id: "demo", + community_id: "buzz-alpha", + action: "add", + }), + ).rejects.toThrow(/grant or revoke/); +}); + +it("counts everyone else who can still open a private app", async () => { + const { call, responses } = await setup(); + responses.push( + json({ + ok: true, + app_id: "demo", + owner: "auth0|owner", + visibility: "restricted", + effective_viewers: ["auth0|owner", "auth0|a", "auth0|b"], + shared_community_ids: ["buzz-alpha", "buzz-beta"], + }), + ); + expect( + JSON.parse( + String( + await call("apps_visibility", { app_id: "demo", audience: "only_you" }), + ), + ).who_can_open, + ).toBe( + "Only your owner. 2 other people added earlier can also open it. So can members of the 2 Buzz communities it is shared with.", + ); +}); From 2f105baa373992e3639a52c38ec1be2fed4f35ce Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 16:41:00 -0400 Subject: [PATCH 3/5] test(builderlab): pin the visibility mismatch message The mismatch test matched only the prefix, so it passed with the old "read the app's access" advice. Assert the full Share & manage text. Co-authored-by: Dylan G <69953f73c9f5149d56f2155107338a5435823b938a6c430c63e779f9a8a446f9@remote-agent-pilot.communities.buzz.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/builderlab/apps/client.test.ts b/src/bundled/builderlab/apps/client.test.ts index 4733d384cc..688123e8d2 100644 --- a/src/bundled/builderlab/apps/client.test.ts +++ b/src/bundled/builderlab/apps/client.test.ts @@ -331,7 +331,7 @@ it("switches visibility without sending viewers", async () => { body: JSON.stringify({ visibility: "restricted" }), }); await expect(client.visibility("demo", "workspace")).rejects.toThrow( - /reported demo as restricted, not demo as organization/, + "Apps Platform reported demo as restricted, not demo as organization. Ask your owner to check who can open it in Share & manage before retrying", ); const other = await setup([ json(200, { ok: true, app_id: "other", visibility: "organization" }), From dae318dd38f69f7b848d8c01644625099c535148 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 17:00:28 -0400 Subject: [PATCH 4/5] Drop "added earlier" from apps_visibility's who-can-open text The remaining viewers on a private app include publishers, who were never "added". Matches the Share & manage dialog wording. Co-authored-by: Dylan G <69953f73c9f5149d56f2155107338a5435823b938a6c430c63e779f9a8a446f9@remote-agent-pilot.communities.buzz.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/index.test.ts | 4 ++-- src/bundled/builderlab/apps/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bundled/builderlab/apps/index.test.ts b/src/bundled/builderlab/apps/index.test.ts index 84c5536636..84f6c4b1d4 100644 --- a/src/bundled/builderlab/apps/index.test.ts +++ b/src/bundled/builderlab/apps/index.test.ts @@ -314,7 +314,7 @@ it("says who can open an app after a visibility switch", async () => { audience: "only_you", shared_community_ids: ["buzz-alpha"], who_can_open: - "Only your owner. 1 other person added earlier can also open it. So can members of the Buzz community it is shared with.", + "Only your owner. 1 other person can also open it. So can members of the Buzz community it is shared with.", }); await expect( call("apps_visibility", { app_id: "demo", audience: "private" }), @@ -394,6 +394,6 @@ it("counts everyone else who can still open a private app", async () => { ), ).who_can_open, ).toBe( - "Only your owner. 2 other people added earlier can also open it. So can members of the 2 Buzz communities it is shared with.", + "Only your owner. 2 other people can also open it. So can members of the 2 Buzz communities it is shared with.", ); }); diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index 0b4e7428e0..1dbfe0f655 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -322,7 +322,7 @@ export function applyApps( : "Only your owner.", ...(audience === "only_you" && others ? [ - `${others} ${others === 1 ? "other person" : "other people"} added earlier can also open it.`, + `${others} ${others === 1 ? "other person" : "other people"} can also open it.`, ] : []), ...(shared.length From 46c92bafa17475f2ed26e37073d67c3eb0a16229 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 18:05:38 -0400 Subject: [PATCH 5/5] Describe apps_community's 409 as a missing sharing lifecycle Compose answers the community sharing read with 409 only when the app has no sharing lifecycle. Reservation sets one, so a created but undeployed app can be shared; the 409 is for older apps, which a deploy fixes. Also scope the README's "keeps existing grants" promise to apps_visibility, since apps_community revoke removes one. Co-authored-by: Dylan G <69953f73c9f5149d56f2155107338a5435823b938a6c430c63e779f9a8a446f9@remote-agent-pilot.communities.buzz.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/README.md | 2 +- src/bundled/builderlab/apps/client.test.ts | 4 ++-- src/bundled/builderlab/apps/client.ts | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index 53b93ef25b..11cebfb4a3 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -26,7 +26,7 @@ bindings, even when its handler serves all content itself. Sharing follows the one-workspace model: `apps_visibility` switches an app between everyone in the owner's workspace and only the owner, and `apps_community` grants or revokes a Buzz community at the grant revision it just -read. Both keep existing viewers and community grants. +read. `apps_visibility` keeps existing viewers and community grants. BBBlock is the employee pilot. BBPublic uses the same authenticated control-plane boundary: the server must grant Builderlab access to the current workspace/account. diff --git a/src/bundled/builderlab/apps/client.test.ts b/src/bundled/builderlab/apps/client.test.ts index 688123e8d2..6857933bf8 100644 --- a/src/bundled/builderlab/apps/client.test.ts +++ b/src/bundled/builderlab/apps/client.test.ts @@ -364,7 +364,7 @@ it.each([ }, ); -it("tells the agent to deploy before sharing an app with no live version", async () => { +it("tells the agent to deploy before sharing an app with no sharing lifecycle", async () => { for (const response of [ json(409, { error: { @@ -377,7 +377,7 @@ it("tells the agent to deploy before sharing an app with no live version", async await expect( client.community("demo", "buzz-alpha", "grant"), ).rejects.toThrow( - "This app has no live version to share yet. Deploy it with apps_deploy, then try again.", + "This app isn't set up for sharing yet. Deploy it with apps_deploy, then try again.", ); expect(requests).toHaveLength(1); } diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 299fcc57c2..89b2cc41dc 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -250,17 +250,18 @@ export function createAppsClient( id, `sharing/communities/${encodeURIComponent(community)}`, ); - const undeployed = new Error( - "This app has no live version to share yet. Deploy it with apps_deploy, then try again.", + const unprepared = new Error( + "This app isn't set up for sharing yet. Deploy it with apps_deploy, then try again.", ); - // Compose answers this read with 409 only when no version is live. + // Compose answers this read with 409 only when the app has no sharing + // lifecycle yet. Reservation sets one, and a deploy sets it on older apps. const current = await call( "GET", path, "Reading community sharing", ).catch((error: unknown) => { throw error instanceof Error && error.message.includes("(HTTP 409)") - ? undeployed + ? unprepared : error; }); const lifecycle = text(current.lifecycle_id);