Skip to content

Commit 9859e4e

Browse files
rest: the 20 REST write routes answer a sandboxed hook's refusal in the hook's own words (#22763)
Fixes #22719 Clause-②: no ## What this does Twenty write routes in `@objectstack/rest` built their error answer by hand from `.message`. For a sandboxed hook's refusal, `.message` is the QuickJS debug wrapper (`hook 'NAME' threw: Error: SENTENCE`), so these routes shipped the wrapper while the other 24 write doors answer the hook's own sentence. Each family now answers through the shared read the conforming doors use (triage `6101568631`), with no local unwrap and no per-route text: - **Sharing rules (3), suggested bindings and the overlay discard (3), approvals (9)**, in `rest-server.ts`. Each family's own arms run first, unchanged. Where its 500 arm used to relay `.message`, it now asks `classifiedRefusalAnswer` first. That is the `/data` door's classification, and it reads `sandboxBusinessMessage`. The answer is re-dressed in the nested envelope through the family's own emitter. The re-dress is one function, `nestedRefusalEnvelope` in `error-response.ts`. It applies the same derivation the record-share family's `respondSharingError` applies inline: `code` falls back to the catalog's floor for the status, and `declaredCode` and `userMessage` come from the classification. - **`POST /packages/publish` (1)**, in `package-routes.ts`. This door runs the dispatcher twin's rule (`resolveThrownHttpError`). It now takes the status it falls back to from `classifiedRefusalAnswer`, and the sentence from `sandboxBusinessMessage`. `code`, `declaredCode`, `userMessage` and `details` stay this door's own mapping, so nothing it already answered moves. - **External datasources (4)**, in `external-datasource-routes.ts`. Both catch arms read the sentence through `sandboxBusinessMessage`. Status and code do not move (see "Not chosen" below). The pin's `ANSWERS_THE_WRAPPER` map is empty. Its 20 rows sit in `ANSWERS_THE_SENTENCE`, each pinned at the status and code it answers, and §3 now holds the map empty. ## Rows whose status or code changed (a sandboxed refusal that declares nothing) | row | before | after | |---|---|---| | `POST /api/v1/sharing/rules` | 500 `RULE_DEFINE_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `DELETE /api/v1/sharing/rules/:idOrName` | 500 `RULE_DELETE_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/sharing/rules/:idOrName/evaluate` | 500 `RULE_EVALUATE_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/security/suggested-bindings/:id/confirm` | 500 `SUGGESTION_CONFIRM_FAILED` | 400 `VALIDATION_ERROR` | | `POST /api/v1/security/suggested-bindings/:id/dismiss` | 500 `SUGGESTION_DISMISS_FAILED` | 400 `VALIDATION_ERROR` | | `POST /api/v1/security/permission-sets/:id/discard-overlay` | 500 `INTERNAL` | 400 `VALIDATION_ERROR` | | `POST /api/v1/approvals/requests/:id/approve` | 500 `APPROVAL_APPROVE_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/reject` | 500 `APPROVAL_REJECT_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/recall` | 500 `APPROVAL_RECALL_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/revise` | 500 `APPROVAL_REVISE_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/resubmit` | 500 `APPROVAL_RESUBMIT_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/reassign` | 500 `APPROVAL_REASSIGN_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/remind` | 500 `APPROVAL_REMIND_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/request-info` | 500 `APPROVAL_REQUEST_INFO_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/approvals/requests/:id/comment` | 500 `APPROVAL_COMMENT_FAILED`, flat | 400 `VALIDATION_ERROR`, nested | | `POST /api/v1/packages/publish` | 500 `INTERNAL_ERROR` | 400 `VALIDATION_ERROR` | | the 4 external-datasource rows | 400 `EXTERNAL_DATASOURCE_ERROR` / `EXTERNAL_IMPORT_ERROR` | status and code unchanged | On every row the message is now the hook's sentence instead of the wrapper. `VALIDATION_ERROR` is `standardErrorCodeForHttpStatus(400)`, the same code the record-share family answers for an undeclared sandboxed refusal. A refusal that declares its own code keeps it, and an unregistered spelling rides `declaredCode`. A declared `userMessage` rides along too. §5 pins both on the 16 re-dressed rows. "Flat" means `{ code, error: 'TEXT' }`, and "nested" means `{ success: false, error: { code, message } }`. Security's own emitter writes `{ error: { code, message } }` on every arm, and still does. ## Ride-along (triage `6101568631`: no decision owed) On the 15 sharing-rule, security and approval rows, a non-sandbox refusal can declare its own `status` (or `statusCode`) and `code` without matching any route-specific arm. It is now answered at its declared status in the nested envelope, where it used to get `500 FAMILY_CODE`. §5 pins this with a declared `409 RESOURCE_CONFLICT`. `POST /packages/publish` already answered a declared status, so it is unchanged. These are unchanged, and §5 pins them on all 20 rows: a plain fault keeps each route's own terminal (its status, its code and its words). The route-specific arms run first and answer exactly as before. Those are the sharing-rule prefixes, the approvals mapping and the security typed arm. ## Not chosen: the external-datasource status (dispatch assumption 5) These four routes answer every refusal at `400` with their own code. Adopting the shared classification there would change a 4xx domain answer. The import route relays the metadata door's declared `409 DESTRUCTIVE_CHANGE` as `400 EXTERNAL_IMPORT_ERROR`, which `external-import-destructive-remedy.dogfood.test.ts` pins, and the classification would answer 409. So these rows take the shared read only. One consequence remains: a sandboxed refusal that declares, for example, `409` answers `400 EXTERNAL_*` here with the correct sentence, where the conforming doors answer 409. Reported, not chosen. ## Verification (head `2525cfcf6`, after merging `origin/main` `7aa8d51c0`) - **The pin** (`rest-write-route-hook-refusal-sentence.ledger.test.ts`, `--project local`): 105 passed (§1 1, §2 44, §3 1, §4 2, §5 57). - **Reverse verification.** The fix was committed first (`b90ead477`). Then the four sources were restored from the base `e7e6544ab` into the working tree only. On-disk proof: the `nestedRefusalEnvelope` / `refusalSentence` / `classifiedRefusalAnswer` counts in the reverted files read 0. The pin then went red on 51 of 105 cases: the 20 moved §2 rows, the 15 declared-409 ride-along cases and the 16 declared-channel cases. The other 54 stayed green: the 24 original §2 rows, the 20 plain-fault cases, the 5 publish and external declared-409 cases, §1, §3, §4 and §5's roster case. The direction was red, as expected. Restoring used `git checkout HEAD`, with a trap. After that, `git hash-object` matched the HEAD blob for all four files and `git diff HEAD` was empty. - **`@objectstack/rest`:** `pnpm --filter @objectstack/rest test` (the `local` project) gave 272 files, 5239 passed and 326 skipped. `test:repo` gave 5 files, 191 passed and 1 skipped. `typecheck` (`tsc --noEmit` plus `check:test-typecheck`) was green. Earlier, both projects together at `150c886a6` gave 277 files, 5373 passed and 327 skipped. - **Gates.** `dispatch-gates --commands` was re-derived on this change: 65 families, the dispatch's 57 plus 8 that the changeset brings. All 65 were run at `2525cfcf6`, and `--ran` reports 65 run, 0 NOT-MEASURED and 0 UNRUN. Selected verdict lines: - `check:route-envelope`: `rest-server.ts` holds at stringError 39 and siblingCode 54, so no new dialect site was added. - `check:error-status-conformance`: every derivable runtime status is documented. - `check:issue-citations`: 9 citations, all resolve. - `check:nul-bytes`: OK. - `check-adr-0087-registration`: no declared-breaking changeset. - **Lint, as a proven narrowing.** `eslint --no-inline-config --format json` ran over the 5 touched TS files: 5 results, 0 errors, 0 warnings, 0 ignored. The population is read from eslint's own config. The changeset `.md` matches no config object (`--print-config` answers `undefined`). Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, as its own note states). Its only cross-file reads are two baseline JSONs this diff does not touch. So the diff cannot change the verdict on any untouched file. ## Acceptance notes - **File surface.** The claim names `rest-server.ts` for the publish answer, but that answer is built in `package-routes.ts`, which this PR edits. `error-response.ts` gains `nestedRefusalEnvelope`, which is not exported from the package index. Both files are in `packages/rest`. No public export, error code or key is added. - **One re-dress, two spellings.** The record-share family's `respondSharingError` keeps its inline copy of the derivation that `nestedRefusalEnvelope` states. Moving it onto the function is a byte-identical refactor of an arm outside this card's surface. Carrier: none. - **Measured but not repaired: a sandboxed body these rows answer as a FAULT still relays the wrapper.** Measured at `2525cfcf6` through this pin's harness, at the route handler: - A body that crashed (`TypeError: boom`) gets `hook 'mz_guard' threw: TypeError: boom` on all 20 rows: 500 with the family code on 16 rows, and 400 `EXTERNAL_*` on the 4 external rows. - A refusal that declared `503` gets 500 with the family code and the wrapper on the 15 rest-server rows. It gets 503 with the sentence on publish, and 400 with the sentence on the external rows. - The data doors answer both shapes as faults with the prose withheld (§4). - This is the fault-side sibling of this card, the shape §4 holds for the import doors. It is in the report for the seat. - **Cross-lane.** Merging `origin/main` at `7aa8d51c0` brought no edit to `packages/rest` from #22739's lane or any other lane. --- _Generated by [Claude Code](https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6981246 commit 9859e4e

6 files changed

Lines changed: 377 additions & 67 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): twenty more REST write routes answer a sandboxed hook's refusal in the hook's own words (#22719)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** When a sandboxed hook refuses a write (`throw new Error('Locked rows cannot be changed here.')`), the error that reaches the route carries the QuickJS debug wrapper on `.message` (`hook 'guard' threw: Error: …`) and the sentence on `.innerMessage`. Twenty write routes built their error answer by hand from `.message`, so they shipped the wrapper, while the other write doors answer the sentence. Sixteen of them also answered the refusal as a server fault (`500`).
10+
11+
**What changes on the wire, for a sandboxed hook's refusal.**
12+
13+
| routes | before | after |
14+
| --- | --- | --- |
15+
| `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested `{ success: false, error: { code, message } }`, the sentence |
16+
| `POST /security/suggested-bindings/:id/confirm` and `/dismiss`, `POST /security/permission-sets/:id/discard-overlay` | `500 SUGGESTION_*_FAILED` / `500 INTERNAL`, the wrapper | `400 VALIDATION_ERROR`, same nested `{ error: { code, message } }` shape, the sentence |
17+
| the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested, the sentence |
18+
| `POST /packages/publish` | `500 INTERNAL_ERROR`, the wrapper | `400 VALIDATION_ERROR`, the sentence |
19+
| `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper | the same status and code, the sentence |
20+
21+
A refusal that declares its own `code` keeps it (an unregistered spelling rides `declaredCode`), a refusal that declares its own `status` is answered at that status, and a `userMessage` the hook set rides the answer, as on every other write door.
22+
23+
**Also moved, on the sharing-rule, suggested-binding and approval routes.** These routes now ask the same classification as the `/data` door before their `500` arm. So a refusal from the service that declares its own `status` (or `statusCode`) and `code`, and that no route-specific arm reads, is answered at that status, in the nested envelope, instead of `500` with the route's own code. The external-datasource routes keep answering every refusal at `400` with their own code, and `POST /packages/publish` already answered a declared status.
24+
25+
**Unchanged.** A plain fault keeps each route's own `500` answer and code. The route-specific refusals (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND`, `SHARING_NOT_ENABLED`, the approval prefixes, the typed security errors) answer exactly as before.
26+
27+
**Upgrading.** A client that reads both envelopes, as `@objectstack/client` does, needs no change. A client of the sharing-rule or approval routes that reads a string `body.error` reads `body.error.message` for these answers.

‎packages/rest/src/error-response.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
} from '@objectstack/types';
7272
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
7373
import type { ErrorCode } from '@objectstack/spec/api';
74+
import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
7475
import { logError } from './log.js';
7576

7677
export {
@@ -677,6 +678,61 @@ export function classifiedRefusalAnswer(
677678
return resolved.status < 500 ? resolved : undefined;
678679
}
679680

681+
/**
682+
* [#22719] {@link classifiedRefusalAnswer}'s answer, re-dressed for a door that
683+
* publishes the NESTED ADR-0112 D5 envelope (`{ error: { code, message } }`)
684+
* rather than the flat one the classification is phrased in.
685+
*
686+
* ## Why it exists
687+
*
688+
* Three hand-built route families in `rest-server.ts` — the sharing-rule, the
689+
* suggested-binding / overlay-discard and the approval arms — answered a
690+
* refusal their own arms could not read as a server fault, relaying
691+
* `.message`. For a sandboxed hook's refusal `.message` is the QuickJS debug
692+
* wrapper, so each answered `500` with `hook '<name>' threw: Error: …` where
693+
* every conforming write door answers the hook's own sentence
694+
* (`rest-write-route-hook-refusal-sentence.ledger.test.ts`). Each family now
695+
* asks {@link classifiedRefusalAnswer} — the `/data` door's classification —
696+
* before that 500, and emits this through its OWN emitter, so the answer's
697+
* position is the family's and its classification is the shared one.
698+
*
699+
* ## The derivation is the record-share family's
700+
*
701+
* `respondSharingError` in `rest-server.ts` re-dresses the same answer inline,
702+
* and its comments carry the argument for each field; they are not restated:
703+
*
704+
* - `code` is REQUIRED by the nested envelope and the flat classification
705+
* legitimately carries none for an undeclared sandbox refusal, so the
706+
* catalog's own floor ({@link standardErrorCodeForHttpStatus}) fills it;
707+
* - `declaredCode` is the CLASSIFICATION's, never the raw thrown field,
708+
* because presence there means demotion;
709+
* - `userMessage` is the classification's, already bounded.
710+
*
711+
* ⛔ Nothing else rides. The flat body's `issues` and `object` are not
712+
* forwarded, the scope that family keeps: `ApiError.details` is a shape
713+
* decision, not a rename. `extra` is absent when there is nothing to carry,
714+
* so an emitter writes exactly `{ code, message }` for a refusal that declared
715+
* nothing.
716+
*/
717+
export function nestedRefusalEnvelope(refusal: { status: number; body: Record<string, unknown> }): {
718+
status: number;
719+
code: ErrorCode;
720+
message: string;
721+
extra?: { declaredCode?: string; userMessage?: string };
722+
} {
723+
const { status, body } = refusal;
724+
const extra = {
725+
...(typeof body.declaredCode === 'string' ? { declaredCode: body.declaredCode } : {}),
726+
...(typeof body.userMessage === 'string' ? { userMessage: body.userMessage } : {}),
727+
};
728+
return {
729+
status,
730+
code: typeof body.code === 'string' ? body.code as ErrorCode : standardErrorCodeForHttpStatus(status),
731+
message: String(body.error ?? ''),
732+
...(Object.keys(extra).length > 0 ? { extra } : {}),
733+
};
734+
}
735+
680736
/**
681737
* Whether a mapped data-error status represents an *expected* client/lifecycle
682738
* outcome (and therefore shouldn't be logged as "[REST] Unhandled error").

‎packages/rest/src/external-datasource-routes.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import type {
1616
SchemaValidationReport,
1717
} from '@objectstack/spec/contracts';
1818
// The declared envelope is written in ONE place for the whole platform (#3973).
19-
import { sendOk, sendError } from '@objectstack/types';
19+
// [#22719] …and so is the read of a sandboxed hook's refusal sentence.
20+
import { sendOk, sendError, sandboxBusinessMessage } from '@objectstack/types';
2021
import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js';
2122

2223
/**
@@ -382,14 +383,36 @@ export function registerExternalDatasourceRoutes(
382383
const unavailable = (res: any) =>
383384
sendError(res, 503, 'SERVICE_UNAVAILABLE', 'The external-datasource service is not available.');
384385

386+
/**
387+
* [#22719] The sentence both catch arms hand the caller: a sandboxed hook's
388+
* own words when the refusal came from one, the thrown message otherwise.
389+
*
390+
* A hook refusing a write the service makes arrives as a `SandboxError`
391+
* whose `.message` is the QuickJS debug wrapper and whose `.innerMessage` is
392+
* the sentence, so `draft`, `import`, `refresh-catalog` and `validate`
393+
* answered `hook '<name>' threw: Error: …` where every conforming write door
394+
* answers the hook's own sentence. {@link sandboxBusinessMessage} is that
395+
* read, shared rather than re-derived — ⛔ never a strip of the wrapper off
396+
* `.message`. It answers `undefined` for anything that is not a sandboxed
397+
* refusal, so every other message here is byte-identical.
398+
*
399+
* ⛔ Only the SENTENCE moves. Both arms keep their status and their code:
400+
* `EXTERNAL_IMPORT_ERROR` is what the import route answers for a refusal it
401+
* relays from the metadata door (a declared `409 DESTRUCTIVE_CHANGE`
402+
* included, which the import dogfood pins at `400`), so this family's status
403+
* is its own decision and the shared classification is not asked here.
404+
*/
405+
const refusalSentence = (err: unknown): string =>
406+
sandboxBusinessMessage(err) ?? (err instanceof Error ? err.message : String(err));
407+
385408
/**
386409
* A refusal from the external-datasource service — same code as the
387410
* admin-routes path (#4249). Named after the service, not one operation:
388411
* since #4264 every route here except the import (which has its own
389412
* registered code) answers its catch with this.
390413
*/
391414
const refused = (res: any, err: unknown) =>
392-
sendError(res, 400, 'EXTERNAL_DATASOURCE_ERROR', err instanceof Error ? err.message : String(err));
415+
sendError(res, 400, 'EXTERNAL_DATASOURCE_ERROR', refusalSentence(err));
393416

394417
/**
395418
* ONE declaration of this registrar's surface (#5822): the array below is
@@ -466,12 +489,7 @@ export function registerExternalDatasourceRoutes(
466489
);
467490
sendOk(res, { object: result }, 201);
468491
} catch (err) {
469-
sendError(
470-
res,
471-
400,
472-
'EXTERNAL_IMPORT_ERROR',
473-
err instanceof Error ? err.message : String(err),
474-
);
492+
sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', refusalSentence(err));
475493
}
476494
},
477495
},

‎packages/rest/src/package-routes.ts‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
resolveThrownHttpError,
1515
demotedDeclaredCode,
1616
looksLikeInternalErrorLeak,
17+
sandboxBusinessMessage,
1718
INTERNAL_ERROR_MESSAGE,
1819
} from '@objectstack/types';
20+
import { classifiedRefusalAnswer } from './error-response.js';
1921
import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js';
2022

2123
/**
@@ -175,13 +177,39 @@ async function refusePackageRequest(
175177
* client-facing messages at all — which is a separate card. Pinned on the
176178
* publish route in `package-door-5xx-message-sanitization.test.ts` so it goes
177179
* red the day either lands.
180+
*
181+
* ## [#22719] …and a sandboxed hook's refusal answers in the hook's words
182+
*
183+
* A hook body refusing a write `packageService.publish` makes (`throw new
184+
* Error('…')`) arrives as a `SandboxError` whose `.message` is the QuickJS
185+
* debug wrapper and whose `.innerMessage` is the sentence. This door read
186+
* neither: it declared no status, so it fell to the 500 above, and its message
187+
* was the wrapper — `500 INTERNAL_ERROR` with `hook '<name>' threw: Error: …`,
188+
* where every conforming write door answers `400` with the hook's own
189+
* sentence. Two shared reads now close it, and nothing else moves:
190+
*
191+
* - the STATUS the door falls back to is the `/data` door's classification
192+
* ({@link classifiedRefusalAnswer}) when it has one. It answers only a
193+
* refusal that declared an envelope — whose own status outranks any
194+
* fallback here anyway — or a sandboxed refusal, so an undeclared sandbox
195+
* refusal takes its `400` and every other undeclared throw keeps the `500`;
196+
* - the SENTENCE is {@link sandboxBusinessMessage}, the read every
197+
* conforming door makes, so a refusal that declared its own status keeps it
198+
* and loses only the wrapper. The leak predicate still reads the RAW
199+
* message, the input it was calibrated on.
200+
*
201+
* `code`, `declaredCode`, `userMessage` and `details` stay this door's own
202+
* mapping, which is why the classification's body is not re-dressed here the
203+
* way the hand-built families in `rest-server.ts` re-dress it: their 500 arm
204+
* read nothing, this door already reads everything but the sandbox.
178205
*/
179206
function sendThrownError(res: any, error: unknown): void {
180-
const thrown = resolveThrownHttpError(error);
181-
// The dispatcher twin's expression, byte for byte — one rule, two doors.
207+
const thrown = resolveThrownHttpError(error, classifiedRefusalAnswer(error)?.status ?? 500);
208+
// The dispatcher twin's withhold, byte for byte — one rule, two doors. Its
209+
// other limb is the shared sandbox read (#22719), not the raw message.
182210
const message = thrown.status >= 500 && looksLikeInternalErrorLeak(thrown.message)
183211
? INTERNAL_ERROR_MESSAGE
184-
: thrown.message;
212+
: sandboxBusinessMessage(error) ?? thrown.message;
185213
// [#12405] The producer's OWN spelling, when the closed vocabulary did not
186214
// admit it — the same demote the dispatcher twin serving this very path has
187215
// emitted since #9106 (`errorFromThrown` in

‎packages/rest/src/rest-server.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ import {
413413
mapDataError,
414414
sandboxBusinessMessage,
415415
classifiedRefusalAnswer,
416+
nestedRefusalEnvelope,
416417
boundedDeclaredUserMessage,
417418
boundedDeclaredRefusalMessage,
418419
declaredHttpStatus,
@@ -12095,6 +12096,26 @@ export class RestServer {
1209512096
msg.replace(/^SHARING_NOT_ENABLED:\s*/, ''),
1209612097
);
1209712098
}
12099+
// [#22719] A refusal none of the prefix arms above can read is asked
12100+
// of the `/data` door's own classification before the 500 below
12101+
// relays `.message`. For a sandboxed hook's refusal `.message` is
12102+
// the QuickJS debug wrapper, so `POST /rules`, `DELETE
12103+
// /rules/:idOrName` and `POST .../evaluate` answered `500` with
12104+
// `hook '<name>' threw: Error: …` where every conforming write door
12105+
// answers the hook's own sentence. A refusal that DECLARED its
12106+
// envelope (`status`/`statusCode` + `code`) is answered at its own
12107+
// status too, as on every door that asks this.
12108+
//
12109+
// AFTER the prefix arms, deliberately: each of them answers a 4xx
12110+
// this family already publishes, and none can read a sandbox error
12111+
// (its `.message` opens with the wrapper, never a prefix), so asking
12112+
// here moves only what used to reach the 500. Emitted in the nested
12113+
// envelope like `SHARING_NOT_ENABLED` above, for that arm's reason.
12114+
const refusal = classifiedRefusalAnswer(err);
12115+
if (refusal) {
12116+
const nested = nestedRefusalEnvelope(refusal);
12117+
return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra);
12118+
}
1209812119
logError(`[REST] sharing-rule ${defaultCode}:`, err);
1209912120
return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
1210012121
};
@@ -12269,8 +12290,13 @@ export class RestServer {
1226912290
* `query-multiplicity.ts`; that helper is shared with the whole file
1227012291
* and stays the reference point rather than being re-implemented here.
1227112292
*/
12272-
const respondError = (res: any, status: number, code: string, message: string) =>
12273-
res.status(status).json({ error: { code, message } });
12293+
// [#22719] `extra` carries the declared channels of a classified refusal
12294+
// (`declaredCode`, `userMessage`) inside the same `error` object; every
12295+
// other arm passes none and writes exactly `{ code, message }`.
12296+
const respondError = (
12297+
res: any, status: number, code: string, message: string,
12298+
extra?: { declaredCode?: string; userMessage?: string },
12299+
) => res.status(status).json({ error: { code, message, ...extra } });
1227412300
const respond501 = (res: any) => respondError(
1227512301
res, 501, 'NOT_IMPLEMENTED', 'Security service is not configured on this deployment',
1227612302
);
@@ -12283,6 +12309,20 @@ export class RestServer {
1228312309
// unchanged — only the position of the code moves.
1228412310
return respondError(res, status, err?.code ?? defaultCode, String(err?.message ?? err));
1228512311
}
12312+
// [#22719] A refusal the typed arm cannot read is asked of the
12313+
// `/data` door's own classification before the 500 below relays
12314+
// `.message`. A sandboxed hook's refusal carries no `statusCode`
12315+
// (the sandbox lifts `status` only), so `confirm`, `dismiss` and
12316+
// `discard-overlay` answered `500` with the QuickJS debug wrapper
12317+
// where every conforming write door answers the hook's own
12318+
// sentence; a refusal declaring `status` + `code` is answered at
12319+
// its own status too. The typed arm above is untouched, and the
12320+
// answer leaves through this family's one emitter.
12321+
const refusal = classifiedRefusalAnswer(err);
12322+
if (refusal) {
12323+
const nested = nestedRefusalEnvelope(refusal);
12324+
return respondError(res, nested.status, nested.code, nested.message, nested.extra);
12325+
}
1228612326
logError(`[REST] suggested-bindings ${defaultCode}:`, err);
1228712327
// The 500 arm keeps its 500-char cap: an unexpected fault's message
1228812328
// is not a contract, and truncating it stays a sanitization step.
@@ -12513,6 +12553,27 @@ export class RestServer {
1251312553
return true;
1251412554
}
1251512555
}
12556+
// [#22719] A refusal no prefix above matches is asked of the `/data`
12557+
// door's own classification before the caller's outer 500 relays
12558+
// `.message`. For a sandboxed hook's refusal `.message` is the
12559+
// QuickJS debug wrapper, so all nine write routes here answered
12560+
// `500 APPROVAL_*_FAILED` with `hook '<name>' threw: Error: …` where
12561+
// every conforming write door answers the hook's own sentence; a
12562+
// refusal declaring `status`/`statusCode` + `code` is answered at
12563+
// its own status too.
12564+
//
12565+
// AFTER the mapping, deliberately: every row above answers a code
12566+
// this family publishes, and none can read a sandbox error (its
12567+
// `.message` opens with the wrapper), so asking here moves only what
12568+
// used to reach the 500. Nested envelope, for the reason the
12569+
// sharing-rule family's `SHARING_NOT_ENABLED` arm gives: a new arm
12570+
// does not copy the flat `{ code, error }` dialect.
12571+
const refusal = classifiedRefusalAnswer(err);
12572+
if (refusal) {
12573+
const nested = nestedRefusalEnvelope(refusal);
12574+
sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra);
12575+
return true;
12576+
}
1251612577
return false;
1251712578
};
1251812579

0 commit comments

Comments
 (0)