Skip to content

Commit cb3bb93

Browse files
fix(runtime,metadata-protocol): a nested hook refusal answers in the hook's words, and $top=0 reports the filtered total (#22690)
Fixes #22588 Clause-②: no Both items were measured on `main` first (`3d0eeefa`), over HTTP on a stack booted with `@objectstack/verify`'s `bootStack` (sqlite-wasm). Each was fixed where the wrong value is produced. In both cases that is not the door the card names: item 1's producer is the sandbox runner's VM hop, and item 2's is `findData`'s page gate. ## Item 1: a nested sandboxed refusal reaches the caller in its author's words Fixture: a sandboxed `beforeUpdate` hook `mz_guard_reopen` that throws `new Error('A finished task cannot be reopened. Create a follow-up task instead.')`, and a script action `mz_reopen` whose body writes through `ctx.api`. | door | `main` 3d0eeef | this branch | |---|---|---| | `PATCH /data/mz_task/ID` (the hook refuses directly) | 400, the sentence | unchanged | | `POST /actions/mz_task/mz_reopen/ID` (action body, then `ctx.api`, then the hook) | 400 `VALIDATION_ERROR` "hook 'mz_guard_reopen' threw: Error: A finished task cannot be reopened. ..." | 400 `VALIDATION_ERROR` "A finished task cannot be reopened. Create a follow-up task instead." | | `POST /actions/mz_task/mz_reopen_decl/ID` (`operation: 'update'`) | 400, the sentence | unchanged | | `PATCH /data/mz_parent/ID` (hook, then `ctx.api`, then the hook) | 400 "hook 'mz_guard_reopen' threw: Error: ..." | 400, the sentence | **Where the defect is.** It is not in the `/actions` routes in `dispatcher-plugin.ts`. Their `catch` runs only when `dispatch()` throws, and the actions domain answers every refusal itself as a handled result. The domain already reads the right field (`innerMessage`); it was handed the wrong text one VM hop earlier. `hostErrorToVm` (`packages/runtime/src/sandbox/quickjs-runner.ts`) copies only a host error's `name` and `message` into the body's VM. A nested hook refusal therefore entered the action's VM as its debug wrapper, and the pump loop built the outer `innerMessage` from that wrapper. #17265 kept this text on purpose ("moves the STATUS, not the sentence"), and its pins used `toContain`, which still passes with the wrapper in front. A hook that writes another object through `ctx.api` goes through the same hop, so `/data` showed the same text. **The change** - `hostErrorToVm` carries the nested refusal's sentence (the `sandboxBusinessMessage` answer) next to the wrapper, together with the message the error was marshalled with. The reject handler passes the sentence on only when the body re-throws that error **unchanged**, and the pump loop uses it as `innerMessage`. - If a body throws its own error, or rewrites the caught error's message first, the caller gets the body's own words. A nested crash has no sentence, so it is still marked as a sandbox fault and answers 500. The outer error's log-only `.message` still names every frame. What a body sees when it catches the error is unchanged. - The runner's `sandboxRefusalMessage` used to repeat the two conditions of `sandboxBusinessMessage`; it now calls it. - `domains/actions.ts` gets its client sentence from `sandboxBusinessMessage` instead of reading `innerMessage` itself, as triage asked ("no third local unwrap"). The wire answer does not change. The two reads differed only for a crash, and the crash terminal answers that with the sanitised 500 either way. ## Item 2: `$top=0` reports the filtered total | request (5 rows, 2 of them in stage `b`) | `main` 3d0eeef | this branch | |---|---|---| | `?top=0` | total 0, 0 rows | total 5, 0 rows | | `?top=1` | total 5 | total 5 | | `?$top=0` | total 0 | total 5 | | `?top=0&stage=b` | total 0 | total 2 | | `?top=1&stage=b` | total 2 | total 2 | | `?top=0&$count=false` | total 0 | total omitted | | `?top=0&skip=2` | total 0 | total 5 | **Where the defect is.** `findData` (`packages/metadata-protocol/src/protocol.ts`) only treated a request as paged when `limit > 0`. `limit: 0` asks for no rows (objectstack#6485, pinned by every driver's pagination conformance), so it fell into the branch that assumes no limit and reports the result set's length as the total. The REST list route passes `req.query` straight to `findData`, so the producer is in the protocol, not in `packages/rest`. **The change.** The check is now `>= 0`, so a zero-row request is a paged request. The three branches keep their meaning: the COUNT runs; `$count=false` still issues no COUNT and now omits `total` instead of reporting the page's `0`; `search` still issues no COUNT and reports its page-local estimate. ## Tests (final head `ee291ebc4`, which merges `origin/main` at `243dd3c62`) - `packages/runtime/src/sandbox/nested-refusal-sentence-relay.test.ts` (new, 9 tests). The real QuickJS runner runs at every level: action over hook, hook over hook (the `/data` case), three levels, a body that re-throws the same error, a body that throws its own error, a body that rewrites the message, a non-sandbox control, and a nested-crash control. - `packages/runtime/src/domains/actions-hook-refusal-sentence.ledger.test.ts` (new, 5 tests). It walks the dispatcher route ledger's `/actions` rows (`ROUTE_LEDGER`, `domain === '/actions'`). Each invocation row goes through `HttpDispatcher.dispatch()` with a real two-level nested refusal, and the test asserts `400`, `error.code === 'VALIDATION_ERROR'`, `error.message ===` the sentence, no `threw:` or `hook '` anywhere in the body, and that `executeAction` ran. `_activation` is exempted by name with its reason. A completeness case fails if an exemption names a row the ledger no longer has. - `nested-hook-refusal-is-a-rejection.test.ts`: the #17265 pins that held the wrapper as the sentence are **flipped** (`toBe(REFUSAL)`), with the reason written next to them. - `packages/metadata-protocol/src/protocol.zero-limit-total.test.ts` (new, 7 tests): `top=0` and `top=1` give the same total, every spelling of the zero window, a filter control (both page sizes change together), offset, the `$count=false` and `search` branches, and the no-limit case. Its engine double honours `limit` as written and refuses combinators (`check:where-matcher`). - Package suites at `ee291ebc4`, after rebuilding the affected closure. `@objectstack/runtime` `vitest run --project local` ran as four `--shard`s so each fits the foreground limit: 347 files, 4922 passed and 19 skipped. One test in shard 2 (`declared-capability-absence-warn-once.test.ts`, the dispatcher's 501 door, not touched here) hit its 30 s timeout at a load average around 7.5; run alone it passed 7/7. It also passed in the unsharded run at `927409be` (347 files, 4923 passed). Runtime `--project repo`: 3 files, 751 passed. `@objectstack/metadata-protocol` `vitest run` in two shards: 225 files passed (3 skipped), 28321 tests passed. Typecheck for both packages exits 0, including runtime's `check:test-typecheck`. `tsc --listFiles` confirms both programs include the new test files. ## Reverse verification (fix committed first; mutated with `scripts/ablation-replace.mjs`, which verified the anchor count and the blob hash on disk; restored with blob == HEAD and an empty `git diff HEAD`) - **A.** Delete `readRelayedRefusal(vm) ?? ` in the runner. Predicted 11 red / 8 green; measured **11 failed / 8 passed (19)**. The received text, `hook 'mz_guard_reopen' threw: Error: A finished task cannot be reopened. ...`, matches the `main` HTTP measurement exactly. The first attempt was refused by the tool before anything ran (the replacement text was a substring of the anchor), so the delete form was used. - **B.** Change `limit >= 0` back to `limit > 0`. Predicted 5 red / 11 green; measured **5 failed / 11 passed (16)**. The `search`, no-limit and #10757 cases stay green. - No `dist/` rebuild between legs: every test reaches the code under test through a relative source import. ## Gates - `dispatch-gates --ran` at `ee291ebc4`: 66 derived families, **66 run, 0 NOT MEASURED** (every recorded exit is 0), plus the two `check-doc-route-spelling` commands from the dispatch list. Before the merge, `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, packages without `dist/`). After the closure was built it exits 0. - `check:plugin-teardown-shape --self-test` first exited 3 because its fixture commit was missing from the shallow clone. After fetching that one commit it exits 0. - `check:where-matcher` flagged the new test double's matcher. Fixed in the test: it now refuses combinators. - `pnpm lint`, narrowed and declared: eslint `--no-inline-config --format json` over the 7 changed `.ts` files at `ee291ebc4` (all 7 are checked, none ignored; the `.changeset` file has no matching config) gives 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (it has no `parserOptions.project` or `projectService`), so this diff cannot change the result for any file it does not touch. CI runs the full repository lint. ## Acceptance notes - **The rest-route-ledger enumeration triage asked for was not built here.** The `/actions` routes are not in `rest-route-ledger.ts`; they are in the dispatcher ledger (`packages/runtime/src/route-ledger.ts`), and that is where this PR's enumeration lives. On the REST side, the single-row and bulk write doors are already pinned row by row (`rest-hook-refusal-message-parity.test.ts`) and measured correct here. One REST ledger write row is **not** correct today: `POST /data/:object/import` reports a hook refusal per row as `results[0].error: "hook 'mz_lock_insert' threw: Error: Locked rows cannot be created by import."`, at both `main` and this branch. The cause is `toFailedResult` in `packages/core/src/utils/import-runner.ts`, which builds the row text from `e.message`. That package is outside this claim, so an enumeration over the REST write rows would either be red or need an exemption that hides a live defect. This finding is in the report for the seat to file. - `search` with `$top=0` now gets the search branch's page-local estimate (`hasMore: true`, total = offset + 1) instead of `0`. Neither number is a count: `engine.count()` cannot reproduce a search, and that was already true at every page size. No known caller sends `search` with `$top: 0`; the Console's footer probe sends `$top: 0` alone. - With `$count=false` and `$top=0`, `hasMore` is `true` because a zero-row page counts as "full". This follows the opt-out's existing page-local rule. Files: `packages/runtime/src/sandbox/quickjs-runner.ts`, `packages/runtime/src/domains/actions.ts`, `packages/metadata-protocol/src/protocol.ts`, four test files, `.changeset/22588-nested-refusal-sentence-zero-row-total.md` (`@objectstack/runtime` patch, `@objectstack/metadata-protocol` patch). --- _Generated by [Claude Code](https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e04759d commit cb3bb93

8 files changed

Lines changed: 634 additions & 31 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588)
7+
8+
Clause-②: no
9+
10+
- **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame.
11+
- **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged.
12+
- **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12924,7 +12924,20 @@ export class ObjectStackProtocolImplementation implements
1292412924
// reproduced by it, so for those we skip the count and fall back to a
1292512925
// page-local estimate (a full page implies there may be more) rather than
1292612926
// reporting a wrong total.
12927-
const pageLimit = typeof options.limit === 'number' && options.limit > 0 ? options.limit : undefined;
12927+
//
12928+
// [#22588] `limit: 0` IS a limit. It asks for no rows (objectstack#6485,
12929+
// the drivers' pagination conformance), so `records.length` is 0 and was
12930+
// reported as the total: `?$top=0` answered `total: 0` while `?$top=1`
12931+
// answered the true count, and the Console's footer probe — which sends
12932+
// exactly `$top: 0` to read the total and nothing else — showed `0` on
12933+
// every list page. Gated `> 0`, a zero-row page fell into the "no limit,
12934+
// the full set is the total" arm, which is true of every limit except
12935+
// this one. So `>= 0`: a zero-row request is a paged request like any
12936+
// other, and the three arms below keep their meaning for it — the count
12937+
// runs (`total` is the filtered count, whatever the page size), `$count=false`
12938+
// still omits `total` instead of reporting the page's 0, and `search`
12939+
// still reports its page-local estimate rather than a count.
12940+
const pageLimit = typeof options.limit === 'number' && options.limit >= 0 ? options.limit : undefined;
1292812941
const pageOffset = typeof options.offset === 'number' && options.offset > 0 ? options.offset : 0;
1292912942
let total: number | undefined = records.length;
1293012943
let hasMore = false;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22588 item 2 — a list's `total` is the filtered count whatever the page size,
5+
* `$top=0` included.
6+
*
7+
* ## What was wrong
8+
*
9+
* `findData` decided "is this a paged request?" with `limit > 0`. `limit: 0`
10+
* asks for NO rows (objectstack#6485 — every driver's pagination conformance
11+
* pins it), so the gate sent a zero-row request into the "no limit, the whole
12+
* result set came back, so its length IS the total" arm — the one arm that is
13+
* false for exactly this limit. Measured over HTTP on a booted stack at
14+
* `3d0eeefa` (five rows, two of them in stage `b`):
15+
*
16+
* GET /data/mz_task?top=0 total 0 ← the defect
17+
* GET /data/mz_task?top=1 total 5
18+
* GET /data/mz_task?top=0&stage=b total 0 ← the defect
19+
* GET /data/mz_task?top=1&stage=b total 2
20+
* GET /data/mz_task?top=0&$count=false total 0 ← the opt-out ignored too
21+
*
22+
* The Console's list footer sends exactly `$top: 0` to read the total and
23+
* nothing else, so every list page showed a record count of `0`.
24+
*
25+
* ## What this suite pins
26+
*
27+
* 1. `top=0` and `top=1` report the SAME `total`, and `top=0` still returns no
28+
* rows — the engine is asked for `limit: 0`, never for the whole set.
29+
* 2. CONTROL — a filter moves both alike. A fix that reported the UNFILTERED
30+
* count for a zero-row page would pass (1) and fail here.
31+
* 3. The two neighbouring arms keep their meaning for a zero-row page:
32+
* `$count=false` still issues no COUNT and omits `total` (it no longer
33+
* reports the page's `0` as if it were a count), and `search` still issues
34+
* no COUNT — `engine.count()` cannot reproduce a search, which is why that
35+
* arm reports a page-local estimate at every page size.
36+
* 4. Absent a limit nothing moves: no COUNT, `total` is the set's length.
37+
*/
38+
39+
import { describe, it, expect, vi } from 'vitest';
40+
import { ObjectStackProtocolImplementation } from './protocol.js';
41+
42+
const SCHEMA = {
43+
name: 'mz_task',
44+
nameField: 'name',
45+
fields: {
46+
name: { name: 'name', type: 'text' },
47+
stage: { name: 'stage', type: 'text' },
48+
},
49+
};
50+
51+
const ROWS = [
52+
{ id: 't0', name: 't0', stage: 'a' },
53+
{ id: 't1', name: 't1', stage: 'a' },
54+
{ id: 't2', name: 't2', stage: 'a' },
55+
{ id: 't3', name: 't3', stage: 'b' },
56+
{ id: 't4', name: 't4', stage: 'b' },
57+
];
58+
59+
/**
60+
* Equality-only `where` — the implicit field filter `?stage=b` lowers to.
61+
* Anything else (a combinator, an operator object) is REFUSED rather than
62+
* answered silently wrong.
63+
*/
64+
function matches(row: Record<string, unknown>, where: unknown): boolean {
65+
if (where == null) return true;
66+
if (typeof where !== 'object' || Array.isArray(where)) {
67+
throw new Error(`test double: unexpected where shape ${JSON.stringify(where)}`);
68+
}
69+
return Object.entries(where as Record<string, unknown>).every(([k, v]) => {
70+
if (k.startsWith('$')) throw new Error(`test double: combinator '${k}' is not implemented`);
71+
if (v !== null && typeof v === 'object') throw new Error(`test double: operator on '${k}' is not implemented`);
72+
return row[k] === v;
73+
});
74+
}
75+
76+
/**
77+
* An engine double that honours `limit` AS WRITTEN — `limit: 0` is no rows,
78+
* the drivers' declared contract — and counts over the same `where`.
79+
*/
80+
function makeProtocol() {
81+
const find = vi.fn(async (_object: string, opts: any = {}) => {
82+
const hits = ROWS.filter((r) => matches(r, opts.where));
83+
const offset = typeof opts.offset === 'number' ? opts.offset : 0;
84+
const sliced = hits.slice(offset);
85+
return opts.limit !== undefined ? sliced.slice(0, opts.limit) : sliced;
86+
});
87+
const count = vi.fn(async (_object: string, opts: any = {}) => ROWS.filter((r) => matches(r, opts.where)).length);
88+
const engine = {
89+
registry: { getObject: (n: string) => (n === SCHEMA.name ? SCHEMA : undefined) },
90+
find,
91+
count,
92+
aggregate: vi.fn(async () => [] as unknown[]),
93+
};
94+
return { p: new ObjectStackProtocolImplementation(engine as any), find, count };
95+
}
96+
97+
async function list(query: Record<string, unknown>) {
98+
const h = makeProtocol();
99+
const result: any = await h.p.findData({ object: SCHEMA.name, query } as never);
100+
return { result, ...h };
101+
}
102+
103+
describe('[#22588] a zero-row page reports the filtered total', () => {
104+
it('top=0 and top=1 report the same total, and top=0 returns no rows', async () => {
105+
const zero = await list({ top: '0' });
106+
const one = await list({ top: '1' });
107+
108+
expect(zero.result.total).toBe(5);
109+
expect(zero.result.total).toBe(one.result.total);
110+
expect(zero.result.records).toEqual([]);
111+
// The engine was asked for NO rows — the fix counts, it never widens
112+
// the read to the whole set to measure it.
113+
expect((zero.find.mock.calls[0] as unknown[])[1]).toMatchObject({ limit: 0 });
114+
expect(zero.result.hasMore).toBe(true);
115+
});
116+
117+
it('every spelling of the zero window reaches the same answer', async () => {
118+
for (const query of [{ $top: '0' }, { limit: '0' }, { top: 0 }]) {
119+
const { result, count } = await list(query);
120+
expect(result.total, JSON.stringify(query)).toBe(5);
121+
expect(count, JSON.stringify(query)).toHaveBeenCalledTimes(1);
122+
}
123+
});
124+
125+
it('CONTROL — a filter changes both alike', async () => {
126+
const zero = await list({ top: '0', stage: 'b' });
127+
const one = await list({ top: '1', stage: 'b' });
128+
129+
expect(zero.result.total).toBe(2);
130+
expect(one.result.total).toBe(2);
131+
expect(zero.result.records).toEqual([]);
132+
expect(one.result.records).toHaveLength(1);
133+
});
134+
135+
it('a zero-row page past an offset still reports the whole filtered total', async () => {
136+
const { result } = await list({ top: '0', skip: '2' });
137+
138+
expect(result.total).toBe(5);
139+
expect(result.records).toEqual([]);
140+
expect(result.hasMore).toBe(true);
141+
});
142+
});
143+
144+
describe('[#22588] the neighbouring arms keep their meaning for a zero-row page', () => {
145+
it('$count=false still issues no COUNT — and omits total instead of reporting the page\'s 0', async () => {
146+
const { result, count } = await list({ $top: '0', $count: 'false' });
147+
148+
expect(count).not.toHaveBeenCalled();
149+
expect('total' in result).toBe(false);
150+
});
151+
152+
it('search still issues no COUNT — engine.count() cannot reproduce a search', async () => {
153+
const { result, count } = await list({ $top: '0', $search: 't' });
154+
155+
expect(count).not.toHaveBeenCalled();
156+
expect(result.records).toEqual([]);
157+
});
158+
159+
it('absent a limit nothing moves: no COUNT, total is the set\'s length', async () => {
160+
const { result, count } = await list({ stage: 'b' });
161+
162+
expect(count).not.toHaveBeenCalled();
163+
expect(result.total).toBe(2);
164+
expect(result.hasMore).toBe(false);
165+
});
166+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22588] Every `/actions` invocation route answers a hook's refusal in the
5+
* hook's own words — enumerated from the route ledger, not hand-listed.
6+
*
7+
* ## What was measured broken
8+
*
9+
* `POST /api/v1/actions/mz_task/mz_reopen/ID` on a booted stack at `3d0eeefa`:
10+
* a script action whose `ctx.api` write a sandboxed `beforeUpdate` hook refused
11+
* answered `400 VALIDATION_ERROR` with
12+
* `hook 'mz_guard_reopen' threw: Error: A finished task cannot be reopened. …`.
13+
* The hotclm browser pass saw the same text in a toast. The producer was the
14+
* sandbox's VM hop, repaired in `sandbox/quickjs-runner.ts`; the door now reads
15+
* its sentence through `sandboxBusinessMessage`, the read `/data` makes.
16+
*
17+
* ## Why the ledger, and what the enumeration buys
18+
*
19+
* The rows are read from {@link ROUTE_LEDGER} — the dispatcher's audited list of
20+
* what it serves — so a new `/actions` row is driven here the day it is
21+
* ledgered, or fails the completeness case until someone says why it cannot
22+
* run an action body. `_activation` is the one such row today.
23+
*
24+
* Each row is driven through `HttpDispatcher.dispatch()` at a concrete path
25+
* built from the row itself, and the refusal each one meets is produced by the
26+
* REAL runner at both levels (a hook body refusing under an action body's
27+
* `ctx.api` write) — the shape that reached the wire, never a hand-built error.
28+
* `executeAction` being called is asserted per row, so a row answered by some
29+
* earlier refusal (a missing declaration, the anonymous floor) cannot pass.
30+
*/
31+
32+
import { describe, it, expect, vi } from 'vitest';
33+
34+
import { HttpDispatcher } from '../http-dispatcher.js';
35+
import { ROUTE_LEDGER } from '../route-ledger.js';
36+
import { QuickJSScriptRunner } from '../sandbox/quickjs-runner.js';
37+
38+
const REFUSAL = 'A finished task cannot be reopened. Create a follow-up task instead.';
39+
const OBJECT = 'mz_task';
40+
const ACTION = 'mz_reopen';
41+
const RECORD = 't1';
42+
43+
/**
44+
* `/actions` rows that do not invoke an action, with the reason. Closed: a new
45+
* row is driven below unless it is named here.
46+
*/
47+
const NOT_AN_INVOCATION: ReadonlyMap<string, string> = new Map([
48+
['POST /actions/_activation/:object/:action',
49+
'ADR-0126 §8 activation door — writes one sys_metadata_activation row and runs no action body, '
50+
+ 'so no hook can refuse inside it'],
51+
]);
52+
53+
const ACTION_ROWS = ROUTE_LEDGER.filter((row) => row.domain === '/actions');
54+
const INVOCATION_ROWS = ACTION_ROWS.filter((row) => !NOT_AN_INVOCATION.has(row.route));
55+
56+
/** `POST /actions/:object/:action/:recordId` → `/actions/mz_task/mz_reopen/t1`; literals stay. */
57+
function concretePath(route: string): string {
58+
const [, pattern] = route.split(' ');
59+
return pattern
60+
.replace(':object', OBJECT)
61+
.replace(':action', ACTION)
62+
.replace(':recordId', RECORD);
63+
}
64+
65+
const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000, actionTimeoutMs: 10_000 });
66+
67+
/**
68+
* What an action body's handler throws when its `ctx.api` write is refused by
69+
* a sandboxed hook — both levels run by the real runner.
70+
*/
71+
async function nestedRefusal(): Promise<unknown> {
72+
const hookRefusal = () => runner.runScript(
73+
{ language: 'js', source: `throw new Error(${JSON.stringify(REFUSAL)});` },
74+
{ input: {} } as any,
75+
{ origin: { kind: 'hook', name: 'mz_guard_reopen' } },
76+
).then(() => { throw new Error('the guard hook resolved'); }, (e: unknown) => e);
77+
return runner.runScript(
78+
{
79+
language: 'js',
80+
source: `await ctx.api.object('${OBJECT}').update({ id: '${RECORD}', done: false });`,
81+
capabilities: ['api.write'],
82+
},
83+
{ input: {}, api: { object: () => ({ update: async () => { throw await hookRefusal(); } }) } } as any,
84+
{ origin: { kind: 'action', name: ACTION } },
85+
).then(() => { throw new Error('the action body resolved'); }, (e: unknown) => e);
86+
}
87+
88+
function makeDispatcher() {
89+
const declared = { name: ACTION, objectName: OBJECT, type: 'script', target: ACTION };
90+
const objectDef = { name: OBJECT, actions: [declared] };
91+
const executeAction = vi.fn(async () => { throw await nestedRefusal(); });
92+
const ql: any = {
93+
executeAction,
94+
getSchema: (n: string) => (n === OBJECT ? objectDef : undefined),
95+
registry: { getObject: (n: string) => (n === OBJECT ? objectDef : undefined), getItem: () => undefined },
96+
find: vi.fn(async () => [{ id: RECORD, done: true }]),
97+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
98+
};
99+
// The object-less rows resolve their declaration here: a global action of the same name.
100+
const global = (type: string, name: string) =>
101+
type === 'action' ? { name, type: 'script', target: name, objectName: 'global' } : null;
102+
const metadata: any = {
103+
load: vi.fn(async (type: string, name: string) => global(type, name)),
104+
loadDiagnosed: vi.fn(async (type: string, name: string) => ({ data: global(type, name), degraded: false, errors: [] })),
105+
listObjects: vi.fn(async () => [objectDef]),
106+
getObject: vi.fn(async (n: string) => (n === OBJECT ? objectDef : undefined)),
107+
};
108+
// `/actions` answers an anonymous caller 401 before addressing anything.
109+
const auth: any = { api: { getSession: async () => ({ user: { id: 'u1' } }) } };
110+
const kernel: any = {
111+
context: {
112+
getService: (n: string) =>
113+
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'auth' ? auth : null,
114+
},
115+
};
116+
return { dispatcher: new HttpDispatcher(kernel) as any, executeAction };
117+
}
118+
119+
describe('[#22588] the /actions ledger rows, each driven with a real nested hook refusal', () => {
120+
it('the ledger has invocation rows to drive, and every exemption names a real row', () => {
121+
expect(INVOCATION_ROWS.length).toBeGreaterThanOrEqual(4);
122+
for (const route of NOT_AN_INVOCATION.keys()) {
123+
expect(ACTION_ROWS.map((r) => r.route), `stale exemption: ${route}`).toContain(route);
124+
}
125+
});
126+
127+
for (const row of INVOCATION_ROWS) {
128+
it(`${row.route} answers the hook's sentence, not its debug wrapper`, async () => {
129+
const { dispatcher, executeAction } = makeDispatcher();
130+
131+
const res = await dispatcher.dispatch('POST', concretePath(row.route), {}, {}, {
132+
request: { headers: {} },
133+
});
134+
135+
// Anti-vacuity: the action body really ran and really refused.
136+
expect(executeAction).toHaveBeenCalled();
137+
expect(res.response.status).toBe(400);
138+
expect(res.response.body.success).toBe(false);
139+
expect(res.response.body.error.code).toBe('VALIDATION_ERROR');
140+
// The wording IS the defect here, so the sentence is asserted whole.
141+
expect(res.response.body.error.message).toBe(REFUSAL);
142+
expect(JSON.stringify(res.response.body)).not.toMatch(/threw:|hook '/);
143+
}, 60_000);
144+
}
145+
});

0 commit comments

Comments
 (0)