Skip to content

Commit 91d11c3

Browse files
committed
Share the item-id owner grammar and harden the repartition migration
1 parent d1ec004 commit 91d11c3

11 files changed

Lines changed: 367 additions & 323 deletions

File tree

bun.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/selfhost/mcp-browser-approval-ownership.test.ts

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
// Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A
22
// signed-in user who does not own the MCP session must not be able to read the
33
// paused execution or record the human decision for it.
4-
import { randomBytes } from "node:crypto";
5-
64
import { expect } from "@effect/vitest";
75
import { Effect } from "effect";
86
import { composePluginApi } from "@executor-js/api/server";
97

108
import { scenario } from "../src/scenario";
119
import { Api, Mcp, Target } from "../src/services";
1210
import { parseBrowserApproval } from "../src/surfaces/mcp";
13-
import type { Identity } from "../src/target";
14-
import { signInSession } from "../targets/selfhost";
11+
import { createInvitedIdentity } from "../targets/selfhost";
1512

1613
const coreApi = composePluginApi([] as const);
1714

@@ -21,46 +18,6 @@ const result = await tools.executor.coreTools.policies.list({});
2118
return JSON.stringify(result);
2219
`;
2320

24-
const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise<Identity> => {
25-
const cookie = admin.headers?.cookie;
26-
expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string");
27-
28-
const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
29-
method: "POST",
30-
headers: {
31-
"content-type": "application/json",
32-
cookie: cookie!,
33-
origin: new URL(baseUrl).origin,
34-
},
35-
body: JSON.stringify({ role: "member" }),
36-
});
37-
expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200);
38-
const inviteBody = (await invite.json()) as { readonly code?: string };
39-
expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string");
40-
41-
const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`;
42-
const password = "approval-cross-user-password-123";
43-
const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
44-
method: "POST",
45-
headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },
46-
body: JSON.stringify({
47-
email,
48-
password,
49-
name: email,
50-
inviteCode: inviteBody.code,
51-
}),
52-
});
53-
expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200);
54-
55-
const session = await signInSession(baseUrl, { email, password });
56-
return {
57-
label: email,
58-
credentials: { email, password },
59-
headers: { cookie: session.cookieHeader },
60-
cookies: session.cookies,
61-
};
62-
};
63-
6421
const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL =>
6522
new URL(
6623
`/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(
@@ -77,7 +34,9 @@ scenario(
7734
const api = yield* Api;
7835
const mcp = yield* Mcp;
7936
const owner = yield* target.newIdentity();
80-
const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner));
37+
const other = yield* Effect.promise(() =>
38+
createInvitedIdentity(target.baseUrl, owner, { emailPrefix: "approval-cross-user" }),
39+
);
8140
const client = yield* api.client(coreApi, owner);
8241

8342
const policy = yield* client.policies.create({

e2e/selfhost/oauth-org-connection-cross-principal.test.ts

Lines changed: 37 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22
// the org, not just whoever's browser session completed the consent.
33
//
44
// Issue #1453: the encrypted-secrets provider (the writable provider on
5-
// self-host and the Cloudflare host) files token rows under the ACTING
5+
// self-host and the Cloudflare host) filed token rows under the ACTING
66
// caller's partition — so completing an org connection's consent in a user
7-
// session lands the tokens at owner='user', subject=<that user>, while the
8-
// connection row itself says org. The consenting user's own probe still reads
9-
// the tokens (user rows shadow org rows), so the connection looks healthy —
10-
// but every other principal resolves no value and fails with
7+
// session landed the tokens at owner='user', subject=<that user>, while the
8+
// connection row itself said org. The consenting user's own calls still read
9+
// the tokens (user rows shadow org rows), so the connection looked healthy —
10+
// but every other principal resolved no value and failed with
1111
// `oauth_connection_missing`. The same mis-filing was fixed for the WorkOS
12-
// Vault provider in #950; encrypted-secrets never got that fix.
12+
// Vault provider in #950.
1313
//
1414
// The journey: the bootstrap admin registers an OAuth-protected integration,
1515
// completes the org-owned authorization-code flow (a real test AS on
16-
// 127.0.0.1), and proves the tool works for them. A second, invited member of
17-
// the same org then invokes the same tool — the guarantee under test is that
18-
// the org connection resolves for them too.
16+
// 127.0.0.1), and proves the tool call carries the minted token upstream. A
17+
// second, invited member of the same org then invokes the same tool — the
18+
// guarantee under test is that the same org connection resolves the same
19+
// token for them too.
1920
import { randomBytes } from "node:crypto";
2021
import { createServer } from "node:http";
2122

@@ -33,23 +34,22 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing";
3334

3435
import { scenario } from "../src/scenario";
3536
import { Api, Target } from "../src/services";
36-
import type { Identity } from "../src/target";
37-
import { signInSession } from "../targets/selfhost";
37+
import { createInvitedIdentity } from "../targets/selfhost";
3838

3939
const api = composePluginApi([openApiHttpPlugin()] as const);
4040

4141
const unique = (prefix: string) => `${prefix}${randomBytes(4).toString("hex")}`;
4242

43-
/** Upstream on 127.0.0.1: `GET /me` is 200 for any bearer. Credential
44-
* resolution is the subject under test, so the upstream only needs to prove
45-
* a token reached it. */
43+
/** Upstream on 127.0.0.1: `GET /me` echoes the authorization header it
44+
* received, so the scenario can assert a real token reached it — not merely
45+
* that a request was made. */
4646
const serveUpstream = () =>
4747
Effect.acquireRelease(
4848
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
4949
const server = createServer((request, response) => {
5050
if (request.method === "GET" && (request.url ?? "").startsWith("/me")) {
5151
response.writeHead(200, { "content-type": "application/json" });
52-
response.end(JSON.stringify({ me: "ok" }));
52+
response.end(JSON.stringify({ authorization: request.headers.authorization ?? null }));
5353
return;
5454
}
5555
response.writeHead(404, { "content-type": "application/json" });
@@ -106,43 +106,6 @@ const specFor = (
106106
},
107107
});
108108

109-
/** A second, distinct member of the (single) selfhost org: admin invite →
110-
* invited email signup → Better Auth session. */
111-
const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise<Identity> => {
112-
const cookie = admin.headers?.cookie;
113-
expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string");
114-
115-
const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
116-
method: "POST",
117-
headers: {
118-
"content-type": "application/json",
119-
cookie: cookie!,
120-
origin: new URL(baseUrl).origin,
121-
},
122-
body: JSON.stringify({ role: "member" }),
123-
});
124-
expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200);
125-
const inviteBody = (await invite.json()) as { readonly code?: string };
126-
expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string");
127-
128-
const email = `org-oauth-member-${randomBytes(5).toString("hex")}@e2e.test`;
129-
const password = "org-oauth-member-password-123";
130-
const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
131-
method: "POST",
132-
headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },
133-
body: JSON.stringify({ email, password, name: email, inviteCode: inviteBody.code }),
134-
});
135-
expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200);
136-
137-
const session = await signInSession(baseUrl, { email, password });
138-
return {
139-
label: email,
140-
credentials: { email, password },
141-
headers: { cookie: session.cookieHeader },
142-
cookies: session.cookies,
143-
};
144-
};
145-
146109
/** Complete the test AS's consent headlessly and return the authorization
147110
* code from the callback redirect. */
148111
const consentCode = (authorizationUrl: string) =>
@@ -164,6 +127,7 @@ const consentCode = (authorizationUrl: string) =>
164127

165128
type ToolEnvelope = {
166129
readonly ok: boolean;
130+
readonly data?: { readonly authorization?: string | null };
167131
readonly error?: { readonly code?: string; readonly message?: string };
168132
};
169133

@@ -261,30 +225,38 @@ scenario(
261225
});
262226
expect(execution.status, `${who}: the execution completes`).toBe("completed");
263227
if (execution.status !== "completed") return yield* Effect.die("not completed");
264-
const envelope = (execution.structured as { result?: ToolEnvelope }).result;
265-
return envelope;
228+
return (execution.structured as { result?: ToolEnvelope }).result;
266229
});
267230

268-
// Sanity: the consenting member sees a healthy connection — this is
269-
// exactly why the bug is invisible to whoever set the connection up.
231+
// Sanity: the consenting member's call carries a real bearer to the
232+
// upstream — this passing is exactly why the bug is invisible to
233+
// whoever set the connection up.
270234
const adminResult = yield* invoke(adminClient, "consenting member");
271235
expect(
272-
adminResult,
273-
"the consenting member's tool call resolves the org connection",
274-
).toMatchObject({ ok: true });
236+
adminResult?.ok,
237+
`the consenting member's tool call resolves the org connection (got: ${JSON.stringify(adminResult)})`,
238+
).toBe(true);
239+
const adminToken = adminResult?.data?.authorization;
240+
expect(adminToken, "the upstream received the minted bearer").toMatch(/^Bearer .+/);
275241

276242
// THE guarantee: a different member of the same org resolves the
277-
// same org-owned connection. Before the fix the token rows sit in
278-
// the consenting user's partition, so this member resolves nothing
279-
// and the call fails with `oauth_connection_missing`.
280-
const member = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, admin));
243+
// same org-owned connection to the same token. Before the fix the
244+
// token rows sat in the consenting user's partition, so this member
245+
// resolved nothing and failed with `oauth_connection_missing`.
246+
const member = yield* Effect.promise(() =>
247+
createInvitedIdentity(target.baseUrl, admin, { emailPrefix: "org-oauth-member" }),
248+
);
281249
const memberClient = yield* makeClient(api, member);
282250
const memberResult = yield* invoke(memberClient, "other member");
283251
expect(
284-
memberResult,
252+
memberResult?.ok,
285253
"another org member's tool call resolves the same org connection " +
286254
`(got: ${JSON.stringify(memberResult)})`,
287-
).toMatchObject({ ok: true });
255+
).toBe(true);
256+
expect(
257+
memberResult?.data?.authorization,
258+
"both members resolve the same org-shared token",
259+
).toBe(adminToken);
288260
}),
289261
Effect.gen(function* () {
290262
yield* adminClient.connections

e2e/targets/selfhost.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// on a throwaway data dir, with Better Auth + the bootstrap admin. MCP OAuth is
33
// headless via `forcedMcpConsent` below. Boot lives in
44
// setup/selfhost.globalsetup.ts.
5+
import { randomBytes } from "node:crypto";
6+
57
import { Effect } from "effect";
68

79
import { e2ePort } from "../src/ports";
@@ -99,6 +101,53 @@ export const forcedMcpConsent =
99101
return { code };
100102
};
101103

104+
// A second, distinct member of the (single) selfhost org: admin invite →
105+
// invited email signup → Better Auth session. What multi-user scenarios use
106+
// until per-test invite-signup isolation lands in newIdentity itself.
107+
export const createInvitedIdentity = async (
108+
baseUrl: string,
109+
admin: Identity,
110+
options?: { readonly role?: "admin" | "member"; readonly emailPrefix?: string },
111+
): Promise<Identity> => {
112+
const cookie = admin.headers?.cookie;
113+
if (typeof cookie !== "string") {
114+
throw new Error("createInvitedIdentity: the admin identity has no Better Auth session cookie");
115+
}
116+
const origin = new URL(baseUrl).origin;
117+
118+
const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
119+
method: "POST",
120+
headers: { "content-type": "application/json", cookie, origin },
121+
body: JSON.stringify({ role: options?.role ?? "member" }),
122+
});
123+
if (invite.status !== 200) {
124+
throw new Error(`createInvitedIdentity: invite create failed (${await invite.text()})`);
125+
}
126+
const inviteBody = (await invite.json()) as { readonly code?: string };
127+
if (typeof inviteBody.code !== "string") {
128+
throw new Error("createInvitedIdentity: invite response carried no redeemable code");
129+
}
130+
131+
const email = `${options?.emailPrefix ?? "invited-member"}-${randomBytes(5).toString("hex")}@e2e.test`;
132+
const password = "invited-member-password-123";
133+
const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
134+
method: "POST",
135+
headers: { "content-type": "application/json", origin },
136+
body: JSON.stringify({ email, password, name: email, inviteCode: inviteBody.code }),
137+
});
138+
if (signup.status !== 200) {
139+
throw new Error(`createInvitedIdentity: invited signup failed (${await signup.text()})`);
140+
}
141+
142+
const session = await signInSession(baseUrl, { email, password });
143+
return {
144+
label: email,
145+
credentials: { email, password },
146+
headers: { cookie: session.cookieHeader },
147+
cookies: session.cookies,
148+
};
149+
};
150+
102151
export const selfhostTarget = (): Target => ({
103152
name: "selfhost",
104153
baseUrl: SELFHOST_BASE_URL,

packages/core/sdk/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ export {
162162
type ExecutorOwnerPolicyContext,
163163
} from "./owner-policy";
164164

165+
// Provider item-id owner grammar — the partition credential providers file
166+
// rows under (issues #950, #1453).
167+
export {
168+
OWNER_SCOPED_ITEM_ID_PREFIXES,
169+
embeddedItemOwner,
170+
ownerForItemId,
171+
} from "./provider-item-owner";
172+
165173
// Tool policies.
166174
export {
167175
matchPattern,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// ---------------------------------------------------------------------------
2+
// The owner a provider item id embeds — the single parser for the id grammar
3+
// the SDK constructs (accessItemId in oauth-service.ts, the connection value
4+
// ids in executor.ts, the oauth-client secret ids):
5+
//
6+
// connection:<owner>:<integration>:<name>:<variable>
7+
// oauth:<owner>:<integration>:<name>[:refresh]
8+
// oauth-client:<owner>:<slug>:secret
9+
//
10+
// Credential providers file plugin-storage rows by THIS owner, not the acting
11+
// caller's binding — an org connection whose OAuth consent completes in one
12+
// member's browser session must produce rows every member can resolve
13+
// (issues #950, #1453).
14+
// ---------------------------------------------------------------------------
15+
16+
import { Owner } from "./ids";
17+
import type { OwnerBinding } from "./plugin";
18+
19+
/** Item-id prefixes whose SECOND colon-segment is the owning partition. */
20+
export const OWNER_SCOPED_ITEM_ID_PREFIXES: ReadonlySet<string> = new Set([
21+
"connection",
22+
"oauth",
23+
"oauth-client",
24+
]);
25+
26+
/** The owner a logical item id embeds, or null for ids that carry none
27+
* (legacy random `secret_*` ids). Reads the second colon-segment of the
28+
* owner-scoped prefixes. */
29+
export const embeddedItemOwner = (id: string): Owner | null => {
30+
const [prefix, owner] = id.split(":");
31+
if (OWNER_SCOPED_ITEM_ID_PREFIXES.has(prefix ?? "") && (owner === "org" || owner === "user")) {
32+
return Owner.make(owner);
33+
}
34+
return null;
35+
};
36+
37+
/** Map the executor's (tenant, subject?) binding onto the storage `Owner`
38+
* literal: a bound subject writes the user's own partition, otherwise the
39+
* org-shared one. Fallback only — prefer `ownerForItemId`. */
40+
const ownerOf = (binding: OwnerBinding): Owner =>
41+
binding.subject == null ? Owner.make("org") : Owner.make("user");
42+
43+
/** The partition a credential belongs to: the CREDENTIAL's owner (embedded in
44+
* the item id), not the acting caller's binding. Ids without an embedded
45+
* owner fall back to the caller binding. */
46+
export const ownerForItemId = (id: string, binding: OwnerBinding): Owner =>
47+
embeddedItemOwner(id) ?? ownerOf(binding);

packages/plugins/encrypted-secrets/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
},
1919
"devDependencies": {
2020
"@effect/vitest": "catalog:",
21-
"@libsql/client": "catalog:",
2221
"@types/node": "catalog:",
2322
"bun-types": "catalog:",
2423
"tsup": "catalog:",

0 commit comments

Comments
 (0)