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
24 changes: 24 additions & 0 deletions .changeset/27506-api-action-door-forward.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/runtime': patch
'@objectstack/plugin-hono-server': patch
'@objectstack/hono': patch
---

fix(runtime)!: a same-origin `type: 'api'` action is dispatched through the action doors, so its packaged-action switch holds (#27506)

Clause-②: no (narrowing) — no key, accepted value or export of `@objectstack/spec` changes. Two published runtime surfaces narrow for their consumers: `POST /actions/_activation/:object/:action` now refuses (409) to flip an off-origin `type: 'api'` action it accepted and recorded before, and the `headers` member of `@objectstack/runtime`'s `HttpDispatcherResult.response` is widened from `string` values to `string | string[]`, which a reader that assigned a value to a `string` no longer compiles against. An api action that reaches `POST /actions/:object/:action` or MCP `run_action` now passes the doors' gates (the sign-in floor, `requiredPermissions`, the activation switch, the param contract, the subject-row load) before its target runs, where the console's direct fetch passed none of them.
Metadata-protocol: none
<!-- adr-0087: not-required (no-migration-prescription) nothing an author writes is retired, renamed or re-shaped: no metadata key, accepted value or stored row moves, and `objectstack migrate meta` has nothing to rewrite. What narrows is a door's answer (409 where a flip of an off-origin api action used to be recorded) and a published TypeScript member's value type (`HttpDispatcherResult.response.headers`, widened to `string | string[]`), whose affected party is a code consumer reached by its own compiler, not a metadata upgrader; the member's declaration is neither a Zod projection nor referenced by any `packages/spec` surface. No ADR-0087 id is named or touched (not registered or already-registered), the packages publish (not unpublished), the widened member was concretely typed at base (not type-surface-only), and no export is retired (not runtime-export-retired). -->
Skills-docs: `content/docs/ui/actions.mdx` (the `type` table and the "Call it over REST" dispatch table) now state the same-origin forward and the off-origin refusal; no file under `skills/**` teaches that an api action is refused by the doors or invisible to MCP (`skills/objectstack-ui/rules/actions.md` shows an api action only as a declaration), so no skill line changes.

Switching off a `type: 'api'` packaged action in the ADR-0126 §8 ledger did not stop it: the console fetched the action's `target` directly, the `/actions` door answered 400 for the type, and the MCP `run_action` bridge refused it outright — so "Ban User switched off" still banned, and an agent could not do what a human click could. Ruled on #27506 (option C, in three steps; this is the server step, ADR text in #27610, the console in #27611):

- **Same-origin api actions are server-dispatched.** `POST /api/v1/actions/:object/:action` and MCP `run_action` accept a `type: 'api'` action whose `target` is a path on this origin. The door runs every gate it already runs, then **forwards the request in-process** to the declared target (`IHttpServer.getRawApp()`'s request injector) with the caller's own credentials (`Authorization`, `Cookie`, the admin-session recovery header, tenant and proxy headers, the proxy headers carried as the caller sent them for the target's own `server.trustProxy` rule to judge) and the body the declaration assembles by the console's own rules — `recordIdParam` / `recordIdField` from the subject row (refused by name when the row cannot supply it), `bodyShape.wrap`, the `outputs.*` fold, `organizationId` for an `/api/v1/auth/` target, `bodyExtra` merged last, `method` (no body on `DELETE`), `${param.*}` / `${ctx.recordId}` / `${ctx.user.id}` / `${ctx.org.id}` / `{field}` in the path. The forwarded URL is `http://<Host header><path>`, the URL the direct request has at this process; a caller-sent `X-Forwarded-Host` / `X-Forwarded-Proto` never chooses it. The target still authorizes the caller itself (ADR-0066 D4); its status and JSON body are the door's answer (its `data` unwrapped, as the console's direct fetch handed `result.data`), and a credential rotation it emits is relayed: over REST every `Set-Cookie` line plus `set-auth-token` / `set-admin-session-token`, so `impersonate_user` works through the door on both the cookie and the bearer lane; over MCP **only the bearer pair** — no `Set-Cookie` line enters a tool result, because the MCP door admits cookie sessions and a tool result is JSON text whatever receives it can read. A disabled api action answers **409 `ACTION_DISABLED`** and the target is never called; a forwarded request that lands back on an action door is refused (400), as is a `${ctx.selection.*}` target token or a `{{page.<var>}}` body token the server cannot resolve (400, naming it; any other `{{…}}` is the author's literal text); a target that answers 2xx with a non-JSON body is refused **502** naming its content-type (the console's direct fetch read such an answer as `{}`); a kernel with no in-process request path answers 503 and does not send the caller around the door.
- **Off-origin api actions stay browser-only, and cannot be switched.** A `target` on another host is still refused by both doors (400, naming the origin), MCP does not list it, and `POST /actions/_activation/:object/:action` refuses to flip it (**409**) with the reason — a ledger row nothing would consult is not recorded.
- **MCP lists same-origin api actions** on the same terms as every other action (`ai.exposed`, the caller's permissions, no `sys_*` object).
- **`HttpDispatcherResult.response.headers`** accepts a `string[]` value, emitted as one header line per element; the Hono server plugin's route `res.header` and the published `@objectstack/hono` adapter's `toResponse` (its `response` arm and both `stream` arms) append an array instead of comma-joining it (the only correct spelling for `Set-Cookie`).

**BREAKING** at two published runtime surfaces (`@objectstack/runtime`).

- **The activation write door, `POST /api/v1/actions/_activation/:object/:action`.** *Before:* flipping a `type: 'api'` action whose `target` is on another origin was accepted and recorded as a ledger row, which nothing ever consulted — the switch read as set and did nothing. *Now:* that request is refused `409` (`error.code` `RESOURCE_CONFLICT`) with a message naming the origin and saying why (the browser calls that origin directly and this server never sees the request), and no row is written. Same-origin api actions, and every other action type, flip exactly as before. *Who sees it:* a Setup caller or script that switched an off-origin api action off and read the 2xx as "stopped". *The remedy:* none at the door — move the action's `target` onto this origin (a path starting with `/`) to make it switchable, or remove the action from the app; a script that treated the 2xx as success now reads the 409 reason.
- **The type `HttpDispatcherResult.response.headers`** (`packages/runtime/src/http-dispatcher.ts`). *Before:* `Record<string, string>`. *Now:* `Record<string, string | string[]>`; an array is one header line per element, and the `/actions` door emits one for `Set-Cookie`. *Who sees it:* an adapter or host outside this repository that reads the member and hands each value to a `string`-typed header setter (`error TS2345`). *The remedy, one line:* branch on `Array.isArray(value)` and emit each element as its own header line (Hono: `c.header(name, line, { append: true })`; Fetch `Headers`: `headers.append(name, line)`); joining the array with `, ` is NOT equivalent for `Set-Cookie`. The two in-repo adapters (`@objectstack/plugin-hono-server`, `@objectstack/hono`) ship that change in this release.
2 changes: 1 addition & 1 deletion content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ back fail-closed.
|:---|:---|:---|
| `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS |
| `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered |
| `api` | HTTP call to `action.target` via a configured `apiClient` | **ObjectOS** runtime only |
| `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 |

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

Expand Down
16 changes: 10 additions & 6 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,15 @@ can reach is the **default exposure policy (v1)**:
are blocked fail-closed.
- **Actions require the author's opt-in**: `ai: { exposed: true }` plus an
`ai.description` of at least 40 characters, and the action must be callable
without a UI. In the open framework that means exactly two types — `script`
(with an inline body or a registered handler) and `flow` (with the automation
service registered). `url`, `modal`, `form`, and `api` have no headless
dispatch here. See [Actions as Tools](/docs/ai/actions-as-tools) and
[Actions](/docs/ui/actions).
without a UI. In the open framework that means three types — `script`
(with an inline body or a registered handler), `flow` (with the automation
service registered), and `api` whose `target` is a **same-origin path**
(starting with `/`): `run_action` dispatches it in-process through the
action door to that target, after the door's gates. `url`, `modal`, and
`form` have no headless dispatch here, and neither does an `api` action
whose `target` is on another origin — the browser calls that origin
directly, so it is not exposed. See [Actions as Tools](/docs/ai/actions-as-tools)
and [Actions](/docs/ui/actions).

<Callout type="info">
This is the default policy, not a ceiling. A finer, **metadata-declared** exposure
Expand Down Expand Up @@ -392,7 +396,7 @@ skill and a guided `/objectstack:connect` command.
| stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback |
| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling |
| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope |
| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (only `script` with a body/handler and `flow` with an automation service are — `url`, `modal`, `form`, and `api` never appear), it targets a `sys_*` object, or the caller fails its `requiredPermissions` |
| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (`script` with a body/handler, `flow` with an automation service, and `api` with a same-origin `target` are — `url`, `modal`, `form`, and an `api` action whose `target` is on another origin never appear), it targets a `sys_*` object (held back fail-closed for every type, api included — #27681 tracks that gap), or the caller fails its `requiredPermissions` |
| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |

## Related
Expand Down
8 changes: 4 additions & 4 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The types you'll actually use:
| `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow |
| `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) |
| `modal` | Open a modal page — client-side only, no server dispatch | `target` names the modal page (to collect input *and* run logic, use `script` + `params`) |
| `api` | Call an HTTP endpoint directly | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request |
| `api` | Call an HTTP endpoint | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request. A same-origin `target` is dispatched through the action doors (REST and MCP), which forward it in-process after their gates; an off-origin one is called by the browser directly |
| `form` | Open a form view, prefilled with the current record — in-shell, and the submit lands on the created record ([contract](/docs/protocol/objectui/actions#what-type-form-promises)) | `target` names the FormView; routed to `/forms/:target?recordId=…` |

<Callout type="warn">
Expand Down Expand Up @@ -575,8 +575,8 @@ Failures speak HTTP — the status code is the signal (#3962):
- **400** — the action ran and **rejected** (a business rule said no, or
validation failed — then with `error.details.fields[]` to anchor the input).
- **404 / 403 / 503** — it never dispatched: no such action, denied, service
unavailable. A `url`/`modal`/`form`/`api` type with no server dispatch is
also a 400.
unavailable. A `url`/`modal`/`form` type, or an `api` type whose `target`
is on another origin, has no server dispatch and is also a 400.
- **500** — it **crashed**: a `TypeError` in your handler, a driver error, a
sandbox timeout. A deliberate `throw new Error('…')` is a 400 rejection,
not a crash.
Expand All @@ -596,7 +596,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
|:---|:---|
| `script` | Runs the registered handler / inline body. |
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE` / **422** `FLOW_INPUT_SCHEMA_INVALID`). A target declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) is refused **403** `PERMISSION_DENIED` to every caller but the system principal, exactly as at the trigger door, and nothing runs; holding the action's `requiredPermissions` does not open it. A target that declares its own `requiredPermissions` is refused **403** `PERMISSION_DENIED` to a caller who does not hold every capability it names, even when the action declares none. See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. |
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
| `api` | **Same-origin `target`** (a path starting with `/`): the door runs its gates — the sign-in floor, `requiredPermissions`, the packaged-action switch, the param contract, the subject-row load — and then **forwards the request in-process** to `target` with your own credentials, assembling the body exactly as the Console does (`recordIdParam` / `recordIdField` from the subject row, `bodyShape`, `bodyExtra` merged last, `organizationId` for an `/api/v1/auth/` target, `method`). The target endpoint still authorizes you itself; its status and body come back as the answer (its `data` unwrapped), and a credential rotation it emits (`Set-Cookie`, `set-auth-token`) is relayed. A `${ctx.selection.*}` or `{{page.<var>}}` token exists only in the browser and is refused **400** by name. **Off-origin `target`** (another host): **400** — the browser calls it directly, and such an action cannot be switched off in the activation ledger (the write door refuses, **409**, with the reason). |
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |

## Headless actions: declare it, then hide it
Expand Down
104 changes: 104 additions & 0 deletions packages/adapters/hono/src/hono-response-header-array.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27506] `toResponse` emits a `string[]` header value as one header line
* per element, on the `response` arm and on the `stream` arms.
*
* `HttpDispatcherResult.response.headers` accepts `string | string[]` since
* #27506: the `/actions` door relays a forwarded target's credential rotation,
* and `/admin/impersonate-user` answers several `Set-Cookie` lines. This
* adapter used to cast every value `as string` — an array reaches `c.header`
* as its `toString()`, one comma-joined line, which a browser parses as ONE
* cookie whose value contains the others. These cases pin the repeated line.
*
* Same harness as `hono-result-response-passthrough.test.ts`: the dispatcher
* is a fixture, so the arms can be driven with exactly the header shapes the
* real door produces.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Hono } from 'hono';

const mockDispatcher = {
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
handleAuth: vi.fn(),
dispatch: vi.fn(),
};

vi.mock('@objectstack/runtime', () => ({
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
}));

import { createHonoApp } from './index';

const PREFIX = '/api/v1';
const PATH = `${PREFIX}/actions/sys_user/impersonate_user`;

const kernel = { name: 'test-kernel' } as any;
const bootApp = (): Hono => createHonoApp({ kernel, prefix: PREFIX });

const COOKIES = [
'better-auth.session_token=new; Path=/; HttpOnly',
'better-auth.admin_session=adm; Path=/; HttpOnly',
];

const setCookieLines = (res: Response): string[] =>
typeof (res.headers as any).getSetCookie === 'function' ? (res.headers as any).getSetCookie() : [];

describe('#27506: a string[] header value is one header line per element', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDispatcher.handleAuth.mockResolvedValue({ handled: false });
});

it('`response.headers` — every Set-Cookie line reaches the caller separately, a string value as before', async () => {
mockDispatcher.dispatch.mockResolvedValue({
handled: true,
response: {
status: 200,
body: { success: true, data: { ok: true } },
headers: { 'set-cookie': COOKIES, 'set-auth-token': 'tok_impersonated' },
},
});

const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' });

expect(res.status).toBe(200);
expect(setCookieLines(res)).toEqual(COOKIES);
expect(res.headers.get('set-auth-token')).toBe('tok_impersonated');
await expect(res.json()).resolves.toEqual({ success: true, data: { ok: true } });
});

it('`result.type: stream` with a raw stream — array headers append, strings set', async () => {
const stream = new ReadableStream({
start(controller) { controller.enqueue(new TextEncoder().encode('data: x\n\n')); controller.close(); },
});
mockDispatcher.dispatch.mockResolvedValue({
handled: true,
result: { type: 'stream', stream, headers: { 'set-cookie': COOKIES, 'x-one': 'line' } },
});

const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' });

expect(res.status).toBe(200);
expect(setCookieLines(res)).toEqual(COOKIES);
expect(res.headers.get('x-one')).toBe('line');
});

it('`result.type: stream` with event frames — array headers append on the built Headers', async () => {
mockDispatcher.dispatch.mockResolvedValue({
handled: true,
result: {
type: 'stream',
events: (async function* () { yield { type: 'text', value: 'hi' }; })(),
headers: { 'set-cookie': COOKIES, 'x-one': 'line' },
},
});

const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' });

expect(res.status).toBe(200);
expect(setCookieLines(res)).toEqual(COOKIES);
expect(res.headers.get('x-one')).toBe('line');
});
});
Loading
Loading