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
13 changes: 13 additions & 0 deletions .changeset/22868-sandbox-fault-not-unknown-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/types': patch
---

fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868)

Clause-②: no

**What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are.

**What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence.

**Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`.
52 changes: 43 additions & 9 deletions packages/core/src/utils/import-runner-sandbox-fault-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@
* not raise. `import-runner-sandbox-refusal-row.test.ts` §4 is the driver-text
* control #22718's ablation B pinned, and it is not edited here.
*
* The sample object name in a rendered message is deliberately NOT the
* import's object. `mapDataError`'s unknown-object heuristic answers `404
* OBJECT_NOT_FOUND` for a text that names the requested object and contains
* `not`, which a capability denial on that object does. That is a door
* defect outside this card; the row does not copy a 4xx sentence.
* [#22868] The sample object name in a rendered message IS the import's
* object. Two sites name it — the `ctx.api.object(…)` capability gate and the
* missing-method arm — and `mapDataError`'s unknown-object heuristic answered
* those `404 OBJECT_NOT_FOUND` (a quoted requested-object name plus `not`)
* until the door asked `isSandboxOwnFault` first. So §1 holds each site that
* names the requested object to the door's `500`, and §3 holds the controls
* that keep the heuristic's other answers where they were.
*/

import { readFileSync } from 'node:fs';
Expand All @@ -68,10 +70,12 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const RUNNER = resolve(HERE, '../../../runtime/src/sandbox/quickjs-runner.ts');
const SOURCE = ts.createSourceFile(RUNNER, readFileSync(RUNNER, 'utf8'), ts.ScriptTarget.Latest, true);

const OBJECT = 'mz_locked';

/**
* One sample per interpolation the runner's fault messages use. A message
* naming an expression not listed here is unrenderable, and §1's census says
* which. `objectName` is not the import's object (see the header).
* which. `objectName` is the import's object (see the header).
*/
const SAMPLES: Readonly<Record<string, string>> = {
'args.origin.kind': 'hook',
Expand All @@ -82,7 +86,7 @@ const SAMPLES: Readonly<Record<string, string>> = {
wallCeiling: '30000',
pumps: '3',
'formatErr(msg)': 'SyntaxError: unexpected token',
objectName: 'mz_other',
objectName: OBJECT,
method: 'find',
required: 'api.read',
fieldName: 'owner',
Expand Down Expand Up @@ -219,6 +223,11 @@ const census = (() => {

const PRODUCER_NAME = producerName();

/** [#22868] The fault sites whose rendered message quotes the requested object. */
function namingTheObject(): Site[] {
return census.faults.filter((s) => s.message.includes(`'${OBJECT}'`));
}

/** The producer's own fault: its declared `name`, and no `innerMessage`. */
function ownFault(message: string): Error {
const err = new Error(message);
Expand All @@ -228,8 +237,6 @@ function ownFault(message: string): Error {

type CreateArgs = Parameters<ImportProtocolLike['createData']>[0];

const OBJECT = 'mz_locked';

const baseOpts = {
objectName: OBJECT,
metaMap: new Map<string, ExportFieldMeta>([['name', { name: 'name', type: 'text' }]]),
Expand Down Expand Up @@ -280,6 +287,11 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\'
}
// §2's population: the constructions that set `innerMessage` (crash and refusal sites).
expect(census.crashes.length).toBeGreaterThanOrEqual(4);
// [#22868] The sites that name the requested object: the `ctx.api.object(…)`
// capability gate and the missing-method arm, each held to `500` below.
const named = namingTheObject();
expect(named.filter((s) => /^capability '/.test(s.message)).length, 'capability denial').toBeGreaterThanOrEqual(1);
expect(named.filter((s) => /not implemented$/.test(s.message)).length, 'missing ctx.api method').toBeGreaterThanOrEqual(1);
});

it.each(census.faults.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => {
Expand Down Expand Up @@ -331,6 +343,28 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () =>
expect((await rowFor(err)).error).toBe(sanitizeRowError(budget()));
});

it.each([
['SQLite', `SQLITE_ERROR: no such table: ${OBJECT}`],
['Postgres', `relation "${OBJECT}" does not exist`],
])('CONTROL [#22868] — a real %s unknown-object text on the requested object still answers 404', (_driver, text) => {
const door = mapDataError(new Error(text), OBJECT);
expect(door.status).toBe(404);
expect(door.body.code).toBe('OBJECT_NOT_FOUND');
});

it('CONTROL [#22868] — the same object-naming texts, thrown by anything but the sandbox, keep the heuristic\'s 404', () => {
// The door asks the producer's identity, never the words: no second rule.
const named = namingTheObject();
expect(named.length).toBeGreaterThanOrEqual(2);
for (const { label, message } of named) {
const err = new Error(message);
expect(isSandboxOwnFault(err), label).toBe(false);
const door = mapDataError(err, OBJECT);
expect(door.status, label).toBe(404);
expect(door.body.code, label).toBe('OBJECT_NOT_FOUND');
}
});

it('CONTROL — a code-defined hook\'s native TypeError keeps its text, as the filing says', async () => {
const text = "Cannot read properties of undefined (reading 'owner')";
const err = () => new TypeError(text);
Expand Down
33 changes: 28 additions & 5 deletions packages/types/src/data-error-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,11 +795,12 @@ function isSandboxOrigin(error: any): boolean {
* runner's row report (`@objectstack/core`) sits below `@objectstack/runtime`
* and never sees the class.
*
* ⛔ Not a withholding rule. {@link mapDataError} asks nothing here: it answers
* these errors from their message like any other unclassified throw, which for
* the runner's fault texts is its sanitised `500 INTERNAL_ERROR` terminal. This
* predicate lets the import row recognise the error so it can take that door's
* sentence, as it does for a crash; the verdict stays the door's.
* [#22868] {@link classifyDataError} asks it at its top, beside
* {@link isSandboxCrash}, and answers the sanitised `500 INTERNAL_ERROR`
* terminal — the answer it already gave these errors from their message,
* except where a text named the request's own object and the unknown-object
* heuristic answered `404`. The import row still takes that door's sentence
* through this predicate, as it does for a crash; the verdict stays the door's.
*/
export function isSandboxOwnFault(error: unknown): boolean {
const name = (error as { name?: unknown } | null | undefined)?.name;
Expand Down Expand Up @@ -1336,6 +1337,28 @@ function classifyDataError(error: any, object?: string): { status: number; body:
// ⛔ The terminal is not duplicated — it MOVED here from inside the unwrap
// door below, which is why that door now reads a body that REPORTED.
if (isSandboxCrash(error)) return UNCLASSIFIED_FAULT();
// [#22868] …and so is the sandbox's OWN fault ({@link isSandboxOwnFault}):
// a capability denial, an unavailable `ctx.api`, a missing `ctx.api`
// method, a budget, an install or marshal failure. Its producer's contract
// calls it a fault, and it carries no `code`, no `status` and no
// `innerMessage`, so nothing below could ever read it as anything else but
// its message TEXT. Two of those texts name the object the body addressed
// — `capability 'api.read' not granted to hook 'H' (called
// ctx.api.object('OBJ').find)` and `ctx.api.object('OBJ').find not
// implemented` — and when OBJ was the request's own object the
// unknown-object substring limb (a quoted object name plus `not`) answered
// `404 OBJECT_NOT_FOUND`, telling the caller the object does not exist.
//
// Asked HERE rather than beside that limb, because every message limb
// below reads text the runner interpolates from the body's own arguments
// (`ctx.api.object(name)`, `ctx.title(field)`), so a limb-local exclusion
// would fix one limb and leave the others free to misread the same fault.
// The answer is the terminal the other runner faults already reached, so
// only the misread ones move; the nested crash the pump loop relays as a
// fault reached it too, through the wrapper fallback below. ⛔ Not a second
// rule: the predicate reads the producer's identity, never the text, so
// the same words thrown by anything else keep the heuristic's answer.
if (isSandboxOwnFault(error)) return UNCLASSIFIED_FAULT();
// [commit 6d178a408] The bespoke structured arms first, exactly as they were inline
// here — same arms, same order, same position — now stated once so
// {@link resolveErrorResponse} can ask them before ITS passthrough too.
Expand Down
Loading