diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index a6225ceec6..11cebfb4a3 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. `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. 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..6857933bf8 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,24 +319,99 @@ 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( + "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" }), + ]); + await expect(other.client.visibility("demo", "workspace")).rejects.toThrow( + /reported other as organization, 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 sharing lifecycle", async () => { + for (const response of [ + json(409, { + error: { + code: "community_grant_conflict", + message: "app sharing state changed; read access and retry", + }, + }), + ]) { + const { client, requests } = await setup([response]); + await expect( + client.community("demo", "buzz-alpha", "grant"), + ).rejects.toThrow( + "This app isn't set up for sharing yet. Deploy it with apps_deploy, then try again.", + ); + expect(requests).toHaveLength(1); + } +}); + +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", ); }); diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 42fc4c6199..89b2cc41dc 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,62 @@ 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( + `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; + }, + /** 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 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 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)") + ? unprepared + : error; + }); + const lifecycle = text(current.lifecycle_id); + const revision = current.community_grant_revision; + if ( + !lifecycle || + typeof revision !== "number" || + !Number.isInteger(revision) || + revision < 0 + ) throw new Error( - `Sharing with a workspace needs restricted visibility; this app is ${String(access.visibility)}`, + "Apps Platform returned no valid sharing revision for this app; nothing was changed", ); - 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 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..84f6c4b1d4 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,110 @@ 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 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: [], + }); +}); + +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 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 fddcd273f5..1dbfe0f655 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"} 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",