Skip to content

Commit 706ad0f

Browse files
claude[bot]claude
andauthored
fix(objectql): name the withheld read-only key when a hook faults reaching through it (#17327)
* fix(objectql): name the withheld read-only key when a hook faults reaching through it Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook reaching THROUGH such a key dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. The refusal is correct and is not touched here. The diagnostic was the defect. Measured at both doors before this change: direct SandboxError: hook 'guard_task_body' threw: TypeError: cannot set property 'who' of undefined REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} The REST reading is the worse of the two and it is the door an author authors against: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so nothing reached the author -- not the key, not the reason, not the remedy. The engine is the only actor that can tell "the platform took this away" from "nobody sent it": the sandbox face is a plain JSON snapshot, so a withheld key is simply absent there. So the explanation is composed at the hide pass, and all three `beforeUpdate` dispatch sites inside the hide window share one wrapper. Nothing is marshalled onto the sandbox face. The composed error names the withheld key, says the platform withheld it because the field is `readonly: true`, points at `ctx.previous`, carries the original fault text, and declares HTTP 400 -- which is what carries it past `mapDataError`'s sanitiser instead of into a blank 500. No error code is registered and no published payload gains a key. Three declines keep it from repeating the card's own defect in the opposite direction: nothing withheld, an authored refusal, and a non-object throw all rethrow the original error untouched. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * fix(objectql): declare `cause` on the fault error instead of passing it through `super()` `pnpm --filter @objectstack/objectql typecheck` exited 2 on the previous commit: src/hook-withheld-readonly-fault.ts(140,20): error TS2554: Expected 0-1 arguments, but got 2. src/hook-withheld-readonly-fault.ts(140,31): error TS2304: Cannot find name 'ErrorOptions'. This repo compiles against `lib: ES2020`, where `Error` has neither a `cause` member nor the `ErrorOptions` constructor overload that carries one. The precedent is in this same package: `duplicate-record-error.ts` declares `readonly cause: unknown` on the class and assigns it by hand, and its comment already records why -- an undeclared assignment would be invisible to every TypeScript consumer of the field. Followed verbatim rather than reinvented. No behaviour change: the same original error is attached under the same name, and both suites plus the reverse verification were re-run against a forced rebuild after the edit. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73a8f08 commit 706ad0f

6 files changed

Lines changed: 623 additions & 12 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219)
6+
7+
Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write.
8+
9+
**The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors:
10+
11+
```
12+
direct SandboxError: hook 'guard_task_body' threw:
13+
TypeError: cannot set property 'who' of undefined
14+
REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
15+
```
16+
17+
The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead.
18+
19+
### Who is affected
20+
21+
Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.<field>`** — the same remedy PR #17195's changeset documents.
22+
23+
### What the message says now
24+
25+
```
26+
A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is
27+
`readonly: true`, and the engine withholds a caller-supplied value for a read-only field
28+
from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the
29+
platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta`
30+
instead. Original fault: TypeError: cannot set property 'who' of undefined
31+
```
32+
33+
The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced.
34+
35+
### Deliberate limits
36+
37+
No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched.

packages/objectql/src/engine-readonly-hook-input.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,180 @@ describe('#16344 — caller-forged readonly values are hidden from beforeUpdate'
407407
expect(afterSubmitted).toEqual([{ id: 'kpi_1', actual_value: 380, target_value: 1 }]);
408408
});
409409
});
410+
411+
/**
412+
* [#17219] The OTHER half of the same hide pass: what an author is told when a
413+
* hook reaches THROUGH a key #16344 withheld.
414+
*
415+
* ⛔ The refusal itself is not under test here and is not moved: a body's
416+
* default `onError: abort` refuses the caller's whole write, and what that
417+
* replaced is a write that succeeded while persisting a value derived from the
418+
* caller's forgery. Every case below re-asserts that the row is untouched, so a
419+
* future repair of the DIAGNOSTIC cannot quietly restore the old write.
420+
*
421+
* Measured on `origin/main` `501959b72a` before this fix, both doors:
422+
*
423+
* direct SandboxError: hook '…' threw: TypeError: cannot set property 'who' of undefined
424+
* REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
425+
*
426+
* The REST reading is the one that decides the shape of the fix: a leading
427+
* `TypeError:` is correctly classified as a crash (#7543) and sanitised, so at
428+
* the door an author actually authors against, the old behaviour said nothing
429+
* at all — not the key, not the reason, not the remedy.
430+
*
431+
* A code hook is the subject rather than a sandboxed body deliberately: the
432+
* dispatch sites and the hide pass are what this card wraps, and they are
433+
* common to both. The sandbox spelling is measured end to end, through a real
434+
* QuickJS, in `runtime`'s `hook-input-writeback-readonly-provenance` suite.
435+
*/
436+
describe('#17219 — a hook that faults reaching THROUGH a withheld readonly key names it', () => {
437+
let engine: ObjectQL;
438+
let storeFor: ReturnType<typeof makeDriver>['storeFor'];
439+
440+
const OBJECT = 'guard_task';
441+
442+
/** Reaches through the read-only `locked_meta`, which the hide pass removed. */
443+
const reachThrough = async (ctx: any) => {
444+
ctx.input.data.locked_meta.who = 'hook';
445+
};
446+
447+
async function boot(
448+
hook: (ctx: any) => Promise<void>,
449+
opts?: { unscopedMulti?: boolean },
450+
) {
451+
engine = new ObjectQL({ logger: {
452+
warn() {}, debug() {}, info() {}, error() {}, trace() {}, fatal() {},
453+
child() { return this as any; },
454+
} as any });
455+
const d = makeDriver();
456+
storeFor = d.storeFor;
457+
engine.registerDriver(d.driver, true);
458+
await engine.init();
459+
engine.registry.registerObject({
460+
name: OBJECT,
461+
fields: {
462+
status: { type: 'text' },
463+
bucket: { type: 'text' },
464+
locked_meta: { type: 'json', readonly: true },
465+
},
466+
} as any);
467+
storeFor(OBJECT).set('t1', {
468+
id: 't1', status: 'open', bucket: 'b1', locked_meta: { seeded: true },
469+
});
470+
engine.registerHook('beforeUpdate', hook, {
471+
object: OBJECT,
472+
priority: 50,
473+
...(opts?.unscopedMulti ? { dispatchUnscopedMultiWrite: true } : {}),
474+
} as any);
475+
}
476+
477+
const row = () => storeFor(OBJECT).get('t1');
478+
479+
/** Every requirement the card places on the message, asserted as one set. */
480+
const expectActionable = (err: any) => {
481+
// ① the withheld KEY is named — the old message named nothing.
482+
expect(err.message).toContain('`locked_meta`');
483+
// ② WITHHELD BY THE PLATFORM, not absent by accident.
484+
expect(err.message).toContain('withheld by the platform, not missing by accident');
485+
// ③ the documented remedy, reachable from the message itself.
486+
expect(err.message).toContain('`ctx.previous.locked_meta`');
487+
// ④ and it reaches the author at the REST door: `declaredHttpStatus` reads
488+
// this, and without it the body is the sanitised 500.
489+
expect(err.status).toBe(400);
490+
// The original fault is carried through, never swallowed.
491+
expect(err.message).toMatch(/cannot set propert|Cannot set propert/);
492+
};
493+
494+
it('BY-ID: the refusal stands and now names the key, the reason and the remedy', async () => {
495+
await boot(reachThrough);
496+
497+
const err = await engine.update(OBJECT, {
498+
id: 't1', status: 'done', locked_meta: { who: 'caller' },
499+
} as any).then(() => null, (e) => e);
500+
501+
expect(err).toBeTruthy();
502+
expectActionable(err);
503+
// ⛔ RULING 1, re-pinned: the write is still refused WHOLE. Neither the
504+
// forged read-only value nor the writable `status` reached the row.
505+
expect(row().locked_meta).toEqual({ seeded: true });
506+
expect(row().status).toBe('open');
507+
});
508+
509+
it('PREDICATE: the per-row dispatch site answers on the same terms', async () => {
510+
await boot(reachThrough);
511+
512+
const err = await engine.update(
513+
OBJECT,
514+
{ status: 'done', locked_meta: { who: 'caller' } } as any,
515+
{ multi: true, where: { bucket: 'b1' } } as any,
516+
).then(() => null, (e) => e);
517+
518+
expect(err).toBeTruthy();
519+
expectActionable(err);
520+
expect(row().locked_meta).toEqual({ seeded: true });
521+
expect(row().status).toBe('open');
522+
});
523+
524+
it('UNSCOPED-MULTI: the third dispatch site inside the hide window answers too', async () => {
525+
await boot(reachThrough, { unscopedMulti: true });
526+
527+
const err = await engine.update(
528+
OBJECT,
529+
{ status: 'done', locked_meta: { who: 'caller' } } as any,
530+
{ multi: true } as any,
531+
).then(() => null, (e) => e);
532+
533+
expect(err).toBeTruthy();
534+
expectActionable(err);
535+
expect(row().locked_meta).toEqual({ seeded: true });
536+
});
537+
538+
it('CONTROL — nothing withheld: an ordinary crash keeps its own raw words', async () => {
539+
// The caller sends NO read-only key, so the hide pass never runs and
540+
// `readonlyHiddenFromHooks` stays unset. The hook still faults (the column
541+
// is simply absent from this payload), and that fault must pass through
542+
// untouched: the diagnostic is tied to the WITHHOLDING, not to any crash
543+
// that happens to occur on an object with a read-only field. Without this
544+
// leg the case above would pass just as well for a wrapper that rewrote
545+
// every hook error it saw.
546+
await boot(reachThrough);
547+
548+
const err = await engine.update(OBJECT, { id: 't1', status: 'done' } as any)
549+
.then(() => null, (e) => e);
550+
551+
expect(err).toBeTruthy();
552+
expect(err.message).not.toContain('withheld by the platform');
553+
expect(err.status).toBeUndefined();
554+
expect(row().status).toBe('open');
555+
});
556+
557+
it('CONTROL — an AUTHORED refusal is never rewritten, even while a key is withheld', async () => {
558+
// The regression this guards is the card's own defect aimed the other way:
559+
// `mapDataError` serves an authored message to the caller verbatim, so
560+
// overwriting it would destroy the author's words to explain a key they
561+
// never asked about.
562+
await boot(async () => { throw new Error('仍有未结清的发票'); });
563+
564+
const err = await engine.update(OBJECT, {
565+
id: 't1', status: 'done', locked_meta: { who: 'caller' },
566+
} as any).then(() => null, (e) => e);
567+
568+
expect(err.message).toBe('仍有未结清的发票');
569+
expect(err.status).toBeUndefined();
570+
expect(row().status).toBe('open');
571+
});
572+
573+
it('CONTROL — a hook that does NOT fault still runs, and the strip still refuses the forgery', async () => {
574+
// The over-narrowing guard: if the wrapper had broken the dispatch, every
575+
// case above would pass for the wrong reason. Here the same withheld key is
576+
// in play, the hook completes, and the write lands MINUS the forgery.
577+
await boot(async (ctx: any) => { ctx.input.data.status = 'hooked'; });
578+
579+
await engine.update(OBJECT, {
580+
id: 't1', status: 'done', locked_meta: { who: 'caller' },
581+
} as any);
582+
583+
expect(row().status).toBe('hooked');
584+
expect(row().locked_meta).toEqual({ seeded: true });
585+
});
586+
});

packages/objectql/src/engine.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, str
214214
// SAME value. Armed and sealed in `update()`; the module owns the argument for
215215
// why neither end may move.
216216
import { recordHookPayloadWrites } from './hook-write-provenance.js';
217+
// [#17219] The hide pass's other half: when a hook faults reaching THROUGH a
218+
// key that pass withheld, this names the key, says the platform withheld it,
219+
// and points at `ctx.previous` — the module owns the measurement and the
220+
// reason the explanation cannot be composed any further downstream.
221+
import { dispatchHooksExplainingWithheldReadonly } from './hook-withheld-readonly-fault.js';
217222
import {
218223
divergingHookPayloadKeys,
219224
MultiUpdateHookKeyDivergenceError,
@@ -11701,7 +11706,13 @@ export class ObjectQL implements IObjectQLEngine {
1170111706
// permanently true here: it states the invariant, and the invariant
1170211707
// outlives this call site.
1170311708
if (priorRecord) hookContext.previous = coerceBooleanFields(updateSchema as any, priorRecord as any) as any;
11704-
await this.triggerHooks('beforeUpdate', hookContext);
11709+
// [#17219] All three `beforeUpdate` dispatch sites inside the hide
11710+
// window share one wrapper, so a hook that faults reaching THROUGH a
11711+
// key this pass withheld names that key instead of surfacing the
11712+
// platform's own contract enforcement as the author's crash. It
11713+
// rethrows the original error untouched on every other path.
11714+
await dispatchHooksExplainingWithheldReadonly(readonlyHiddenFromHooks, 'beforeUpdate',
11715+
() => this.triggerHooks('beforeUpdate', hookContext));
1170511716
// The retired lever, refused. Everything above — `previous`, and
1170611717
// below it the `readonlyWhen` strip and every validation rule — was
1170711718
// computed against the row the ladder chose.
@@ -11772,7 +11783,8 @@ export class ObjectQL implements IObjectQLEngine {
1177211783
// predicate is unscoped.
1177311784
const rawWhere = (hookContext.input.options as { where?: unknown } | undefined)?.where;
1177411785
if (rawWhere === undefined || rawWhere === null) {
11775-
await this.dispatchUnscopedMultiWriteHooks('beforeUpdate', object, hookContext);
11786+
await dispatchHooksExplainingWithheldReadonly(readonlyHiddenFromHooks, 'beforeUpdate',
11787+
() => this.dispatchUnscopedMultiWriteHooks('beforeUpdate', object, hookContext));
1177611788
}
1177711789
const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
1177811790
readPriorRows = async () => {
@@ -11804,7 +11816,8 @@ export class ObjectQL implements IObjectQLEngine {
1180411816
// [D1] Zero matched rows is zero dispatches — a batch that
1180511817
// changed nothing is not a record change.
1180611818
if (perRowBeforeHooks && rows.length > 0) {
11807-
await this.dispatchPerRowBeforeHooks(object, 'beforeUpdate', rows, hookContext);
11819+
await dispatchHooksExplainingWithheldReadonly(readonlyHiddenFromHooks, 'beforeUpdate',
11820+
() => this.dispatchPerRowBeforeHooks(object, 'beforeUpdate', rows, hookContext));
1180811821
}
1180911822
}
1181011823
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#17219] The composer's contract, at the seam rather than through a driver.
5+
*
6+
* The three DECLINE conditions carry as much weight as the accept case, and for
7+
* the reason the card is about: a diagnostic that fires on the wrong error is
8+
* the same defect this fixes, aimed the other way. So each decline is asserted
9+
* with the SAME error the accept case uses wherever the condition permits it,
10+
* which is what makes the assertions about the condition rather than about the
11+
* error.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import {
16+
withheldReadonlyHookFault,
17+
dispatchHooksExplainingWithheldReadonly,
18+
HookWithheldReadonlyFaultError,
19+
} from './hook-withheld-readonly-fault.js';
20+
21+
/** The real shape `quickjs-runner.ts` throws — name `SandboxError`, native prefix kept on `innerMessage`. */
22+
class SandboxErrorLike extends Error {
23+
innerMessage?: string;
24+
constructor(message: string, innerMessage?: string) {
25+
super(message);
26+
this.name = 'SandboxError';
27+
this.innerMessage = innerMessage;
28+
}
29+
}
30+
31+
/** Measured verbatim on `origin/main` `501959b72a` — see the module header. */
32+
const REAL_CRASH = () =>
33+
new SandboxErrorLike(
34+
"hook 'guard_task_body' threw: TypeError: cannot set property 'who' of undefined",
35+
"TypeError: cannot set property 'who' of undefined",
36+
);
37+
38+
const HIDDEN = { locked_meta: { who: 'caller' } };
39+
40+
describe('#17219 withheldReadonlyHookFault', () => {
41+
it('names the key, says the platform withheld it, and points at ctx.previous', () => {
42+
const out = withheldReadonlyHookFault(REAL_CRASH(), HIDDEN, 'beforeUpdate');
43+
expect(out).toBeInstanceOf(HookWithheldReadonlyFaultError);
44+
const msg = out!.message;
45+
// ① the withheld KEY is named …
46+
expect(msg).toContain('`locked_meta`');
47+
// ② … as WITHHELD BY THE PLATFORM, not absent by accident …
48+
expect(msg).toContain('withheld by the platform, not missing by accident');
49+
expect(msg).toContain('`readonly: true`');
50+
// ③ … and the documented remedy is reachable from the message itself.
51+
expect(msg).toContain('`ctx.previous.locked_meta`');
52+
// The original fault is carried, never replaced: an author debugging the
53+
// body still gets the line that actually threw.
54+
expect(msg).toContain("TypeError: cannot set property 'who' of undefined");
55+
expect(out!.withheldKeys).toEqual(['locked_meta']);
56+
expect(out!.cause).toBeDefined();
57+
});
58+
59+
it('declares 400 — the whole envelope change, and what makes the message reachable', () => {
60+
// Measured: without a declared status `mapDataError` answers
61+
// `UNCLASSIFIED_FAULT` (500, sanitised body) and the message above never
62+
// reaches the author at the REST door. `packages/rest`'s
63+
// `declaredHttpStatus` reads exactly this property.
64+
expect(withheldReadonlyHookFault(REAL_CRASH(), HIDDEN, 'beforeUpdate')!.status).toBe(400);
65+
});
66+
67+
it('names every withheld key when the pass hid more than one', () => {
68+
const out = withheldReadonlyHookFault(
69+
REAL_CRASH(), { locked_meta: {}, locked_note: 'CALLER' }, 'beforeUpdate',
70+
);
71+
expect(out!.message).toContain('`locked_meta`, `locked_note`');
72+
expect(out!.message).toContain('`ctx.previous.locked_meta`, `ctx.previous.locked_note`');
73+
expect(out!.withheldKeys).toEqual(['locked_meta', 'locked_note']);
74+
});
75+
76+
it('DECLINES when nothing was withheld — an unrelated crash keeps its own words', () => {
77+
expect(withheldReadonlyHookFault(REAL_CRASH(), undefined, 'beforeUpdate')).toBeUndefined();
78+
expect(withheldReadonlyHookFault(REAL_CRASH(), {}, 'beforeUpdate')).toBeUndefined();
79+
});
80+
81+
it('DECLINES on an AUTHORED refusal, so a business message is never rewritten', () => {
82+
// The one that would be a real regression: `mapDataError` serves this text
83+
// to the caller verbatim at 400, and overwriting it would destroy the
84+
// author's own words while a readonly key happened to be hidden.
85+
const authored = new SandboxErrorLike("hook 'guard' threw: 仍有未结清的发票", '仍有未结清的发票');
86+
expect(withheldReadonlyHookFault(authored, HIDDEN, 'beforeUpdate')).toBeUndefined();
87+
// And the non-sandboxed spelling of the same thing.
88+
expect(withheldReadonlyHookFault(new Error('仍有未结清的发票'), HIDDEN, 'beforeUpdate')).toBeUndefined();
89+
});
90+
91+
it('ACCEPTS a CODE hook crash, which carries the native name in `name` instead', () => {
92+
const out = withheldReadonlyHookFault(
93+
new TypeError("Cannot set properties of undefined (setting 'who')"), HIDDEN, 'beforeUpdate',
94+
);
95+
expect(out).toBeInstanceOf(HookWithheldReadonlyFaultError);
96+
expect(out!.message).toContain('`locked_meta`');
97+
});
98+
99+
it('declines on a non-object throw rather than fabricating a shape', () => {
100+
expect(withheldReadonlyHookFault('boom', HIDDEN, 'beforeUpdate')).toBeUndefined();
101+
});
102+
});
103+
104+
describe('#17219 dispatchHooksExplainingWithheldReadonly', () => {
105+
it('is transparent on success', async () => {
106+
await expect(dispatchHooksExplainingWithheldReadonly(HIDDEN, 'beforeUpdate', async () => 'ok'))
107+
.resolves.toBe('ok');
108+
});
109+
110+
it('rethrows the ORIGINAL error object when the composer declines', async () => {
111+
const authored = new Error('仍有未结清的发票');
112+
await expect(
113+
dispatchHooksExplainingWithheldReadonly(HIDDEN, 'beforeUpdate', async () => { throw authored; }),
114+
).rejects.toBe(authored);
115+
});
116+
117+
it('replaces an anonymous crash with the named refusal', async () => {
118+
await expect(
119+
dispatchHooksExplainingWithheldReadonly(HIDDEN, 'beforeUpdate', async () => { throw REAL_CRASH(); }),
120+
).rejects.toBeInstanceOf(HookWithheldReadonlyFaultError);
121+
});
122+
});

0 commit comments

Comments
 (0)