Skip to content

Commit dfeaf6f

Browse files
committed
refactor: add gateway method dispatch contract
1 parent cd9b2c0 commit dfeaf6f

16 files changed

Lines changed: 273 additions & 10 deletions

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,10 @@
480480
"types": "./dist/plugin-sdk/security-runtime.d.ts",
481481
"default": "./dist/plugin-sdk/security-runtime.js"
482482
},
483+
"./plugin-sdk/gateway-method-runtime": {
484+
"types": "./dist/plugin-sdk/gateway-method-runtime.d.ts",
485+
"default": "./dist/plugin-sdk/gateway-method-runtime.js"
486+
},
483487
"./plugin-sdk/gateway-runtime": {
484488
"types": "./dist/plugin-sdk/gateway-runtime.d.ts",
485489
"default": "./dist/plugin-sdk/gateway-runtime.js"

packages/plugin-sdk/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@
108108
"types": "./dist/src/plugin-sdk/cli-runtime.d.ts",
109109
"default": "./src/cli-runtime.ts"
110110
},
111+
"./gateway-method-runtime": {
112+
"types": "./dist/src/plugin-sdk/gateway-method-runtime.d.ts",
113+
"default": "./src/gateway-method-runtime.ts"
114+
},
111115
"./error-runtime": {
112116
"types": "./dist/src/plugin-sdk/error-runtime.d.ts",
113117
"default": "./src/error-runtime.ts"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "../../../src/plugin-sdk/gateway-method-runtime.js";

scripts/lib/plugin-sdk-entrypoints.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"secret-ref-runtime",
9696
"secret-file-runtime",
9797
"security-runtime",
98+
"gateway-method-runtime",
9899
"gateway-runtime",
99100
"cli-runtime",
100101
"cli-backend",

src/gateway/server-plugins.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -299,17 +299,20 @@ function mergeGatewayClientInternal(
299299

300300
type DispatchGatewayMethodInProcessOptions = {
301301
allowSyntheticModelOverride?: boolean;
302+
disableSyntheticClient?: boolean;
302303
expectFinal?: boolean;
303304
forceSyntheticClient?: boolean;
304305
pluginRuntimeOwnerId?: string;
306+
requireScopedClient?: boolean;
305307
syntheticScopes?: string[];
306308
timeoutMs?: number;
307309
};
308310

309-
type GatewayMethodDispatchResponse = {
311+
export type GatewayMethodDispatchResponse = {
310312
ok: boolean;
311313
payload?: unknown;
312314
error?: ErrorShape;
315+
meta?: Record<string, unknown>;
313316
};
314317

315318
function unwrapGatewayMethodDispatchResponse(
@@ -322,11 +325,11 @@ function unwrapGatewayMethodDispatchResponse(
322325
return response.payload;
323326
}
324327

325-
async function dispatchGatewayMethod<T>(
328+
export async function dispatchGatewayMethodInProcessRaw(
326329
method: string,
327-
params: Record<string, unknown>,
330+
params: unknown,
328331
options?: DispatchGatewayMethodInProcessOptions,
329-
): Promise<T> {
332+
): Promise<GatewayMethodDispatchResponse> {
330333
const scope = getPluginRuntimeGatewayRequestScope();
331334
const context = scope?.context ?? getFallbackGatewayContext();
332335
const isWebchatConnect = scope?.isWebchatConnect ?? (() => false);
@@ -335,6 +338,11 @@ async function dispatchGatewayMethod<T>(
335338
`In-process gateway dispatch requires a gateway request scope (method: ${method}). No scope set and no fallback context available.`,
336339
);
337340
}
341+
if (options?.requireScopedClient === true && !scope?.client) {
342+
throw new Error(
343+
`In-process gateway dispatch requires an authenticated plugin request scope (method: ${method}).`,
344+
);
345+
}
338346

339347
let firstResponse: GatewayMethodDispatchResponse | undefined;
340348
let finalResponse: GatewayMethodDispatchResponse | undefined;
@@ -353,6 +361,9 @@ async function dispatchGatewayMethod<T>(
353361
scope?.client,
354362
pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : undefined,
355363
);
364+
if (options?.disableSyntheticClient === true && !scopedClient) {
365+
throw new Error(`In-process gateway dispatch requires a scoped client (method: ${method}).`);
366+
}
356367
await handleGatewayRequest({
357368
req: {
358369
type: "req",
@@ -361,10 +372,12 @@ async function dispatchGatewayMethod<T>(
361372
params,
362373
},
363374
client:
364-
options?.forceSyntheticClient === true ? syntheticClient : (scopedClient ?? syntheticClient),
375+
options?.forceSyntheticClient === true
376+
? syntheticClient
377+
: (scopedClient ?? (options?.disableSyntheticClient === true ? null : syntheticClient)),
365378
isWebchatConnect,
366-
respond: (ok, payload, error) => {
367-
const response = { ok, payload, error };
379+
respond: (ok, payload, error, meta) => {
380+
const response = { ok, payload, error, ...(meta ? { meta } : {}) };
368381
if (!firstResponse) {
369382
firstResponse = response;
370383
return;
@@ -382,7 +395,7 @@ async function dispatchGatewayMethod<T>(
382395
}
383396
const firstPayload = firstResponse.payload as { status?: unknown } | undefined;
384397
if (options?.expectFinal !== true || firstPayload?.status !== "accepted") {
385-
return unwrapGatewayMethodDispatchResponse(method, firstResponse) as T;
398+
return firstResponse;
386399
}
387400
const final =
388401
finalResponse ??
@@ -412,7 +425,16 @@ async function dispatchGatewayMethod<T>(
412425
resolve(response);
413426
};
414427
}));
415-
return unwrapGatewayMethodDispatchResponse(method, final) as T;
428+
return final;
429+
}
430+
431+
async function dispatchGatewayMethod<T>(
432+
method: string,
433+
params: unknown,
434+
options?: DispatchGatewayMethodInProcessOptions,
435+
): Promise<T> {
436+
const response = await dispatchGatewayMethodInProcessRaw(method, params, options);
437+
return unwrapGatewayMethodDispatchResponse(method, response) as T;
416438
}
417439

418440
export async function dispatchGatewayMethodInProcess<T>(

src/gateway/server/plugins-http.runtime-scopes.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ function createRoute(params: {
2020
auth: "gateway" | "plugin";
2121
match?: "exact" | "prefix";
2222
gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
23+
gatewayMethodDispatchAllowed?: boolean;
2324
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean>;
2425
}) {
2526
return {
2627
pluginId: "route",
2728
path: params.path,
2829
auth: params.auth,
2930
gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
31+
gatewayMethodDispatchAllowed: params.gatewayMethodDispatchAllowed,
3032
match: params.match ?? "exact",
3133
handler: params.handler ?? (() => true),
3234
source: "route",
@@ -142,6 +144,51 @@ describe("plugin HTTP route runtime scopes", () => {
142144
expect(log.warn).not.toHaveBeenCalled();
143145
});
144146

147+
it("threads plugin route identity and gateway dispatch entitlement into runtime scope", async () => {
148+
let observed:
149+
| {
150+
pluginId: string | undefined;
151+
pluginSource: string | undefined;
152+
gatewayMethodDispatchAllowed: boolean | undefined;
153+
}
154+
| undefined;
155+
const handler = createGatewayPluginRequestHandler({
156+
registry: createTestRegistry({
157+
httpRoutes: [
158+
createRoute({
159+
path: "/secure-hook",
160+
auth: "gateway",
161+
gatewayMethodDispatchAllowed: true,
162+
handler: async () => {
163+
const scope = getPluginRuntimeGatewayRequestScope();
164+
observed = {
165+
pluginId: scope?.pluginId,
166+
pluginSource: scope?.pluginSource,
167+
gatewayMethodDispatchAllowed: scope?.gatewayMethodDispatchAllowed,
168+
};
169+
return true;
170+
},
171+
}),
172+
],
173+
}),
174+
log: createMockLogger(),
175+
});
176+
177+
const { res } = makeMockHttpResponse();
178+
const handled = await handler({ url: "/secure-hook" } as IncomingMessage, res, undefined, {
179+
gatewayAuthSatisfied: true,
180+
gatewayRequestOperatorScopes: ["operator.write"],
181+
});
182+
183+
expect(handled).toBe(true);
184+
expect(res.statusCode).toBe(200);
185+
expect(observed).toEqual({
186+
pluginId: "route",
187+
pluginSource: "route",
188+
gatewayMethodDispatchAllowed: true,
189+
});
190+
});
191+
145192
it("does not give approval-scoped gateway-auth routes global approval visibility", async () => {
146193
const manager = new ExecApprovalManager<{ command: string }>();
147194
const record = manager.create({ command: "echo ok" }, 60_000, "route-hidden-approval");

src/gateway/server/plugins-http.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export function createGatewayPluginRequestHandler(params: {
147147
{
148148
client: runtimeClient,
149149
isWebchatConnect: () => false,
150+
...(route.pluginId ? { pluginId: route.pluginId } : {}),
151+
...(route.source ? { pluginSource: route.source } : {}),
152+
...(route.gatewayMethodDispatchAllowed === true
153+
? { gatewayMethodDispatchAllowed: true }
154+
: {}),
150155
},
151156
async () => route.handler(req, res),
152157
);
@@ -243,6 +248,11 @@ export function createGatewayPluginUpgradeHandler(params: {
243248
{
244249
client: runtimeClient,
245250
isWebchatConnect: () => false,
251+
...(route.pluginId ? { pluginId: route.pluginId } : {}),
252+
...(route.source ? { pluginSource: route.source } : {}),
253+
...(route.gatewayMethodDispatchAllowed === true
254+
? { gatewayMethodDispatchAllowed: true }
255+
: {}),
246256
},
247257
async () => route.handleUpgrade?.(req, socket, head),
248258
);

src/gateway/sessions-history-http.revocation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ vi.mock("./http-utils.js", () => ({
4747
authorizeScopedGatewayHttpRequestOrReply: async () => ({
4848
cfg: { gateway: { webchat: { chatHistoryMaxChars: 2000 } } },
4949
requestAuth: { trustDeclaredOperatorScopes: true },
50+
operatorScopes: ["operator.read"],
5051
}),
5152
checkGatewayHttpRequestAuth: async (params: {
5253
trustedProxies?: string[];
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
3+
import { dispatchGatewayMethod } from "./gateway-method-runtime.js";
4+
5+
const { dispatchGatewayMethodInProcessRaw } = vi.hoisted(() => ({
6+
dispatchGatewayMethodInProcessRaw: vi.fn(),
7+
}));
8+
9+
vi.mock("../gateway/server-plugins.js", () => ({
10+
dispatchGatewayMethodInProcessRaw,
11+
}));
12+
13+
describe("plugin-sdk/gateway-method-runtime", () => {
14+
it("rejects callers without the gateway method dispatch contract", async () => {
15+
await expect(
16+
withPluginRuntimeGatewayRequestScope(
17+
{
18+
pluginId: "plain-plugin",
19+
client: {
20+
id: "plugin",
21+
connect: { scopes: ["operator.write"] },
22+
} as never,
23+
isWebchatConnect: () => false,
24+
},
25+
() => dispatchGatewayMethod("health", {}),
26+
),
27+
).rejects.toThrow(
28+
'contracts.gatewayMethodDispatch: ["authenticated-request"] for plugin "plain-plugin"',
29+
);
30+
expect(dispatchGatewayMethodInProcessRaw).not.toHaveBeenCalled();
31+
});
32+
33+
it("dispatches through the scoped client for entitled plugin HTTP routes", async () => {
34+
dispatchGatewayMethodInProcessRaw.mockResolvedValueOnce({ ok: true, payload: { ok: true } });
35+
36+
const result = await withPluginRuntimeGatewayRequestScope(
37+
{
38+
pluginId: "admin-http-rpc",
39+
gatewayMethodDispatchAllowed: true,
40+
client: {
41+
id: "plugin",
42+
connect: { scopes: ["operator.admin"] },
43+
} as never,
44+
isWebchatConnect: () => false,
45+
},
46+
() => dispatchGatewayMethod("health", {}, { timeoutMs: 500 }),
47+
);
48+
49+
expect(result).toEqual({ ok: true, payload: { ok: true } });
50+
expect(dispatchGatewayMethodInProcessRaw).toHaveBeenCalledWith(
51+
"health",
52+
{},
53+
{
54+
disableSyntheticClient: true,
55+
requireScopedClient: true,
56+
timeoutMs: 500,
57+
},
58+
);
59+
});
60+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { dispatchGatewayMethodInProcessRaw } from "../gateway/server-plugins.js";
2+
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
3+
4+
export type GatewayMethodDispatchError = {
5+
code: string;
6+
message: string;
7+
details?: unknown;
8+
retryable?: boolean;
9+
retryAfterMs?: number;
10+
};
11+
12+
export type GatewayMethodDispatchResponse = {
13+
ok: boolean;
14+
payload?: unknown;
15+
error?: GatewayMethodDispatchError;
16+
meta?: Record<string, unknown>;
17+
};
18+
19+
export type GatewayMethodDispatchOptions = {
20+
expectFinal?: boolean;
21+
timeoutMs?: number;
22+
};
23+
24+
/**
25+
* Dispatch a Gateway control-plane method from an authenticated plugin request scope.
26+
*/
27+
export async function dispatchGatewayMethod(
28+
method: string,
29+
params?: unknown,
30+
options?: GatewayMethodDispatchOptions,
31+
): Promise<GatewayMethodDispatchResponse> {
32+
const scope = getPluginRuntimeGatewayRequestScope();
33+
if (scope?.gatewayMethodDispatchAllowed !== true) {
34+
const pluginLabel = scope?.pluginId ? ` for plugin "${scope.pluginId}"` : "";
35+
throw new Error(
36+
`Gateway method dispatch is reserved for plugin HTTP routes that declare contracts.gatewayMethodDispatch: ["authenticated-request"]${pluginLabel}.`,
37+
);
38+
}
39+
return await dispatchGatewayMethodInProcessRaw(method, params, {
40+
disableSyntheticClient: true,
41+
requireScopedClient: true,
42+
...(options?.expectFinal !== undefined ? { expectFinal: options.expectFinal } : {}),
43+
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
44+
});
45+
}

0 commit comments

Comments
 (0)