Skip to content

Commit fc92c62

Browse files
fix(app-shell): the Studio hook editor grants a sandboxed-JS body its capabilities (#26936)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8NXMWsw485tJX2E1nPnfkda)_ Fixes #26726 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 钩子编辑器的「函数」区,在沙箱 JS 函数体下新增「能力授权」勾选列表(api.read / api.write / api.transaction / crypto.uuid / log),选项直接读 spec 的 `HookBodyCapability`,勾选即写 `body.capabilities`。 - 为什么改:调用 `ctx.api` 等的钩子必须声明能力,否则运行时抛错;之前 Studio 没有任何入口能写这个列表,只能走 API 修。这是本卡的 ui 半张,服务端 500 的半张另有子卡。 - 风险与回滚:只改编辑器写出的元数据,沙箱的能力校验原样不动;表达式函数体不显示该控件,切到表达式会去掉授权(表达式体本就拒收该键)。有问题直接 revert 本 PR。 - 先看:`HookDefaultInspector.tsx` 里的 `toggleCapability` 与 `setLanguage`,然后 Function 区新增的 `hook-body-capabilities` 块,最后 `HookDefaultInspector.capabilities-26726.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.capabilities-26726.test.tsx`,预期 9 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes Base `c4a4aefc`, then `origin/main` `57820085` merged in at `5e04bdcd` (no conflict; it moved rows into the same `studio` i18n namespace but none of the `engine.inspector.hook.*` rows). - **`HookDefaultInspector.tsx`, Function section.** For a `language: 'js'` body, a "Capabilities" checkbox list, one box per `HookBodyCapability` token read from the spec enum (`HookBodyCapability.options`), each with the `ctx` call it unlocks underneath (`ctx.api.object(…).find / findOne / count / aggregate`, and so on). Ticking writes `body.capabilities` in the spec's token order; unticking the last writes `[]`, the spec default. - **Expression bodies.** No control is drawn, and no edit of an expression body writes the key. Switching a body's language away from `js` drops `capabilities`, `timeoutMs` and `memoryMb` from the written body (the keys of `ScriptBodySchema.shape` that `ExpressionBodySchema.shape` lacks, read off the spec), because `ExpressionBodySchema` is strict and refuses each by name (its L2-only guidance). - **A stored token the spec does not list** (a typo, the retired `crypto.hash`) stays on screen as an extra, ticked, flagged box, and is kept on the next write until the author unticks it, so it never disappears unseen; the save's parse still names it. - **Nothing is inferred from the body's source.** - **"More fields".** No change needed: `body` was already a curated key as a whole (`CURATED_FIELDS`), which is exactly why nothing reached `body.capabilities`. The docblock claimed the js capabilities fell through to that form; it now says what is true. - **`ObjectHooksPanel.tsx`.** Unchanged: it merges each patch shallowly and the control's patch is a whole `body`, so the host needs nothing new. - **i18n.** `engine.inspector.hook.capabilities`, `engine.inspector.hook.capabilitiesHint`, `engine.inspector.hook.capabilityUnknown` in the lazy `studio` namespace, `en` and `zh`. Patch changeset for `@object-ui/i18n`; `@object-ui/app-shell` is `private: true`. ## Why Triage first grade on the card, verbatim: "**p2, two halves:** the Studio hook editor has no `capabilities` control (ui), and a hook refused for a missing capability answers the client 500, not a named refusal (`domain:server`, a child card filed on claim). ⛔ The capability check stays as strict as it is." This PR is the ui half; the server half is the child card #26903 and is not touched here. ## Tests `HookDefaultInspector.capabilities-26726.test.tsx`, ten cases. Every draft is the authored hook fed through `HookSchema.parse`, and every written body is parsed back through `HookSchema`: - one box per spec token, in enum order, read off `HookBodyCapability.options`, with the `ctx` call hint; - picking `api.read` writes `body.capabilities: ['api.read']` and the saved hook parses; - an existing grant renders ticked, and a new grant is written in spec order beside it, `source` untouched; - clearing the last grant writes `[]`, which parses to the same body as no grant; - an expression body shows no control, and its source edit writes no `capabilities`; - switching a granted js body to an expression drops the grant and the saved hook parses; - switching a js body with limits (`timeoutMs`, `memoryMb`) to an expression drops every L2-only key and the saved hook parses; - an unlisted stored token stays visible, flagged and kept, and unticking it removes it; - "More fields", fed a server schema that carries `body.capabilities`, draws no second control for it (positive control: it does draw the uncurated `timeout`); - the label and hint read in the designer locale (zh catalogue preloaded with `await loadStudioCatalogue('zh')`). <details><summary>Runs</summary> - New file: 9/9 passed (lock VERDICT command-exit 0), on `cef362e9` and again on `5e04bdcd`. - Neighbours: every `HookDefaultInspector.*.test.tsx`, `inspectorStrings.hookAction.i18n-10586`, every `ObjectHooksPanel*.test.tsx`, plus `packages/i18n/`: 101 files, 1432 passed / 13 skipped, exit 0, on `cef362e9` and again on `5e04bdcd`. - `pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/i18n run type-check`: exit 0 on both heads (`tsconfig.test.json` compiles the new test). - `eslint` on the two touched app-shell files: 0 errors (one pre-existing `react-refresh/only-export-components` warning on `HookTargetScopeContext`). - `pnpm check:new-line-citations` (ui): 0 new citations. </details> <details><summary>Ablation (one-shot, through scripts/ablation-replace.mjs; each leg restored to the HEAD blob with an empty git diff HEAD)</summary> - Leg A, drop the write (`patchBody({ capabilities: … })` replaced by a no-op): 4 of 9 red, exactly the pick, round-trip, clear and unlisted-token cases. - Leg B, keep the grant across a language switch (the `delete next.capabilities` line removed): 1 of 9 red, the language-switch case. - Leg C (patch round 1, head `c042e7be`), the L2-only delete loop removed: 2 of 10 red, both switch cases; restored. </details> ## Acceptance notes - The options are read from the spec at runtime (`import { HookBodyCapability } from '@objectstack/spec/data'`), not copied. The inspector already imports runtime values from that same entry through `ConditionBuilder`, and it loads behind the deferred designer loader (`pageRouteLoadsNoStudioChunks-12187.test.tsx`), so the read adds nothing to the console's eager closure; `Build & E2E UI` weighs that closure in CI. - Not changed, noted: `body.timeoutMs` and `body.memoryMb` still have no Studio control of their own (the language switch now drops them, patch round 1). Read from the code, not measured; left for a follow-up if wanted. - The sandbox's capability check and the server's refusal mapping are untouched (#26903 carries the server half). ## Verification Patch round 1 at `c042e7be`: the same 34 families, all exit 0 (`--ran` 34/34); `pnpm --dir ui exec vitest run scripts/` timed out two cases under shared-box load on the first pass, then exit 0 alone and on a full re-run. Gates derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `5e04bdcd` (the same 34 families as on `cef362e9`): 34 run, all exit 0, reconciled by `--ran` (34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN). Every family CI runs is read from this PR's check-runs, not re-run locally. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; the repo-wide `pnpm lint` is CI's run, read from `Lint UI` / `Lint & Repo Gates`.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZCsAdVhYHddQY84CE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent daf52bc commit fc92c62

5 files changed

Lines changed: 331 additions & 5 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
fix(app-shell,i18n): the Studio hook editor grants a sandboxed-JS body its capabilities
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A hook body that calls `ctx.api`, `ctx.log` or `ctx.crypto` needs the matching token in `body.capabilities`, or the sandbox throws when the hook runs. The Studio hook editor had no control for that list, so a hook that read data could only be fixed through the metadata API.
11+
12+
- **Studio (Data › Hooks, and the metadata admin's hook editor).** A sandboxed-JS body now shows a "Capabilities" checkbox per `HookBodyCapability` token (`api.read`, `api.write`, `api.transaction`, `crypto.uuid`, `log`), each with the `ctx` call it unlocks. Ticking one writes `body.capabilities`; unticking the last writes `[]`, the spec's default. An expression body shows no control, and switching a body to an expression drops its grant and its `timeoutMs` / `memoryMb` limits, since an expression body refuses those keys. A stored token the spec does not list stays on screen, flagged, until it is unticked. Nothing is inferred from the body's source.
13+
- **`@object-ui/i18n`.** The `studio` namespace gains `engine.inspector.hook.capabilities`, `engine.inspector.hook.capabilitiesHint` and `engine.inspector.hook.capabilityUnknown` in `en` and `zh`.
14+
15+
No metadata key is added or changed, and the sandbox's capability check is as strict as before. What to do: nothing.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26726 — the Hook inspector authors `body.capabilities`.
5+
*
6+
* The sandbox throws on a `ctx` call the body was not granted, and the grant
7+
* list was reachable from no control: `body` is a curated key as a whole, so
8+
* the "More fields" form never drew its members either. A Studio-authored hook
9+
* that read data could only be fixed through the API.
10+
*
11+
* FIXTURE DISCIPLINE (the #3218 method): every draft is the authored hook fed
12+
* through `HookSchema.parse`, and every written body is parsed back through the
13+
* same schema, so a case cannot pass on a shape the spec refuses.
14+
*/
15+
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
18+
import { HookSchema, HookBodyCapability } from '@objectstack/spec/data';
19+
20+
// The object picker fetches on mount; every fixture uses `object: '*'`, so
21+
// stub the shared client and keep that fetch off the network.
22+
const state = vi.hoisted(() => ({
23+
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) },
24+
}));
25+
vi.mock('../useMetadata', () => ({
26+
useMetadataClient: () => state.metadataClient,
27+
}));
28+
29+
import { t } from '../i18n';
30+
import { HookDefaultInspector } from './HookDefaultInspector';
31+
// `engine.inspector.*` rows live in the lazy `studio` namespace (objectstack#25922).
32+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
33+
await loadStudioCatalogue('zh');
34+
35+
afterEach(cleanup);
36+
37+
function hookDraft(body: Record<string, unknown>): Record<string, unknown> {
38+
return HookSchema.parse({
39+
name: 'reads_accounts',
40+
object: '*',
41+
events: ['beforeInsert'],
42+
body,
43+
}) as unknown as Record<string, unknown>;
44+
}
45+
46+
const JS_BODY = { language: 'js', source: 'await ctx.api.object("account").find({});' };
47+
48+
function renderInspector(
49+
draft: Record<string, unknown>,
50+
{ serverSchema, locale = 'en-US' }: { serverSchema?: Record<string, unknown>; locale?: string } = {},
51+
) {
52+
const onPatch = vi.fn();
53+
render(
54+
<HookDefaultInspector
55+
type="hook"
56+
name={String(draft.name)}
57+
draft={draft}
58+
onPatch={onPatch}
59+
readOnly={false}
60+
locale={locale as never}
61+
serverSchema={serverSchema}
62+
/>,
63+
);
64+
return onPatch;
65+
}
66+
67+
/** The body the last patch wrote. */
68+
function writtenBody(onPatch: ReturnType<typeof vi.fn>): Record<string, unknown> {
69+
expect(onPatch).toHaveBeenCalled();
70+
const patch = onPatch.mock.calls.at(-1)![0] as Record<string, unknown>;
71+
return patch.body as Record<string, unknown>;
72+
}
73+
74+
/** The saved hook — the draft with the patch applied — through the spec. */
75+
function savedHook(draft: Record<string, unknown>, body: Record<string, unknown>) {
76+
return HookSchema.parse({ ...draft, body });
77+
}
78+
79+
const box = (name: string) => screen.getByRole('checkbox', { name }) as HTMLInputElement;
80+
81+
describe('HookDefaultInspector — body capabilities (objectstack#26726)', () => {
82+
it('offers one checkbox per spec token on a js body, read from the enum', () => {
83+
renderInspector(hookDraft(JS_BODY));
84+
const control = screen.getByTestId('hook-body-capabilities');
85+
const names = within(control)
86+
.getAllByRole('checkbox')
87+
.map((el) => el.closest('label')!.textContent);
88+
expect(names).toEqual([...HookBodyCapability.options]);
89+
// Each token names the ctx call it unlocks.
90+
expect(within(control).getByText('ctx.api.object(…).find / findOne / count / aggregate')).toBeInTheDocument();
91+
expect(within(control).getByText('ctx.log.debug / info / warn / error')).toBeInTheDocument();
92+
});
93+
94+
it('picking api.read writes body.capabilities ["api.read"], and the saved hook parses', () => {
95+
const draft = hookDraft(JS_BODY);
96+
const onPatch = renderInspector(draft);
97+
98+
fireEvent.click(box('api.read'));
99+
100+
const body = writtenBody(onPatch);
101+
expect(body).toEqual({ ...draft.body as object, capabilities: ['api.read'] });
102+
expect(savedHook(draft, body).body).toMatchObject({ language: 'js', capabilities: ['api.read'] });
103+
});
104+
105+
it('an existing grant round-trips: shown ticked, and a new grant is written in spec order beside it', () => {
106+
const draft = hookDraft({ ...JS_BODY, capabilities: ['log', 'api.read'] });
107+
const onPatch = renderInspector(draft);
108+
109+
expect(box('api.read').checked).toBe(true);
110+
expect(box('log').checked).toBe(true);
111+
expect(box('api.write').checked).toBe(false);
112+
113+
fireEvent.click(box('api.write'));
114+
115+
const body = writtenBody(onPatch);
116+
expect(body.capabilities).toEqual(['api.read', 'api.write', 'log']);
117+
expect(body.source).toBe(JS_BODY.source);
118+
expect(savedHook(draft, body).body).toMatchObject({ capabilities: ['api.read', 'api.write', 'log'] });
119+
});
120+
121+
it('clearing the last grant writes [], the spec default', () => {
122+
const draft = hookDraft({ ...JS_BODY, capabilities: ['api.read'] });
123+
const onPatch = renderInspector(draft);
124+
125+
fireEvent.click(box('api.read'));
126+
127+
const body = writtenBody(onPatch);
128+
expect(body.capabilities).toEqual([]);
129+
expect(savedHook(draft, body).body).toEqual(hookDraft(JS_BODY).body);
130+
});
131+
132+
it('an expression body shows no control and its edits never write the key', () => {
133+
const draft = hookDraft({ language: 'expression', source: 'record.amount >= 0' });
134+
const onPatch = renderInspector(draft);
135+
136+
expect(screen.queryByTestId('hook-body-capabilities')).toBeNull();
137+
expect(screen.queryByRole('checkbox', { name: 'api.read' })).toBeNull();
138+
139+
fireEvent.change(screen.getByTestId('hook-body-source'), { target: { value: 'record.amount > 0' } });
140+
const body = writtenBody(onPatch);
141+
expect(body).not.toHaveProperty('capabilities');
142+
expect(savedHook(draft, body).body).toEqual({ language: 'expression', source: 'record.amount > 0' });
143+
});
144+
145+
it('switching a granted js body to expression drops the grant, so the save still parses', async () => {
146+
const draft = hookDraft({ language: 'js', source: 'true', capabilities: ['api.read'] });
147+
const onPatch = renderInspector(draft);
148+
149+
// The language select is a Radix combobox button; pick through its listbox.
150+
const language = screen.getAllByRole('combobox').find((el) => el.tagName === 'BUTTON')!;
151+
fireEvent.click(language);
152+
fireEvent.click(await screen.findByRole('option', { name: t('engine.inspector.hook.bodyLang.expression', 'en-US') }));
153+
154+
const body = writtenBody(onPatch);
155+
expect(body).toEqual({ language: 'expression', source: 'true' });
156+
expect(() => savedHook(draft, body)).not.toThrow();
157+
});
158+
159+
it('switching a js body with limits to expression drops every L2-only key, so the save still parses', async () => {
160+
const draft = hookDraft({ language: 'js', source: 'true', capabilities: ['log'], timeoutMs: 250, memoryMb: 64 });
161+
const onPatch = renderInspector(draft);
162+
163+
const language = screen.getAllByRole('combobox').find((el) => el.tagName === 'BUTTON')!;
164+
fireEvent.click(language);
165+
fireEvent.click(await screen.findByRole('option', { name: t('engine.inspector.hook.bodyLang.expression', 'en-US') }));
166+
167+
const body = writtenBody(onPatch);
168+
expect(body).toEqual({ language: 'expression', source: 'true' });
169+
expect(savedHook(draft, body).body).toEqual({ language: 'expression', source: 'true' });
170+
});
171+
172+
it('a stored token the spec does not list stays visible, flagged, and is kept on the next write', () => {
173+
// Not parsed: the spec refuses this token, which is why it must not vanish unseen.
174+
const draft = {
175+
name: 'reads_accounts',
176+
object: '*',
177+
events: ['beforeInsert'],
178+
body: { ...JS_BODY, capabilities: ['api.read', 'crypto.hash'] },
179+
};
180+
const onPatch = renderInspector(draft);
181+
182+
const flagged = box(`crypto.hash ${t('engine.inspector.hook.capabilityUnknown', 'en-US')}`);
183+
expect(flagged.checked).toBe(true);
184+
185+
fireEvent.click(box('log'));
186+
expect(writtenBody(onPatch).capabilities).toEqual(['api.read', 'log', 'crypto.hash']);
187+
188+
fireEvent.click(flagged);
189+
expect(writtenBody(onPatch).capabilities).toEqual(['api.read']);
190+
});
191+
192+
it('"More fields" does not draw the key a second time', () => {
193+
const serverSchema = {
194+
type: 'object',
195+
properties: {
196+
body: {
197+
type: 'object',
198+
properties: {
199+
language: { type: 'string', enum: ['js', 'expression'] },
200+
capabilities: { type: 'array', items: { type: 'string', enum: [...HookBodyCapability.options] } },
201+
},
202+
},
203+
timeout: { type: 'number', title: 'Timeout' },
204+
},
205+
};
206+
renderInspector(hookDraft(JS_BODY), { serverSchema });
207+
208+
expect(screen.getAllByRole('checkbox', { name: 'api.read' })).toHaveLength(1);
209+
const moreFields = screen.getByText(t('engine.inspector.moreFields', 'en-US')).closest('.border-t') as HTMLElement;
210+
// Positive control: the form is drawn, with the uncurated key in it.
211+
expect(within(moreFields).getAllByText(/timeout/i).length).toBeGreaterThan(0);
212+
expect(within(moreFields).queryByText(/capabilit/i)).toBeNull();
213+
expect(within(moreFields).queryByRole('checkbox', { name: 'api.read' })).toBeNull();
214+
});
215+
216+
it('reads its words in the designer locale', () => {
217+
renderInspector(hookDraft(JS_BODY), { locale: 'zh-CN' });
218+
const control = screen.getByTestId('hook-body-capabilities');
219+
const zh = t('engine.inspector.hook.capabilities', 'zh-CN');
220+
expect(zh).not.toBe(t('engine.inspector.hook.capabilities', 'en-US'));
221+
expect(within(control).getByText(zh)).toBeInTheDocument();
222+
expect(within(control).getByText(t('engine.inspector.hook.capabilitiesHint', 'zh-CN'))).toBeInTheDocument();
223+
});
224+
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.tsx‎

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,23 @@
1010
* free-text box — the reason this exists)
1111
* 2. Events — the lifecycle events it subscribes to (write + query)
1212
* 3. Function — language (expression / sandboxed JS) + the handler body in a
13-
* DEDICATED code editor (the second reason this exists)
13+
* DEDICATED code editor (the second reason this exists), and
14+
* for a sandboxed-JS body the capabilities it is granted
1415
* 4. Options — priority / async / a CEL run condition
1516
*
16-
* Rare/advanced props (handler ref, js capabilities/timeout/memory, …) fall
17-
* through to a collapsed "More fields" SchemaForm fed the live server schema,
18-
* with the curated keys hidden so nothing is double-edited or lost.
17+
* Rare/advanced props (handler ref, …) fall through to a collapsed "More
18+
* fields" SchemaForm fed the live server schema, with the curated keys hidden
19+
* so nothing is double-edited or lost. `body` is one of those curated keys, as
20+
* a whole, so none of its members reaches that form.
21+
*
22+
* Capabilities (objectstack#26726). The sandbox throws on a `ctx` call the body
23+
* was not granted, and `body.capabilities` was reachable from no control here,
24+
* so a Studio-authored hook that read data could only be fixed through the
25+
* API. The grant list is a checkbox per `HookBodyCapability` token, read from
26+
* the spec enum, shown for a `js` body only. An expression body is strict and
27+
* refuses every L2-only key by name (`capabilities`, `timeoutMs`, `memoryMb`),
28+
* so a switch away from `js` drops them rather than carrying keys the save
29+
* would refuse. Nothing is inferred from the source text.
1930
*
2031
* `object` is spec `string | string[]` (or `'*'` for every object). The picker
2132
* normalises to/from that: `'*'` ⇒ all, one selection ⇒ a string, many ⇒ an
@@ -47,6 +58,7 @@ import {
4758
flagUnknownValue,
4859
rosterFrom,
4960
} from './_shared.js';
61+
import { ExpressionBodySchema, HookBodyCapability, ScriptBodySchema } from '@objectstack/spec/data';
5062
import { useObjectOptions } from '../previews/useObjectOptions.js';
5163
import { ConditionBuilder, RECORD_CONDITION_SUBJECTS } from './ConditionBuilder.js';
5264
import { expressionSource, writeExpressionSource } from './expression-envelope.js';
@@ -77,6 +89,25 @@ const QUERY_EVENTS = [
7789

7890
const ALL_OBJECTS = '*';
7991

92+
/** The grantable tokens, in the spec's order — the order a grant is written in. */
93+
const CAPABILITY_TOKENS: readonly string[] = HookBodyCapability.options;
94+
95+
/** The `ctx` call each token unlocks, shown under its checkbox: the methods
96+
* the sandbox runner gates on that token. Code, so it reads the same in every
97+
* locale; a token with no entry shows none. */
98+
const CAPABILITY_CALLS: Partial<Record<HookBodyCapability, string>> = {
99+
'api.read': 'ctx.api.object(…).find / findOne / count / aggregate',
100+
'api.write': 'ctx.api.object(…).insert / update / delete / updateMany / deleteMany / upsert',
101+
'api.transaction': 'ctx.api.transaction(async () => { … })',
102+
'crypto.uuid': 'ctx.crypto.randomUUID()',
103+
log: 'ctx.log.debug / info / warn / error',
104+
};
105+
106+
/** The keys only a `js` body admits, read off the spec's two body shapes. */
107+
const L2_ONLY_BODY_KEYS: readonly string[] = Object.keys(ScriptBodySchema.shape).filter(
108+
(k) => !(k in ExpressionBodySchema.shape),
109+
);
110+
80111
/**
81112
* objectui#11820 — what the host knows about where the hook is authored: the
82113
* names of the objects of that package, published and draft. Membership is
@@ -209,6 +240,29 @@ export function HookDefaultInspector({
209240

210241
const patchBody = (p: Record<string, unknown>) => onPatch({ body: { ...body, ...p } });
211242

243+
// objectstack#26726 — a stored token the spec does not list (a typo, a
244+
// retired `crypto.hash`) stays on screen, flagged, so it is never dropped
245+
// unseen; the save's parse names it, and unticking it removes it.
246+
const granted: string[] = Array.isArray(body.capabilities)
247+
? body.capabilities.filter((c): c is string => typeof c === 'string')
248+
: [];
249+
const capabilityOptions = [
250+
...CAPABILITY_TOKENS,
251+
...granted.filter((c) => !CAPABILITY_TOKENS.includes(c)),
252+
];
253+
const toggleCapability = (cap: string, on: boolean) => {
254+
const next = new Set(granted);
255+
if (on) next.add(cap);
256+
else next.delete(cap);
257+
// Unticking the last grant writes `[]`, the spec's default.
258+
patchBody({ capabilities: capabilityOptions.filter((c) => next.has(c)) });
259+
};
260+
const setLanguage = (v: string) => {
261+
const next: Record<string, unknown> = { ...body, language: v };
262+
if (v !== 'js') for (const key of L2_ONLY_BODY_KEYS) delete next[key];
263+
onPatch({ body: next });
264+
};
265+
212266
const toggleObject = (name: string, on: boolean) => {
213267
const next = on ? [...new Set([...objectNames, name])] : objectNames.filter((n) => n !== name);
214268
onPatch({ object: writeObjects(false, next) });
@@ -385,7 +439,7 @@ export function HookDefaultInspector({
385439
label={tr('engine.inspector.hook.language')}
386440
value={language}
387441
options={BODY_LANG_OPTS.map((o) => ({ value: o.value, label: tr(o.labelKey) }))}
388-
onCommit={(v) => patchBody({ language: v })}
442+
onCommit={setLanguage}
389443
disabled={readOnly}
390444
/>
391445
<div className="space-y-1">
@@ -406,6 +460,33 @@ export function HookDefaultInspector({
406460
: tr('engine.inspector.hook.bodyHint.js')}
407461
</div>
408462
</div>
463+
{language === 'js' && (
464+
<div className="space-y-1" data-testid="hook-body-capabilities">
465+
<Label className="text-xs text-muted-foreground">{tr('engine.inspector.hook.capabilities')}</Label>
466+
<div className="space-y-1 rounded-md border p-2">
467+
{capabilityOptions.map((cap) => (
468+
<div key={cap} className="space-y-0.5">
469+
<InspectorCheckboxField
470+
label={
471+
CAPABILITY_TOKENS.includes(cap)
472+
? cap
473+
: flagUnknownValue(cap, tr('engine.inspector.hook.capabilityUnknown'), locale)
474+
}
475+
value={granted.includes(cap)}
476+
onCommit={(on) => toggleCapability(cap, on)}
477+
disabled={readOnly}
478+
/>
479+
{CAPABILITY_CALLS[cap as HookBodyCapability] && (
480+
<div className="pl-5 font-mono text-[10px] text-muted-foreground/70">
481+
{CAPABILITY_CALLS[cap as HookBodyCapability]}
482+
</div>
483+
)}
484+
</div>
485+
))}
486+
</div>
487+
<div className="text-[11px] text-muted-foreground/70">{tr('engine.inspector.hook.capabilitiesHint')}</div>
488+
</div>
489+
)}
409490
</div>
410491

411492
{/* 4 ─ Options */}

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,9 @@ const STUDIO_EN: Record<string, string> = {
13531353
'engine.inspector.hook.handlerBody': 'Handler body',
13541354
'engine.inspector.hook.bodyHint.expression': 'A single L1 expression evaluated against the record / ctx.',
13551355
'engine.inspector.hook.bodyHint.js': 'Runs in the sandbox as (ctx) => Promise<void>.',
1356+
'engine.inspector.hook.capabilities': 'Capabilities',
1357+
'engine.inspector.hook.capabilitiesHint': 'The ctx APIs this body may call. A call it is not granted throws when the hook runs.',
1358+
'engine.inspector.hook.capabilityUnknown': '(not a capability the sandbox grants)',
13561359
'engine.inspector.hook.options': 'Options',
13571360
'engine.inspector.hook.priority': 'Priority',
13581361
'engine.inspector.hook.async': 'Run asynchronously (after commit)',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,6 +1217,9 @@ const STUDIO_ZH: Record<string, string> = {
12171217
'engine.inspector.hook.handlerBody': '处理函数主体',
12181218
'engine.inspector.hook.bodyHint.expression': '针对 record / ctx 求值的单个 L1 表达式。',
12191219
'engine.inspector.hook.bodyHint.js': '在沙箱中以 (ctx) => Promise<void> 的形式运行。',
1220+
'engine.inspector.hook.capabilities': '能力授权',
1221+
'engine.inspector.hook.capabilitiesHint': '此函数体可调用的 ctx API。调用未授权的 API 会在钩子运行时抛错。',
1222+
'engine.inspector.hook.capabilityUnknown': '(沙箱不授予此能力)',
12201223
'engine.inspector.hook.options': '选项',
12211224
'engine.inspector.hook.priority': '优先级',
12221225
'engine.inspector.hook.async': '异步运行(提交之后)',

0 commit comments

Comments
 (0)