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
10 changes: 10 additions & 0 deletions .changeset/32887-scoped-url-api-forward-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/runtime': patch
---

fix(runtime): on a multi-kernel host, a same-origin `type: 'api'` action run by a REST or MCP request placed only by its scoped URL forwards into the environment the request ran in

Clause-②: no
Metadata-protocol: none

- **The forward is placed where the request ran, at every door.** A same-origin `type: 'api'` action forwards into the host's raw app, which places the forwarded request as it places any request. A request placed by its bound hostname or by its `X-Environment-Id` carries that signal into its forward. A request placed only by its scoped URL (`POST /environments/:env/actions/:object/:action`, or `run_action` on `/environments/:env/mcp`) did not, because the forward targets the action's own path: on a shared origin with no hostname binding the forward landed in no environment, and its target answered a member who may run the action `401`. On a host with a `kernel-resolver`, the forward from the REST, MCP and `dispatchAction` doors now carries the environment the resolver placed the request in as `X-Environment-Id`, replacing any `X-Environment-Id` the caller sent. A request placed in no environment, or in `platform` (the control plane), and every request on a host with no `kernel-resolver`, forwards exactly as before.
78 changes: 73 additions & 5 deletions packages/runtime/src/action-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@
* REST door's scoped path is served from, through the same placement
* step, and a failing resolver refuses it at both doors.
* 1c. On a hosted environment kernel, a same-origin `type: 'api'` action's
* forward is placed in the environment the call ran in, through both
* doors; an environment's own kernel, handed to `dispatchAction`, has no
* socket to forward through and is refused 503.
* forward is placed in the environment the call ran in, through every
* door (REST and MCP placed only by a scoped URL on a shared origin
* included); an environment's own kernel, handed to `dispatchAction`, has
* no socket to forward through and is refused 503.
* 2. The MCP refusals that change because the bridge now runs the REST
* door's sequence (the PR's "Served refusal codes that change"): each
* was refused before and stays refused; only the code, or the detail
Expand Down Expand Up @@ -483,12 +484,29 @@ describe('dispatchAction on a multi-kernel host runs on the environment\'s own k
describe('a same-origin api action on a hosted environment kernel forwards as the REST door forwards it', () => {
const ENV_T = 'env_t';
const TENANT_HOST = 'acme.example.test';
/** The host's shared origin: no environment is bound to it, so only a scoped URL places a request. */
const SHARED_HOST = 'shared.example.test';
const SESSION = 'os.session=member';
const probe = { name: 'probe_api', label: 'Probe', type: 'api', target: '/api/v1/probe/echo', method: 'POST', ai: EXPOSED };
const auth = { api: { getSession: async () => ({ user: { id: MEMBER_EC.userId } }) } };

function hostedEnvironment() {
const env = makeRig([probe], { services: { auth } });
// The environment's MCP runtime: one `run_action` call over the
// dispatcher's real bridge, its answer (or the throw, read as the MCP
// tool reads it) written back as JSON.
const mcp = {
handleHttpRequest: async (_req: Request, opts: { bridge: any }) => {
let answer: unknown;
try {
answer = { ok: true, result: await opts.bridge.runAction(probe.name, { objectName: OBJECT }) };
} catch (err) {
const read = actionsDoorAnswerOf(err, OBJECT);
answer = { ok: false, status: read.status, code: read.code };
}
return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } });
},
};
const env = makeRig([probe], { services: { auth, mcp } });
const place = (ctx: any) => {
const headers = ctx.request?.headers ?? {};
const named = headers.host === TENANT_HOST || headers['x-environment-id'] === ENV_T || ctx.urlEnvironmentId === ENV_T;
Expand Down Expand Up @@ -517,9 +535,59 @@ describe('a same-origin api action on a hosted environment kernel forwards as th
const viaHost = (kernel: unknown, call: Partial<ActionDispatchRequest> = {}) => dispatchAction(kernel as any, {
executionContext: MEMBER_EC as any, objectName: OBJECT, actionName: probe.name, credentialHeaders: { cookie: SESSION }, ...call,
});
return { env, host, forwards, viaRest, viaHost };
/**
* A request on the host's SHARED origin (no hostname binding), placed
* only by its scoped URL `/environments/:env/…`, through the real
* `dispatch()`: the REST action route, or the MCP endpoint's `run_action`.
*/
const viaScoped = async (route: string, headers: Record<string, string> = {}) => ((await new HttpDispatcher(host.kernel).dispatch(
'POST', `/environments/${ENV_T}${route}`, {}, {},
{ request: { method: 'POST', url: `/api/v1/environments/${ENV_T}${route}`, headers: { host: SHARED_HOST, cookie: SESSION, ...headers } } } as any,
)) as any).response as { status: number; body: any };
return { env, host, forwards, viaRest, viaHost, viaScoped };
}

// [#32887] The REST and MCP doors run the api arm on the dispatch request
// itself: on a shared origin a request placed only by its scoped URL
// carried no routing identity into its forward (no bound `Host`, no
// environment header), so the raw app placed the forward nowhere and the
// target answered the member 401.
it('a REST call placed only by its scoped URL forwards into the environment it was placed in', async () => {
const h = hostedEnvironment();

const answer = await h.viaScoped(`/actions/${OBJECT}/${probe.name}`);

expect({ status: answer.status, data: answer.body?.data }).toEqual({ status: 200, data: { echoed: {} } });
expect(h.forwards.map((f) => ({ url: f.url, placed: f.placed, environment: f.headers['x-environment-id'] })))
.toEqual([{ url: `http://${SHARED_HOST}${probe.target}`, placed: true, environment: ENV_T }]);
expect(h.env.rows.map((r) => r.outcome)).toEqual(['succeeded']);
});

it('an MCP `run_action` placed only by its scoped URL forwards into the environment it was placed in', async () => {
const h = hostedEnvironment();

const answer = await h.viaScoped('/mcp');

// The bridge's own answer wraps the forward's result (`{ ok, action, objectName, result }`).
expect({ status: answer.status, ok: answer.body?.ok, result: answer.body?.result?.result })
.toEqual({ status: 200, ok: true, result: { echoed: {} } });
expect(h.forwards.map((f) => ({ url: f.url, placed: f.placed, environment: f.headers['x-environment-id'] })))
.toEqual([{ url: `http://${SHARED_HOST}${probe.target}`, placed: true, environment: ENV_T }]);
});

it('the scoped forward names the environment the request was placed in, never the caller\'s environment header', async () => {
const h = hostedEnvironment();

// The rig's resolver places by the bound hostname, then the header,
// then the scoped URL: a header naming another environment is not one
// it places, so the URL places the call and the forward names ENV_T.
const answer = await h.viaScoped(`/actions/${OBJECT}/${probe.name}`, { 'x-environment-id': 'env_elsewhere' });

expect(answer.status).toBe(200);
expect(h.forwards.map((f) => ({ placed: f.placed, environment: f.headers['x-environment-id'] })))
.toEqual([{ placed: true, environment: ENV_T }]);
});

it('a call naming the environment on the host kernel forwards into that environment and answers what the REST door answers, with its invocation row', async () => {
const h = hostedEnvironment();

Expand Down
47 changes: 19 additions & 28 deletions packages/runtime/src/action-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import { actorUserFromExecutionContext, resolveActorDisplayName } from './securi
import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js';
import { objectValueFailure } from './object-value-shape.js';
import { apiErrorResponse } from './error-envelope.js';
import { HttpDispatcher, type HttpProtocolContext, type HttpDispatcherResult } from './http-dispatcher.js';
import { HttpDispatcher, resolverPlacedEnvironmentOf, type HttpProtocolContext, type HttpDispatcherResult } from './http-dispatcher.js';
import type { DomainHandlerDeps } from './domain-handler-registry.js';
// [ADR-0106 D1] The run doors' visibility question: the `/meta` projection's.
import { actionRunFieldVisibilityRefusal, actionRunMasker, projectActionForCaller } from './action-run-visibility.js';
Expand Down Expand Up @@ -529,10 +529,17 @@ async function dispatchAdmittedAction(
recordId, record: actionExec.apiForwardSubjectRecord(recordId, subject), params, ec,
});
if ('message' in built) return { kind: 'refused', refusal: { message: built.message, status: built.status } };
// [#32658, #32887] On a multi-kernel host the forward is placed in
// the environment the host's resolver placed THIS request in,
// whichever door asked and whatever signal placed it (a bound
// hostname, the environment header, a scoped URL, the host door's
// named environment): the forward targets the action's own path,
// so a scoped URL's environment does not cross with it.
const placedEnvironment = resolverPlacedEnvironmentOf(requestContext);
const forward = await actionExec.forwardApiAction(inject, requestContext, built, {
objectName, actionName, target: actionDef?.target,
...(recordId ? { recordId: String(recordId) } : {}),
});
}, placedEnvironment !== undefined ? { environmentId: placedEnvironment } : undefined);
if (forward.status >= 200 && forward.status < 300) {
return { kind: 'answered', result: actionExec.apiActionResultOf(forward), headers: forward.headers };
}
Expand Down Expand Up @@ -910,14 +917,6 @@ function actionRoutePath(namespace: string | undefined, objectName: string, acti
return `/actions${namespace !== undefined ? `/${segment(namespace)}` : ''}/${segment(objectName)}/${segment(actionName)}`;
}

/**
* The declared environment header (`X-Environment-Id`, lower-case as the
* forward's header bag spells it), which a host's `kernel-resolver` reads to
* place a request in its environment and which the api forward carries across
* from its request (`action-execution.ts`'s forward allow-list).
*/
const FORWARD_ENVIRONMENT_HEADER = 'x-environment-id';

/**
* The host door over a dispatcher's deps: the REST door's sequence for a
* call that arrived as values, not as a path. Writes the invocation row with
Expand Down Expand Up @@ -979,24 +978,16 @@ export async function dispatchActionRequest(
} catch (err) {
return deps.errorFromThrown(err, 500);
}
// [#32658] THE FORWARD'S ROUTING IDENTITY. A same-origin `type: 'api'`
// action forwards into the host's raw app, which places the forwarded
// request as it places any request it receives. A REST request placed by
// its hostname or its `X-Environment-Id` carries that signal into its
// forward, so the forward lands in the environment the call ran in. This
// door's request carries neither: on a multi-kernel host its forward
// reached the raw app as `Host: localhost`, which places it in no
// environment, and the target answered the caller 401. The environment the
// host's resolver placed the call in is written as the declared
// environment header, so the forward is placed where the call ran. It is
// the resolver's answer, never the caller's: it replaces any spelling the
// credential headers carried. Read before the no-resolver fallbacks below
// write an environment, so a host that placed nothing (a single kernel)
// routes nothing; `platform` is the control plane, not an environment.
const placedEnvironment = context.environmentId;
if (typeof placedEnvironment === 'string' && placedEnvironment !== '' && placedEnvironment !== 'platform') {
headers[FORWARD_ENVIRONMENT_HEADER] = placedEnvironment;
}
// [#32658] THE FORWARD'S ROUTING IDENTITY. This door's request carries no
// hostname and no environment header, so on a multi-kernel host its api
// forward reached the raw app as `Host: localhost`, placed in no
// environment. The placement step above records the environment the
// host's resolver placed the call in, and the api arm writes it onto the
// forward as it does at the REST and MCP doors
// (`resolverPlacedEnvironmentOf`), replacing any spelling the credential
// headers carried. Recorded before the no-resolver fallbacks below write
// an environment, so a host that placed nothing (a single kernel) routes
// nothing; `platform` is the control plane, not an environment.
// On a host with NO resolver nothing places the call, so the environment
// the host named is carried as it always was — never silently dropped for
// the default one. Written after placement, so the scope touch, which this
Expand Down
21 changes: 21 additions & 0 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,15 @@ const API_FORWARD_IDENTITY_HEADERS: ReadonlySet<string> = new Set([
*/
export const API_FORWARD_MARK_HEADER = 'x-objectstack-action-forward';

/**
* [#32658, #32887] The declared environment header (`X-Environment-Id`,
* lower-case as the forward's header bag spells it): what a host's
* `kernel-resolver` reads to place a request in its environment, written onto
* a forward from the environment its request was placed in
* ({@link forwardApiAction}'s `routing`).
*/
const API_FORWARD_ENVIRONMENT_HEADER = 'x-environment-id';

/** The response headers relayed back to the caller — the credential rotations. */
const API_FORWARD_RELAYED_RESPONSE_HEADERS: readonly string[] = [
// better-auth's bearer lane (`bearer()` emits `set-auth-token`; #8243's
Expand Down Expand Up @@ -2078,6 +2087,16 @@ export function apiActionForwardLoopError(action: any, mark: string): string {
* A caller whose credential the target's door cannot read is never
* forwarded ({@link apiForwardCredentialRefusal}).
*
* [#32658, #32887] `routing.environmentId` is the environment a multi-kernel
* host's resolver placed the request in. The forward targets the action's own
* path, so a scoped URL's environment does not cross with it, and the host
* door's request has no hostname at all: the forward carries the placement as
* the declared environment header, which places it in the same environment at
* the raw app. It replaces any spelling the caller sent, so the forward cannot
* be steered anywhere the request did not run. Absent (a single kernel, no
* placement, the control plane), nothing is written and the caller's own
* allow-listed headers cross as they always did.
*
* [ADR-0090 D10 rule 5] An AI agent principal is the one caller whose own
* credential is never carried. At the two cells the table flips
* ({@link API_FORWARD_AGENT_CREDENTIAL_DOORS}) the forward drops every caller
Expand All @@ -2102,6 +2121,7 @@ export async function forwardApiAction(
requestContext: HttpProtocolContext,
req: ApiActionRequest,
mark: { objectName: string; actionName: string; target?: string; recordId?: string },
routing?: { environmentId?: string },
): Promise<ApiActionForwardAnswer> {
// A caller whose credential the target's door cannot read (the table,
// {@link API_FORWARD_DOOR_ACCEPTS}) is refused here, before the target is
Expand Down Expand Up @@ -2135,6 +2155,7 @@ export async function forwardApiAction(
if (agent && API_FORWARD_IDENTITY_HEADERS.has(key)) continue;
headers[key] = Array.isArray(v) ? v.join(', ') : String(v);
}
if (routing?.environmentId) headers[API_FORWARD_ENVIRONMENT_HEADER] = routing.environmentId;
headers.accept = 'application/json';
headers[API_FORWARD_MARK_HEADER] = `${mark.objectName}/${mark.actionName}`;
const init: RequestInit = { method: req.method, headers };
Expand Down
35 changes: 35 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,34 @@ type ClassifiedServiceLookup =
| { readonly outcome: 'never-registered' }
| { readonly outcome: 'no-instance-in-scope'; readonly scopeId: string };

/**
* [#32887] The environment a host's {@link KernelResolver} placed each request
* in, kept beside the request and readable only through
* {@link resolverPlacedEnvironmentOf}.
*
* A same-origin `type: 'api'` action forwards into the host's raw app, which
* places the forwarded request as it places any request it receives. The
* forward needs the placement's answer, not the signal that produced it: a
* request placed by its bound hostname or its `X-Environment-Id` carries that
* signal across, but one placed only by its scoped URL
* (`/environments/:id/…`) does not — the forward targets the action's own
* path — so on a shared origin it was placed in no environment. Kept off
* {@link HttpProtocolContext} on purpose: a caller builds that object, and the
* forward's routing identity must be the resolver's answer, never a field a
* caller could pre-write.
*/
const resolverPlacements = new WeakMap<HttpProtocolContext, string>();

/**
* The environment the host's {@link KernelResolver} placed this request in, as
* the placement step left it: `undefined` when no resolver is wired (a single
* kernel), when it placed the request in no environment, or in `platform` (the
* control plane, not an environment).
*/
export function resolverPlacedEnvironmentOf(context: HttpProtocolContext | undefined): string | undefined {
return context ? resolverPlacements.get(context) : undefined;
}

/**
* The HTTP dispatch engine — translates an inbound (method, path, body, ctx)
* request into a kernel response. Used directly by the framework's HTTP adapters
Expand Down Expand Up @@ -784,6 +812,13 @@ export class HttpDispatcher {
// request onto the second one's environment.
if (this.kernelResolver) {
context.kernel = (await this.kernelResolver.resolveKernel(context, this.defaultKernel)) ?? this.defaultKernel;
// [#32887] What the resolver answered, kept for the api forward
// ({@link resolverPlacedEnvironmentOf}). Read here, before any door
// writes a fallback environment, so a request nothing placed
// routes nothing.
const placed = context.environmentId;
if (typeof placed === 'string' && placed !== '' && placed !== 'platform') resolverPlacements.set(context, placed);
else resolverPlacements.delete(context);
} else {
context.kernel = this.defaultKernel;
}
Expand Down
Loading