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.