From af75addba9482c58332769588e31cd0b5d163c75 Mon Sep 17 00:00:00 2001 From: os-dev Date: Thu, 6 Aug 2026 12:36:58 +0000 Subject: [PATCH] =?UTF-8?q?docs(kernel):=20runtime-services=20=E7=9A=84=20?= =?UTF-8?q?hook=20=E7=A4=BA=E4=BE=8B=E6=94=B9=E6=95=99=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E9=80=9A=E9=81=93=20=E2=80=94=E2=80=94=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=20`ctx.services`=20(#5720)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hook 上下文由引擎逐键构造(object/event/input/session/provenance/user/api/ transaction/ql),从来没有 `services` 键;`services.*` 是本页记录的**契约签名面** (见 runtime-services 索引页的 binding note),托管运行时才注入。照抄旧示例的 `beforeUpdate` 因可选链短路成 `undefined`,`if (!ok) throw` 会无条件拒掉每一次 写入,失败方向还伪装成"正常拒绝"。 - examples.mdx 第 1 节:实测 flow `script` 函数按契约是纯函数 (`handlerContract: 'pure'`),运行时只交 input/variables/automation/logger, **没有任何数据句柄**——既无 `services` 也无 `ctx.api`。故改写为真实通道: 声明式 `get_record` 读行、`script` 节点把变量映射进函数 `inputs`、函数返回值 由后续声明式节点落库。整块过 `defineFlow` + `defineStack` 真实解析。 - examples.mdx 第 2 节:改为 hook 的真实数据通道 `ctx.api`,示例语义(写前校验 + 拒绝路径)保留,但换成引擎无法代劳的**业务**规则;共享强制由 plugin-sharing 的 引擎中间件按动词自动执行(update → canEdit,delete → canDelete,拒绝抛 FORBIDDEN),hook 手查是冗余教学,故删除。 - 两块示例入参从 `ctx: any` 改 `(ctx: HookContext)`:`any` 让 `{/* os:check */}` 变成空门(块内每次属性访问都不被检查)。反向验证:把旧函数体按 HookContext 如实标注后,两处 `ctx.services` 均报 TS2339 —— 键确实不存在,而门此前是绿的。 - sharing-service.mdx:裸 `services.sharing` 的 Example 实测教的是 hook 语境 (注释自称 "The hook exposes …"、读 ctx.input/ctx.session),不是 action 面。 改为"由持有该服务的代码调用"(契约类型 ISharingService),并新增一节写明强制 自动执行、hook 不得复查;该块补 os:check 标记,首次真正把本页签名钉在契约上。 门禁:check:skill-examples 206 → 207 块全绿(runtime-services 三块从 "标记了但零覆盖"变为真检查);check:doc-authoring 362 文件干净; check:nul-bytes、check:docs-audit-scope 均绿。 Closes #5720 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE --- .../docs/kernel/runtime-services/examples.mdx | 159 +++++++++++++++--- .../runtime-services/sharing-service.mdx | 45 ++++- 2 files changed, 172 insertions(+), 32 deletions(-) diff --git a/content/docs/kernel/runtime-services/examples.mdx b/content/docs/kernel/runtime-services/examples.mdx index bd4728a20d..8ba05ef416 100644 --- a/content/docs/kernel/runtime-services/examples.mdx +++ b/content/docs/kernel/runtime-services/examples.mdx @@ -5,42 +5,153 @@ description: Practical examples for flow nodes, hooks, and plugin event subscrip # Runtime Service Examples -## 1) Flow custom node: read related records + + +These pages document the `services.*` **contract surface** — the signatures, not a +binding every surface receives (see the [binding note](/docs/kernel/runtime-services)). +The examples below therefore use the channel each runtime surface is really handed: + +| Surface | Data channel | +|:--|:--| +| Data hook (`beforeInsert`, `beforeUpdate`, …) | `ctx.api` — the scoped cross-object API the engine binds per operation (`buildHookApi`, `packages/objectql/src/engine.ts`) | +| Flow `script` function | none — a function is **pure** by contract (`handlerContract: 'pure'`), so its record I/O lives on the flow graph | +| Plugin | the plugin context (`ctx.hook`, the kernel service registry) | + +A hook context is built key by key by the engine and carries **no `services` key**, so +`ctx.services?.sharing?.canEdit(…)` there evaluates to `undefined` — and a guard written on +it (`if (!ok) throw new Error('PERMISSION_DENIED')`) rejects **every** write instead of +checking anything ([#5720](https://github.com/objectstack-ai/objectstack/issues/5720)). + + + +## 1) Flow: read related records, then compute in a pure function + +The read is a declarative `get_record` node that binds its rows to a flow variable; the +`script` node maps that variable into a registered function's `inputs`, and the function +**returns** its result for a later declarative node to persist. A flow function is handed +`input` / `variables` / `automation` / `logger` and **no data engine** — see +[Flows](/docs/automation/flows) for why that purity rule keeps a run's record counts honest. {/* os:check */} ```ts -export async function run(ctx: any) { - const { record: order } = await ctx.services.data.get('sales_order', ctx.input.orderId); - const lines = await ctx.services.data.find('sales_order_line', { - where: { sales_order_id: order.id }, - orderBy: [{ field: 'line_no', order: 'asc' }], - limit: 200, - }); +import { defineFlow, defineStack } from '@objectstack/spec'; + +interface OrderTotalsInput { + lines: Array<{ amount?: number }>; +} +/** Pure: it computes from its mapped `inputs` and returns — no data handle needed. */ +function orderTotals(ctx: { input: OrderTotalsInput }) { + const lines = ctx.input.lines ?? []; return { - order, - lines: lines.records ?? [], + line_count: lines.length, + total: lines.reduce((sum, line) => sum + (line.amount ?? 0), 0), }; } + +export const stack = defineStack({ + functions: { 'sales.orderTotals': orderTotals }, +}); + +export const RollUpOrderTotals = defineFlow({ + name: 'sales_order_roll_up_totals', + label: 'Roll up order line totals', + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Order Update', + config: { objectName: 'sales_order', triggerType: 'record-after-update' }, + }, + { + id: 'read_lines', + type: 'get_record', + label: 'Read the order lines', + config: { + objectName: 'sales_order_line', + filter: { sales_order_id: '{record.id}' }, + fields: ['amount'], + limit: 200, + outputVariable: 'lines', + }, + }, + { + id: 'totals', + type: 'script', + label: 'Sum the lines', + config: { + function: 'sales.orderTotals', + inputs: { lines: '{lines}' }, + outputVariable: 'totals', + }, + }, + { + id: 'apply', + type: 'update_record', + label: 'Write the totals back', + config: { + objectName: 'sales_order', + filter: { id: '{record.id}' }, + fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read_lines' }, + { id: 'e2', source: 'read_lines', target: 'totals' }, + { id: 'e3', source: 'totals', target: 'apply' }, + { id: 'e4', source: 'apply', target: 'end' }, + ], +}); ``` -## 2) Hook: check sharing permission before mutation +## 2) Hook: validate a write against another object + +A `before*` hook reaches other objects through `ctx.api`, bound to the caller's execution +context and transaction, and rejects the write by throwing. + +Record-level **sharing is not a hook's job**: when `@objectstack/plugin-sharing` is +installed its engine middleware gates every by-id write itself — `canEdit` before an +update, `canDelete` before a delete — and throws `FORBIDDEN` on denial, before any hook +could re-ask ([`services.sharing`](/docs/kernel/runtime-services/sharing-service)). What a +hook adds is the **business** rule the engine cannot know. {/* os:check */} ```ts -export async function beforeUpdate(ctx: any) { - const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, { - userId: ctx.session?.userId, - // Read the caller's org under `organizationId` (the `session.tenantId` alias - // was removed in v11, #3290); it feeds the sharing context's `tenantId`. - tenantId: ctx.session?.organizationId, - positions: ctx.session?.positions, - }); - - if (!ok) { - throw new Error('PERMISSION_DENIED'); - } -} +import { defineHook, type HookContext } from '@objectstack/spec/data'; + +/** + * The one call this hook makes on `ctx.api`. The contract declares + * `HookContext.api` opaque (`api: unknown`) because the object the engine binds is + * ObjectQL's `ScopedContext`, so a typed handler names the slice it uses. + */ +type CrossObjectApi = { + object(name: string): { + findOne(query: { where: Record }): Promise<{ credit_limit?: number } | null>; + }; +}; + +export const ContractWithinCreditLimit = defineHook({ + name: 'contract_within_credit_limit', + object: 'contract', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx: HookContext) => { + const accountId = ctx.input.account_id; + if (typeof accountId !== 'string') return; + + const api = ctx.api as CrossObjectApi; + const account = await api.object('crm_account').findOne({ where: { id: accountId } }); + + const limit = account?.credit_limit ?? 0; + const amount = Number(ctx.input.amount ?? 0); + if (limit > 0 && amount > limit) { + throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit'); + } + }, +}); ``` ## 3) Plugin: subscribe to kernel lifecycle events diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index b2d07157ed..374696207d 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -56,15 +56,44 @@ mask AND-ed with object CRUD, not a fourth `access_level`. - `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead. - `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, or `controlled_by_parent`). +## Enforcement is automatic — do not re-check it in a hook + +With `@objectstack/plugin-sharing` installed, the gates run **inside the engine**: its +middleware picks the gate by verb — `canEdit` before a by-id update, `canDelete` before a +delete — and throws `FORBIDDEN` before the hook chain could ask anything. A hook that +re-checks adds nothing, and it cannot ask this service at all: a hook context is built key +by key by the engine (`object` / `event` / `input` / `session` / `provenance` / `user` / +`api` / `transaction` / `ql`) and carries **no `services` key**, so +`ctx.services?.sharing?.canEdit(…)` is `undefined` there and `if (!ok) throw …` rejects +every write ([#5720](https://github.com/objectstack-ai/objectstack/issues/5720)). A hook's +own channel is `ctx.api` — use it for *business* rules +([examples](/docs/kernel/runtime-services/examples)). + ## Example +Call `canEdit` only from code that **holds** the service — a plugin that resolved it from +the kernel service registry, or a managed runtime's `services.sharing` binding — for +example to pre-flight an affordance before offering it: + +{/* os:check */} ```ts -const allowed = await services.sharing.canEdit('contract', ctx.input.id, { - userId: ctx.session?.userId, - // The hook exposes the caller's org as `organizationId` (the `session.tenantId` - // alias was removed in v11, #3290); it feeds the sharing context's `tenantId`. - tenantId: ctx.session?.organizationId, - positions: ctx.session?.positions, -}); -if (!allowed) throw new Error('PERMISSION_DENIED'); +import type { ISharingService } from '@objectstack/spec/contracts'; + +export async function mayEditContract( + sharing: ISharingService, + recordId: string, + session: { userId?: string; organizationId?: string; positions?: string[] }, +): Promise { + return sharing.canEdit('contract', recordId, { + userId: session.userId, + // `SharingExecutionContext` names the org `tenantId`; a session exposes the + // same value as `organizationId` (the `session.tenantId` alias was removed in + // v11, #3290). + tenantId: session.organizationId, + positions: session.positions, + }); +} ``` + +`canEdit` returns `false` rather than throwing, so a caller decides what a denial means — +hiding a button, or raising its own error.