From 73767428d53e62647dae5cd383ab78e5f437a750 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:38:11 +0000 Subject: [PATCH 1/2] fix(plugin-audit): restore the #4630 unscoped multi-delete refusal on sys_comment through the wired engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-row dispatch (#5038/#5574) binds input.id on every beforeDelete dispatch of a predicate delete, so sys_comment's declared #4630 refusal of a predicate-less `multi: true` delete always took resolveTargetRows' by-id branch — and a zero-match predicate dispatched nothing at all. The refusal could not fire through ObjectQL.delete on exactly the shape it refuses; an unscoped multi-delete silently wiped every comment the caller was entitled to. The sys_comment access-hook registration now declares `dispatchUnscopedMultiDelete` — the engine mechanism landed by #9719 — so the whole-operation context reaches the handler once, before any row is resolved, zero-match included. Same declaration sys_attachment carries. Tests: the unscoped block that pinned the refusal by DIRECT handler call — green on both verbs while the wired engine refused neither — is re-pointed at a real ObjectQL engine. The delete limbs are restored and asserted through the wire (rows survive, per-row refusal stays distinguishable, scoped controls unaffected). The UPDATE limb has no engine mechanism (the flag is beforeDelete-only by design) and is pinned as MEASURED: refused only when it happens to sweep a row the caller may not touch, resolving otherwise, zero-match included. Split out as #9974. Fixes #9798 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../comment-unscoped-multi-delete-refusal.md | 21 ++ .../src/comment-access-hooks.test.ts | 349 +++++++++++++++++- .../plugin-audit/src/comment-access-hooks.ts | 52 ++- 3 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 .changeset/comment-unscoped-multi-delete-refusal.md diff --git a/.changeset/comment-unscoped-multi-delete-refusal.md b/.changeset/comment-unscoped-multi-delete-refusal.md new file mode 100644 index 0000000000..f679b7d08c --- /dev/null +++ b/.changeset/comment-unscoped-multi-delete-refusal.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-audit': patch +--- + +Restore the #4630 unscoped multi-delete refusal on `sys_comment` through the wired engine. + +`ObjectQL.delete('sys_comment', { multi: true })` with no `where` at all silently deleted +every comment the caller was entitled to, instead of being refused outright as declared. +The per-row dispatch contract (#5038/#5574) binds `input.id` on every `beforeDelete` +dispatch of a predicate delete, so the guard always took its by-id branch — and a +zero-match predicate dispatched nothing at all, so the guard never ran. + +The `sys_comment` access-hook registration now declares `dispatchUnscopedMultiDelete` +(the engine mechanism added in #9719), so the whole-operation context reaches the handler +once, before any row is resolved, zero-match included. An unscoped `multi: true` delete of +comments is now refused with `RECORD_NOT_ACCESSIBLE` / 403. Scoped deletes are unaffected: +by id, by a real `where`, and the match-all `where: {}` all behave exactly as before — +only an absent or `null` predicate is refused. + +The unscoped multi-**update** half of the same guard is unchanged and still not reachable +through the wire; it is tracked as its own decision card (#9974). diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts index 05c78fa3a7..9434452e38 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; import { installCommentAccessHooks, parseCommentThreadId, @@ -260,15 +261,13 @@ describe('comment access — beforeDelete (author or parent editor)', () => { ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE' }); }); - it('refuses an UNSCOPED multi-write (no id, no where) instead of reading it as "nothing to authorize"', async () => { - const { beforeDelete, beforeUpdate } = install({ comments: [row], sharing: { canEdit: async () => true } }); - await expect( - beforeDelete(writeCtx('beforeDelete', { options: { multi: true } }, { userId: 'me' })), - ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); - await expect( - beforeUpdate(writeCtx('beforeUpdate', { data: { body: 'x' }, options: { multi: true } }, { userId: 'me' })), - ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); - }); + // [#9798] The UNSCOPED multi-write block that used to sit here called the + // handlers DIRECTLY with a whole-operation context of this file's own + // construction — a shape the engine's per-row dispatch (#5038/#5574) never + // produced on either verb, so it stayed green for a behaviour the wired + // engine did the opposite of. It is re-pointed at the REAL engine below — + // see "#4630 through the wired engine", which also PINS the update half's + // still-unreachable state rather than asserting it away. it('a dangling-thread comment is modifiable only by its author', async () => { const orphan = { id: 'c9', thread_id: 'crm_opportunity:', author_id: 'rep1', body: 'orphan' }; @@ -532,3 +531,335 @@ describe('#7141 — caller envelope forwarded to the sharing gate', () => { expect((canEdit.mock.calls[0]![2] as any).tenantId).toBeUndefined(); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// #4630's unscoped multi-write refusals through the WIRED engine (#9798) +// +// A real `ObjectQL` + in-memory driver + this module's installer — the exact +// path `ql.delete('sys_comment', …)` / `ql.update('sys_comment', …)` take in +// production. The block these replace called the handlers directly with a +// whole-operation context this file built itself: a shape the per-row dispatch +// (#5038/#5574) never produces, so it was green on both verbs while the wired +// engine refused neither. +// +// The two verbs land in DIFFERENT states here, and that asymmetry is the point: +// - DELETE is restored — the registration declares `dispatchUnscopedMultiDelete`, +// so the whole-operation dispatch delivers the shape before any row resolves. +// - UPDATE has no such dispatch (the engine refuses the flag on that event by +// design), so its declared refusal is still unreachable. That limb is PINNED +// as the measured gap rather than asserted away — see the note on it. +// +// Every refusal asserts the rows SURVIVED: the defect being replaced was a +// green suite over a table the engine was willing to wipe. +// +// NOTE `@objectstack/objectql` is deliberately un-aliased in this package's +// vitest config (`KNOWN_UNALIASED_TEST_IMPORTS`), so these pins run against +// objectql's BUILT dist — rebuild `@objectstack/objectql` before trusting a +// verdict from this block. +// ───────────────────────────────────────────────────────────────────────── + +const COMMENT_FIELDS = { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + thread_id: { name: 'thread_id', label: 'Thread', type: 'text' as const }, + author_id: { name: 'author_id', label: 'Author', type: 'text' as const }, + body: { name: 'body', label: 'Body', type: 'text' as const }, +}; +const sysCommentObject = { name: 'sys_comment', label: 'Comment', fields: COMMENT_FIELDS }; +const crmOpportunityObject = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + }, +}; + +/** In-memory driver whose WHERE matcher REFUSES combinators/operator values by + * throwing — the conforming shape `check-where-matcher-conformance.mjs` asks of + * a double (a silently wrong answer is the defect class, not incompleteness). */ +function makeWiredDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: Record, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) throw new Error(`wired stub driver: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object') throw new Error(`wired stub driver: unsupported operator value on ${k}`); + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const d: any = { + name: 'memory', version: '0.0.0', supports: {}, stores, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, async syncSchema() {}, + async find(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Record) { + const id = String(data.id); + const row = { ...data, id }; + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Record) { + const row = storeFor(o).get(String(id)); + if (!row) return null; + const next = { ...row, ...data, id: String(id) }; + storeFor(o).set(String(id), next); + return next; + }, + async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, + async count(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; + }, + async deleteMany(o: string, ast: any) { + const doomed = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + for (const r of doomed) storeFor(o).delete(String(r.id)); + return doomed.length; + }, + async updateMany(o: string, ast: any, data: Record) { + const hit = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + for (const r of hit) storeFor(o).set(String(r.id), { ...r, ...data, id: String(r.id) }); + return hit.length; + }, + }; + return d; +} + +async function bootWired(opts: { + comments?: Array>; + opportunities?: Array>; + sharing?: CommentSharingLike | null; +} = {}) { + const ql = new ObjectQL(); + const driver = makeWiredDriver(); + ql.registerDriver(driver, true); + await ql.init(); + ql.registry.registerObject(sysCommentObject as any, 'app:test'); + ql.registry.registerObject(crmOpportunityObject as any, 'app:test'); + // `engine as any` mirrors the production wiring in audit-plugin.ts. + installCommentAccessHooks(ql as any, () => opts.sharing ?? null, silentLogger()); + if (!driver.stores.get('sys_comment')) driver.stores.set('sys_comment', new Map()); + for (const r of opts.comments ?? []) driver.stores.get('sys_comment')!.set(String(r.id), { ...r }); + for (const r of opts.opportunities ?? []) { + if (!driver.stores.get('crm_opportunity')) driver.stores.set('crm_opportunity', new Map()); + driver.stores.get('crm_opportunity')!.set(String(r.id), { ...r }); + } + const remaining = () => driver.stores.get('sys_comment')?.size ?? 0; + const bodies = () => + Array.from(driver.stores.get('sys_comment')?.values() ?? []).map((r) => r.body); + return { ql, driver, remaining, bodies }; +} + +const wiredComment = (id: string, authorId: string, threadId = 'crm_opportunity:opp1') => ({ + id, thread_id: threadId, author_id: authorId, body: `body of ${id}`, +}); + +/** ADR-0112 envelope of the #4630 refusal. The first sentence IS the declared + * contract (the issue's quoted wording), so it is asserted alongside the code + * and status rather than instead of them. */ +const UNSCOPED_REFUSAL = expect.objectContaining({ + code: 'RECORD_NOT_ACCESSIBLE', + status: 403, + message: expect.stringContaining('Refusing an unscoped multi-delete of comments'), +}); + +describe('unscoped multi-DELETE (no id, no where) — #4630 through the wired engine (#9798)', () => { + it('refuses `{ multi: true }` even when the caller AUTHORED every matched row — and the rows survive', async () => { + // The measured gap verbatim: before the fix this call resolved and wiped + // both rows — the per-row author shortcut licensed each one and no dispatch + // ever carried the unscoped shape. + const { ql, remaining } = await bootWired({ + comments: [wiredComment('c1', 'me'), wiredComment('c2', 'me', 'crm_opportunity:opp2')], + }); + await expect( + ql.delete('sys_comment', { multi: true, context: { userId: 'me' } } as any), + ).rejects.toEqual(UNSCOPED_REFUSAL); + expect(remaining()).toBe(2); + }); + + it('refuses an explicitly null `where` the same way', async () => { + const { ql, remaining } = await bootWired({ comments: [wiredComment('c1', 'me')] }); + await expect( + ql.delete('sys_comment', { multi: true, where: null, context: { userId: 'me' } } as any), + ).rejects.toEqual(UNSCOPED_REFUSAL); + expect(remaining()).toBe(1); + }); + + it('refuses on an EMPTY table — "nothing was ever queried" is not "nothing to authorize"', async () => { + // The zero-match limb: the per-row dispatch is gated on matched rows, so a + // handler-only fix can never fire here — a caller probing against an empty + // table would see success and ship the unscoped delete. + const { ql } = await bootWired({ comments: [] }); + await expect( + ql.delete('sys_comment', { multi: true, context: { userId: 'me' } } as any), + ).rejects.toEqual(UNSCOPED_REFUSAL); + }); + + it('positive control: an empty table is not refused per se — a scoped `where: {}` delete of it resolves', async () => { + // Proves the empty-table refusal above measures the SHAPE, not emptiness. + const { ql } = await bootWired({ comments: [] }); + await expect( + ql.delete('sys_comment', { multi: true, where: {}, context: { userId: 'me' } } as any), + ).resolves.toBeDefined(); + }); + + it('the per-row gate is a DIFFERENT refusal and still fires through the wire', async () => { + // A scoped delete matching a row the caller may not touch answers with the + // PER-ROW message, not the unscoped one — the two limbs stay distinguishable. + const { ql, remaining } = await bootWired({ + comments: [wiredComment('c1', 'me'), wiredComment('c2', 'someone-else')], + sharing: { canEdit: async () => false }, + }); + await expect( + ql.delete('sys_comment', { + multi: true, + where: { thread_id: 'crm_opportunity:opp1' }, + context: { userId: 'me' }, + } as any), + ).rejects.toEqual( + expect.objectContaining({ + code: 'RECORD_NOT_ACCESSIBLE', + status: 403, + message: expect.stringContaining('Cannot delete comment'), + }), + ); + expect(remaining()).toBe(2); + }); + + it('scoped controls still pass: by id, by a real `where`, and by the match-all `where: {}`', async () => { + // Over-firing here would break every legitimate comment delete. + const byId = await bootWired({ comments: [wiredComment('c1', 'me')] }); + await expect( + byId.ql.delete('sys_comment', { where: { id: 'c1' }, context: { userId: 'me' } } as any), + ).resolves.toBeDefined(); + expect(byId.remaining()).toBe(0); + + const byWhere = await bootWired({ + comments: [wiredComment('c1', 'me'), wiredComment('c2', 'me', 'crm_opportunity:opp2')], + }); + await expect( + byWhere.ql.delete('sys_comment', { + multi: true, + where: { author_id: 'me' }, + context: { userId: 'me' }, + } as any), + ).resolves.toBeDefined(); + expect(byWhere.remaining()).toBe(0); + + // `where: {}` is a REAL match-all query (the declared semantics): every + // matched row is authorized per row, and an entitled caller may empty the + // table with it — the refusal is about an ABSENT predicate only. + const matchAll = await bootWired({ comments: [wiredComment('c1', 'me')] }); + await expect( + matchAll.ql.delete('sys_comment', { multi: true, where: {}, context: { userId: 'me' } } as any), + ).resolves.toBeDefined(); + expect(matchAll.remaining()).toBe(0); + }); + + it('system context still bypasses the refusal — engine self-writes and seeds are not the caller', async () => { + const { ql, remaining } = await bootWired({ comments: [wiredComment('c1', 'me')] }); + await expect( + ql.delete('sys_comment', { multi: true, context: { userId: 'x', isSystem: true } } as any), + ).resolves.toBeDefined(); + expect(remaining()).toBe(0); + }); +}); + +describe('unscoped multi-UPDATE (no id, no where) — the still-unreachable half (#9798)', () => { + // ⚠️ THESE PINS DOCUMENT A LIVE FAIL-OPEN, NOT APPROVED BEHAVIOUR. ⚠️ + // + // `resolveTargetRows` declares the same refusal for `update`, and the block + // these replace asserted it by direct handler call — green while the wired + // engine did the opposite. The refusal cannot be restored the way DELETE's + // was: `dispatchUnscopedMultiDelete` is valid on `beforeDelete` only, and + // the engine refuses it elsewhere BY DESIGN, because extending the + // whole-operation dispatch to `beforeUpdate`'s predicate path is a + // product-behaviour decision rather than drift (#9719's + // `assertValidUnscopedMultiDeleteFlag`). + // + // So the gap is pinned as MEASURED, deliberately: a suite that simply + // dropped the false-green block would leave nothing to notice the fail-open, + // and one that asserted the refusal would be false-green again. When the + // decision lands and the dispatch exists, these pins go RED — that is their + // job, and the fix is to replace them with the refusal assertions, not to + // relax them. Tracked at #9974 (the decision card this half was split into). + it('MEASURED GAP: an unscoped `{ multi: true }` update the caller AUTHORED every row of is not refused — the whole table is rewritten', async () => { + // The delete half's first limb, verb-swapped: the declared refusal is about + // the SHAPE, so it must fire whatever the rows say. It does not — the + // per-row author shortcut licenses each row and no dispatch ever carries + // the unscoped shape. The blast radius is "every row the caller happens to + // be entitled to", which is the issue's measured claim. + const { ql, bodies } = await bootWired({ + comments: [wiredComment('c1', 'me'), wiredComment('c2', 'me', 'crm_opportunity:opp2')], + sharing: { canEdit: async () => false }, + }); + await expect( + ql.update('sys_comment', { body: 'rewritten' }, { multi: true, context: { userId: 'me' } } as any), + ).resolves.toBeDefined(); + expect(bodies()).toEqual(['rewritten', 'rewritten']); + }); + + it('MEASURED GAP: an unscoped `{ multi: true }` update of an EMPTY table is not refused either', async () => { + // The zero-match limb: the per-row dispatch is gated on matched rows, so + // nothing runs at all and the caller sees success — the probe that tells an + // attacker the unscoped shape is accepted. + const { ql } = await bootWired({ comments: [] }); + await expect( + ql.update('sys_comment', { body: 'rewritten' }, { multi: true, context: { userId: 'me' } } as any), + ).resolves.toBeDefined(); + }); + + it('the per-row gate DOES still fire on an unscoped update — it is a partial, row-dependent guard', async () => { + // Why the update half is a smaller hole than delete's was, and why it is + // still a hole: rows the caller may not touch are refused individually + // (with the PER-ROW message, not the unscoped one), so the unscoped update + // is caught only when it happens to sweep a row the caller lacks rights to. + const { ql, bodies } = await bootWired({ + comments: [wiredComment('c1', 'me'), wiredComment('c2', 'someone-else')], + sharing: { canEdit: async () => false }, + }); + await expect( + ql.update('sys_comment', { body: 'rewritten' }, { multi: true, context: { userId: 'me' } } as any), + ).rejects.toEqual( + expect.objectContaining({ + code: 'RECORD_NOT_ACCESSIBLE', + status: 403, + message: expect.stringContaining('Cannot update comment c2'), + }), + ); + expect(bodies()).toEqual(['body of c1', 'body of c2']); + }); + + it('control: the scoped update paths are gated exactly as declared', async () => { + // The gap above is about the UNSCOPED shape only — a real predicate still + // reaches the per-row author-or-parent-editor gate, and refuses. + const { ql, bodies } = await bootWired({ + comments: [wiredComment('c1', 'me'), wiredComment('c2', 'someone-else')], + sharing: { canEdit: async () => false }, + }); + await expect( + ql.update('sys_comment', { body: 'rewritten' }, { + multi: true, + where: { thread_id: 'crm_opportunity:opp1' }, + context: { userId: 'me' }, + } as any), + ).rejects.toEqual( + expect.objectContaining({ + code: 'RECORD_NOT_ACCESSIBLE', + status: 403, + message: expect.stringContaining('Cannot update comment'), + }), + ); + expect(bodies()).toEqual(['body of c1', 'body of c2']); + }); +}); diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.ts index 0c3807f9ef..d992f925f2 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.ts @@ -28,7 +28,15 @@ * public-model parents are editable by design) — the attachment kit's * uploader-or-parent-editor rule. A multi-row write requires EVERY * matched row to pass, and an update that re-points `thread_id` must - * additionally satisfy the insert rule on the NEW thread. + * additionally satisfy the insert rule on the NEW thread. A write + * carrying NEITHER an id NOR a `where` is refused outright rather than + * authorizing the whole table by resolving zero rows — on DELETE that + * refusal reaches this handler through the `dispatchUnscopedMultiDelete` + * whole-operation dispatch the registration declares (#9719/#9798); on + * UPDATE the engine has no such dispatch yet, so the shape check is + * declared but unreachable through the wire — only the per-row gate + * catches an unscoped update, and only by accident (decision card #9974; + * see the note on `resolveTargetRows`). * - {@link installCommentReadVisibility} — the read side: a * `find`/`findOne`/`count`/`aggregate` middleware that intersects the query * with the threads whose parent record the caller can actually read. @@ -66,7 +74,17 @@ export interface CommentAccessEngine { registerHook( event: string, handler: (ctx: any) => void | Promise, - options?: { object?: string; packageId?: string }, + options?: { + object?: string; + packageId?: string; + /** [#9798] Opt-in whole-operation `beforeDelete` dispatch for an UNSCOPED + * predicate delete (`multi: true`, no `where`) — the engine declaration + * added by #9719. Declared here because this file's registration passes + * it; the mechanism, its `beforeDelete`-only validity and its rebind + * refusal are owned by `HookEntry.dispatchUnscopedMultiDelete` in + * `@objectstack/objectql`, not restated. */ + dispatchUnscopedMultiDelete?: boolean; + }, ): void; /** Onion-model data middleware (runs for find/findOne/count/aggregate AND * writes) — the only seam that filters `count()` (→ list `total`) identically @@ -352,6 +370,27 @@ export function installCommentAccessHooks( // No id and no predicate: a bulk write over the WHOLE table. Nothing to // authorize row-by-row, and "nothing to authorize" must never read as // "allowed" (the engine would hand an unscoped AST to deleteMany). + // + // [#9798] Which dispatch can actually deliver this shape differs BY VERB, + // and the difference is load-bearing — the branch reads as verb-neutral + // but is not: + // - `delete`: reachable, and only through the + // `dispatchUnscopedMultiDelete` whole-operation dispatch this module's + // `beforeDelete` registration declares (see below). The per-row + // contract (#5038/#5574) binds `input.id` on every predicate dispatch, + // which routes into the by-id branch above, and a zero-match predicate + // dispatches nothing at all. + // - `update`: NOT reachable through the wired engine today. The engine + // has no whole-operation dispatch on `beforeUpdate`'s predicate path + // and refuses the flag on that event by design, because extending it + // is a product-behaviour decision rather than drift (#9719's + // `assertValidUnscopedMultiDeleteFlag`). Measured: an unscoped + // `multi: true` UPDATE is refused only when it happens to sweep a row + // the caller may not touch — the PER-ROW gate catching it by accident + // — and resolves when the caller is entitled to every row, zero-match + // included. Split out as the decision card #9974; the wired-path pins + // in the test suite measure all three limbs. Do not "fix" it by + // widening this branch: the missing half is a dispatch, not a policy. forbid(`Refusing an unscoped multi-${verb} of comments — scope the write to the rows you mean`); } const rows = await engine.find('sys_comment', { @@ -446,7 +485,14 @@ export function installCommentAccessHooks( if (!rows.length) return; // nothing matched — nothing to authorize await authorizeRows(ctx, rows, 'delete'); }, - { object: 'sys_comment', packageId: PACKAGE_ID }, + // [#9798] `dispatchUnscopedMultiDelete` is what makes the #4630 unscoped + // refusal in `resolveTargetRows` REACHABLE through the wired engine: the + // predicate path dispatches per row with `input.id` bound (so the by-id + // branch shadows the check), and a zero-match predicate dispatches nothing + // at all — the engine's opt-in whole-operation dispatch is the one call + // that arrives with no id and the caller's raw `options`, before any row is + // resolved. Same declaration `sys_attachment` carries (#9719). + { object: 'sys_comment', packageId: PACKAGE_ID, dispatchUnscopedMultiDelete: true }, ); } From 3a4b035c1efb38d718be4418c841d1dfaa71585b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 12:09:09 +0000 Subject: [PATCH 2/2] test(plugin-audit): type the wired harness's row-body reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsc --noEmit read the `bodies()` helper's rows as `unknown` (TS18046) — the package's tsconfig includes its tests, so this was a real red in `pnpm --filter @objectstack/plugin-audit typecheck` while vitest stayed green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../plugins/plugin-audit/src/comment-access-hooks.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts index 9434452e38..7e4fe9547f 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts @@ -655,8 +655,12 @@ async function bootWired(opts: { driver.stores.get('crm_opportunity')!.set(String(r.id), { ...r }); } const remaining = () => driver.stores.get('sys_comment')?.size ?? 0; - const bodies = () => - Array.from(driver.stores.get('sys_comment')?.values() ?? []).map((r) => r.body); + const bodies = (): unknown[] => { + const rows = driver.stores.get('sys_comment') as + | Map> + | undefined; + return Array.from(rows?.values() ?? []).map((r) => r.body); + }; return { ql, driver, remaining, bodies }; }