Skip to content

Commit 8304b08

Browse files
fix(objectql,runtime)!: an app-authored hook handler's permanent delete is refused (#31065)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Clause-②: yes (narrowing) — the in-process `handler` of a hook bound from metadata is app-authored code, and its `ctx.api` now refuses `delete(…, { permanent })` with any value but `false`, under every `runAs`, on every api derived from it; a handler that passed the key past `HookDeleteOptions` with a cast and purged the record is refused instead. Metadata-protocol: none Skills-docs: none — no skill or doc teaches `permanent` on a hook's delete; the refusal is the one sandboxed bodies already answer. ## 维护者速读 - **改了什么**:经 hook binder 绑定、以进程内 `handler` 运行的 hook 属于应用编写的代码;它的 `ctx.api`(及由它派生的 `sudo()`、`withRunAs`、事务上下文)上的 `delete(…, { permanent })` 现在与沙箱 body 一样被拒:`400 VALIDATION_ERROR`、`param: 'permanent'`、同一句话。拒绝逻辑移入 `@objectstack/objectql`,runtime 沙箱改为复用这一份。 - **为什么改**:维护者裁决 A(记录 6691358077):应用编写的代码不能永久删除记录,删除一律进回收站,永久删除只属于持有 `manageRecycleBin` 的主体。#30990 只在沙箱 body 上执行了这条;已弃用的进程内 `handler` 路线此前只有类型层面的约束。 - **风险与回滚**:收窄只影响以类型转换传入 `permanent` 的应用 handler,仓内没有这种用法;平台代码(`engine.delete`、代码注册的 hook、`createContext`、`purge`/`empty`)不变。回滚即 revert 本 PR。 - **先看**:① `packages/objectql/src/recycle-bin/app-authored-delete.ts`(拒绝本体与选项副本);② `packages/objectql/src/engine-scoped-context.ts` 的 `object()`(按 `appAuthored` 包装);③ `packages/runtime/src/sandbox/body-runner.ts`(删掉本地副本,改引共享拒绝)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/recycle-bin/purge.test.ts`,预期 `Tests 39 passed (39)`。 - **需要你做的**:无,席位落地。 ## What changes - **The handler route is refused at run time.** A hook bound through the hook binder is app-authored by the engine's own definition (`APP_AUTHORED_HOOK_API`, `hook-run-as.ts`), and its in-process `handler` receives a real `ScopedContext`. `ScopedContext.object()` on an app-authored api now returns its repository wrapped by `refuseAppAuthoredPermanentDelete`: `delete(options)` whose `options.permanent` is anything but `false` or absent answers `400 VALIDATION_ERROR` with `param: 'permanent'`, `object: NAME`, and the sentence a sandboxed body gets, with the face named `hook handler`: "A hook handler cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin. Remove 'permanent' from the delete of 'NAME'." It is answered before the engine is called, so no middleware, hook or driver call runs. - **Every derived api keeps it.** `appAuthored` is carried by `derive()`, the one constructor of `sudo()`, `withRunAs(...)`, `transaction(cb)`, `beginTransaction()` and the user-grant seeding, so the refusal holds under the triggering caller, `runAs: 'user'` and `runAs: 'system'`. - **One refusal.** The refusal moved from `packages/runtime/src/sandbox/body-runner.ts` to `packages/objectql/src/recycle-bin/app-authored-delete.ts` and is exported from `@objectstack/objectql`; the sandbox's `refuseBodyPermanentDeletes` applies that function, so bodies and handlers share one code and one sentence. The developer message names the handler face beside the three body faces. - **The value judged is the value forwarded.** The options are read once into a shallow copy, and the copy is what the repository's `delete` receives. A sandboxed body's options are plain JSON, for which the copy is the same value. - **Unchanged:** `engine.delete(…, { permanent: true })`, `engine.createContext(...)`, a hook registered in code with `engine.registerHook` (no metadata binding, so not app-authored), `purge` / `empty`, and every ordinary delete. `deleteById(id)` takes no options. `ObjectRepository` has no other delete verb. ## Tests New top-level describe in the recycle-bin harness, `packages/objectql/src/recycle-bin/purge.test.ts` ("an app-authored hook handler's permanent delete (ruling A)"): a real `ObjectQL`, a row-keeping driver, the bin registered as `@objectstack/platform-objects` declares it, and a middleware log of every dispatch. The hook is bound through `bindHooksToEngine` with an inline `handler` and fired by an insert from an organization user. - refused **by id** and **in bulk** under `runAs` `inherit`, `user` and `system` (6): the envelope (`code`, `status`, `param`, `object`), the message's first sentence, no dispatch on the target object, the row still present, the bin empty. The bulk legs assert the sentence because the engine already refuses a permanent delete by filter, with another sentence; - refused on the api `sudo()` derives and on the one `transaction(cb)` hands its callback (2); - a `permanent` that answers differently on a second read is read once, and the delete that runs is the ordinary one (1); - controls: no `permanent` key and `permanent: false`, under `inherit` and `system`, are the ordinary delete: one `delete` dispatch, the row gone, one bin entry (4); - platform: a hook registered in code and `engine.createContext({ isSystem: true })` keep the permanent delete: two `purge` dispatches, no bin entry (1). The runtime's existing body pins (`packages/runtime/src/sandbox/body-runner.test.ts`: job, hook and action bodies refused; `false` and absent keys pass; host purge kept) run unchanged against the shared refusal, which the runtime reaches through objectql's `dist/`. <details><summary>Ablations (each committed first; restore proven by blob hash and an empty `git diff HEAD`)</summary> 1. **The handler refusal**, `packages/objectql/src/engine-scoped-context.ts`: anchor `return this.appAuthored ? refuseAppAuthoredPermanentDelete(name, repo, 'hook handler') : repo;` x1 to x0 (`return repo;`), blob `91de02fe` to `969ca039`. `purge.test.ts`: `Tests 8 failed | 31 passed (39)`, exactly the six refused legs and the two derived-api legs; the controls, the platform leg and the 25 earlier cases stay green. The by-id legs fail because the record is purged; the bulk legs because the engine's own filter-delete refusal answers another sentence. Restored, blob `91de02fe` equals HEAD. 2. **The judged copy**, `packages/objectql/src/recycle-bin/app-authored-delete.ts`: anchor `return value.apply(target, [judged, ...rest]);` x1 to x0 (forward the original `args`), blob `7aa3fa4e` to `4418631c`. `Tests 1 failed | 38 passed (39)`, the read-once leg alone. Restored, blob `7aa3fa4e` equals HEAD. 3. **The shared refusal through `dist/`** (the runtime reaches it there): anchor `throw appAuthoredPermanentDeleteRefusal(objectName, face);` x1 to x0, blob `7aa3fa4e` to `f42cd278`; objectql rebuilt; `ablation-dist-preflight` found the marker in 4 built files. `packages/runtime` `body-runner.test.ts`: `Tests 3 failed | 39 passed (42)`, exactly the three body-refusal cases (job refused, job uncaught, hook and action body). Restored, objectql rebuilt, preflight `--absent` over 14 built files, tree clean. </details> ## Acceptance notes - **Route usage in the repository:** two shipped example hooks bind an in-process `handler` (`examples/app-crm/src/hooks/opportunity.hook.ts`, `examples/app-todo/src/objects/task.hook.ts`), plus test fixtures (`packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts`, `packages/cli/test/fixtures/option-b-collection-zoo.ts`). None of them passes `permanent`, and no production code in the repository passes `permanent: true` to any delete. Retiring the deprecated route would touch those two examples; this PR leaves the route in place and refuses at run time. - **Scope:** the refusal is on the `ctx.api` the route hands its handler and on every api derived from it. - `test:repo` (the vitest `repo` project) is CI's, in `Test Core`; not run locally. ## Verification <details><summary>Local runs</summary> All readings on the head `5f8df60f19` (the merge of `main` at `23cf11a4b`) unless a line names `743fff85e3` (the same diff before that merge, which brought in three commits touching neither package). - `pnpm --filter @objectstack/objectql test` (`743fff85e3`): `Test Files 563 passed (563)`, `Tests 11372 passed (11372)`. - `pnpm --filter @objectstack/runtime test` (`743fff85e3`): `Test Files 553 passed (553)`, `Tests 8869 passed | 19 skipped (8888)`. Lock `VERDICT command-exit 0` for both. - After the merge: `typecheck` of `@objectstack/objectql` and `@objectstack/runtime` (each `tsc --noEmit` plus `check:test-typecheck` OK); `src/recycle-bin/purge.test.ts` `Tests 39 passed (39)`; `src/sandbox/body-runner.test.ts` `Tests 42 passed (42)`; lock `VERDICT command-exit 0`. - Dogfood battery, `pnpm --filter @objectstack/dogfood test` after building its closure: `Test Files 431 passed | 3 skipped (434)`, `Tests 3580 passed | 41 skipped (3621)`, lock `VERDICT command-exit 0`. - `node scripts/pm/dispatch-gates.mjs --commands`: 62 commands, every one exit 0; `--ran` reconciliation: `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED`. Among them `check:adr-0087-registration` (the changeset's `not-required (no-migration-prescription)` disposition accepted), `check:empty-changeset`, `check:changeset-no-major`, `check:engine-double-contract`, `check:doc-authoring`, `check:nul-bytes`, `check:dual-build-cjs-loads` (116 entry points across 71 packages load). - `test:repo` and the full lint farm: CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a1afbb9 commit 8304b08

8 files changed

Lines changed: 274 additions & 61 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(objectql,runtime)!: an app-authored hook handler's permanent delete is refused
7+
8+
Clause-②: yes (narrowing) — the in-process `handler` of a hook bound from metadata is app-authored code, and its `ctx.api` now refuses `delete(…, { permanent })` with any value but `false`, under every `runAs`, on every api derived from it; a handler that passed the key past `HookDeleteOptions` with a cast and purged the record is refused instead.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no metadata value changes meaning; HookDeleteOptions already omits permanent. The refusal is the 400 VALIDATION_ERROR sandboxed bodies already answer, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing on the `ctx.api` an app-authored hook's in-process `handler` holds (`@objectstack/objectql`).
14+
15+
App-authored code cannot delete a record permanently: its deletes enter the recycle bin, and the permanent delete stays with a principal that holds `manageRecycleBin` (ADR-0148 D3). A sandboxed hook, action or job body was already held to this. A hook bound through the hook binder with an in-process `handler` is app-authored too, and its `ctx.api.object(name).delete(…)` with `permanent` set to anything but `false` now answers `400 VALIDATION_ERROR`, `param: 'permanent'`, with the same sentence a body gets, before anything is removed. It holds under the triggering caller, under `runAs: 'user'` and under `runAs: 'system'`, by id and in bulk, and on the apis `sudo()`, `withRunAs(...)` and a transaction derive. The handler's ordinary delete (no `permanent`, or `permanent: false`) is unchanged and enters the bin.
16+
17+
Platform code is unchanged: `engine.delete(…, { permanent: true })`, `engine.createContext(...)`, a hook registered in code with `engine.registerHook`, and `purge` / `empty` keep the permanent delete.
18+
19+
The refusal now lives in `@objectstack/objectql` (`refuseAppAuthoredPermanentDelete`), and the runtime's sandbox applies that same refusal to every body (`@objectstack/runtime`). It reads the delete's options once, into a copy, and the delete receives that copy.

‎packages/objectql/src/engine-scoped-context.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { driverSupportsTransactions } from '@objectstack/spec/data';
3434
import type { IScopedContext } from '@objectstack/spec/contracts';
3535
import { appAutomationElevationOf, markAppAutomationElevation } from '@objectstack/core';
3636
import { UnscopedHookApi } from './hook-run-as.js';
37+
import { refuseAppAuthoredPermanentDelete } from './recycle-bin/app-authored-delete.js';
3738
import type { HookRunAs, HookRunAsRef } from './hook-run-as.js';
3839
import { ObjectRepository } from './engine.js';
3940
import type { ObjectQL, ScopedContext } from './engine.js';
@@ -58,8 +59,13 @@ export interface ScopedContextHost {
5859
/** The bodies of `ScopedContext`'s methods: a stateless facet, called by the class's stubs. */
5960
export class ScopedContextFacet {
6061
/** The body of `ScopedContext#object`; the method's docblock there is its contract. */
61-
static object(this: Pick<ScopedContextHost, 'engine' | 'executionContext'>, name: string): ObjectRepository {
62-
return new ObjectRepository(name, this.executionContext, this.engine as any);
62+
static object(this: Pick<ScopedContextHost, 'appAuthored' | 'engine' | 'executionContext'>, name: string): ObjectRepository {
63+
const repo = new ObjectRepository(name, this.executionContext, this.engine as any);
64+
// [ADR-0148 D3, ruling A recorded in 6691358077] An app-authored hook's
65+
// api (and every api derived from it) refuses a permanent delete: the same
66+
// refusal a sandboxed body's api applies, here for the hook's in-process
67+
// `handler`, whatever its `runAs`.
68+
return this.appAuthored ? refuseAppAuthoredPermanentDelete(name, repo, 'hook handler') : repo;
6369
}
6470

6571
/** The body of `ScopedContext#sudo`; the method's docblock there is its contract. */

‎packages/objectql/src/engine.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10661,7 +10661,7 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
1066110661
/**
1066210662
* [#25653] Set on the api an app-authored hook is handed
1066310663
* ({@link APP_AUTHORED_HOOK_API}) and kept by every api derived from it
10664-
* ({@link derive}); read by {@link sudo}.
10664+
* ({@link derive}); read by {@link sudo} and {@link object}.
1066510665
*/
1066610666
private appAuthored = false;
1066710667

@@ -10672,7 +10672,16 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
1067210672
return child;
1067310673
}
1067410674

10675-
/** Get a repository scoped to this context */
10675+
/**
10676+
* Get a repository scoped to this context.
10677+
*
10678+
* [ADR-0148 D3, ruling A recorded in 6691358077] On an app-authored hook's
10679+
* api ({@link APP_AUTHORED_HOOK_API}) and every api derived from it, the
10680+
* repository's `delete(…, { permanent })` is refused (`400
10681+
* VALIDATION_ERROR`, `param: 'permanent'`): app-authored code cannot purge,
10682+
* so its deletes enter the recycle bin, under any `runAs`. Platform code's
10683+
* api is not app-authored, and keeps the engine's permanent delete.
10684+
*/
1067610685
object(name: string): ObjectRepository {
1067710686
// [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`).
1067810687
return ScopedContextFacet.object.call(scopedContextHost.of(this), name);

‎packages/objectql/src/hook-run-as.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ export async function seedHookUserGrants(api: unknown): Promise<unknown> {
264264
* - its `sudo()`, on it and on every api derived from it (`transaction`,
265265
* `withRunAs`), marks the elevation it makes.
266266
*
267+
* [ADR-0148 D3, ruling A recorded in 6691358077] And every repository it and
268+
* the apis derived from it serve refuses a permanent delete
269+
* (`refuseAppAuthoredPermanentDelete`, `recycle-bin/app-authored-delete.ts`):
270+
* a hook's in-process `handler` is app-authored code, so its deletes enter the
271+
* recycle bin, under the caller and under `runAs: 'system'` alike.
272+
*
267273
* A symbol, and ⛔ not re-exported from the package entry, for the reason
268274
* {@link SEED_USER_GRANTS} gives.
269275
*/

‎packages/objectql/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,10 @@ export { parseLifecycleDuration } from './lifecycle/duration.js';
633633
// [#30237, ADR-0148 D3] What a host hands `ObjectQLPlugin`'s `recycleBinRetention`.
634634
export type { RecycleBinRetentionSettings } from './recycle-bin/retention.js';
635635
export type { RecycleBinRemovalOptions } from './recycle-bin/purge.js';
636+
// [ADR-0148 D3, ruling A recorded in 6691358077] The one refusal of an
637+
// app-authored permanent delete: the engine applies it to an app-authored
638+
// hook's api, and the runtime's sandbox to every body's.
639+
export { refuseAppAuthoredPermanentDelete } from './recycle-bin/app-authored-delete.js';
636640
export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
637641

638642
// [#4551] Read-only referential-integrity audit — the reporting half of the
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0148 D3, ruling A recorded in 6691358077] App-authored code cannot
5+
* delete a record permanently: its deletes always enter the recycle bin, and
6+
* the permanent delete stays with a principal that holds `manageRecycleBin`.
7+
*
8+
* This module is the ONE refusal, held where every face that hands app code a
9+
* scoped API can reach it:
10+
*
11+
* - a sandboxed body (hook, action, job): `@objectstack/runtime`'s
12+
* `buildSandboxApi` wraps every repository the body's API serves;
13+
* - an app-authored hook's in-process `handler` (a hook bound through the
14+
* hook binder, `APP_AUTHORED_HOOK_API` in `hook-run-as.ts`): the engine's
15+
* own `ScopedContext.object()` wraps every repository of an app-authored
16+
* api, and of every api derived from it (`sudo()`, `withRunAs(...)`, a
17+
* transaction's context), so it holds under the caller and under
18+
* `runAs: 'system'` alike.
19+
*
20+
* The engine's `delete(…, { permanent: true })` is unchanged for every other
21+
* caller (Q3 = A): platform code, a hook registered in code with
22+
* `engine.registerHook`, and `engine.createContext(...)`.
23+
*/
24+
25+
/**
26+
* The refusal of an app-authored `delete(…, { permanent })`: `400
27+
* VALIDATION_ERROR`, `param: 'permanent'`. The message names the rule, because
28+
* code that asked for a purge has to learn it got none: dropping the key would
29+
* delete into the bin and answer success, which reads as the purge it asked for.
30+
*
31+
* `face` names what asked (`'hook body'`, `'job body'`, `'hook handler'`, …).
32+
*/
33+
function appAuthoredPermanentDeleteRefusal(objectName: string, face: string): Error {
34+
const err: any = new Error(
35+
`A ${face} cannot delete a record permanently: app-authored code cannot purge; ` +
36+
`its deletes enter the recycle bin. Remove 'permanent' from the delete of '${objectName}'.`,
37+
);
38+
err.developerMessage =
39+
`'permanent' was passed to ctx.api.object('${objectName}').delete() in a ${face}. App-authored code ` +
40+
`(hook, action and job bodies, and the in-process handler of a hook bound from metadata) cannot erase a ` +
41+
`record past the recycle bin, under any execution context, a job's system envelope and a hook's ` +
42+
`runAs: 'system' included; the permanent delete stays with a principal holding manageRecycleBin ` +
43+
`(ADR-0148 D3).`;
44+
err.code = 'VALIDATION_ERROR';
45+
err.status = 400;
46+
err.httpStatus = 400;
47+
err.param = 'permanent';
48+
err.object = objectName;
49+
return err;
50+
}
51+
52+
/**
53+
* One repository as app-authored code holds it: `delete(options)` whose
54+
* `options` carry a `permanent` key with any value but `false` is refused
55+
* before the underlying verb is called, so nothing is removed and no bin entry
56+
* is skipped. `false` and an absent key are the ordinary delete, and go
57+
* through. Every other member is handed through.
58+
*
59+
* The options are read ONCE, into a shallow copy, and that copy is what the
60+
* verb receives: the key judged is the key forwarded. An in-process caller can
61+
* hand an object whose `permanent` answers differently on a second read (a
62+
* getter, a Proxy) or is inherited rather than own; judging one read and
63+
* forwarding the original would let the verb see another value than the one
64+
* judged. A sandboxed body's options arrive as plain JSON, for which the copy
65+
* is the same value.
66+
*/
67+
export function refuseAppAuthoredPermanentDelete<T>(objectName: string, repo: T, face: string): T {
68+
if (repo === null || typeof repo !== 'object') return repo;
69+
return new Proxy(repo as unknown as Record<PropertyKey, unknown>, {
70+
get(target, prop) {
71+
const value = Reflect.get(target, prop, target);
72+
if (typeof value !== 'function') return value;
73+
if (prop !== 'delete') return value.bind(target);
74+
return async (...args: unknown[]) => {
75+
const [options, ...rest] = args;
76+
if (options === null || (typeof options !== 'object' && typeof options !== 'function')) {
77+
return value.apply(target, args);
78+
}
79+
const judged: Record<string, unknown> = { ...(options as Record<string, unknown>) };
80+
if (judged.permanent !== undefined && judged.permanent !== false) {
81+
throw appAuthoredPermanentDeleteRefusal(objectName, face);
82+
}
83+
return value.apply(target, [judged, ...rest]);
84+
};
85+
},
86+
}) as unknown as T;
87+
}

‎packages/objectql/src/recycle-bin/purge.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { describe, it, expect } from 'vitest';
1616
import { RECYCLE_BIN_ENTRY_FIELDS } from '@objectstack/spec/data';
1717
import { ObjectQL } from '../engine.js';
18+
import { bindHooksToEngine } from '../hook-binder.js';
1819

1920
type Row = Record<string, unknown>;
2021

@@ -412,3 +413,130 @@ describe('empty(object)', () => {
412413
expect(dispatches).toEqual([]);
413414
});
414415
});
416+
417+
/**
418+
* [ADR-0148 D3, ruling A recorded in 6691358077] A hook bound from metadata
419+
* (through the hook binder) is app-authored code, and so is its in-process
420+
* `handler`: its `ctx.api` refuses a permanent delete as a sandboxed body's
421+
* does, with the same code and sentence, under the caller and under
422+
* `runAs: 'system'`, by id and in bulk, on every api derived from it. Its
423+
* ordinary delete still enters the bin; platform code keeps the permanent
424+
* delete.
425+
*/
426+
describe("an app-authored hook handler's permanent delete (ruling A)", () => {
427+
const RULE_SENTENCE =
428+
'A hook handler cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin.';
429+
430+
/**
431+
* Bind one hook through the binder, on `account`'s `beforeInsert`, whose
432+
* handler runs `use(ctx.api)` and keeps what it answered or threw; then fire
433+
* it once with an insert by `ORG1_USER`.
434+
*/
435+
async function runHandler(
436+
engine: ObjectQL,
437+
runAs: 'inherit' | 'user' | 'system',
438+
use: (api: any) => Promise<unknown>,
439+
): Promise<{ value?: unknown; error?: any }> {
440+
const seen: { value?: unknown; error?: any } = {};
441+
const bound = bindHooksToEngine(engine, [{
442+
name: 'purge_on_create',
443+
object: 'account',
444+
events: ['beforeInsert'],
445+
runAs,
446+
handler: async (ctx: any) => {
447+
try { seen.value = await use(ctx.api); } catch (e) { seen.error = e; }
448+
},
449+
} as any], { packageId: 'app_under_test' });
450+
expect(bound).toMatchObject({ registered: 1, errors: [] });
451+
await engine.insert('account', { id: 'acc_trigger', name: 'Trigger', organization_id: 'org_1' }, ORG1_USER);
452+
return seen;
453+
}
454+
455+
const lonerDispatches = (dispatches: Dispatch[]) => dispatches.filter((d) => d.object === 'loner');
456+
457+
for (const runAs of ['inherit', 'user', 'system'] as const) {
458+
for (const [shape, options] of [
459+
['by id', { where: { id: 'l1' }, permanent: true }],
460+
['in bulk', { where: { name: 'one' }, multi: true, permanent: true }],
461+
] as const) {
462+
it(`runAs '${runAs}': \`delete(…, { permanent: true })\` ${shape} is refused 400 naming the rule; nothing is removed`, async () => {
463+
const { engine, driver, dispatches } = await makeEngine();
464+
driver.seed('loner', { id: 'l1', name: 'one' });
465+
466+
const { error } = await runHandler(engine, runAs, (api) => api.object('loner').delete(options));
467+
468+
expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent', object: 'loner' });
469+
expect(String(error?.message).startsWith(RULE_SENTENCE)).toBe(true);
470+
expect(lonerDispatches(dispatches)).toEqual([]);
471+
expect(driver.rowsOf('loner').map((r: Row) => r.id)).toEqual(['l1']);
472+
expect(entriesOf(driver)).toHaveLength(0);
473+
});
474+
}
475+
}
476+
477+
for (const [derivation, use] of [
478+
['`sudo()`', (api: any) => api.sudo().object('loner').delete({ where: { id: 'l1' }, permanent: true })],
479+
['`transaction(cb)`', (api: any) => api.transaction((trx: any) => trx.object('loner').delete({ where: { id: 'l1' }, permanent: true }))],
480+
] as const) {
481+
it(`is refused the same way on the api ${derivation} derives`, async () => {
482+
const { engine, driver, dispatches } = await makeEngine();
483+
driver.seed('loner', { id: 'l1', name: 'one' });
484+
485+
const { error } = await runHandler(engine, 'inherit', use);
486+
487+
expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent', object: 'loner' });
488+
expect(String(error?.message).startsWith(RULE_SENTENCE)).toBe(true);
489+
expect(lonerDispatches(dispatches)).toEqual([]);
490+
expect(driver.rowsOf('loner').map((r: Row) => r.id)).toEqual(['l1']);
491+
});
492+
}
493+
494+
it('judges the options it forwards: a `permanent` that answers differently on a second read is read once', async () => {
495+
const { engine, driver, dispatches } = await makeEngine();
496+
driver.seed('loner', { id: 'l1', name: 'one' });
497+
let reads = 0;
498+
const options = { where: { id: 'l1' }, get permanent() { reads += 1; return reads > 1; } };
499+
500+
const { error } = await runHandler(engine, 'system', (api) => api.object('loner').delete(options));
501+
502+
expect(error).toBeUndefined();
503+
expect(reads).toBe(1);
504+
expect(lonerDispatches(dispatches).map((d) => d.operation)).toEqual(['delete']);
505+
expect(entriesOf(driver).map((e) => e.record_id)).toEqual(['l1']);
506+
});
507+
508+
for (const runAs of ['inherit', 'system'] as const) {
509+
for (const [label, options] of [
510+
['no `permanent` key', { where: { id: 'l1' } }],
511+
['`permanent: false`', { where: { id: 'l1' }, permanent: false }],
512+
] as const) {
513+
it(`runAs '${runAs}': the handler's delete with ${label} is the ordinary delete, which enters the bin`, async () => {
514+
const { engine, driver, dispatches } = await makeEngine();
515+
driver.seed('loner', { id: 'l1', name: 'one' });
516+
517+
const { error } = await runHandler(engine, runAs, (api) => api.object('loner').delete(options));
518+
519+
expect(error).toBeUndefined();
520+
expect(lonerDispatches(dispatches).map((d) => d.operation)).toEqual(['delete']);
521+
expect(driver.rowsOf('loner')).toEqual([]);
522+
expect(entriesOf(driver).map((e) => e.record_id)).toEqual(['l1']);
523+
});
524+
}
525+
}
526+
527+
it("platform code keeps the permanent delete: a hook registered in code, and the engine's own context", async () => {
528+
const { engine, driver, dispatches } = await makeEngine();
529+
driver.seed('loner', { id: 'l1', name: 'one' });
530+
driver.seed('loner', { id: 'l2', name: 'two' });
531+
engine.registerHook('beforeInsert', async (ctx: any) => {
532+
await ctx.api.object('loner').delete({ where: { id: 'l1' }, permanent: true });
533+
}, { object: 'account' });
534+
535+
await engine.insert('account', { id: 'acc_trigger', name: 'Trigger', organization_id: 'org_1' }, ORG1_USER);
536+
await engine.createContext({ isSystem: true }).object('loner').delete({ where: { id: 'l2' }, permanent: true });
537+
538+
expect(lonerDispatches(dispatches).map((d) => d.operation)).toEqual(['purge', 'purge']);
539+
expect(driver.rowsOf('loner')).toEqual([]);
540+
expect(entriesOf(driver)).toHaveLength(0);
541+
});
542+
});

0 commit comments

Comments
 (0)