Skip to content

Commit 776c1d8

Browse files
fix(runtime): refuse an OAuth agent's api-action forward by name from one credential × target-door table, and mark it in list_actions (#28442)
🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V Fixes #28178 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:通过 OAuth 连接的 AI 智能体,运行一个会转发到其他接口的 `api` 类动作时,现在在转发之前就被明确拒绝,拒绝语句写清是哪个动作、转发到哪里、为什么不能跑、换什么方式能跑;`list_actions` 照样列出这些动作,但标上 `unavailable: { code, reason }`,智能体能直接告诉用户原因。 - 为什么改:OAuth 访问令牌只在 MCP 端点上有效,转发到数据接口或身份接口时目标认不出它,把已登录的智能体当成匿名用户,回一个「请先登录」的 401,用户和智能体都看不懂。维护者裁决(选项 B,6540286612):不放宽令牌的接受范围,改为按一张「凭据类型 × 目标接口」表在转发前点名拒绝。 - 风险与回滚:没有放宽任何接口接受的凭据,OAuth 令牌仍只在 MCP 端点有效;会话与 API Key 能跑的转发照旧能跑。API Key 调身份接口的既有拒绝(#28217)并入同一张表,语句不变。回滚即 revert 本 PR,无数据迁移。 - 先看:`packages/runtime/src/action-execution.ts`(`API_FORWARD_DOOR_ACCEPTS` 表与 `apiForwardCredentialRefusal`);`packages/runtime/src/domains/mcp.ts`(`list_actions` 加标记);`packages/mcp/src/mcp-http-tools.ts`(新增可选成员 `unavailable` 与导出类型)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts`,预期全绿,其中 `[#28178]` 一组 7 个用例覆盖表格逐格、两扇门的拒绝和 `list_actions` 的标记。 - 需要你做的:无,席位落地。 ## What changes An agent connected to MCP over OAuth holds an access token the platform honours on the MCP endpoint only. A same-origin `type: 'api'` action is forwarded with the caller's own credentials, so the forward reached every other target as nobody, and the agent got the target's anonymous `401` ("Sign in first") although it had authenticated. Ruling B (6540286612) on this card: refuse by name before the forward, from one table, and move no boundary. - **One table, credential kind × target door** (`API_FORWARD_DOOR_ACCEPTS` in `packages/runtime/src/action-execution.ts`): an identity route (`/api/v1/auth/…`) accepts a session only; the MCP endpoint a session, an API key or an OAuth token; every other same-origin target a session or an API key. `apiForwardCredentialKind` reads the admitted credential (`oauth` when the execution context carries `oauthScopes`, else the door's `admission.credential`); `apiForwardCredentialRefusal` reads the table. - **Both run doors refuse before the forward.** `forwardApiAction` (shared by MCP `run_action` and REST `POST /actions/:object/:action`) answers `401` `UNAUTHENTICATED` with a sentence naming the action, its declared target and what would let it run. The target is never called. - **`list_actions` marks, it does not hide.** `buildMcpBridge.listActions` reads the same predicate and adds `unavailable: { code, reason }` with the same code and sentence `run_action` would answer. `McpActionSummary` gains the optional `unavailable` member and `@objectstack/mcp` exports the `McpActionUnavailable` type; the `list_actions` tool description says what the mark means. - **The API-key lane (#28217) is now one cell of the table** (`api_key` × `identity`): its sentence is unchanged, and the listing marks it for an API-key caller too. - **Unchanged:** where an OAuth access token is accepted (the MCP endpoint only; the dispatcher's `acceptOAuthAccessToken` is not touched), which credentials any target door accepts, and every forward the table allows. - Docs: `content/docs/ai/actions-as-tools.mdx` (the `list_actions` row and the `api` row). Changeset: `@objectstack/runtime` and `@objectstack/mcp`, `patch` (`Metadata-protocol: none`). **One-PR exception.** The card was relabelled to `domain:spec` on triage's retriage answer because the MCP listing's exported shape widens; the `packages/mcp` half (`index.ts`, `mcp-http-tools.ts`) and the runtime table ride this one PR under that answer's declared file surface. No `packages/spec` file is touched. ## Tests - `packages/runtime/src/domains/api-action-forward.test.ts`, new `[#28178]` block: the table cell by cell; MCP `run_action` for an OAuth agent on a data-door-style target and on an identity-route target (named `401` `UNAUTHENTICATED`, nothing forwarded); the REST door's shared forward refusing the same caller; session and API-key callers of the same action still forwarded; `list_actions` marking every api action for the OAuth agent, nothing for a session, the identity-route action only for an API key. The `[#28217]` block passes unchanged on the moved cell. - `packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts`: over a real OAuth access token on the booted showcase, the listing marks, `run_action` answers the named refusal and leaves the row untouched, the identity-route Ban action bans nobody, session and API-key lanes unchanged, and the boundary pin: the same token sent straight to the data door or an identity route is still refused. ## Acceptance notes - The forward credential that would let an OAuth agent actually run these actions is decision card #28367 (option A, an ADR-0090 D10 amendment), not this PR. - Independent review: none run (quota); the PR waits for the seat's contract-review hand-off. ## Verification All readings on head `373031aae6` (the branch after merging `origin/main` `a8d2fd7f30`, a clean merge; the branch delta stayed the same 9 files, +528 / -38). <details><summary>Targeted suites and typecheck</summary> - `pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts`: Test Files 1 passed, Tests 49 passed. - `pnpm --filter @objectstack/runtime test` (the merge brought in runtime commits, so the whole package was rerun): Test Files 493 passed, Tests 8031 passed, 19 skipped. - `pnpm --filter @objectstack/mcp test`: Test Files 47 passed, Tests 603 passed. - `pnpm --filter @objectstack/runtime typecheck`, `pnpm --filter @objectstack/mcp typecheck`: exit 0. - `pnpm --filter @objectstack/dogfood exec vitest run test/mcp-oauth-api-action-forward.dogfood.test.ts`: Test Files 1 passed, Tests 5 passed. </details> <details><summary>Gate families</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 91 families derived for the 9 paths; every one run, exit codes captured before any pipe. - 88 exited 0 on the first pass. 3 exited 3 (PREREQUISITE NOT MET, nothing measured): `check:skill-examples` and `check:dual-build-cjs-loads` (packages outside the dogfood build closure had no `dist/`) and `check:type-check-debt` (its closure build did not get a turn on the shared lock). After building those packages, and running `check:type-check-debt` under the lock, all three exited 0. - `--ran` reconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). - `pnpm lint`: exit 0. </details> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e87403 commit 776c1d8

9 files changed

Lines changed: 528 additions & 38 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/mcp': patch
4+
---
5+
6+
fix(runtime): an agent connected over OAuth is refused by name before an api action's forward, and `list_actions` marks such an action instead of the target answering it as anonymous
7+
8+
Clause-②: yes (widening: a listed action gains the optional `unavailable: { code, reason }` member, `McpActionSummary.unavailable`, with the new `McpActionUnavailable` type export; every existing member keeps its value)
9+
Metadata-protocol: none
10+
Ruling-ref: #28178 ruling `6540286612`, option B
11+
12+
A same-origin `type: 'api'` action is forwarded by the action doors (MCP `run_action` and `POST /actions/:object/:action`) with the caller's own credentials. An agent connected to MCP over OAuth holds an access token that the platform honours on the MCP endpoint only, so the forward reached every other target as nobody, and the agent got the target's anonymous `401` ("Authentication is required…", "Sign in first") although it had authenticated.
13+
14+
- **Now.** One table of credential kind × target door decides what the forward can carry: an identity route (`/api/v1/auth/…`) accepts a signed-in session only, the MCP endpoint a session, an API key or an OAuth token, every other target a session or an API key. A caller whose credential the target cannot read is refused before the forward with `401` and the standard `UNAUTHENTICATED` code, and a sentence naming the action, its target and what would let it run. Nothing is run.
15+
- **`list_actions`.** Such an action is still listed, with `unavailable: { code, reason }`: the code and the sentence `run_action` would answer, so an agent can tell its user why it cannot run it.
16+
- **Same table, earlier lane.** The API-key refusal on an identity route (#28217) is now one cell of that table; its sentence is unchanged, and `list_actions` marks it for an API-key caller too.
17+
- **Unchanged.** Where an OAuth access token is accepted (the MCP endpoint only), which credentials any target accepts, and every forward the table allows. A server-built forward credential that would let an OAuth agent run these actions is a separate decision (#28367).

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ bound to the caller's principal (the API key acts as the user):
4949

5050
| Tool | What it does |
5151
|:---|:---|
52-
| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, its declared params, and `aiExposed: true` (the author's opt-in, attested by the bridge). |
52+
| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, its declared params, and `aiExposed: true` (the author's opt-in, attested by the bridge). An `api` action that `run_action` would refuse for the caller's credential (see the `api` row below) is still listed, with `unavailable: { code, reason }`: `code` is `UNAUTHENTICATED`, the code `run_action` answers with, and `reason` is the same sentence, so an agent can tell its user why it cannot run the action. |
5353
| `run_action` | Invokes an action by name with `{ recordId, params }`. Invocation is gated (author opt-in + capabilities); the action body then runs the app's registered logic as trusted code. |
5454
| `resume_run` | Continues a flow run that `run_action` answered with `status: "paused"` and a `screen`, by submitting that screen's field values as `{ runId, values }`. Only the user who started the run can resume it, behind the same gates as `run_action`. See [Completing a paused screen flow](#completing-a-paused-screen-flow). |
5555

@@ -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. 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 |
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. The forward carries only a credential the target's door accepts: an identity route (`/api/v1/auth/…`) reads a signed-in session only, every other target a session or an API key, and an OAuth access token is honoured on the MCP endpoint only. Any other caller is refused `401` `UNAUTHENTICATED` before the forward, with a sentence naming the action, its target and what would let it run: an API key on an identity route, and an agent connected over OAuth on any target outside the MCP endpoint | 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/mcp/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type {
3838
McpActionBridge,
3939
McpActionSummary,
4040
McpActionParamSummary,
41+
McpActionUnavailable,
4142
RegisterActionToolsOptions,
4243
McpFederationBridge,
4344
} from './mcp-http-tools.js';

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,25 @@ export interface McpActionSummary {
240240
aiExposed?: boolean;
241241
/** Declared input parameters. */
242242
params?: McpActionParamSummary[];
243+
/**
244+
* Present when `run_action` would refuse this action for THIS caller's
245+
* credential before running it: an `api` action whose target endpoint does
246+
* not accept the credential the caller connected with (an OAuth access token
247+
* is honoured on the MCP endpoint only, so an endpoint outside it cannot read
248+
* it). The action is still listed, so an agent can tell its user why it
249+
* cannot run it; `code` is the standard error code `run_action` answers with
250+
* (`UNAUTHENTICATED`), `reason` the sentence it carries, including what would
251+
* let the action run. Absent when the action can be run.
252+
*/
253+
unavailable?: McpActionUnavailable;
254+
}
255+
256+
/** Why `run_action` would refuse a listed action for this caller ({@link McpActionSummary.unavailable}). */
257+
export interface McpActionUnavailable {
258+
/** The standard error code `run_action` answers with. */
259+
code: string;
260+
/** The refusal sentence `run_action` carries: why, and what would let the action run. */
261+
reason: string;
243262
}
244263

245264
/**
@@ -1387,7 +1406,10 @@ export function registerActionTools(
13871406
'List the business actions you can invoke in this app (e.g. "complete task", "convert lead"). ' +
13881407
'Returns each action\'s name, the object it operates on, a description, whether it needs a record id, ' +
13891408
'whether it is destructive, and its input parameters. Only actions the app author has exposed to AI ' +
1390-
'and that the caller is permitted to run are returned. Use run_action to invoke one.',
1409+
'and that the caller is permitted to run are returned. Use run_action to invoke one. An action that ' +
1410+
'carries "unavailable" cannot be run with the credential you connected with: run_action would refuse ' +
1411+
'it with unavailable.code, and unavailable.reason says why and what would let it run, so tell the ' +
1412+
'user instead of calling it.',
13911413
inputSchema: strictToolInput({ surface: 'this list_actions call' }, {}),
13921414
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
13931415
},
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28178] An AI agent connected to MCP over OAuth runs a same-origin
4+
// `type: 'api'` action. The action doors forward it with the caller's OWN
5+
// credentials, and the platform honours an OAuth access token on the MCP
6+
// endpoint only, so the target read the agent as nobody and answered its
7+
// anonymous 401. Ruled B (fable 总监决裁 5, Elon 2026-10-31): refuse by name
8+
// before the forward, from one table of credential kind × target door that
9+
// `run_action` and `list_actions` share, and MARK such an action in the
10+
// listing instead of hiding it. A server-built forward credential that would
11+
// let the agent through is a separate decision (#28367), so the last pin here
12+
// is that the token is still refused when presented to a target directly.
13+
//
14+
// Booted showcase, a REAL OAuth access token (DCR → authorize with the MCP
15+
// resource → consent → token), a session and an API key for the unchanged lanes.
16+
import { createHash, randomBytes } from 'node:crypto';
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import { ObjectSchema, Field } from '@objectstack/spec/data';
19+
import { type VerifyStack } from '@objectstack/verify';
20+
import { MCPServerPlugin } from '@objectstack/mcp';
21+
import showcaseStack from '@objectstack/example-showcase';
22+
import { bootShowcase } from './showcase-boot.js';
23+
24+
const SYS = { isSystem: true } as const;
25+
const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []);
26+
const REDIRECT_URI = 'http://127.0.0.1:33418/callback';
27+
const ai = (d: string) => ({ exposed: true, description: d });
28+
let banTarget = '';
29+
30+
const Probe = ObjectSchema.create({
31+
name: 'probe_fwd_thing',
32+
label: 'Forward Probe',
33+
sharingModel: 'public_read_write',
34+
fields: { name: Field.text({ label: 'Name' }) },
35+
actions: [
36+
{ name: 'probe_get_session', label: 'Get Session', type: 'api', method: 'POST', target: '/api/v1/auth/get-session', locations: ['record_header'], ai: ai('Read the session the forward target sees for this caller.') },
37+
{ name: 'probe_data_patch', label: 'Data Patch', type: 'api', method: 'PATCH', target: '/api/v1/data/probe_fwd_thing/${ctx.recordId}', bodyExtra: { name: 'patched' }, locations: ['record_header'], ai: ai('Patch the probe record through the data door as the caller.') },
38+
{ name: 'probe_ban', label: 'Ban', type: 'api', method: 'POST', target: '/api/v1/auth/admin/ban-user', params: [{ name: 'userId', label: 'User', type: 'text', required: true }], locations: ['record_header'], ai: ai('Ban the probe target user through the better-auth admin endpoint.') },
39+
],
40+
} as any);
41+
42+
describe('[#28178] an OAuth-connected agent and api actions: refused by name, marked in list_actions, boundary unmoved', () => {
43+
let stack: VerifyStack;
44+
let ql: any;
45+
let session: string;
46+
let apiKey: string;
47+
let oauth: string;
48+
let recordId: string;
49+
let prior: string | undefined;
50+
let priorOidc: string | undefined;
51+
52+
const rpc = async (headers: Record<string, string>, name: string, args: Record<string, unknown>) => {
53+
const res = await stack.api('/mcp', {
54+
method: 'POST',
55+
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
56+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
57+
});
58+
const text = await res.text();
59+
const parsed: any = JSON.parse(text);
60+
const content = parsed?.result?.content?.[0]?.text;
61+
return { http: res.status, isError: parsed?.result?.isError === true, body: content ? JSON.parse(content) : parsed };
62+
};
63+
const runAction = (headers: Record<string, string>, actionName: string, args: Record<string, unknown> = {}) =>
64+
rpc(headers, 'run_action', { actionName, objectName: 'probe_fwd_thing', recordId, ...args });
65+
const listProbeActions = async (headers: Record<string, string>) => {
66+
const r = await rpc(headers, 'list_actions', {});
67+
expect(r.isError, JSON.stringify(r.body)).toBe(false);
68+
return Object.fromEntries((r.body.actions as any[]).filter((a) => a.objectName === 'probe_fwd_thing').map((a) => [a.name, a]));
69+
};
70+
const rowName = async () =>
71+
rowsOf(await ql.find('probe_fwd_thing', { where: { id: recordId }, limit: 1 }, { context: SYS }))[0]?.name;
72+
const isBanned = async () =>
73+
rowsOf(await ql.find('sys_user', { where: { id: banTarget }, limit: 1 }, { context: SYS }))[0]?.banned === true;
74+
75+
beforeAll(async () => {
76+
prior = process.env.OS_SCIM_ENABLED;
77+
priorOidc = process.env.OS_OIDC_PROVIDER_ENABLED;
78+
process.env.OS_SCIM_ENABLED = 'true';
79+
process.env.OS_OIDC_PROVIDER_ENABLED = 'true';
80+
const declared = showcaseStack as unknown as { objects?: unknown[] };
81+
stack = await bootShowcase(
82+
{ extraPlugins: [new MCPServerPlugin()] },
83+
{ ...(showcaseStack as unknown as Record<string, unknown>), objects: [...(declared.objects ?? []), Probe] },
84+
);
85+
session = await stack.signIn();
86+
ql = await stack.kernel.getServiceAsync('objectql');
87+
const created = await stack.apiAs(session, 'POST', '/data/probe_fwd_thing', { name: 'probe' });
88+
const cj: any = await created.json();
89+
recordId = String(cj?.data?.id ?? cj?.id);
90+
await stack.signUp('fwd.target@example.com', 'Target-Pass-123');
91+
banTarget = String(rowsOf(await ql.find('sys_user', { where: { email: 'fwd.target@example.com' }, limit: 1 }, { context: SYS }))[0]?.id);
92+
// API key
93+
const k = await stack.apiAs(session, 'POST', '/keys', { name: 'fwd-probe' });
94+
const kj: any = await k.json();
95+
apiKey = String(kj?.data?.key);
96+
// OAuth: DCR → authorize → consent → token
97+
const authSvc: any = await stack.kernel.getServiceAsync('auth');
98+
const resource = authSvc.getMcpResourceUrl();
99+
const scope = 'openid profile email offline_access data:read data:write actions:execute';
100+
const reg = await stack.api('/auth/oauth2/register', {
101+
method: 'POST', headers: { 'content-type': 'application/json' },
102+
body: JSON.stringify({ client_name: 'fwd probe', redirect_uris: [REDIRECT_URI], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'native', scope }),
103+
});
104+
const regBody: any = await reg.json();
105+
expect(reg.status, JSON.stringify(regBody)).toBe(201);
106+
const signin = await stack.api('/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }) });
107+
const cookie = ((signin.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; ');
108+
const verifier = randomBytes(32).toString('base64url');
109+
const challenge = createHash('sha256').update(verifier).digest('base64url');
110+
const qs = new URLSearchParams({ client_id: regBody.client_id, redirect_uri: REDIRECT_URI, response_type: 'code', scope, state: 'st', code_challenge: challenge, code_challenge_method: 'S256', resource });
111+
const az = await stack.api(`/auth/oauth2/authorize?${qs}`, { headers: { cookie }, redirect: 'manual' });
112+
const loc = az.headers.get('location') ?? '';
113+
let code = new URL(loc, 'http://localhost:3000').searchParams.get('code');
114+
if (!code) {
115+
const consent = await stack.api('/auth/oauth2/consent', { method: 'POST', headers: { 'content-type': 'application/json', cookie, origin: 'http://localhost:3000' }, body: JSON.stringify({ accept: true, oauth_query: loc.slice(loc.indexOf('?')) }) });
116+
const cb: any = await consent.json().catch(() => null);
117+
code = new URL(cb?.redirect_uri ?? cb?.url).searchParams.get('code');
118+
}
119+
const tok = await stack.api('/auth/oauth2/token', {
120+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
121+
body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: REDIRECT_URI, client_id: regBody.client_id, code_verifier: verifier, resource }).toString(),
122+
});
123+
const tj: any = await tok.json();
124+
expect(tok.status, JSON.stringify(tj)).toBe(200);
125+
oauth = tj.access_token;
126+
}, 300_000);
127+
128+
afterAll(async () => {
129+
await stack?.stop?.();
130+
if (prior === undefined) delete process.env.OS_SCIM_ENABLED; else process.env.OS_SCIM_ENABLED = prior;
131+
if (priorOidc === undefined) delete process.env.OS_OIDC_PROVIDER_ENABLED; else process.env.OS_OIDC_PROVIDER_ENABLED = priorOidc;
132+
});
133+
134+
it('list_actions LISTS the api actions for the OAuth agent and marks each with the refusal, code UNAUTHENTICATED', async () => {
135+
const listed = await listProbeActions({ Authorization: `Bearer ${oauth}` });
136+
137+
expect(Object.keys(listed).sort()).toEqual(['probe_ban', 'probe_data_patch', 'probe_get_session']);
138+
expect(listed.probe_data_patch.unavailable?.code).toBe('UNAUTHENTICATED');
139+
expect(listed.probe_data_patch.unavailable?.reason).toMatch(
140+
/^Action 'probe_data_patch' forwards to an endpoint \('\/api\/v1\/data\/probe_fwd_thing\/.*'\) that accepts a signed-in session or an API key, and this request was authenticated with an OAuth access token, which is honoured on the MCP endpoint only\. The action was not run\. Connect with an API key/,
141+
);
142+
expect(listed.probe_ban.unavailable?.code).toBe('UNAUTHENTICATED');
143+
expect(listed.probe_ban.unavailable?.reason).toMatch(/identity endpoint \('\/api\/v1\/auth\/admin\/ban-user'\) that accepts a signed-in session only/);
144+
});
145+
146+
it('run_action: the OAuth agent gets the named refusal the listing carried, not the target\'s anonymous 401, and the row is untouched', async () => {
147+
const before = await rowName();
148+
const listed = await listProbeActions({ Authorization: `Bearer ${oauth}` });
149+
150+
const r = await runAction({ Authorization: `Bearer ${oauth}` }, 'probe_data_patch');
151+
152+
expect(r.isError).toBe(true);
153+
expect(r.body.error).toMatchObject({ code: 'UNAUTHENTICATED', status: 401, message: listed.probe_data_patch.unavailable.reason });
154+
expect(r.body.error.message).not.toBe('Authentication is required to access this endpoint.');
155+
expect(await rowName()).toBe(before);
156+
});
157+
158+
it('run_action: the OAuth agent running the identity-route Ban action is refused by name and nobody is banned', async () => {
159+
const r = await runAction({ Authorization: `Bearer ${oauth}` }, 'probe_ban', { params: { userId: banTarget } });
160+
161+
expect(r.isError).toBe(true);
162+
expect(r.body.error.code).toBe('UNAUTHENTICATED');
163+
expect(r.body.error.message).toMatch(/^Action 'probe_ban' forwards to an identity endpoint .* OAuth access token, which is honoured on the MCP endpoint only\. The action was not run\./);
164+
expect(await isBanned()).toBe(false);
165+
});
166+
167+
it('unchanged lanes: a session lists the actions unmarked; an API key is marked on the identity route only and its data-door action is served', async () => {
168+
const asSession = await listProbeActions({ Authorization: `Bearer ${session}` });
169+
expect(Object.values(asSession).filter((a: any) => a.unavailable)).toEqual([]);
170+
171+
const asKey = await listProbeActions({ 'x-api-key': apiKey });
172+
expect(asKey.probe_ban.unavailable?.code).toBe('UNAUTHENTICATED');
173+
expect(asKey.probe_data_patch.unavailable).toBeUndefined();
174+
175+
const r = await runAction({ 'x-api-key': apiKey }, 'probe_data_patch');
176+
expect(r.isError, JSON.stringify(r.body)).toBe(false);
177+
expect(await rowName()).toBe('patched');
178+
});
179+
180+
it('the boundary is unmoved: the same OAuth token presented to the data door or an identity route directly is still refused', async () => {
181+
const data = await stack.api(`/data/probe_fwd_thing/${recordId}`, { headers: { Authorization: `Bearer ${oauth}` } });
182+
expect(data.status).toBe(401);
183+
const identity = await stack.api('/auth/get-session', { headers: { Authorization: `Bearer ${oauth}` } });
184+
expect(identity.status).toBe(401);
185+
});
186+
});

0 commit comments

Comments
 (0)