Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/hook-body-mail-rule-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

docs(spec): a hook's published `body` description says a hook body cannot send mail

Clause-②: no
Metadata-protocol: none

The `body` key of `HookSchema` now states the rule its parse already holds: a `js` hook body granted `email.send` is refused, because a message cannot be taken back if the write that runs the hook rolls back, and mail on a record change goes through an action, a job or a flow. It also says a hook body may add a timeline entry (`activity.append`), which commits or rolls back with the write. The generated hook reference page carries the same sentence. No key, value or check changes; what a hook accepts is unchanged.
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const result = HookSchema.parse(data);
| **object** | `string \| string[]` | ✅ | Target object(s) |
| **events** | `Enum<'beforeFind' \| 'afterFind' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| 'afterUpdate' \| 'beforeDelete' \| 'afterDelete'>[]` | ✅ | Lifecycle events |
| **handler** | `string` | optional | Handler function name (string, post-build) or inline function (pre-build) — DEPRECATED, prefer `body` |
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log' \| …>[]; timeoutMs?: integer; … }` | optional | Hook body — expression (L1) or sandboxed JS (L2) |
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log' \| …>[]; timeoutMs?: integer; … }` | optional | Hook body — expression (L1) or sandboxed JS (L2). A hook body cannot send mail: a js body granted email.send is refused, because a message cannot be taken back if the write that runs the hook rolls back; send mail on a record change from an action, a job or a flow. A hook body may add a timeline entry (activity.append), which commits or rolls back with the write. |
| **priority** | `number` | optional (default: `100`) | Execution priority |
| **async** | `boolean` | optional (default: `false`) | Run specifically as fire-and-forget |
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL); hook runs only when TRUE. e.g. P`record.status == "closed" && record.amount != null && record.amount > 1000` |
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/utils/extract-hook-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,8 +509,10 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | '
// Match `ctx.api.object(...)` directly OR a local alias like
// `const api = ctx.api;` then `api.object(...)`. We accept any
// identifier (or chain) ending in `.object(...)` followed by a known
// read/write method — over-inclusive but safe (false-positive caps
// get rejected at the runtime by the sandbox if not actually granted).
// read/write method — over-inclusive, and for these tokens harmless: an
// over-inferred grant is one the body never exercises, and the sandbox
// checks a grant only at a call. One token is the exception, see the door
// patterns at the end of this list.
{ rx: /\.object\s*\([^)]+\)\s*\.\s*(?:find|findOne|count|aggregate|get|list)\b/, cap: 'api.read' },
{ rx: /\.object\s*\([^)]+\)\s*\.\s*(?:insert|update|upsert|delete|patch|remove|create)\b/, cap: 'api.write' },
{ rx: /ctx\.crypto\.randomUUID\b/, cap: 'crypto.uuid' },
Expand All @@ -536,6 +538,17 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | '
// grants a token the body may not use, which the sandbox simply never checks.
{ rx: /\.\s*title\s*\(\s*[^)\s]/, cap: 'api.read' },
// The two platform doors: a call to the door is what its token grants.
//
// ⛔ An over-inferred `email.send` is NOT harmless on a hook. A hook body may
// not be granted it (ruling 6843314411 on #32004: a message cannot be taken
// back if the write that runs the hook rolls back), and the hook's parse
// refuses a body that carries it, so the build fails on a handler whose
// source merely spells `ctx.email.send` without calling it: a string, or a
// branch that never runs (a comment does not count; the lowered source
// carries none). That failure is the rule working: the remedy is
// to take the spelling out of the hook handler, and mail on a record change
// goes through an action, a job or a flow. An action or a job body is
// granted the token as before.
{ rx: /ctx\.email\.send\b/, cap: 'email.send' },
{ rx: /ctx\.activity\.append\b/, cap: 'activity.append' },
];
Expand Down
14 changes: 13 additions & 1 deletion packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,8 +530,20 @@ export const HookSchema = lazySchema(() => strictObject(
*
* Authoring convenience: `objectstack build` extracts inline TS handlers from
* `*.hook.ts` files, AST-checks them, and emits the result here. See plan §6.
*
* A hook body cannot send mail (ruling 6843314411 on #32004): a message
* cannot be taken back if the write that runs the hook rolls back, so a
* `js` body granted `email.send` is refused at parse
* (`refuseHookBodyMailGrant`). Mail on a record change goes through an
* action, a job or a flow. A hook body may add a timeline entry
* (`activity.append`); the entry commits or rolls back with the write.
*/
body: HookBodySchema.optional().describe('Hook body — expression (L1) or sandboxed JS (L2)'),
body: HookBodySchema.optional().describe(
'Hook body — expression (L1) or sandboxed JS (L2). A hook body cannot send mail: a js body granted ' +
'email.send is refused, because a message cannot be taken back if the write that runs the hook rolls ' +
'back; send mail on a record change from an action, a job or a flow. A hook body may add a timeline ' +
'entry (activity.append), which commits or rolls back with the write.',
),

/**
* Execution Order
Expand Down
Loading