Skip to content

Commit ab9fb5c

Browse files
os-zhuangclaude
andauthored
fix(spec,objectql)!: an empty hook target is refused, not widened to the wildcard (#4281)
HookSchema.object had no emptiness constraint, so '', [] and [''] all parsed. normalizeObjects then mapped the first two to ['*'] — the engine's match-everything sentinel — so a hook whose target was left blank registered on EVERY object, on every event it listed, silently. That is #4001 pointed the wrong way: the usual silent strip narrows what was written, this one widened blank intent into the broadest possible blast radius. [''] failed the other way, registering on an object name nothing matches (ADR-0078 silently-inert). Both are refused now, in two places: HookSchema gains a refinement whose error names the spellings that work, and the binder — which accepts unparsed Hook input, so the schema alone would not cover it — skips and records an untargetable hook instead of widening it. A wildcard hook stays legitimate; it has to be spelled '*' so a reviewer sees it. Also fixes bindHooksToEngine's strict option: documented as fail-fast, it never threw, because the per-hook try/catch swallowed the throw its own strict branch raised — the failure was recorded twice and binding carried on. Proven by probe before changing it. Docs: hooks.mdx gains a Targeting objects section covering the three forms, the refusal, and the review bar a wildcard earns. First-party scan: zero assets use an empty target; the wildcards in plugin.ts / permission sets are code-registered and unaffected. Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 64f8cbe commit ab9fb5c

6 files changed

Lines changed: 272 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
A hook with an empty `object` target is refused instead of silently widened to the wildcard.
7+
8+
`HookSchema.object` had no emptiness constraint, so `''`, `[]` and `['']` all parsed. The binder's `normalizeObjects` then mapped the first two to `['*']` — the engine's match-everything sentinel — so a hook whose target was left blank registered on **every** object in the tenant, on every event it listed, with no diagnostic anywhere. `['']` failed the other way, registering on an object name nothing matches: a hook that could never fire (ADR-0078). Both shapes are now refused, at parse time and again in the binder (which accepts unparsed input, so the guard has to hold in both places). The error names the two spellings that work and the wildcard the blank silently became. A wildcard hook stays legitimate — it just has to be spelled `'*'`, so it is a choice visible in a diff.
9+
10+
Also fixes `bindHooksToEngine`'s `strict` option, which is documented as "fail fast on misconfiguration" but never threw: the per-hook `try`/`catch` swallowed the throw its own strict branch raised, recording the failure twice and carrying on. Under `strict` a bind failure is now fatal, as advertised.

content/docs/automation/hooks.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,49 @@ Logic that genuinely needs code still doesn't have to be a hook: a flow
4848
keeps the orchestration in checked metadata and the code in one named,
4949
testable unit.
5050

51+
## Targeting objects
52+
53+
`object` takes three forms, and the third one carries a review cost the other
54+
two do not:
55+
56+
```typescript
57+
object: 'account' // one object
58+
object: ['account', 'contact'] // a named set
59+
object: '*' // every object in the tenant
60+
```
61+
62+
<Callout type="warn">
63+
**An empty target is refused, not treated as "no target".** `''`, `[]` and
64+
`['']` used to parse; the binder then widened `''` and `[]` to the wildcard, so
65+
a blank target registered the hook on **every** object, on every event it
66+
listed, with no diagnostic. (`['']` failed the other way — an object name
67+
nothing matches, a hook that could never fire.) All three are now a parse
68+
error naming the two spellings that work. A wildcard hook stays entirely
69+
legitimate; it just has to be **spelled** `'*'`, so it is a choice a reviewer
70+
sees in the diff rather than a default someone fell into ([#4001](https://github.com/objectstack-ai/objectstack/issues/4001)).
71+
</Callout>
72+
73+
### Wildcard hooks earn a higher review bar
74+
75+
`object: '*'` is the right tool for genuinely cross-cutting concerns — audit
76+
trails, provenance stamping, tenant-wide guards. It is also the broadest thing
77+
a hook can be, so review it as such:
78+
79+
- **Blast radius is every object, including ones added later.** A wildcard hook
80+
written today runs against objects that do not exist yet, whose fields it was
81+
never checked against. Prefer a named set when the list is actually known.
82+
- **Cost multiplies by object count and write volume.** A `beforeUpdate`
83+
wildcard hook runs on every write in the tenant; anything non-trivial in it
84+
belongs behind a `condition` (evaluated before the handler) rather than an
85+
early `return` inside the body.
86+
- **Reads and writes both widen.** A wildcard hook holding an L2 body with
87+
`api.write` can write anywhere. Whether the fields it writes exist is
88+
[not statically checked](/docs/automation/hook-bodies#not-statically-checked-the-write-set),
89+
so a wildcard write set is the least verifiable shape in the system.
90+
91+
Where a wildcard is the honest answer, say so in `description` — it is the one
92+
place a reviewer can find out why the broad target was chosen.
93+
5194
## Before Hook
5295

5396
Mutate the incoming record before it is saved. The engine exposes the pending

packages/objectql/src/hook-binder.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,109 @@ describe('bindHooksToEngine', () => {
7777
expect(result.errors[0]?.reason).toMatch(/unknown function/);
7878
});
7979

80+
// #4001: `normalizeObjects` used to widen a blank target to `['*']`, the
81+
// engine's match-everything sentinel — so a hook whose target was empty
82+
// registered on EVERY object, on every event it listed, silently. The binder
83+
// takes unparsed `Hook` input, so this guard has to hold here independently
84+
// of the `HookSchema` refinement.
85+
describe('an empty object target is skipped, never widened to the wildcard', () => {
86+
it.each([
87+
['empty string', ''],
88+
['empty array', [] as string[]],
89+
['array of blanks', ['', ' '] as string[]],
90+
['undefined', undefined],
91+
])('skips a hook targeting %s', async (_label, object) => {
92+
const engine = makeEngine();
93+
const calls: string[] = [];
94+
const hook = {
95+
name: 'blank_target', object, events: ['beforeInsert'], priority: 100,
96+
handler: () => { calls.push('ran'); },
97+
} as unknown as Hook;
98+
99+
const result = bindHooksToEngine(engine, [hook], { packageId: 'p' });
100+
101+
expect(result.registered).toBe(0);
102+
expect(result.skipped).toBe(1);
103+
expect(result.errors[0]?.reason).toMatch(/names no object/);
104+
105+
// The point of the guard: it must not have become a wildcard.
106+
await engine.triggerHooks('beforeInsert', makeCtx({ object: 'anything' }));
107+
expect(calls).toEqual([]);
108+
});
109+
110+
it('drops only the blank members of an otherwise valid list', async () => {
111+
const engine = makeEngine();
112+
const calls: string[] = [];
113+
const hook = {
114+
name: 'mixed_target', object: ['account', ''], events: ['beforeInsert'], priority: 100,
115+
handler: (ctx: HookContext) => { calls.push(ctx.object); },
116+
} as unknown as Hook;
117+
118+
const result = bindHooksToEngine(engine, [hook], { packageId: 'p' });
119+
expect(result.registered).toBe(1);
120+
121+
await engine.triggerHooks('beforeInsert', makeCtx({ object: 'account' }));
122+
await engine.triggerHooks('beforeInsert', makeCtx({ object: 'lead' }));
123+
expect(calls).toEqual(['account']);
124+
});
125+
126+
it('still binds an explicitly spelled wildcard', async () => {
127+
const engine = makeEngine();
128+
const calls: string[] = [];
129+
const hook: Hook = {
130+
name: 'explicit_wildcard', object: '*', events: ['beforeInsert'], priority: 100,
131+
handler: (ctx) => { calls.push(ctx.object); },
132+
};
133+
134+
const result = bindHooksToEngine(engine, [hook], { packageId: 'p' });
135+
expect(result.registered).toBe(1);
136+
137+
await engine.triggerHooks('beforeInsert', makeCtx({ object: 'account' }));
138+
await engine.triggerHooks('beforeInsert', makeCtx({ object: 'lead' }));
139+
expect(calls).toEqual(['account', 'lead']);
140+
});
141+
142+
it('is fatal under strict', () => {
143+
const engine = makeEngine();
144+
const hook = {
145+
name: 'blank_strict', object: [], events: ['beforeInsert'], priority: 100,
146+
handler: () => {},
147+
} as unknown as Hook;
148+
149+
expect(() => bindHooksToEngine(engine, [hook], { packageId: 'p', strict: true }))
150+
.toThrow(/names no object/);
151+
});
152+
});
153+
154+
// `strict` is documented as "fail fast on misconfiguration", but the per-hook
155+
// try/catch swallowed the throw its own strict branch raised — the failure was
156+
// recorded twice (once by the skip path, once by the caught throw) and binding
157+
// carried on. A runtime that opted into fail-fast never got it.
158+
describe('strict really is fatal (the catch no longer swallows its own throw)', () => {
159+
it('throws on an unresolvable handler ref instead of recording it twice', () => {
160+
const engine = makeEngine();
161+
const hook: Hook = {
162+
name: 'unresolvable', object: 'account', events: ['beforeInsert'], priority: 100,
163+
handler: 'no_such_fn',
164+
};
165+
166+
expect(() => bindHooksToEngine(engine, [hook], { strict: true }))
167+
.toThrow(/unknown function 'no_such_fn'/);
168+
});
169+
170+
it('still records-and-continues when strict is off', () => {
171+
const engine = makeEngine();
172+
const hook: Hook = {
173+
name: 'unresolvable', object: 'account', events: ['beforeInsert'], priority: 100,
174+
handler: 'no_such_fn',
175+
};
176+
177+
const result = bindHooksToEngine(engine, [hook], {});
178+
expect(result.skipped).toBe(1);
179+
expect(result.errors).toHaveLength(1);
180+
});
181+
});
182+
80183
it('replaces existing hooks under the same packageId on re-bind', async () => {
81184
const engine = makeEngine();
82185
const calls: string[] = [];

packages/objectql/src/hook-binder.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,25 @@ export function bindHooksToEngine(
166166
});
167167
}
168168

169-
const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics });
170169
const objects = normalizeObjects(hook.object);
170+
if (objects.length === 0) {
171+
result.skipped += 1;
172+
const reason =
173+
'hook target names no object — an empty `object` is refused rather than widened to '
174+
+ "the wildcard '*' (#4001). Name the object(s), or write `object: '*'` if firing on "
175+
+ 'every object is the intent.';
176+
result.errors.push({ hook: hook.name, reason });
177+
if (opts.strict) {
178+
throw new Error(`[hook-binder] strict: cannot bind hook '${hook.name}': ${reason}`);
179+
}
180+
logger.warn('[hook-binder] skipping hook with an empty object target', {
181+
hook: hook.name,
182+
object: hook.object,
183+
});
184+
continue;
185+
}
186+
187+
const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics });
171188
const events = Array.isArray(hook.events) ? hook.events : [];
172189

173190
for (const event of events) {
@@ -185,6 +202,12 @@ export function bindHooksToEngine(
185202
}
186203
}
187204
} catch (err: any) {
205+
// `strict` is documented as "fail fast on misconfiguration", but this
206+
// catch swallowed the throw the strict branches raise — including its
207+
// own — so the option only ever recorded the failure twice and carried
208+
// on. A production runtime that opted into fail-fast never got it.
209+
// Under strict, every bind failure is fatal, as advertised.
210+
if (opts.strict) throw err;
188211
result.errors.push({ hook: hook.name, reason: err?.message ?? String(err) });
189212
logger.error('[hook-binder] failed to bind hook', {
190213
hook: hook.name,
@@ -204,10 +227,23 @@ export function bindHooksToEngine(
204227
return result;
205228
}
206229

230+
/**
231+
* Resolve a hook's declared target to the object names it registers on.
232+
*
233+
* Returns `[]` when the target names nothing. This used to fall back to
234+
* `['*']`, which is the engine's match-everything sentinel — so a hook whose
235+
* target was blank (`''`, `[]`, or a list of blanks) silently registered on
236+
* EVERY object, on every event it listed. Blank intent became the broadest
237+
* possible blast radius with no diagnostic (#4001).
238+
*
239+
* `HookSchema` now refuses those shapes at parse time, but this binder accepts
240+
* unparsed `Hook` input, so the guard has to hold here too. An empty result is
241+
* skipped and recorded by the caller rather than widened: a wildcard hook is
242+
* legitimate, it just has to be spelled `'*'` so a reviewer can see it.
243+
*/
207244
function normalizeObjects(target: Hook['object']): string[] {
208-
if (Array.isArray(target)) return target.length > 0 ? target : ['*'];
209-
if (typeof target === 'string' && target.length > 0) return [target];
210-
return ['*'];
245+
const names = Array.isArray(target) ? target : [target];
246+
return names.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
211247
}
212248

213249
function resolveHandler(

packages/spec/src/data/hook.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,41 @@ describe('HookSchema', () => {
140140

141141
expect(hook.object).toBe('*');
142142
});
143+
144+
// #4001: an empty target used to parse, and the binder widened `''` / `[]`
145+
// to the wildcard `'*'` — so a blank target registered the hook on EVERY
146+
// object. `['']` failed the other way: an object name nothing matches, a
147+
// hook that could never fire. Both are refused; a wildcard must be spelled.
148+
describe('an empty target is refused, not widened to the wildcard', () => {
149+
it.each([
150+
['empty string', ''],
151+
['whitespace-only string', ' '],
152+
['empty array', [] as string[]],
153+
['array of one blank', [''] as string[]],
154+
['array with a blank member', ['account', ''] as string[]],
155+
])('rejects %s', (_label, object) => {
156+
const result = HookSchema.safeParse({
157+
name: 'blank_target',
158+
object,
159+
events: ['beforeInsert'],
160+
});
161+
162+
expect(result.success).toBe(false);
163+
const message = result.error!.issues.map((i) => i.message).join('\n');
164+
// The error has to be fixable, not merely loud (#4001): it names both
165+
// spellings that work and the wildcard that the blank silently became.
166+
expect(message).toMatch(/must name at least one object/);
167+
expect(message).toMatch(/object: '\*'/);
168+
});
169+
170+
it('still accepts a wildcard inside an array', () => {
171+
expect(HookSchema.parse({
172+
name: 'array_wildcard',
173+
object: ['*'],
174+
events: ['afterUpdate'],
175+
}).object).toEqual(['*']);
176+
});
177+
});
143178
});
144179

145180
describe('Event Subscription', () => {

packages/spec/src/data/hook.zod.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,35 @@ import { HookBodySchema } from './hook-body.zod';
3333
* - hook `retryPolicy.backoffMs` vs datasource `retryPolicy.baseDelayMs`
3434
*/
3535

36+
/*
37+
* ── An empty target is not "no target" ──────────────────────────────────────
38+
*
39+
* `object` had no emptiness constraint, so `''`, `[]` and `['']` all parsed.
40+
* The binder (`normalizeObjects` in `packages/objectql/src/hook-binder.ts`)
41+
* then mapped the first two to `['*']` — and `'*'` is the match-everything
42+
* sentinel in the engine's dispatch (`targets.includes('*')`). An author who
43+
* left the target blank therefore got a hook registered on EVERY object in the
44+
* tenant, on every event listed, with no diagnostic anywhere.
45+
*
46+
* That is the #4001 failure mode pointed the wrong way: the usual silent strip
47+
* narrows what was written, this one WIDENS it — blank intent became the
48+
* broadest possible blast radius. `['']` failed the other way, registering on
49+
* an object named `''` that nothing matches — a hook that can never fire
50+
* (ADR-0078 "no silently inert metadata").
51+
*
52+
* Both are refused here, and the binder no longer escalates: a target it cannot
53+
* make sense of is skipped and recorded, never widened. A wildcard hook remains
54+
* entirely legitimate — it just has to be SPELLED `'*'`, so that it is a choice
55+
* a reviewer can see in the diff rather than a default someone fell into.
56+
*/
57+
const hookTargetError =
58+
'A hook `object` target must name at least one object. An empty target is not '
59+
+ '"no target": until #4001 `\'\'` and `[]` were widened to the wildcard `\'*\'`, '
60+
+ 'registering the hook on EVERY object, and `[\'\']` registered it on an object '
61+
+ 'name nothing matches, so it could never fire. Name the object(s) — '
62+
+ "`object: 'account'` or `object: ['account', 'contact']` — or, if firing on "
63+
+ "every object really is the intent, write the wildcard explicitly: `object: '*'`.";
64+
3665
/** Keys {@link HookSchema} declares (drift-guarded by hook.test.ts). */
3766
const HOOK_KEYS = [
3867
'name', 'label', 'object', 'events', 'handler', 'body', 'priority',
@@ -142,8 +171,19 @@ export const HookSchema = lazySchema(() => z.object({
142171
* - Single object: "account"
143172
* - List of objects: ["account", "contact"]
144173
* - Wildcard: "*" (All objects)
174+
*
175+
* Must name at least one object. An empty target (`''`, `[]`, `['']`) is
176+
* refused rather than widened to the wildcard — see the note above
177+
* {@link HOOK_KEYS}.
145178
*/
146-
object: z.union([z.string(), z.array(z.string())]).describe('Target object(s)'),
179+
object: z.union([z.string(), z.array(z.string())])
180+
.refine(
181+
(v) => (Array.isArray(v)
182+
? v.length > 0 && v.every((name) => name.trim().length > 0)
183+
: v.trim().length > 0),
184+
{ error: hookTargetError },
185+
)
186+
.describe('Target object(s)'),
147187

148188
/**
149189
* Events to subscribe to

0 commit comments

Comments
 (0)