Skip to content

Commit 66a82a1

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/database-connection-safeguards
2 parents 4fb6ddd + 2e5aa16 commit 66a82a1

18 files changed

Lines changed: 551 additions & 68 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/react": patch
3+
---
4+
5+
Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/sdk": patch
3+
---
4+
5+
Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy".
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Effect } from "effect";
2+
import { scenario } from "../src/scenario";
3+
import { Target } from "../src/services";
4+
import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5+
import { forBrowser, joinOrg } from "./support/session";
6+
7+
scenario(
8+
"Integration creation · cloud members see admin guidance and admins can add",
9+
{ timeout: 180_000 },
10+
Effect.gen(function* () {
11+
const target = yield* Target;
12+
const admin = yield* target.newIdentity();
13+
const invitee = yield* target.newIdentity({ org: false });
14+
const member = yield* joinOrg(target, admin, invitee);
15+
yield* integrationCreationPermissions(forBrowser(admin), forBrowser(member));
16+
}),
17+
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Effect } from "effect";
2+
import { scenario } from "../src/scenario";
3+
import { Target } from "../src/services";
4+
import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5+
import { createInvitedIdentity } from "../targets/selfhost";
6+
7+
scenario(
8+
"Integration creation · self-host members see admin guidance and owners can add",
9+
{ timeout: 180_000 },
10+
Effect.gen(function* () {
11+
const target = yield* Target;
12+
const admin = yield* target.newIdentity();
13+
const member = yield* Effect.promise(() =>
14+
createInvitedIdentity(target.baseUrl, admin, {
15+
role: "member",
16+
emailPrefix: "integration-permissions",
17+
}),
18+
);
19+
yield* integrationCreationPermissions(admin, member);
20+
}),
21+
);
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { randomBytes } from "node:crypto";
2+
import { expect } from "@effect/vitest";
3+
import { Effect } from "effect";
4+
import { composePluginApi } from "@executor-js/api/server";
5+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
6+
import { IntegrationSlug } from "@executor-js/sdk/shared";
7+
8+
import { Api, Browser } from "./services";
9+
import type { Identity } from "./target";
10+
import { visit } from "./surfaces/browser";
11+
12+
const api = composePluginApi([openApiHttpPlugin()] as const);
13+
14+
/** Exercise integration creation and member restrictions through the shared console. */
15+
export const integrationCreationPermissions = (admin: Identity, member: Identity) =>
16+
Effect.gen(function* () {
17+
const browser = yield* Browser;
18+
const { client } = yield* Api;
19+
const adminClient = yield* client(api, admin);
20+
const title = `Permissions API ${randomBytes(4).toString("hex")}`;
21+
const slug = IntegrationSlug.make(title.toLowerCase().replaceAll(" ", "_"));
22+
const spec = JSON.stringify({
23+
openapi: "3.0.3",
24+
info: { title, version: "1.0.0" },
25+
servers: [{ url: "https://api.example.com" }],
26+
paths: {},
27+
components: {
28+
securitySchemes: { apiKey: { type: "apiKey", in: "header", name: "X-API-Key" } },
29+
},
30+
security: [{ apiKey: [] }],
31+
});
32+
33+
yield* Effect.ensuring(
34+
Effect.gen(function* () {
35+
yield* browser.session(admin, async ({ page, step }) => {
36+
await step("Admin opens the integration catalog", async () => {
37+
await visit(page, "/");
38+
await page.getByRole("button", { name: "Browse integrations", exact: true }).waitFor();
39+
await page.keyboard.press("ControlOrMeta+k");
40+
await page.getByRole("option", { name: /^Add OpenAPI/ }).waitFor();
41+
await page.keyboard.press("Escape");
42+
await page.getByRole("link", { name: "Add integration", exact: true }).click();
43+
await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
44+
await page
45+
.getByRole("textbox", { name: "Search integrations, or paste a URL" })
46+
.waitFor();
47+
});
48+
await step("Admin creates an integration from the setup form", async () => {
49+
await visit(page, "/integrations/add/openapi");
50+
await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec);
51+
await page.getByRole("button", { name: "Add integration", exact: true }).click();
52+
await page.waitForURL((url) => url.pathname.endsWith(`/integrations/${slug}`), {
53+
timeout: 30_000,
54+
});
55+
await page.getByRole("button", { name: "Edit", exact: true }).waitFor();
56+
await page.getByRole("button", { name: "Delete", exact: true }).waitFor();
57+
});
58+
});
59+
expect(yield* adminClient.integrations.get({ params: { slug } })).toMatchObject({
60+
name: title,
61+
});
62+
63+
yield* browser.session(member, async ({ page, step }) => {
64+
await step(
65+
"Member sees disabled creation controls with an admin explanation",
66+
async () => {
67+
await visit(page, "/");
68+
await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
69+
await page.getByTestId(`integration-entry-${slug}`).waitFor();
70+
const add = page.getByRole("button", { name: "Add integration", exact: true });
71+
await add.waitFor();
72+
expect(await add.isDisabled()).toBe(true);
73+
expect(
74+
await page
75+
.getByRole("button", { name: "Browse integrations", exact: true })
76+
.isDisabled(),
77+
).toBe(true);
78+
const hint = page
79+
.getByRole("group", { name: "Requires a workspace admin" })
80+
.filter({ has: add });
81+
await hint.hover();
82+
await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor();
83+
await hint.focus();
84+
const before = page.url();
85+
await page.keyboard.press("Enter");
86+
expect(page.url()).toBe(before);
87+
},
88+
);
89+
await step(
90+
"Member sees disabled add commands and can still find existing integrations",
91+
async () => {
92+
await page.keyboard.press("ControlOrMeta+k");
93+
const palette = page.getByRole("dialog");
94+
await palette.getByRole("option", { name: new RegExp(title) }).waitFor();
95+
const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ });
96+
await addCommand.waitFor();
97+
expect(await addCommand.getAttribute("aria-disabled")).toBe("true");
98+
expect(await addCommand.textContent()).toContain("Admin only");
99+
await page.keyboard.press("Escape");
100+
},
101+
);
102+
await step("Member sees disabled Edit and Delete actions", async () => {
103+
await page.getByTestId(`integration-entry-${slug}`).click();
104+
await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
105+
for (const name of ["Edit", "Delete"]) {
106+
const action = page.getByRole("button", { name, exact: true });
107+
await action.waitFor();
108+
expect(await action.isDisabled()).toBe(true);
109+
}
110+
});
111+
await step("Member can still add a personal connection", async () => {
112+
await page.getByRole("button", { name: "Add connection", exact: true }).click();
113+
const dialog = page.getByRole("dialog");
114+
await dialog.waitFor();
115+
expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0);
116+
});
117+
await step("Member browses the catalog with disabled Add buttons", async () => {
118+
await visit(page, "/integrations/browse");
119+
await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
120+
await page
121+
.getByText("Requires a workspace admin to add integrations.", { exact: true })
122+
.waitFor();
123+
const addButtons = page.getByRole("button", { name: /^Add / });
124+
await addButtons.first().waitFor();
125+
for (const button of await addButtons.all())
126+
expect(await button.isDisabled()).toBe(true);
127+
const scratch = page.getByRole("button", {
128+
name: "New OpenAPI integration from scratch",
129+
exact: true,
130+
});
131+
expect(await scratch.isDisabled()).toBe(true);
132+
const view = page.getByRole("link", { name: `View ${title}`, exact: true });
133+
await view.waitFor();
134+
expect(await view.isEnabled()).toBe(true);
135+
});
136+
await step("Member cannot add a URL with the button or Enter key", async () => {
137+
const input = page.getByRole("textbox", {
138+
name: "Search integrations, or paste a URL",
139+
});
140+
await input.fill("https://api.example.com/openapi.json");
141+
expect(
142+
await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(),
143+
).toBe(true);
144+
const before = page.url();
145+
await input.press("Enter");
146+
expect(page.url()).toBe(before);
147+
});
148+
for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) {
149+
await step(`Member follows ${path} and sees the admin explanation`, async () => {
150+
await visit(page, path);
151+
await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor();
152+
expect(await page.getByRole("textbox").count()).toBe(0);
153+
expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0);
154+
});
155+
}
156+
await step("Member returns to their existing integrations", async () => {
157+
await page.getByRole("link", { name: "Back to integrations" }).click();
158+
await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
159+
});
160+
});
161+
}),
162+
adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
163+
);
164+
});

‎packages/core/sdk/src/executor.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3605,7 +3605,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
36053605
? { tools_synced_at: Date.now() }
36063606
: {
36073607
tools_synced_at: Date.now(),
3608-
last_health: health ?? toolSyncHealth(reason),
3608+
// A plugin-supplied verdict (e.g. the MCP server
3609+
// rejecting the token during discovery) is still
3610+
// sync-stamped: mark it so credential-only health
3611+
// checks cannot bury it under "healthy", and a
3612+
// later successful sync clears it.
3613+
last_health:
3614+
health === undefined
3615+
? toolSyncHealth(reason)
3616+
: { ...health, reason: health.reason ?? "tool_sync_failed" },
36093617
updated_at: new Date(),
36103618
},
36113619
})
@@ -5138,7 +5146,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
51385146
): Effect.Effect<void> =>
51395147
findConnectionRow(ref).pipe(
51405148
Effect.flatMap((fresh) =>
5141-
fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null
5149+
fresh === null ||
5150+
oauthReauthRequiredFromProviderState(fresh.provider_state) !== null ||
5151+
// A credential verdict cannot refute a failed tool sync; only a
5152+
// successful sync clears that record (see `isToolSyncHealth`).
5153+
isToolSyncHealth(Option.getOrNull(decodeLastHealth(fresh.last_health)))
51425154
? Effect.void
51435155
: persistHealthResult(ref, fresh, result),
51445156
),
@@ -5241,6 +5253,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
52415253
// failure is the one real signal this path can produce, and it
52425254
// must not hide inside a green span.
52435255
oauthCredentialHealthWithoutProbe(connectionRow).pipe(
5256+
// A resolvable token says nothing about whether the
5257+
// upstream accepts it. When tool sync has already recorded
5258+
// that it does not (a rejected discovery handshake, an
5259+
// unreachable server), that verdict stands until a sync
5260+
// succeeds — serving "healthy" here would hide a connection
5261+
// that has no tools behind a green badge.
5262+
Effect.map((result) =>
5263+
result.status === "healthy" && previous !== null && isToolSyncHealth(previous)
5264+
? previous
5265+
: result,
5266+
),
52445267
Effect.tap((result) => persistProbeHealthResult(ref, result)),
52455268
Effect.map((result) => ({
52465269
source: "credential_only" as const,

‎packages/core/sdk/src/health-check.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type;
145145
export const toolSyncHealthDetailPrefix = "Tool sync failing";
146146

147147
export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean =>
148+
result?.reason === "tool_sync_failed" ||
148149
result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true;
149150

150151
// ---------------------------------------------------------------------------

‎packages/core/sdk/src/oauth-flow.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,73 @@ describe("oauth token refresh in resolveConnectionValue", () => {
20332033
),
20342034
);
20352035

2036+
it.effect(
2037+
"checkHealth without a probe serves a sync-stamped verdict instead of burying it under healthy",
2038+
() =>
2039+
Effect.scoped(
2040+
Effect.gen(function* () {
2041+
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2042+
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2043+
yield* executor.acme.seed();
2044+
2045+
yield* executor.oauth.createClient({
2046+
owner: "org",
2047+
slug: CLIENT,
2048+
authorizationUrl: server.authorizationEndpoint,
2049+
tokenUrl: server.tokenEndpoint,
2050+
grant: "authorization_code",
2051+
clientId: "test-client",
2052+
clientSecret: "test-secret",
2053+
resource: server.mcpResourceUrl,
2054+
});
2055+
2056+
const started = yield* executor.oauth.start({
2057+
owner: "org",
2058+
client: CLIENT,
2059+
clientOwner: "org",
2060+
name: ConnectionName.make("main"),
2061+
integration: INTEG,
2062+
template: TEMPLATE,
2063+
});
2064+
expect(started.status).toBe("redirect");
2065+
if (started.status !== "redirect") return;
2066+
const callback = yield* server.completeAuthorizationCodeFlow({
2067+
authorizationUrl: started.authorizationUrl,
2068+
});
2069+
yield* executor.oauth.complete({ state: started.state, code: callback.code });
2070+
2071+
// Tool sync found the upstream rejecting the freshly minted token
2072+
// (e.g. an MCP discovery handshake answering 401) and stamped it.
2073+
// The token itself still resolves, so a credential-only check would
2074+
// otherwise report healthy and hide a connection that has no tools.
2075+
const stamped = {
2076+
status: "expired",
2077+
checkedAt: Date.now(),
2078+
detail: "MCP OAuth reauthorization required",
2079+
reason: "tool_sync_failed",
2080+
};
2081+
yield* Effect.promise(() =>
2082+
config.db.updateMany("connection", {
2083+
where: (b) => b("name", "=", "main"),
2084+
set: { last_health: stamped },
2085+
}),
2086+
);
2087+
2088+
const result = yield* executor.connections.checkHealth({
2089+
owner: "org",
2090+
integration: INTEG,
2091+
name: ConnectionName.make("main"),
2092+
});
2093+
expect(result).toMatchObject(stamped);
2094+
2095+
const row = yield* Effect.promise(() =>
2096+
config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }),
2097+
);
2098+
expect(row?.last_health).toMatchObject(stamped);
2099+
}),
2100+
),
2101+
);
2102+
20362103
it.effect("records missing authorization-code scopes without blocking the connection", () =>
20372104
Effect.scoped(
20382105
Effect.gen(function* () {

0 commit comments

Comments
 (0)