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
17 changes: 17 additions & 0 deletions .changeset/27672-hook-aborted-thrown-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/types': patch
'@objectstack/rest': patch
'@objectstack/runtime': patch
---

fix(types): a hook that aborts with free text answers `HOOK_ABORTED` on package publish and the runtime `/actions` doors too, not `VALIDATION_ERROR`

Clause-②: no
Metadata-protocol: none

- **Before.** Since #27597 a sandboxed body that aborts with free text (`throw new Error('…')`, no `code`) answers `/api/v1/data` with `HOOK_ABORTED`. The doors that take their code from `resolveThrownHttpError` (`@objectstack/types`) instead of from the `/data` classification still answered the same abort with the code its `400` derives, `VALIDATION_ERROR`, so a client branching on the code had to know which door the abort came through.
- **Now.** `resolveThrownHttpError` answers `UNDECLARED_HOOK_REFUSAL_CODE` (`HOOK_ABORTED`) for a sandboxed body's refusal that declared no code, at a 4xx status. It is the one constant `/data` already reads, now declared beside the resolver. The value moves, from `VALIDATION_ERROR` to `HOOK_ABORTED`, on these doors:
- `POST /api/v1/packages/publish` (`@objectstack/rest`);
- the runtime `/actions` doors: `POST /api/v1/actions/:object/:action`, `POST /api/v1/actions/:object/:action/:recordId`, `POST /api/v1/actions/global/:action` and `POST /api/v1/actions//:action`, for an action body's own free-text throw, a hook abort relayed through an action body, and an action body's unreadable rejection (`throw ''`, `throw Object.create(null)`) (`@objectstack/runtime`).
- **Unchanged.** A code the body declared is kept: a registered code is still its own wire `code`, and an unregistered one (a `refusals` catalogue entry) still rides `declaredCode` beside the status-derived `code`. `declaredCode` stays absent for an undeclared abort. A producer that is not a sandboxed body still derives its code from the status, a crashed body is still the sanitised `500 INTERNAL_ERROR`, and a sandboxed refusal resolved at a 5xx keeps the fault's code. `/api/v1/data` answers exactly as before.
- **Moved, not added.** No error code is added; `HOOK_ABORTED` is the one #27597 registered. `sandboxBusinessMessage` moved to its own module so the resolver can read it without an import cycle; `@objectstack/types` exports the same names as before.
2 changes: 1 addition & 1 deletion docs/qa/platform-checklist/areas/api-backend.json
Original file line number Diff line number Diff line change
Expand Up @@ -3307,7 +3307,7 @@
"ref": "packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts + packages/rest/src/rest-hook-script-fault-envelope.test.ts + packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts + packages/services/service-storage/src/storage-routes.server-fault-withheld.test.ts (acceptance[0], unit and engine) + packages/qa/dogfood/test/error-sentences-live.dogfood.test.ts (acceptance[1]–[3] over HTTP on the scratch app packages/qa/dogfood/test/fixtures/error-sentences-fixture.ts: the hook's sentence at 400 with no code on the /data doors, 409 UNIQUE_VIOLATION with details exactly developerMessage + object at the action door, and a storage route's 500 INTERNAL withholding the caught text)"
},
"source": [
"packages/types/src/data-error-classification.ts#sandboxBusinessMessage",
"packages/types/src/sandbox-business-message.ts#sandboxBusinessMessage",
"packages/types/src/data-error-classification.ts#isSandboxOwnFault",
"packages/types/src/response-envelope.ts#sendServerFault",
"packages/runtime/src/http-dispatcher.ts",
Expand Down
100 changes: 100 additions & 0 deletions packages/rest/src/package-door-undeclared-hook-abort-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27672] `POST /api/v1/packages/publish` answers a sandboxed hook's
* undeclared abort with `HOOK_ABORTED`, the code `/api/v1/data` has answered
* since #27597, instead of the `VALIDATION_ERROR` its 400 derives.
*
* This door does not answer from the `/data` classification: `sendThrownError`
* (`package-routes.ts`) takes the status from it and the CODE from
* `resolveThrownHttpError` (`@objectstack/types`). The resolver now answers
* `UNDECLARED_HOOK_REFUSAL_CODE` for a sandboxed refusal that declared no code,
* so this door reads the one rule rather than a copy of it. §2 is the control:
* a code the body declared is kept, and a plain 400 with no code still derives
* `VALIDATION_ERROR`.
*/

import { describe, it, expect } from 'vitest';
import { ApiErrorSchema } from '@objectstack/spec/api';
import type { RouteHandler } from '@objectstack/spec/contracts';
import { UNDECLARED_HOOK_REFUSAL_CODE } from '@objectstack/types';
import { registerPackageRoutes } from './package-routes.js';

const PUBLISH = 'POST:/api/v1/packages/publish';
const BODY = { manifest: { id: 'com.acme.crm', version: '1.0.0' }, metadata: {} };

/** The shape `runtime/src/sandbox/quickjs-runner.ts` throws for a body's own `throw`. */
function sandboxRefusal(businessMessage: string, extra: Record<string, unknown> = {}) {
const err: any = new Error(`hook 'guard' threw: Error: ${businessMessage}`);
err.name = 'SandboxError';
err.innerMessage = businessMessage;
return Object.assign(err, extra);
}

async function publishRejectingWith(error: unknown): Promise<{ status: number; body: any }> {
const routes = new Map<string, RouteHandler>();
const server = {
get: () => {},
post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); },
put: () => {}, delete: () => {}, patch: () => {}, use: () => {},
listen: async () => {}, close: async () => {},
} as any;
const svc = { publish: async () => { throw error; } };
registerPackageRoutes(server, () => svc as any, '/api/v1', {
resolveExecutionContext: async () => ({
userId: 'u_pkg',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
}),
} as any);
const captured = { status: 0, body: undefined as any };
const res: any = {
json(data: any) { captured.body = data; },
send() {},
status(code: number) { captured.status = code; return res; },
header() { return res; },
};
await routes.get(PUBLISH)!(
{ params: {}, query: {}, headers: {}, method: 'POST', path: '/api/v1/packages/publish', body: BODY } as any,
res,
);
return captured;
}

describe('[#27672] §1 an undeclared hook abort answers HOOK_ABORTED at package publish', () => {
it('400, the hook\'s own sentence, and the code /data answers', async () => {
const { status, body } = await publishRejectingWith(sandboxRefusal('Publishing is frozen.'));
expect(status).toBe(400);
expect(body.error.code).toBe('HOOK_ABORTED');
expect(body.error.code).toBe(UNDECLARED_HOOK_REFUSAL_CODE);
expect(body.error.message).toBe('Publishing is frozen.');
// The body declared no code, so none is reported as declared.
expect(body.error).not.toHaveProperty('declaredCode');
expect(ApiErrorSchema.safeParse(body.error).success).toBe(true);
});

it('a 4xx status the body declared without a code keeps the status', async () => {
const { status, body } = await publishRejectingWith(sandboxRefusal('Frozen.', { status: 422 }));
expect(status).toBe(422);
expect(body.error.code).toBe('HOOK_ABORTED');
});
});

describe('[#27672] §2 CONTROLS — a declared code is kept; nothing is invented elsewhere', () => {
it('a registered code the body declared is its own code', async () => {
const { status, body } = await publishRejectingWith(sandboxRefusal('Locked.', { code: 'RECORD_LOCKED', status: 409 }));
expect(status).toBe(409);
expect(body.error.code).toBe('RECORD_LOCKED');
});

it('an unregistered code the body declared rides declaredCode beside the derived code', async () => {
const { body } = await publishRejectingWith(sandboxRefusal('Over the limit.', { code: 'CREDIT_LIMIT', status: 400 }));
expect(body.error.code).toBe('VALIDATION_ERROR');
expect(body.error.declaredCode).toBe('CREDIT_LIMIT');
});

it('a non-sandbox producer that declared a 400 and no code still derives VALIDATION_ERROR', async () => {
const { status, body } = await publishRejectingWith(Object.assign(new Error('Manifest is invalid.'), { status: 400 }));
expect(status).toBe(400);
expect(body.error.code).toBe('VALIDATION_ERROR');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,6 @@ const IMPORT_BODY = { format: 'json', rows: [{ name: 'x' }] };
const importRow = (res: any) => res.body?.results?.[0]?.error;
const jobRow = async (_res: any, b: Booted) => (await terminalJobPatch(b)).results?.items?.[0]?.error;

/**
* [#22719] An undeclared refusal re-dressed in the nested envelope: the code
* the classification leaves out is the catalog's own floor for `400`.
*/
const REFUSED_400 = { status: 400, code: 'VALIDATION_ERROR' } as const;

/**
* [#27597] The same refusal where the family re-dresses the `/data` door's
* classification whole: a sandboxed hook's undeclared abort answers the
Expand Down Expand Up @@ -373,8 +367,11 @@ const ANSWERS_THE_SENTENCE: Record<string, Driver> = {
'POST /api/v1/approvals/requests/:id/comment': { called: 'approvals.comment', body: {}, answers: HOOK_ABORTED_400 },
// Package publish: the classification decides the status the door falls
// back to, the shared read the sentence. Was `500 INTERNAL_ERROR`.
// [#27672] FLIPPED on purpose from `400 VALIDATION_ERROR`: the door's code
// is `resolveThrownHttpError`'s, which now answers an undeclared hook abort
// `HOOK_ABORTED`, the code `/data` answers.
'POST /api/v1/packages/publish': {
called: 'service:package.publish', answers: REFUSED_400,
called: 'service:package.publish', answers: HOOK_ABORTED_400,
body: { manifest: { id: 'p1', version: '1.0.0' }, metadata: {} },
},
// External datasources: the shared read only, so status and code are this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ describe('[#22588] the /actions ledger rows, each driven with a real nested hook
expect(executeAction).toHaveBeenCalled();
expect(res.response.status).toBe(400);
expect(res.response.body.success).toBe(false);
expect(res.response.body.error.code).toBe('VALIDATION_ERROR');
// [#27672] FLIPPED on purpose from `VALIDATION_ERROR`: an undeclared
// abort answers `HOOK_ABORTED` here, the code `/data` answers.
expect(res.response.body.error.code).toBe('HOOK_ABORTED');
// The wording IS the defect here, so the sentence is asserted whole.
expect(res.response.body.error.message).toBe(REFUSAL);
expect(JSON.stringify(res.response.body)).not.toMatch(/threw:|hook '/);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27672] The runtime `/actions` door answers a sandboxed body's undeclared
* abort with `HOOK_ABORTED`, the code `/api/v1/data` has answered since
* #27597, instead of the `VALIDATION_ERROR` its 400 derives.
*
* The door's catch hands a rejection to `errorFromThrown`
* (`http-dispatcher.ts`), whose code is `resolveThrownHttpError`'s
* (`@objectstack/types`). The resolver now answers
* `UNDECLARED_HOOK_REFUSAL_CODE` for a sandboxed refusal that declared no code,
* so this door reads the one rule rather than a copy of it. §2 is the control:
* a code the body declared is kept, a registered handler's plain rejection
* still derives `VALIDATION_ERROR`, and a crash is still the sanitised 500.
*/

import { describe, it, expect, vi } from 'vitest';
import { UNDECLARED_HOOK_REFUSAL_CODE } from '@objectstack/types';

import { HttpDispatcher } from '../http-dispatcher.js';
import { SandboxError } from '../sandbox/quickjs-runner.js';

const scriptAction = {
name: 'submit_signoff',
objectName: 'crm_invoice',
type: 'script',
body: { language: 'js', source: 'return 1;', capabilities: ['api.write'] },
};

/** A dispatcher whose action handler rejects with `thrown`. */
function makeDispatcher(thrown: unknown) {
const objectDef = { name: 'crm_invoice', actions: [scriptAction] };
const ql: any = {
executeAction: vi.fn(async () => { throw thrown; }),
getSchema: (n: string) => (n === objectDef.name ? objectDef : undefined),
registry: { getObject: (n: string) => (n === objectDef.name ? objectDef : undefined), getItem: () => undefined },
find: vi.fn(async () => [{ id: 'inv_1', status: 'draft' }]),
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
};
const metadata: any = {
load: vi.fn(async () => null),
listObjects: vi.fn(async () => [objectDef]),
getObject: vi.fn(async () => objectDef),
};
const kernel: any = {
context: {
getService: (n: string) =>
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : null,
},
};
return new HttpDispatcher(kernel);
}

async function invoke(thrown: unknown) {
const res: any = await makeDispatcher(thrown).handleActions(
'/crm_invoice/submit_signoff/inv_1',
'POST',
{},
{ request: {}, environmentId: 'platform', executionContext: { userId: 'u1', systemPermissions: [] } } as any,
);
return res.response;
}

describe('[#27672] §1 an undeclared abort answers HOOK_ABORTED at /actions', () => {
it('the action body\'s own free-text throw', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await invoke(
new SandboxError("action 'submit_signoff' threw: Contact has no phone", 'Contact has no phone'),
);
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('HOOK_ABORTED');
expect(response.body.error.code).toBe(UNDECLARED_HOOK_REFUSAL_CODE);
expect(response.body.error.message).toBe('Contact has no phone');
expect(response.body.error).not.toHaveProperty('declaredCode');
});

it('a hook abort relayed through the action body (the nested refusal\'s shape)', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await invoke(new SandboxError(
"action 'submit_signoff' threw: hook 'guard' threw: Error: A finished invoice cannot be signed.",
'A finished invoice cannot be signed.',
));
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('HOOK_ABORTED');
expect(response.body.error.message).toBe('A finished invoice cannot be signed.');
});

it('a 4xx status the body declared without a code keeps the status', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await invoke(new SandboxError("action 'submit_signoff' threw: Closed.", 'Closed.', { status: 422 }));
expect(response.status).toBe(422);
expect(response.body.error.code).toBe('HOOK_ABORTED');
});
});

describe('[#27672] §2 CONTROLS — a declared code is kept; nothing is invented elsewhere', () => {
it('a registered code the body declared is its own code', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await invoke(new SandboxError(
"action 'submit_signoff' threw: Locked.", 'Locked.', { code: 'RECORD_LOCKED', status: 409 },
));
expect(response.status).toBe(409);
expect(response.body.error.code).toBe('RECORD_LOCKED');
});

it('an unregistered code the body declared rides declaredCode beside the derived code', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await invoke(new SandboxError(
"action 'submit_signoff' threw: Over the limit.", 'Over the limit.', { code: 'CREDIT_LIMIT', status: 400 },
));
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.declaredCode).toBe('CREDIT_LIMIT');
});

it('a registered handler\'s plain rejection (no sandbox) still derives VALIDATION_ERROR', async () => {
const response = await invoke(new Error('Lead is already converted'));
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});

it('a body that CRASHED is still the sanitised 500', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await invoke(new SandboxError(
"action 'submit_signoff' threw: TypeError: x is not a function", 'TypeError: x is not a function',
));
expect(response.status).toBe(500);
expect(response.body.error.code).toBe('INTERNAL_ERROR');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ describe('[#23718] the action door answers a body\'s thrown plain object with it
it('CONTROL — a thrown Error answers as before', async () => {
const response = await postAction('throw_error');
expect(response.status, JSON.stringify(response.body)).toBe(400);
expect(response.body?.error?.code).toBe('VALIDATION_ERROR');
// [#27672] FLIPPED on purpose from `VALIDATION_ERROR`: a thrown Error
// with no code is an undeclared abort, answered `HOOK_ABORTED` as `/data`
// answers it. The sentence is this control's subject and is unchanged.
expect(response.body?.error?.code).toBe('HOOK_ABORTED');
expect(response.body?.error?.message).toBe('x');
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,18 @@ describe('[#23723] the action door answers an unreadable rejection at once, as a
it("throw '': 400, the fixed sentence", async () => {
const response = await postAction('throw_empty_string');
expect(response.status, JSON.stringify(response.body)).toBe(400);
expect(response.body?.error?.code).toBe('VALIDATION_ERROR');
// [#27672] FLIPPED on purpose from `VALIDATION_ERROR`: the body declared
// no code, so this is an undeclared abort, answered `HOOK_ABORTED`.
expect(response.body?.error?.code).toBe('HOOK_ABORTED');
expect(response.body?.error?.message).toBe(UNREADABLE);
});

it('throw Object.create(null): 400, the fixed sentence', async () => {
const response = await postAction('throw_null_proto');
expect(response.status, JSON.stringify(response.body)).toBe(400);
expect(response.body?.error?.code).toBe('VALIDATION_ERROR');
// [#27672] FLIPPED on purpose from `VALIDATION_ERROR`: the body declared
// no code, so this is an undeclared abort, answered `HOOK_ABORTED`.
expect(response.body?.error?.code).toBe('HOOK_ABORTED');
expect(response.body?.error?.message).toBe(UNREADABLE);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,17 @@ describe('[#17265] a nested sandboxed hook refusal keeps its business message',
expect(err.innerMessage).not.toContain('SandboxError:');
});

it('the wire answer matches /data: 400 VALIDATION_ERROR with the sentence', async () => {
it('the wire answer matches /data: 400 HOOK_ABORTED with the sentence', async () => {
// /data answers this refusal `400` with `error.innerMessage` verbatim
// (`error-response.ts`'s sandbox unwrap door, `declared ?? 400`). The
// action route must not answer a second thing for one refusal.
const response = await wireAnswer(await escapeOf(nestedHookRefusal));

expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
// [#27672] FLIPPED on purpose from `VALIDATION_ERROR`, which /data
// stopped answering in #27597: the undeclared abort is `HOOK_ABORTED`
// at both doors now, so this pin matches /data again.
expect(response.body.error.code).toBe('HOOK_ABORTED');
// [#22588] `toBe`, not `toContain`: a `toContain` pin stayed green
// while the wire carried the hook's debug wrapper in front of it.
expect(response.body.error.message).toBe(REFUSAL);
Expand Down
Loading
Loading