Skip to content

Commit 744ad0a

Browse files
fix(runtime): an API-key caller of an api action whose target is an identity route is refused by name, before the forward (#28263)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DCfTM9VrSoNVfHfD6abTWmM)_ domain:server#3 · os-elon-musk · session_016WV4d1MkJvadehWmrLUUT1 Fixes #28217 Clause-②: no (a call that was already refused now says why and is refused before its target is called; no credential is newly accepted anywhere, and no error code, status name, answer member or package export is added) Metadata-protocol: none ## 维护者速读 - 改了什么:用 API key 调用的 `type: 'api'` 动作,如果目标是身份路由(`/api/v1/auth/…`,比如 Ban User),两个动作门(REST `/actions` 和 MCP `run_action`)在转发前就按名拒绝:401 `UNAUTHENTICATED`,说明这个端点只接受已登录会话,不接受 API key。目标不会被调用。 - 为什么改:身份路由只认自己的会话,从不认 API key,所以以前这个调用到了目标那里就成了匿名,拿回一句 "Sign in first",而调用方其实已经认证过了。 - 没变的:身份路由接受哪些凭据不变;会话(token 或 cookie)照旧转发;API key 调其他目标(数据门、声明的端点)照旧转发并成功。 - 先看:`packages/runtime/src/action-execution.ts` 的 `forwardApiAction` 开头和 `apiActionIdentityRouteKeyRefusal`。 - 需要你做的:无;Tier S,席位验收后落地。 ## What changes `forwardApiAction` (`packages/runtime/src/action-execution.ts`), the one forward both action doors call, now refuses one combination before it calls the target: a caller the door admitted by API key, running an action whose `target` is an identity route (under `/api/v1/auth/`). The answer is `401` with the standard `UNAUTHENTICATED` code and a sentence that names the action and its identity endpoint, says the endpoint accepts a signed-in session only and not an API key, and says the action was not run. Both doors relay it the way they relay a target's own refusal: the REST door answers it, and MCP `run_action` throws it with the same status and code. - The identity-route reading is one private predicate, `isIdentityRouteTarget`, which the existing `organizationId` stamp in `buildApiActionRequest` now reads too, so the two rules cannot disagree about which targets are identity routes. - The sentence lives in `apiActionIdentityRouteKeyRefusal`, a module export like the file's other refusal sentences (`@objectstack/runtime` exports only its root entry, which does not re-export this module). - Docs: `content/docs/ai/actions-as-tools.mdx`, the `api` row of the dispatch table, says an API-key caller of an identity-route target is refused `401` before the forward. Unchanged: which credentials an identity route accepts. A signed-in session (session token or cookie) is forwarded to it exactly as before. An API-key caller is still forwarded to every target that is not an identity route, and served. This does not touch #28178 (the OAuth-agent lane), which is waiting on its own decision. ## Why An api action's forward carries the caller's own credentials so the target authorizes the same principal the door admitted. An identity route reads its caller from its own signed-in session only, so a key-admitted caller arrived there as nobody, and the target answered its anonymous `401` ("Sign in first") to a caller that had authenticated. It failed closed (triage 6530162284: a broken function, not a hole). Triage ruled that refusing by name before the forward, and pinning it, needs no decision, and that any change to which credentials an identity route accepts does. ## Verification Premise re-checked on `main` `86cf7cef` before the change; it held as the card states. - `packages/runtime/src/domains/api-action-forward.test.ts` (+5): REST door, key-admitted caller of an identity-route action: the named `401`, the target never called; the same action for a session-admitted caller: forwarded as before; a key-admitted caller of a data-door target: forwarded with the key carried across. MCP `run_action`: the key-admitted identity-route call throws the same refusal and forwards nothing; a key-admitted non-identity target is forwarded. 42/42. - `packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts` (+2, booted showcase): an admin's API key running `ban_user` gets the named refusal, not "Sign in first", and nobody is banned; the same admin's API key running an api action whose target is the data door is served and the row changes. 10/10. - Ablations: refusal removed ⇒ the two identity-route key pins red in the unit file, and the dogfood key pin red (it answered "Sign in first"); refusal keyed on the target alone ⇒ 13 red, the session lane among them; keyed on the credential alone ⇒ the two non-identity key pins red. With the fix, all green. - `pnpm --filter @objectstack/runtime typecheck` green; the runtime suite green (487 files, 7912 passed); the dogfood package compiles with no error in the touched file. - Every family `dispatch-gates --commands` derives (91) ran and exited 0, reconciled with `--ran` (0 NOT-MEASURED, after a whole-tree build); `pnpm lint` exits 0. Skills-docs: `grep -rniE "forward|api.?key" skills content/docs` read for api-action forwarding and API-key callers: `content/docs/ai/actions-as-tools.mdx` describes the forward and gains the sentence above; no skill describes the forward or API-key callers of actions. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the lane's maintainer summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1MkJvadehWmrLUUT1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be646ed commit 744ad0a

5 files changed

Lines changed: 219 additions & 8 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an API-key caller of an api action whose target is an identity route is refused by name, before the forward
6+
7+
Clause-②: no (a call that was already refused now says why and is refused before its target is called; no credential is newly accepted anywhere, and no error code, status name, answer member or package export is added)
8+
Metadata-protocol: none
9+
10+
A same-origin `type: 'api'` action is forwarded by the action doors (`POST /actions/:object/:action` and MCP `run_action`) with the caller's own credentials, so its target authorizes the same principal the door admitted. An identity route (under `/api/v1/auth/`, for example the `sys_user` Ban User action's target) reads its caller only from its own signed-in session and admits no API key. So a caller the door admitted by API key reached it as nobody, and got the identity route's anonymous `401` ("Sign in first") although it had authenticated.
11+
12+
- **Now.** Both doors refuse that call before the target is called, with `401` and the standard `UNAUTHENTICATED` code, and a sentence naming the action, its identity endpoint, and that the endpoint accepts a signed-in session only, not an API key. Nothing is run.
13+
- **Unchanged.** Which credentials an identity route accepts. A signed-in session (session token or cookie) is forwarded to it through the door exactly as before, and an API-key caller is still forwarded to every other target (the data door, a declared endpoint) and served.

‎content/docs/ai/actions-as-tools.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ through the metadata API.
113113
|:---|:---|:---|
114114
| `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS |
115115
| `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered |
116-
| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed |
116+
| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line. A caller authenticated with an API key is refused `401` before the forward when the `target` is an identity route (`/api/v1/auth/…`), which reads a signed-in session only | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed |
117117

118118
Console-only types (`url`, `modal`, `form`) are always skipped.
119119

‎packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts‎

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@
3333
// - the off-origin case: the activation write door refuses to flip it, and
3434
// the action door refuses to run it;
3535
// - MCP: the type gate is open — `run_action` on a same-origin api action
36-
// now reaches the exposure gate instead of "cannot be invoked via MCP".
36+
// now reaches the exposure gate instead of "cannot be invoked via MCP";
37+
// - the API-key lane (#28217): an identity route reads only its own
38+
// signed-in session, so a key caller of `ban_user` is refused BY NAME at
39+
// the door (the target's anonymous "Sign in first" was the defect) and
40+
// nobody is banned, while a key caller of an api action whose target is
41+
// the data door is still forwarded and served.
3742
//
3843
// `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without
3944
// better-auth's admin plugin, and that env knob is the one that reaches it
@@ -56,10 +61,11 @@ const TARGET_PASS = 'Target-Pass-123';
5661
const MEMBER_EMAIL = 'door.forward.member@example.com';
5762

5863
/**
59-
* The one off-origin api action this file needs and no shipped app declares:
60-
* a probe object added to the showcase for this boot only. Its `target` names
61-
* a host the platform never sees, which is exactly the case the ruling keeps
62-
* browser-only.
64+
* The two api actions this file needs and no shipped app declares, on a probe
65+
* object added to the showcase for this boot only. `ping_remote`'s `target`
66+
* names a host the platform never sees, which is exactly the case the ruling
67+
* keeps browser-only. `rename_probe` targets the data door, which admits an
68+
* API key: the API-key lane's control against the identity-route refusal.
6369
*/
6470
const RemoteProbe = ObjectSchema.create({
6571
name: 'probe_remote_thing',
@@ -74,6 +80,15 @@ const RemoteProbe = ObjectSchema.create({
7480
target: 'https://example.invalid/ping',
7581
locations: ['record_header'],
7682
},
83+
{
84+
name: 'rename_probe',
85+
label: 'Rename Probe',
86+
type: 'api',
87+
method: 'PATCH',
88+
target: '/api/v1/data/probe_remote_thing/${ctx.recordId}',
89+
bodyExtra: { name: 'renamed-through-the-door' },
90+
locations: ['record_header'],
91+
},
7792
],
7893
} as any);
7994

@@ -87,14 +102,15 @@ describe('[#27506] a same-origin api action is dispatched through the action doo
87102
let priorScim: string | undefined;
88103

89104
const call = async (
90-
auth: { token?: string; cookie?: string } | null,
105+
auth: { token?: string; cookie?: string; key?: string } | null,
91106
method: string,
92107
path: string,
93108
body?: unknown,
94109
): Promise<Answer> => {
95110
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
96111
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
97112
if (auth?.cookie) headers.Cookie = auth.cookie;
113+
if (auth?.key) headers['x-api-key'] = auth.key;
98114
const res = await stack.api(path, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
99115
const text = await res.text();
100116
let json: any;
@@ -222,6 +238,43 @@ describe('[#27506] a same-origin api action is dispatched through the action doo
222238
expect(anonymous.status, anonymous.text).toBe(401);
223239
}, 120_000);
224240

241+
it('API-key lane: ban_user is refused by name at the door, before its identity route is called, and nobody is banned', async () => {
242+
// `ban_user` was switched back on above, and the admin's session lane is
243+
// served (that case). The key is the SAME admin's.
244+
const minted = await call({ token: admin }, 'POST', '/keys', { name: 'door-forward-key' });
245+
expect(minted.status, minted.text).toBe(201);
246+
const key = String(minted.json?.data?.key);
247+
248+
const refused = await call({ key }, 'POST', '/actions/sys_user/ban_user', {
249+
recordId: targetUserId, params: { banReason: 'dogfood-key' },
250+
});
251+
252+
expect(refused.status, refused.text).toBe(401);
253+
expect(refused.json?.error?.code).toBe('UNAUTHENTICATED');
254+
const message = String(refused.json?.error?.message);
255+
// The door's sentence, not the identity route's anonymous "Sign in first".
256+
expect(message).toContain("Action 'ban_user' forwards to an identity endpoint ('/api/v1/auth/admin/ban-user')");
257+
expect(message).toContain('accepts a signed-in session only');
258+
expect(message).toContain('authenticated with an API key');
259+
expect(message).not.toContain('Sign in first');
260+
expect(await isBanned()).toBe(false);
261+
}, 120_000);
262+
263+
it('API-key lane: an api action whose target is the data door is still forwarded with the key and served', async () => {
264+
const minted = await call({ token: admin }, 'POST', '/keys', { name: 'door-forward-data-key' });
265+
expect(minted.status, minted.text).toBe(201);
266+
const key = String(minted.json?.data?.key);
267+
const created = await call({ token: admin }, 'POST', '/data/probe_remote_thing', { name: 'before' });
268+
expect(created.status, created.text).toBe(201);
269+
const id = String(created.json?.data?.id ?? created.json?.id);
270+
271+
const res = await call({ key }, 'POST', '/actions/probe_remote_thing/rename_probe', { recordId: id });
272+
273+
expect(res.status, res.text).toBe(200);
274+
const [row] = rowsOf(await ql.find('probe_remote_thing', { where: { id }, limit: 1 }, { context: SYS }));
275+
expect(row?.name).toBe('renamed-through-the-door');
276+
}, 120_000);
277+
225278
it('off-origin: the write door refuses to switch `ping_remote`, and the action door refuses to run it', async () => {
226279
const flipAttempt = await call({ token: admin }, 'POST', activation('probe_remote_thing', 'ping_remote'), { enabled: false });
227280
expect(flipAttempt.status, flipAttempt.text).toBe(409);

‎packages/runtime/src/action-execution.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ export function buildApiActionRequest(
13961396
body[recordIdParam] = value;
13971397
}
13981398
}
1399-
if (/\/api\/v1\/auth\//.test(path) && body.organizationId === undefined && organizationId) {
1399+
if (isIdentityRouteTarget(path) && body.organizationId === undefined && organizationId) {
14001400
body.organizationId = organizationId;
14011401
}
14021402
if (action?.bodyExtra && typeof action.bodyExtra === 'object' && !Array.isArray(action.bodyExtra)) {
@@ -1408,6 +1408,40 @@ export function buildApiActionRequest(
14081408
return method === 'GET' || method === 'DELETE' ? { method, path } : { method, path, body };
14091409
}
14101410

1411+
/**
1412+
* Is this forward `path` an identity route — one under the auth mount
1413+
* (`/api/v1/auth/…`)? That mount reads its caller from its OWN signed-in
1414+
* session (a session bearer or cookie) and admits no API key, so a principal
1415+
* the door admitted by key reaches it as nobody. Read once, here, for the two
1416+
* rules that depend on it: the `organizationId` stamp in
1417+
* {@link buildApiActionRequest} and the key refusal in {@link forwardApiAction}.
1418+
*/
1419+
function isIdentityRouteTarget(path: string): boolean {
1420+
return /\/api\/v1\/auth\//.test(path);
1421+
}
1422+
1423+
/**
1424+
* The refusal a door answers, before any forward, when an API-key caller runs
1425+
* an action whose `target` is an identity route ({@link isIdentityRouteTarget}).
1426+
*
1427+
* The forward carries the caller's own credentials so the target authorizes
1428+
* the SAME principal the door admitted. An identity route cannot read a key,
1429+
* so it would authorize no one: the request would leave the door and come
1430+
* back as the target's anonymous 401 ("Sign in first"), telling a caller who
1431+
* did authenticate to sign in. This says what actually happened. Which
1432+
* credentials the identity route accepts is unchanged: a signed-in session
1433+
* still reaches it through the door exactly as before, and a key still
1434+
* reaches every other target.
1435+
*/
1436+
export function apiActionIdentityRouteKeyRefusal(action: any, target: string): string {
1437+
return (
1438+
`Action '${action?.name ?? 'unknown'}' forwards to an identity endpoint ('${target}') that accepts a ` +
1439+
`signed-in session only, and this request was authenticated with an API key, which that endpoint ` +
1440+
`cannot read. The action was not run. Run it from a signed-in session (a session token or the ` +
1441+
`Console's sign-in cookie) instead of an API key.`
1442+
);
1443+
}
1444+
14111445
/** What the in-process forward answered — status, parsed JSON body, relayed headers. */
14121446
export type ApiActionForwardAnswer = {
14131447
status: number;
@@ -1516,6 +1550,8 @@ export function apiActionForwardLoopError(action: any, mark: string): string {
15161550
* ({@link API_FORWARD_HEADER_ALLOWLIST}); `Content-Length` / `Host` are the
15171551
* injector's to set. The forward is marked ({@link API_FORWARD_MARK_HEADER})
15181552
* so a door reached by it refuses instead of looping.
1553+
* One caller is never forwarded: an API-key caller of an identity route,
1554+
* which cannot read a key ({@link apiActionIdentityRouteKeyRefusal}).
15191555
*
15201556
* Relayed back: the status, the JSON body, and the credential-rotation
15211557
* headers a target like `/admin/impersonate-user` emits — `set-auth-token` /
@@ -1532,6 +1568,24 @@ export async function forwardApiAction(
15321568
req: ApiActionRequest,
15331569
mark: { objectName: string; actionName: string },
15341570
): Promise<ApiActionForwardAnswer> {
1571+
// An API-key caller cannot be carried to an identity route (see
1572+
// {@link apiActionIdentityRouteKeyRefusal}): refused here, before the
1573+
// target is called, with the status the target would have answered and
1574+
// the sentence that says why. Both doors relay it as they relay a target's
1575+
// refusal.
1576+
if (requestContext?.admission?.credential === 'api_key' && isIdentityRouteTarget(req.path)) {
1577+
return {
1578+
status: 401,
1579+
body: {
1580+
success: false,
1581+
error: {
1582+
code: standardErrorCodeForHttpStatus(401),
1583+
message: apiActionIdentityRouteKeyRefusal({ name: mark.actionName }, req.path.split('?')[0]!),
1584+
},
1585+
},
1586+
headers: {},
1587+
};
1588+
}
15351589
const incoming: Record<string, unknown> =
15361590
requestContext?.request?.headers && typeof requestContext.request.headers === 'object'
15371591
? requestContext.request.headers

‎packages/runtime/src/domains/api-action-forward.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ import {
3636
isHeadlessInvokableAction,
3737
headlessActionTypeError,
3838
offOriginApiActivationRefusal,
39+
apiActionIdentityRouteKeyRefusal,
3940
API_FORWARD_MARK_HEADER,
4041
} from '../action-execution.js';
42+
import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
4143
import { ActionActivationProjection } from '@objectstack/objectql';
4244

4345
const OBJECT = 'crm_lead';
@@ -720,3 +722,92 @@ describe('buildApiActionRequest — the declaration\'s request, by the console\'
720722
expect((explicit as any).body).toEqual({ organizationId: 'org_other' });
721723
});
722724
});
725+
726+
// [#28217] An identity route (the auth mount) reads its caller from its own
727+
// signed-in session and admits no API key, so an API-key caller forwarded to it
728+
// arrived as nobody and came back as the target's anonymous 401. The doors now
729+
// refuse that one combination by name, before the target is called; which
730+
// credentials the identity route accepts is unchanged, so the session lane is
731+
// forwarded exactly as before and a key still reaches every other target.
732+
describe('[#28217] an API-key caller is refused by name before a forward to an identity route', () => {
733+
/** A caller the door admitted by API key: the key header, no session bearer or cookie. */
734+
const KEY_CTX = (): HttpProtocolContext => {
735+
const ctx: any = CTX({ 'x-api-key': 'osk_member_key' });
736+
delete ctx.request.headers.authorization;
737+
delete ctx.request.headers.cookie;
738+
ctx.admission = { credential: 'api_key', authGate: undefined };
739+
return ctx as HttpProtocolContext;
740+
};
741+
const SESSION_CTX = (): HttpProtocolContext =>
742+
({ ...CTX(), admission: { credential: 'session', authGate: undefined } } as unknown as HttpProtocolContext);
743+
const runViaMcp = async (h: ReturnType<typeof boot>, ctx: HttpProtocolContext, name: string, input: any = {}) => {
744+
const deps: any = {
745+
resolveService: async (_ctx: unknown, service: string) =>
746+
service === 'metadata' ? h.metadata
747+
: service === 'automation' ? null
748+
: service === 'http.server' ? { getRawApp: () => ({ request: h.request }) }
749+
: h.ql,
750+
getObjectQL: async () => h.ql,
751+
};
752+
return invokeBusinessAction(deps, ctx as any, name, input, {
753+
driver: h.ql,
754+
ec: { userId: 'u_member', tenantId: 'org_1', systemPermissions: [] },
755+
getMeta: () => h.metadata,
756+
callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }),
757+
});
758+
};
759+
760+
it('REST: a key-admitted caller of an identity-route action gets the named 401 and the target is never called', async () => {
761+
const { dispatcher, request } = boot();
762+
763+
const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, KEY_CTX());
764+
765+
expect(statusOf(res)).toBe(401);
766+
expect(codeOf(res)).toBe(standardErrorCodeForHttpStatus(401));
767+
expect(messageOf(res)).toBe(apiActionIdentityRouteKeyRefusal(BAN_LEAD, BAN_LEAD.target));
768+
expect(request).not.toHaveBeenCalled();
769+
});
770+
771+
it('REST: the same action for a session-admitted caller is forwarded exactly as before', async () => {
772+
const { dispatcher, injected } = boot({ answer: { body: { success: true, data: { banned: true } } } });
773+
774+
const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, SESSION_CTX());
775+
776+
expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200);
777+
expect(injected).toHaveLength(1);
778+
expect(headersOf(injected[0].init).authorization).toBe('Bearer tok_member');
779+
});
780+
781+
it('REST: a key-admitted caller of a NON-identity target is still forwarded, the key carried across', async () => {
782+
const { dispatcher, injected } = boot();
783+
784+
const res = await dispatcher.handleActions(`/${OBJECT}/${DELETE_LEAD.name}`, 'POST', { recordId: 'lead 7' }, KEY_CTX());
785+
786+
expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200);
787+
expect(injected).toHaveLength(1);
788+
expect(injected[0].url).toBe('http://app.example.test/api/v1/data/crm_lead/lead%207');
789+
expect(headersOf(injected[0].init)['x-api-key']).toBe('osk_member_key');
790+
});
791+
792+
it('MCP `run_action`: the key-admitted caller gets the same refusal on the throw, and nothing is forwarded', async () => {
793+
const h = boot();
794+
795+
const thrown = await runViaMcp(h, KEY_CTX(), BAN_LEAD.name, { recordId: 'u_target' }).catch((e) => e);
796+
797+
expect(thrown).toBeInstanceOf(Error);
798+
expect((thrown as any).status).toBe(401);
799+
expect((thrown as any).code).toBe(standardErrorCodeForHttpStatus(401));
800+
expect(String(thrown.message)).toBe(apiActionIdentityRouteKeyRefusal(BAN_LEAD, BAN_LEAD.target));
801+
expect(h.request).not.toHaveBeenCalled();
802+
});
803+
804+
it('MCP `run_action`: a key-admitted caller of a non-identity target is forwarded', async () => {
805+
const h = boot({ answer: { body: { success: true, data: { recalculated: 1 } } } });
806+
807+
const result: any = await runViaMcp(h, KEY_CTX(), RECALC_LEAD.name, { recordId: 'u_target' });
808+
809+
expect(result.ok).toBe(true);
810+
expect(result.result).toEqual({ recalculated: 1 });
811+
expect(h.injected[0].url).toBe('http://app.example.test/api/v1/apps/showcase/recalc');
812+
});
813+
});

0 commit comments

Comments
 (0)