Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/bundled/builderlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 93 additions & 22 deletions src/bundled/builderlab/apps/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function deferred<T>() {
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";
Expand All @@ -50,19 +50,15 @@ 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";
await session.signIn();
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);
Expand Down Expand Up @@ -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",
);
});

Expand Down
74 changes: 60 additions & 14 deletions src/bundled/builderlab/apps/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Audience, string> = {
workspace: "organization",
only_you: "restricted",
};
export const SIGN_IN =
"Ask your owner to sign in at Settings → Plugins → Builderlab, then try again.";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
);
},
Expand Down
110 changes: 109 additions & 1 deletion src/bundled/builderlab/apps/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.",
);
});
Loading
Loading