diff --git a/.changeset/22566-comment-reaction-record.md b/.changeset/22566-comment-reaction-record.md new file mode 100644 index 00000000000..d5ba5fd69f4 --- /dev/null +++ b/.changeset/22566-comment-reaction-record.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-audit': minor +'@objectstack/spec': minor +--- + +`sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user + +Clause-②: yes (widening) + +- **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write. +- **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody. +- **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row. +- **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`. +- **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row. +- **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it. +- **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package. +- **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and reactions stored in it are not converted into records. diff --git a/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts b/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts index 479453ff921..4ef8ba65ecb 100644 --- a/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts @@ -40,6 +40,7 @@ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; import { SysAuditLog } from '../src/objects/sys-audit-log.object.js'; import { SysActivity } from '../src/objects/sys-activity.object.js'; import { SysComment } from '../src/objects/sys-comment.object.js'; +import { SysCommentReaction } from '../src/objects/sys-comment-reaction.object.js'; import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; @@ -60,7 +61,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; * precision: the annotated type is the function's own return type. */ const config: ObjectStackDefinition = defineStack({ - objects: [SysAuditLog, SysActivity, SysComment] as any, + objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction] as any, translations: [ { en: { objects: enObjects } }, { 'zh-CN': { objects: zhCNObjects } }, diff --git a/packages/plugins/plugin-audit/src/audit-plugin.test.ts b/packages/plugins/plugin-audit/src/audit-plugin.test.ts index 88046b5c4d2..2e17983f17b 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.test.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.test.ts @@ -147,6 +147,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => { sys_audit_log: 'telemetry', sys_activity: 'telemetry', sys_comment: 'sqlite', + sys_comment_reaction: 'sqlite', }); const { ctx, logs, fireReady } = makeCtx(engine); const plugin = new AuditPlugin(); @@ -159,6 +160,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => { expect(placement).toContain('sys_audit_log→telemetry'); expect(placement).toContain('sys_activity→telemetry'); expect(placement).toContain('sys_comment→sqlite'); + expect(placement).toContain('sys_comment_reaction→sqlite'); // …and the split itself is called out, because "absent from the database I // am looking at" is not "never created". @@ -176,6 +178,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => { sys_audit_log: 'sqlite', sys_activity: 'sqlite', sys_comment: 'sqlite', + sys_comment_reaction: 'sqlite', }); const { ctx, logs, fireReady } = makeCtx(engine); const plugin = new AuditPlugin(); @@ -196,6 +199,7 @@ describe('AuditPlugin — provisioning is audible (#4887)', () => { sys_audit_log: undefined, sys_activity: 'sqlite', sys_comment: 'sqlite', + sys_comment_reaction: 'sqlite', }); const { ctx, logs, fireReady } = makeCtx(engine); const plugin = new AuditPlugin(); diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 3b41f97049a..62c66faef04 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -3,7 +3,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveLocalizationContext } from '@objectstack/core'; import type { IDataEngine, II18nService, ISecurityService, ISharingService } from '@objectstack/spec/contracts'; -import { SysAuditLog, SysActivity, SysComment } from './objects/index.js'; +import { SysAuditLog, SysActivity, SysComment, SysCommentReaction } from './objects/index.js'; // `sys_notification` was parked here "until that [ADR-0030] migration lands". // It has landed, so the contribution moved to @objectstack/service-messaging — // the service that writes the row on every `emit()` (#4154). This plugin never @@ -141,7 +141,8 @@ function createHostLocaleReader( /** * AuditPlugin * - * Registers the sys_audit_log / sys_activity / sys_comment system objects + * Registers the sys_audit_log / sys_activity / sys_comment / sys_comment_reaction + * system objects * and installs ObjectQL hook subscribers that automatically write audit * trail + activity stream rows on every data mutation. * @@ -180,7 +181,7 @@ export class AuditPlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysAuditLog, SysActivity, SysComment], + objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction], // ADR-0029 D7 — contribute the Audit Logs entries into the Setup app's // `group_diagnostics` slot. The plugin owns sys_audit_log (K2), so both // doors onto it live and die with this plugin and need no item gate. @@ -349,6 +350,9 @@ export class AuditPlugin implements Plugin { // to `enforceFeedsCapability` above, which gates `enable.feeds`, not // access. The sharing service resolves lazily so plugin order doesn't // matter; without it the edit checks degrade to parent read visibility. + // [#22566] The same two installers gate `sys_comment_reaction`: a + // reaction is readable exactly when its comment is, and created only on a + // comment the caller can read (`comment-access-hooks.ts`, module header). if (typeof (engine as any).registerHook === 'function') { installCommentAccessHooks( engine as any, @@ -388,11 +392,11 @@ export class AuditPlugin implements Plugin { installCommentReadVisibility(engine as any, ctx.logger); } else { ctx.logger.warn( - 'AuditPlugin: engine has no middleware seam — sys_comment READ visibility NOT installed ' + - '(comments on records the caller cannot read would be listable)', + 'AuditPlugin: engine has no middleware seam — sys_comment and sys_comment_reaction READ visibility NOT ' + + 'installed (comments on records the caller cannot read, and their reactions, would be listable)', ); } - ctx.logger.info('AuditPlugin: sys_comment record-level access gates installed'); + ctx.logger.info('AuditPlugin: sys_comment and sys_comment_reaction record-level access gates installed'); } // sys_activity READ visibility — an activity row is readable when the @@ -515,7 +519,8 @@ export class AuditPlugin implements Plugin { // wholesale" and "provisioning ran fine" produced identical logs. Name // the consequence, not just the condition. ctx.logger.warn( - 'AuditPlugin: this engine exposes no syncObjectSchema() — sys_audit_log / sys_activity / sys_comment were NOT ' + + 'AuditPlugin: this engine exposes no syncObjectSchema() — sys_audit_log / sys_activity / sys_comment / ' + + 'sys_comment_reaction were NOT ' + 'provisioned up-front and stay lazy-created on first WRITE. An env that READS one first (the home page ' + 'activity feed queries sys_activity before any mutation) will log "no such table" until something writes to it.', ); @@ -533,7 +538,7 @@ export class AuditPlugin implements Plugin { const placements: string[] = []; const offDefault: string[] = []; - for (const obj of [SysAuditLog, SysActivity, SysComment]) { + for (const obj of [SysAuditLog, SysActivity, SysComment, SysCommentReaction]) { try { await sync.call(engine, obj.name); } catch (err) { diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.ts index 7718798ea3e..0caf131192f 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.ts @@ -80,6 +80,31 @@ * (`enable.feeds`), this one answers "may THIS caller see/touch THIS record's * comments". Both are fail-closed 403s, so their relative order is not * load-bearing. + * + * ## A comment's reactions ride the same two installers + * + * `sys_comment_reaction` (#22566, ruling A amended on #22505) is one emoji + * reaction, the reactor's own record, keyed to its comment by `comment_id`. + * Its access derives from its COMMENT the way a comment's derives from its + * parent record, so the two installers below answer for it too, with the + * same handler registered on both objects: + * + * - read: a reaction is readable exactly when its comment is. The gate asks + * {@link resolveReadableParentIds} about `sys_comment` under the caller's + * context — one caller-scoped read of the comments, which runs the + * comment's own grant, row scope and thread gate. No rule about who may + * read a comment is restated for its reactions. + * - create: a caller reacts only to a comment that same question says they + * can read, and `user_id` is stamped from the session. + * - delete: deliberately NOT here. A reaction is its reactor's own record, so + * the platform's own-record floor (`owner_only_deletes`, plugin-security) + * is the whole rule; a second, gate-local ownership check would be the + * widener-blind second implementation that floor's provenance warns about. + * - update: not offered (`apiMethods`), so nothing to gate. + * + * One handler per seam, not one per object, on purpose: the system and + * context-less early return each installer opens with is read ONCE for both + * objects, so the two can never disagree about who is exempt. */ import { withoutOperationPrivateKeys } from '@objectstack/core'; @@ -177,6 +202,18 @@ const READ_OPS = new Set(['find', 'findOne', 'count', 'aggregate']); * `{ id: '__deny_all__' }` read-filter deny). */ const READ_DENY_ALL = { id: '__comment_thread_denied__' } as const; +/** The object a reaction's access derives from. */ +const COMMENT_OBJECT = 'sys_comment'; + +/** + * A comment's emoji reactions — one row per (comment, emoji, user), the + * reactor's own record (`objects/sys-comment-reaction.object.ts`). + */ +export const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; + +/** The reaction read gate's fail-closed sentinel. */ +const REACTION_READ_DENY_ALL = { id: '__comment_reaction_denied__' } as const; + /** Object machine-name shape (`ObjectSchema.name` in packages/spec). */ const OBJECT_NAME_RE = /^[a-z_][a-z0-9_]*$/; @@ -449,39 +486,78 @@ export function installCommentAccessHooks( return answer.outcome === 'allow' || answer.outcome === 'not_applicable'; }; + /** + * [#22566] Create a reaction: the reactor is the session's user, and the + * comment must be one the caller can read. + * + * `user_id` is stamped, never taken from the client — a reaction recorded + * under someone else's name is the spoofed provenance `author_id` stamping + * exists to stop. A session with no user has nobody to record, so it is + * refused rather than left to whatever the client sent. + * + * "Can read the comment" is asked of {@link resolveReadableParentIds}, the + * evaluator the reaction read gate asks, so the create door never admits a + * reaction the read door would hide. + */ + const authorizeReactionInsert = async (ctx: any, data: Record): Promise => { + const userId: unknown = ctx.session.userId; + if (typeof userId !== 'string' || userId === '') { + forbid('Cannot react: there is no signed-in user to record as the one reacting'); + } + data.user_id = userId; + + const commentId = data.comment_id; + if ((typeof commentId !== 'string' && typeof commentId !== 'number') || String(commentId) === '') { + forbid(`Cannot react: comment_id ${JSON.stringify(commentId ?? null)} does not name a comment`); + } + const readable = await resolveReadableParentIds( + engine, + callerContext(ctx), + new Map([[COMMENT_OBJECT, new Set([String(commentId)])]]), + ); + if (!readable.get(COMMENT_OBJECT)?.has(String(commentId))) { + forbid(`Cannot react to comment ${String(commentId)}: the comment does not exist or you cannot read it`); + } + }; + // ── Create: parent-record READ access + author_id stamping ────────── - engine.registerHook( - 'beforeInsert', - async (ctx: any) => { - if (ctx?.session?.isSystem) return; - if (!ctx?.session) return; // context-less programmatic call (bare kernel) - const data: any = ctx?.input?.data; - if (!data || typeof data !== 'object') return; + // One handler for both objects (see "A comment's reactions" in the module + // header): the system / context-less exemption is decided once. + const onInsert = async (ctx: any): Promise => { + if (ctx?.session?.isSystem) return; + if (!ctx?.session) return; // context-less programmatic call (bare kernel) + const data: any = ctx?.input?.data; + if (!data || typeof data !== 'object') return; + + if (ctx.object === COMMENT_REACTION_OBJECT) { + await authorizeReactionInsert(ctx, data); + return; + } - // Server stamps provenance: the session identity wins over whatever the - // client sent. Without this the author-or-parent-editor rule below is - // spoofable — a caller could post as anyone, then "author-delete" it. - if (ctx.session.userId) data.author_id = ctx.session.userId; + // Server stamps provenance: the session identity wins over whatever the + // client sent. Without this the author-or-parent-editor rule below is + // spoofable — a caller could post as anyone, then "author-delete" it. + if (ctx.session.userId) data.author_id = ctx.session.userId; - const target = parseCommentThreadId(data.thread_id); - if (!target) { - forbid( - `Cannot comment: thread_id ${JSON.stringify(data.thread_id ?? null)} does not name a record ` + - '(expected `{object_name}:{record_id}`)', - ); - } - // Commenting requires READ on the parent — a user who may see a record - // may discuss it. The parent's OWD/sharing/RLS/CRUD decide, so a - // permission set that reads nothing (a portal/guest set) cannot comment. - if (!(await callerCanRead(ctx, target))) { - forbid( - `Cannot comment on ${target.object}/${target.recordId}: the record does not exist or you cannot read it`, - target.object, - ); - } - }, - { object: 'sys_comment', packageId: PACKAGE_ID }, - ); + const target = parseCommentThreadId(data.thread_id); + if (!target) { + forbid( + `Cannot comment: thread_id ${JSON.stringify(data.thread_id ?? null)} does not name a record ` + + '(expected `{object_name}:{record_id}`)', + ); + } + // Commenting requires READ on the parent — a user who may see a record + // may discuss it. The parent's OWD/sharing/RLS/CRUD decide, so a + // permission set that reads nothing (a portal/guest set) cannot comment. + if (!(await callerCanRead(ctx, target))) { + forbid( + `Cannot comment on ${target.object}/${target.recordId}: the record does not exist or you cannot read it`, + target.object, + ); + } + }; + engine.registerHook('beforeInsert', onInsert, { object: COMMENT_OBJECT, packageId: PACKAGE_ID }); + engine.registerHook('beforeInsert', onInsert, { object: COMMENT_REACTION_OBJECT, packageId: PACKAGE_ID }); /** Resolve every sys_comment row a write matches, under SYSTEM context — the * caller may legitimately be unable to READ rows they are allowed to touch, @@ -705,27 +781,101 @@ export function installCommentReadVisibility( ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, filter] } : filter; }; - engine.registerMiddleware( - async (ctx, next) => { - // Only reads carry an `ast` to constrain; writes are gated by the hooks - // above. System / context-less (internal) reads are not narrowed. - if (!READ_OPS.has(ctx.operation) || !ctx.ast || !ctx.context || ctx.context.isSystem) { - return next(); - } - try { - const filter = await computeThreadVisibilityFilter(engine, ctx, logger); - if (filter) andIn(ctx, filter); - } catch (err) { - // A filter-compute failure must never fall open into a leak. - logger.warn( - `[audit] comment read visibility: filter failed, denying all (${(err as Error)?.message ?? err})`, - ); - andIn(ctx, READ_DENY_ALL); - } + // One handler for both objects (see "A comment's reactions" in the module + // header): the system / context-less exemption is decided once. + const gate = async (ctx: CommentReadMiddlewareCtx, next: () => Promise): Promise => { + // Only reads carry an `ast` to constrain; writes are gated by the hooks + // above. System / context-less (internal) reads are not narrowed. + if (!READ_OPS.has(ctx.operation) || !ctx.ast || !ctx.context || ctx.context.isSystem) { return next(); - }, - { object: 'sys_comment' }, - ); + } + const reaction = ctx.object === COMMENT_REACTION_OBJECT; + try { + const filter = reaction + ? await computeReactionVisibilityFilter(engine, ctx, logger) + : await computeThreadVisibilityFilter(engine, ctx, logger); + if (filter) andIn(ctx, filter); + } catch (err) { + // A filter-compute failure must never fall open into a leak. + logger.warn( + `[audit] ${reaction ? 'comment reaction' : 'comment'} read visibility: filter failed, denying all ` + + `(${(err as Error)?.message ?? err})`, + ); + andIn(ctx, reaction ? REACTION_READ_DENY_ALL : READ_DENY_ALL); + } + return next(); + }; + engine.registerMiddleware(gate, { object: COMMENT_OBJECT }); + engine.registerMiddleware(gate, { object: COMMENT_REACTION_OBJECT }); +} + +/** + * [#22566] Resolve the WHERE predicate for one `sys_comment_reaction` read: a + * reaction is readable exactly when its comment is. + * + * Returns `null` when the query matches no rows (nothing to narrow), a + * `comment_id` `$in` of the comments the caller can read, or the deny-all + * sentinel. + * + * Nothing here decides whether a comment is readable. The candidate comment + * ids are handed to {@link resolveReadableParentIds} as one caller-scoped read + * of `sys_comment`, and that read runs everything a direct read of those + * comments runs: the caller's grant on `sys_comment`, its row scope, and the + * thread gate above, which in turn asks the record each thread names. A + * reaction whose comment no longer exists is not found by that read, so it is + * excluded (fail closed), as is a row naming no comment at all. + */ +async function computeReactionVisibilityFilter( + engine: CommentAccessEngine, + ctx: CommentReadMiddlewareCtx, + logger: CommentAccessLogger, +): Promise { + // 1. The comment ids the query would touch, read under SYSTEM context (the + // caller may not see the rows yet; that is what is being decided). The + // caller's own order rides along, so on a table larger than the scan + // bound the window scanned is the window the caller pages through. + const orderBy = (ctx.ast as { orderBy?: unknown } | undefined)?.orderBy; + const candidates = await engine.find(COMMENT_REACTION_OBJECT, { + where: (ctx.ast?.where as Record) ?? {}, + fields: ['comment_id'], + ...(Array.isArray(orderBy) && orderBy.length > 0 ? { orderBy } : {}), + limit: READ_SCAN_LIMIT, + context: { ...SYSTEM_CTX }, + }); + if (!candidates.length) return null; + if (candidates.length >= READ_SCAN_LIMIT) { + // Not silent (fail-closed truncation): rows beyond the scan window are + // excluded, so a very broad read may omit reactions the caller could see. + // A comment feed reads the reactions of the comments it shows, by + // `comment_id`, and never hits this. + logger.warn( + `[audit] comment reaction read visibility: candidate pre-scan hit the ${READ_SCAN_LIMIT}-row cap; ` + + 'the visibility filter for this broad read is fail-closed and may omit visible rows — scope the query by comment_id', + ); + } + + /** comment id as compared → the value as STORED, which is what the emitted + * filter carries, so it can only match rows the pre-scan actually saw. */ + const commentIds = new Map(); + for (const row of candidates) { + const stored = row.comment_id; + if (typeof stored !== 'string' && typeof stored !== 'number') continue; + const key = String(stored); + if (key === '' || commentIds.has(key)) continue; + commentIds.set(key, stored); + } + if (commentIds.size === 0) return REACTION_READ_DENY_ALL; + + // 2. The one readability answer: the comments the CALLER can read. + const readable = ( + await resolveReadableParentIds(engine, ctx.context, new Map([[COMMENT_OBJECT, new Set(commentIds.keys())]])) + ).get(COMMENT_OBJECT); + + // 3. Keep the stored values of the readable comments. + const kept: unknown[] = []; + for (const [key, stored] of commentIds) if (readable?.has(key)) kept.push(stored); + if (kept.length === 0) return REACTION_READ_DENY_ALL; + return { comment_id: { $in: kept } }; } /** diff --git a/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts new file mode 100644 index 00000000000..46592fc9cf1 --- /dev/null +++ b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts @@ -0,0 +1,408 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_comment_reaction` on the plugin's real read and write paths (#22566, + * ruling A amended on #22505). + * + * A reaction is one (comment, emoji, user) row, the reactor's own record. This + * file pins the half of its contract this plugin owns: + * + * - READ: a reaction is readable exactly when its comment is — for every + * caller, the set of comments its readable reactions point at equals the + * set of reacted comments it can read, on `find`, `count`, `findOne` and + * `aggregate`; + * - CREATE: a member reacts to another member's comment it can read, and is + * refused on one it cannot (or one that does not exist), with `user_id` + * stamped from the session and `created_by` — the key of the platform's + * own-record delete floor — carrying the same reactor; + * - UNIQUENESS: two members reacting at once keep two rows; the same + * (comment, emoji, user) twice is refused by the declared index and reaches + * the data door as `409 UNIQUE_VIOLATION`; + * - BULK CREATE: the engine calls the batch doors make (`insert` of an array, + * `insertMany`) run the create gate on every row — stamped, or refused; + * - a deleted comment does not take its author's delete down with it, and its + * reactions are read by nobody. + * + * The delete half — a member removes their own reaction and not another + * member's — is the platform's own-record floor, and is pinned where that + * floor lives (plugin-security, `comment-reaction-own-record-floor.test.ts`). + * + * ## Why a real engine, a real driver and the real plugin + * + * The read rule is a WHERE the gate ANDs into the caller's query, built from a + * caller-scoped read of `sys_comment` that itself runs the comment's thread + * gate. Whether that composition selects the right rows is a question about + * the engine's middleware chain and the Filter Protocol as a driver runs it, + * so both are real here, and the gates are mounted by `AuditPlugin` itself. + * + * ## The one stand-in + * + * PARENT-RECORD READABILITY, exactly as in `activity-read-visibility.integration.test.ts`: + * one engine middleware on the owned parent object plays the part the + * parent's own sharing, RLS and object-level CRUD play in a deployment — a + * member reads the ledger rows it owns, the admin reads them all. The comment + * gate asks the engine under the caller's context, so it cannot tell the + * stand-in from plugin-security. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { mapDataError } from '@objectstack/types'; + +import { AuditPlugin } from './audit-plugin.js'; + +const COMMENT = 'sys_comment'; +const REACTION = 'sys_comment_reaction'; +/** A parent object whose rows each caller reads only when it owns them. */ +const OWNED = 'rx_ledger'; +/** A parent object every caller reads. */ +const OPEN = 'rx_board'; +const HARNESS_PACKAGE = 'com.objectstack.audit.test.comment-reaction'; + +const SYS = { isSystem: true } as const; +const MEMBER = { userId: 'u_member', positions: ['org_member'] }; +const OTHER = { userId: 'u_other', positions: ['org_member'] }; +const ADMIN = { userId: 'u_admin', positions: ['org_admin'] }; +/** A session that names no user: nobody to record as the one reacting. */ +const NO_USER = { positions: ['org_member'] }; + +const ownedObject = { + name: OWNED, + label: 'Ledger', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + owner: { name: 'owner', label: 'Owner', type: 'text' as const }, + }, +}; +const openObject = { + name: OPEN, + label: 'Board', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +const READ_OPS = new Set(['find', 'findOne', 'count', 'aggregate']); + +/** `sys_comment` reads issued under a caller (non-system) context. */ +const commentProbes: Array<{ operation: string; userId?: string }> = []; + +type Row = Record; +type Caller = typeof MEMBER; + +interface Outcome { ok: boolean; code?: string; status?: number; error?: unknown } +const attempt = async (run: () => Promise): Promise => { + try { + await run(); + return { ok: true }; + } catch (e) { + const err = e as { code?: string; status?: number; statusCode?: number }; + return { ok: false, code: err.code, status: err.status ?? err.statusCode, error: e }; + } +}; + +describe('sys_comment_reaction — the plugin read and write paths', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + const ids: Record = {}; + const comments: Record = {}; + + const react = (caller: Caller | typeof NO_USER, commentId: unknown, emoji: string, extra: Row = {}) => + engine.insert(REACTION, { comment_id: commentId, emoji, ...extra }, { context: caller }); + const readableCommentIds = async (caller: Caller): Promise => + (await engine.find(COMMENT, { context: caller })).map((r: Row) => String(r.id)).sort(); + const allReactions = async (): Promise => engine.find(REACTION, { context: SYS }); + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject(ownedObject as any, HARNESS_PACKAGE); + engine.registry.registerObject(openObject as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + + // Parent readability: a non-admin caller reads the ledger rows it owns. + engine.registerMiddleware(async (op: any, next: () => Promise) => { + if (READ_OPS.has(op.operation) && op.context && !op.context.isSystem && op.ast) { + if (op.context.userId !== ADMIN.userId) { + const mine = { owner: op.context.userId ?? '__nobody__' }; + op.ast.where = op.ast.where ? { $and: [op.ast.where, mine] } : mine; + } + } + return next(); + }, { object: OWNED }); + // Registered after AuditPlugin's gate, so it observes the caller-scoped + // comment reads the reaction gate issues. + engine.registerMiddleware(async (op: any, next: () => Promise) => { + if (READ_OPS.has(op.operation) && op.context && !op.context.isSystem) { + commentProbes.push({ operation: op.operation, userId: op.context.userId }); + } + return next(); + }, { object: COMMENT }); + + ids.mine = (await engine.insert(OWNED, { name: 'mine', owner: MEMBER.userId }, { context: SYS })).id; + ids.theirs = (await engine.insert(OWNED, { name: 'theirs', owner: OTHER.userId }, { context: SYS })).id; + ids.board = (await engine.insert(OPEN, { name: 'board' }, { context: SYS })).id; + + const post = async (threadId: string, author: string, body: string) => + String((await engine.insert(COMMENT, { thread_id: threadId, author_id: author, body }, { context: SYS })).id); + // The member's own record, another member's record, and a shared board — + // the board comment is the OTHER member's, so a reaction on it is a + // reaction on someone else's comment. + comments.onMine = await post(`${OWNED}:${ids.mine}`, MEMBER.userId, 'on the member ledger'); + comments.onTheirs = await post(`${OWNED}:${ids.theirs}`, OTHER.userId, 'on the other ledger'); + comments.onBoard = await post(`${OPEN}:${ids.board}`, OTHER.userId, 'on the board'); + comments.unreacted = await post(`${OPEN}:${ids.board}`, OTHER.userId, 'nobody reacts here'); + + // Reactions made through the gate, by the members themselves. + await react(MEMBER, comments.onBoard, '👍'); + await react(MEMBER, comments.onMine, '🎉'); + await react(OTHER, comments.onBoard, '👍'); + await react(OTHER, comments.onTheirs, '❤️'); + }, 120_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([kernel.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); + } + }, 30_000); + + it('control: the comment gate answers each member exactly the comments it can read', async () => { + expect(await readableCommentIds(MEMBER)).toEqual([comments.onMine, comments.onBoard, comments.unreacted].sort()); + expect(await readableCommentIds(OTHER)).toEqual([comments.onTheirs, comments.onBoard, comments.unreacted].sort()); + }); + + describe('create', () => { + it('a member reacts to ANOTHER member’s comment, and the row is theirs', async () => { + const rows = (await allReactions()).filter((r) => r.comment_id === comments.onBoard); + expect(rows.map((r) => r.user_id).sort()).toEqual([MEMBER.userId, OTHER.userId]); + // The board comment's author is OTHER; MEMBER's row records MEMBER. + const mine = rows.find((r) => r.user_id === MEMBER.userId)!; + // `created_by` is the column the platform's own-record delete floor + // keys on — it carries the same reactor, so the floor sees the + // reaction as the reactor's own. + expect(mine.created_by).toBe(MEMBER.userId); + }); + + it('user_id is stamped from the session — a client-supplied value never wins', async () => { + const created = await react(MEMBER, comments.onBoard, '😂', { user_id: OTHER.userId }); + const row = await engine.findOne(REACTION, { where: { id: created.id }, context: SYS }); + expect(row?.user_id).toBe(MEMBER.userId); + expect(row?.created_by).toBe(MEMBER.userId); + await engine.delete(REACTION, { where: { id: created.id }, context: SYS }); + }); + + it('a comment the member cannot read is refused, on the envelope, and nothing is written', async () => { + const before = (await allReactions()).length; + const outcome = await attempt(() => react(MEMBER, comments.onTheirs, '👍')); + expect(outcome).toMatchObject({ ok: false, code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + expect((await allReactions()).length).toBe(before); + }); + + it('a comment that does not exist is refused the same way (existence is not disclosed)', async () => { + const outcome = await attempt(() => react(MEMBER, 'cmt_does_not_exist', '👍')); + expect(outcome).toMatchObject({ ok: false, code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + }); + + it('a reaction naming no comment is refused', async () => { + for (const missing of [undefined, '']) { + const outcome = await attempt(() => react(MEMBER, missing, '👍')); + expect(outcome).toMatchObject({ ok: false, code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + } + }); + + it('a session with no user is refused rather than recorded under a client-supplied id', async () => { + const outcome = await attempt(() => react(NO_USER, comments.onBoard, '😮', { user_id: OTHER.userId })); + expect(outcome).toMatchObject({ ok: false, code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + const forged = (await allReactions()).filter((r) => r.emoji === '😮'); + expect(forged).toEqual([]); + }); + }); + + describe('read: a reaction is readable exactly when its comment is', () => { + it.each([ + ['MEMBER', MEMBER], + ['OTHER', OTHER], + ['ADMIN', ADMIN], + ] as const)('%s: the comments its reactions point at are the reacted comments it can read', async (_n, caller) => { + const reacted = new Set((await allReactions()).map((r) => String(r.comment_id))); + const readable = (await readableCommentIds(caller)).filter((id) => reacted.has(id)); + const seen = [...new Set((await engine.find(REACTION, { context: caller })).map((r: Row) => String(r.comment_id)))]; + expect(seen.sort()).toEqual(readable.sort()); + expect(seen.length).toBeGreaterThan(0); + }); + + it('find: a member reads the reactions on its readable comments, and not on another member’s ledger', async () => { + const rows = await engine.find(REACTION, { context: MEMBER }); + expect(rows.map((r: Row) => `${r.comment_id}:${r.user_id}:${r.emoji}`).sort()).toEqual( + [ + `${comments.onBoard}:${MEMBER.userId}:👍`, + `${comments.onBoard}:${OTHER.userId}:👍`, + `${comments.onMine}:${MEMBER.userId}:🎉`, + ].sort(), + ); + }); + + it('count: the total is narrowed identically to the rows', async () => { + expect(await engine.count(REACTION, {}, { context: MEMBER })).toBe(3); + expect(await engine.count(REACTION, {}, { context: OTHER })).toBe(3); + }); + + it('findOne: a reaction on an unreadable comment is absent by id', async () => { + const hidden = (await allReactions()).find((r) => r.comment_id === comments.onTheirs)!; + const shown = (await allReactions()).find((r) => r.comment_id === comments.onMine)!; + expect(await engine.findOne(REACTION, { where: { id: hidden.id }, context: MEMBER })).toBeNull(); + expect((await engine.findOne(REACTION, { where: { id: shown.id }, context: MEMBER }))?.id).toBe(shown.id); + }); + + it('aggregate: a grouped count sees only the readable rows', async () => { + const groups = await engine.aggregate( + REACTION, + { groupBy: ['comment_id'], aggregations: [{ function: 'count', alias: 'n' }], context: MEMBER }, + ); + const byComment = Object.fromEntries(groups.map((g: Row) => [String(g.comment_id), Number(g.n)])); + expect(byComment).toEqual({ [comments.onBoard]: 2, [comments.onMine]: 1 }); + }); + + it('the reader’s one batched read: comment_id $in the feed, grouped client-side, one comment probe', async () => { + commentProbes.length = 0; + const feed = [comments.onMine, comments.onTheirs, comments.onBoard, comments.unreacted]; + const rows = await engine.find(REACTION, { where: { comment_id: { $in: feed } }, context: MEMBER }); + const grouped: Record> = {}; + for (const r of rows) { + ((grouped[r.comment_id] ??= {})[r.emoji] ??= []).push(r.user_id); + } + for (const byEmoji of Object.values(grouped)) for (const users of Object.values(byEmoji)) users.sort(); + expect(grouped).toEqual({ + [comments.onBoard]: { '👍': [MEMBER.userId, OTHER.userId].sort() }, + [comments.onMine]: { '🎉': [MEMBER.userId] }, + }); + // One caller-scoped read of the comments per reaction read — never one + // per row. + expect(commentProbes.filter((p) => p.userId === MEMBER.userId)).toEqual([{ operation: 'find', userId: MEMBER.userId }]); + }); + + it('a query scoped to a comment the member cannot read returns nothing', async () => { + const rows = await engine.find(REACTION, { where: { comment_id: comments.onTheirs }, context: MEMBER }); + expect(rows).toEqual([]); + }); + + it('a system read is not narrowed', async () => { + const rows = await engine.find(REACTION, { context: SYS }); + expect(rows.map((r: Row) => r.comment_id)).toEqual(expect.arrayContaining([comments.onTheirs, comments.onMine])); + }); + }); + + describe('uniqueness and concurrency', () => { + it('two members reacting at once keep two rows', async () => { + await Promise.all([react(MEMBER, comments.unreacted, '🎉'), react(OTHER, comments.unreacted, '🎉')]); + const rows = (await allReactions()).filter((r) => r.comment_id === comments.unreacted); + expect(rows.map((r) => `${r.user_id}:${r.emoji}`).sort()).toEqual([`${MEMBER.userId}:🎉`, `${OTHER.userId}:🎉`].sort()); + }); + + it('the same (comment, emoji, user) twice is one row; the second is refused as 409 UNIQUE_VIOLATION', async () => { + const outcome = await attempt(() => react(MEMBER, comments.onBoard, '👍')); + expect(outcome).toMatchObject({ ok: false, code: 'DUPLICATE_RECORD', status: 409 }); + // …which the data door answers on the wire as the standard conflict. + const wire = mapDataError(outcome.error, REACTION); + expect(wire.status).toBe(409); + expect(wire.body.code).toBe('UNIQUE_VIOLATION'); + const rows = (await allReactions()).filter( + (r) => r.comment_id === comments.onBoard && r.user_id === MEMBER.userId && r.emoji === '👍', + ); + expect(rows).toHaveLength(1); + }); + + it('a different emoji from the same member is a second row', async () => { + const created = await react(MEMBER, comments.onBoard, '❤️'); + expect(created.id).toBeTruthy(); + await engine.delete(REACTION, { where: { id: created.id }, context: SYS }); + }); + }); + + // The object grants `bulk`. The batch create door (`createManyData`) is ONE + // `engine.insert` of the rows, and the partial-success batch + // (`insertManyData`) is `engine.insertMany`; the engine runs `beforeInsert` + // once per row in both, so each row meets the create gate. Driven here at + // those exact engine calls. + describe('bulk create: every row meets the create gate', () => { + const idsOf = (rows: Row[]) => rows.map((r) => String(r.id)); + const dropAll = async (ids: string[]) => { + for (const id of ids) await engine.delete(REACTION, { where: { id }, context: SYS }); + }; + + it('createMany: every row is stamped with the session user, whatever each row sent', async () => { + const created: Row[] = await engine.insert( + REACTION, + [ + { comment_id: comments.onBoard, emoji: '🚀', user_id: OTHER.userId }, + { comment_id: comments.onMine, emoji: '🚀' }, + ], + { context: MEMBER }, + ); + const rows = await engine.find(REACTION, { where: { id: { $in: idsOf(created) } }, context: SYS }); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.user_id).toBe(MEMBER.userId); + expect(row.created_by).toBe(MEMBER.userId); + } + await dropAll(idsOf(created)); + }); + + it('createMany: one row on a comment the member cannot read refuses the batch, on the envelope, and nothing is written', async () => { + const before = (await allReactions()).length; + const outcome = await attempt(() => + engine.insert( + REACTION, + [ + { comment_id: comments.onBoard, emoji: '🛸' }, + { comment_id: comments.onTheirs, emoji: '🛸' }, + ], + { context: MEMBER }, + ), + ); + expect(outcome).toMatchObject({ ok: false, code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + expect((await allReactions()).length).toBe(before); + }); + + it('insertMany: a row on a comment the member cannot read is refused, on the envelope, and nothing is written', async () => { + const before = (await allReactions()).length; + const outcome = await attempt(() => + engine.insertMany( + REACTION, + [ + { comment_id: comments.onBoard, emoji: '🌱' }, + { comment_id: comments.onTheirs, emoji: '🌱' }, + ], + { context: MEMBER }, + ), + ); + expect(outcome).toMatchObject({ ok: false, code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + expect((await allReactions()).length).toBe(before); + }); + }); + + describe('a deleted comment', () => { + it('does not block its author’s delete, and its reactions are read by nobody but the system', async () => { + const doomed = String( + (await engine.insert(COMMENT, { thread_id: `${OPEN}:${ids.board}`, author_id: OTHER.userId, body: 'doomed' }, { context: SYS })).id, + ); + await react(MEMBER, doomed, '👍'); + // The comment's author deletes it: the gate admits its own author, and a + // reaction is not a reference that restricts or cascades. + const deleted = await attempt(() => engine.delete(COMMENT, { where: { id: doomed }, context: OTHER })); + expect(deleted).toMatchObject({ ok: true }); + for (const caller of [MEMBER, OTHER, ADMIN]) { + const rows = await engine.find(REACTION, { where: { comment_id: doomed }, context: caller }); + expect(rows, caller.userId).toEqual([]); + } + expect((await engine.find(REACTION, { where: { comment_id: doomed }, context: SYS })).length).toBe(1); + }); + }); +}); diff --git a/packages/plugins/plugin-audit/src/objects/index.ts b/packages/plugins/plugin-audit/src/objects/index.ts index 1331df6c66a..4ae187eef98 100644 --- a/packages/plugins/plugin-audit/src/objects/index.ts +++ b/packages/plugins/plugin-audit/src/objects/index.ts @@ -5,7 +5,8 @@ * (ADR-0029 K2). Moved here from the `@objectstack/platform-objects` monolith * so the plugin owns its data model + behavior — exactly the objects the audit * writers produce/observe (sys_audit_log + sys_activity rows; sys_comment - * @mention hook). + * @mention hook), and `sys_comment_reaction`, the reactor's own record of an + * emoji reaction to a comment. * * Intentionally NOT moved here: * - `sys_notification` — the ADR-0030 rework landed, and the object is the @@ -19,3 +20,4 @@ export { SysAuditLog } from './sys-audit-log.object.js'; export { SysActivity } from './sys-activity.object.js'; export { SysComment } from './sys-comment.object.js'; +export { SysCommentReaction } from './sys-comment-reaction.object.js'; diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts new file mode 100644 index 00000000000..e75a10fd2bf --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { StorageNameMapping } from '@objectstack/spec/system'; +import { ObjectSchema, checkManagedApiMethodAffordances } from '@objectstack/spec/data'; +import { SysCommentReaction } from './index.js'; + +/** + * The declaration of `sys_comment_reaction` (#22566, ruling A amended on + * #22505), held to the properties the rest of its contract rests on. + * + * Each assertion names the mechanism that reads the property, because each is + * a place a later "tidy-up" silently moves the object out from under a rule + * that lives in another package: + * + * - the own-record delete floor (plugin-security `owner_only_deletes`) keys on + * the engine-stamped `created_by` and yields on no `sharingModel` here; + * - the read gate and the create gate (`comment-access-hooks.ts`) read + * `comment_id`; + * - the reader's grouping reads `(comment_id, emoji, user_id)`, which the + * unique index makes one row. + */ +describe('sys_comment_reaction declaration', () => { + it('is a canonical sys_ system object whose name is its table', () => { + expect(SysCommentReaction.name).toBe('sys_comment_reaction'); + expect(StorageNameMapping.resolveTableName(SysCommentReaction)).toBe('sys_comment_reaction'); + expect(SysCommentReaction.isSystem).toBe(true); + expect((SysCommentReaction as any).namespace).toBeUndefined(); + expect((SysCommentReaction as any).tableName).toBeUndefined(); + }); + + it('parses as an object schema', () => { + expect(ObjectSchema.safeParse(SysCommentReaction).success).toBe(true); + }); + + it('declares the triple a reader groups by, and the reactor as a user', () => { + const f = SysCommentReaction.fields as Record; + expect(f.comment_id).toMatchObject({ type: 'text', required: true, maxLength: 255 }); + expect(f.emoji).toMatchObject({ type: 'text', required: true }); + expect(f.user_id).toMatchObject({ type: 'lookup', reference: 'sys_user', required: true }); + }); + + it('keys comment_id as an id column, never a reference with delete behaviour', () => { + // A cascade would delete other members' reactions under the comment + // author's own authority, which the own-record floor refuses; a required + // set_null escalates to restrict. Either would stop an author deleting + // their own comment once someone reacted. + const f = SysCommentReaction.fields as Record; + expect(f.comment_id.reference).toBeUndefined(); + expect(f.comment_id.deleteBehavior).toBeUndefined(); + }); + + it('holds (comment_id, emoji, user_id) unique, with the scope spelled out', () => { + expect(SysCommentReaction.indexes).toEqual([ + { fields: ['comment_id', 'emoji', 'user_id'], unique: 'organization' }, + ]); + }); + + it('leaves the own-record floor in force: no sharing model, audit columns injected', () => { + // `owner_only_deletes` survives every sharing model but `controlled_by_parent` + // and the write model a `public_read_write` OWD replaces only on update; an + // absent model is the one the floor was written for. `created_by` is the + // floor's key, injected unless the object opts its audit columns out. + expect((SysCommentReaction as any).sharingModel).toBeUndefined(); + expect((SysCommentReaction as any).systemFields?.audit).not.toBe(false); + }); + + it('offers create, read and delete over the data API, in batches too — never update', () => { + // `bulk` admits only children that are allowed one at a time (`bulk ∧ + // derived(child)`), so it opens createMany / deleteMany and never updateMany. + expect(SysCommentReaction.enable?.apiMethods).toEqual(['get', 'list', 'create', 'delete', 'bulk']); + expect((SysCommentReaction as any).userActions).toMatchObject({ edit: false, import: false }); + // The registry strips an advertised verb the managed bucket does not + // afford; nothing advertised here may be stripped. + expect(checkManagedApiMethodAffordances(SysCommentReaction)).toEqual([]); + }); + + it('is no feed target and mirrors no activity', () => { + expect(SysCommentReaction.enable).toMatchObject({ feeds: false, activities: false, trackHistory: false }); + }); +}); diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts new file mode 100644 index 00000000000..d9f1d29c2a5 --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_comment_reaction — one emoji reaction, the reactor's own record. + * + * One row is one (comment, emoji, user). The row belongs to the user who + * reacted, so the platform's ordinary own-record rule is the whole write rule: + * a member adds a reaction by creating their row and removes it by deleting + * that row, on anyone's comment, and nobody else's row is ever written. The + * author-only update floor on `sys_comment` is not touched, and two members + * reacting at once write two rows, so there is no shared column to overwrite. + * Maintainer ruling A, amended, on #22505 (director record 6091550406); the + * delegated judgment 6091621568 keeps NO aggregate on the comment row: a reader + * does one batched `find` here with `comment_id` `$in` the comments it shows + * and groups the rows itself. + * + * ## Who may do what, and where each rule lives + * + * - **Read** — a reaction is readable exactly when its comment is. + * `installCommentReadVisibility` (`comment-access-hooks.ts`) narrows every + * read of this object to the rows whose `comment_id` the CALLER can read, + * asked as one caller-scoped read of `sys_comment` — so the comment's own + * grant, row scope and thread gate decide, and no second copy of the + * comment's readability rule exists. + * - **Create** — `installCommentAccessHooks` refuses a reaction on a comment + * the caller cannot read (the same evaluator the read gate asks) and stamps + * `user_id` from the session; a client-supplied value never wins. + * - **Delete** — the platform's own-record floor (`owner_only_deletes`, + * `created_by == current_user.id`, plugin-security), which this object meets + * like any other: the engine stamps `created_by` with the reactor, the + * object declares no `sharingModel` that would yield the floor, and no + * alternate match relieves it. So a member deletes their own reaction and + * not another member's. + * - **Update** — not offered. `apiMethods` lists no `update` and + * `userActions` hides the edit affordance: a reaction is not edited, it is + * deleted and made again. + * - **Bulk** — offered, and it adds no path around the rules above. The batch + * doors admit a child operation only when it is itself allowed (`bulk ∧ + * derived(child)`), so `updateMany` stays refused. `createMany` is ONE engine + * insert of the rows, and the engine runs `beforeInsert` per row, so every + * row meets the create gate. `deleteMany` is one by-id delete per id, so every + * row meets the own-record floor. + * + * ## Why `comment_id` is an id column and not a lookup + * + * The same reason `sys_comment.thread_id` and `sys_attachment`'s parent pair + * are id columns: referential behaviour on delete would run as the CALLER. A + * `cascade` lookup makes deleting a comment delete every reaction on it under + * the comment author's own authority, which the own-record floor refuses as + * soon as another member has reacted — so an author could no longer delete + * their own comment. A `set_null` lookup on a required key escalates to + * `restrict`, which refuses the same delete outright. As an id column, a + * deleted comment's reactions stay behind and are read by nobody: the read + * gate keeps only rows whose comment the caller can read, and a comment that + * no longer exists is read by no one. + * + * ## Uniqueness + * + * `(comment_id, emoji, user_id)` is one row, held by a declared unique index — + * the platform's existing answer. A second identical reaction is refused by + * the store and reaches the data door as `409 UNIQUE_VIOLATION`; it is not + * silently absorbed. + * + * @namespace sys + */ +export const SysCommentReaction = ObjectSchema.create({ + name: 'sys_comment_reaction', + label: 'Comment Reaction', + pluralLabel: 'Comment Reactions', + icon: 'smile-plus', + isSystem: true, + managedBy: 'platform', + description: 'An emoji reaction to a comment, owned by the user who reacted', + displayNameField: 'emoji', + nameField: 'emoji', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + + fields: { + id: Field.text({ + label: 'Reaction ID', + required: true, + readonly: true, + group: 'System', + }), + + // ── Reaction ───────────────────────────────────────────────── + // The id of the reacted comment. 255 is the width of the physical `id` + // column it holds a value of, the bound `sys_activity.record_id` carries. + comment_id: Field.text({ + label: 'Comment', + required: true, + maxLength: 255, + description: 'The id of the sys_comment this reaction is on. The reaction is readable exactly when that comment is.', + group: 'Reaction', + }), + + emoji: Field.text({ + label: 'Emoji', + required: true, + maxLength: 64, + description: 'The emoji, as the character sequence the client renders (for example 👍)', + group: 'Reaction', + }), + + user_id: Field.lookup('sys_user', { + label: 'User', + required: true, + description: 'The user who reacted. Stamped from the session on create; a client-supplied value is replaced.', + group: 'Reaction', + }), + + created_at: Field.datetime({ + label: 'Created At', + required: true, + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + // [ADR-0120 D1] Scope spelled explicitly: one row per (comment, emoji, + // user) within the organization the comment lives in. Its leading column + // also serves the reader's one batched `comment_id` `$in` read. + { fields: ['comment_id', 'emoji', 'user_id'], unique: 'organization' }, + ], + + // A reaction is made and removed, never edited, and never loaded from a file. + userActions: { edit: false, import: false }, + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'delete', 'bulk'], + // Not a record anyone discusses, and its CRUD is not a timeline event. + feeds: false, + activities: false, + clone: false, + }, +}); diff --git a/packages/plugins/plugin-audit/src/translations/bundle-ownership.test.ts b/packages/plugins/plugin-audit/src/translations/bundle-ownership.test.ts index c6fe8d33d53..134de14b56c 100644 --- a/packages/plugins/plugin-audit/src/translations/bundle-ownership.test.ts +++ b/packages/plugins/plugin-audit/src/translations/bundle-ownership.test.ts @@ -20,6 +20,7 @@ const OWNED_OBJECTS = new Set([ 'sys_audit_log', 'sys_activity', 'sys_comment', + 'sys_comment_reaction', ]); describe('objects translation bundle ownership (ADR-0029 D8)', () => { diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 0a2970b0702..030c6eb64cc 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -234,5 +234,30 @@ export const enObjects: NonNullable = { label: "Updated At" } } + }, + sys_comment_reaction: { + label: "Comment Reaction", + pluralLabel: "Comment Reactions", + description: "An emoji reaction to a comment, owned by the user who reacted", + fields: { + id: { + label: "Reaction ID" + }, + comment_id: { + label: "Comment", + help: "The id of the sys_comment this reaction is on. The reaction is readable exactly when that comment is." + }, + emoji: { + label: "Emoji", + help: "The emoji, as the character sequence the client renders (for example 👍)" + }, + user_id: { + label: "User", + help: "The user who reacted. Stamped from the session on create; a client-supplied value is replaced." + }, + created_at: { + label: "Created At" + } + } } }; diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index d8c852f7831..f7eca4e5a7e 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -234,5 +234,30 @@ export const esESObjects: NonNullable = { label: "Actualizado el" } } + }, + sys_comment_reaction: { + label: "Comment Reaction", + pluralLabel: "Comment Reactions", + description: "An emoji reaction to a comment, owned by the user who reacted", + fields: { + id: { + label: "Reaction ID" + }, + comment_id: { + label: "Comment", + help: "The id of the sys_comment this reaction is on. The reaction is readable exactly when that comment is." + }, + emoji: { + label: "Emoji", + help: "The emoji, as the character sequence the client renders (for example 👍)" + }, + user_id: { + label: "User", + help: "The user who reacted. Stamped from the session on create; a client-supplied value is replaced." + }, + created_at: { + label: "Created At" + } + } } }; diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.source-hashes.generated.ts index a013415bcb1..0ed9ce92c67 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.source-hashes.generated.ts @@ -22,4 +22,15 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_activity.fields.type.help": "5d8df777db464241", "objects.sys_activity.fields.url.label": "2203b0b9f72534f7", "objects.sys_audit_log.fields.actor.label": "b155813f8a7f06e3", + "objects.sys_comment_reaction.description": "d0fe7e44049573c2", + "objects.sys_comment_reaction.fields.comment_id.help": "054975bf4ebc079c", + "objects.sys_comment_reaction.fields.comment_id.label": "98e6afea98d2698b", + "objects.sys_comment_reaction.fields.created_at.label": "1f02d416befb595b", + "objects.sys_comment_reaction.fields.emoji.help": "3db9cdfe983791c3", + "objects.sys_comment_reaction.fields.emoji.label": "f281bddefc11042a", + "objects.sys_comment_reaction.fields.id.label": "effa90a604eeb22e", + "objects.sys_comment_reaction.fields.user_id.help": "dfde9740ea61d31b", + "objects.sys_comment_reaction.fields.user_id.label": "6c0d22a931e235bd", + "objects.sys_comment_reaction.label": "8139110e90c79f0a", + "objects.sys_comment_reaction.pluralLabel": "d95da4d8f4a703b4", }; diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 1924d25a147..d1ea3c1cb74 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -234,5 +234,30 @@ export const jaJPObjects: NonNullable = { label: "更新日時" } } + }, + sys_comment_reaction: { + label: "Comment Reaction", + pluralLabel: "Comment Reactions", + description: "An emoji reaction to a comment, owned by the user who reacted", + fields: { + id: { + label: "Reaction ID" + }, + comment_id: { + label: "Comment", + help: "The id of the sys_comment this reaction is on. The reaction is readable exactly when that comment is." + }, + emoji: { + label: "Emoji", + help: "The emoji, as the character sequence the client renders (for example 👍)" + }, + user_id: { + label: "User", + help: "The user who reacted. Stamped from the session on create; a client-supplied value is replaced." + }, + created_at: { + label: "Created At" + } + } } }; diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.source-hashes.generated.ts index 908cf67d9dd..d111ba6827a 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.source-hashes.generated.ts @@ -20,4 +20,15 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_activity.fields.type.help": "5d8df777db464241", "objects.sys_activity.fields.url.label": "2203b0b9f72534f7", + "objects.sys_comment_reaction.description": "d0fe7e44049573c2", + "objects.sys_comment_reaction.fields.comment_id.help": "054975bf4ebc079c", + "objects.sys_comment_reaction.fields.comment_id.label": "98e6afea98d2698b", + "objects.sys_comment_reaction.fields.created_at.label": "1f02d416befb595b", + "objects.sys_comment_reaction.fields.emoji.help": "3db9cdfe983791c3", + "objects.sys_comment_reaction.fields.emoji.label": "f281bddefc11042a", + "objects.sys_comment_reaction.fields.id.label": "effa90a604eeb22e", + "objects.sys_comment_reaction.fields.user_id.help": "dfde9740ea61d31b", + "objects.sys_comment_reaction.fields.user_id.label": "6c0d22a931e235bd", + "objects.sys_comment_reaction.label": "8139110e90c79f0a", + "objects.sys_comment_reaction.pluralLabel": "d95da4d8f4a703b4", }; diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 3e2c2f24d0f..4fbc6eb85dc 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -234,5 +234,30 @@ export const zhCNObjects: NonNullable = { label: "更新时间" } } + }, + sys_comment_reaction: { + label: "Comment Reaction", + pluralLabel: "Comment Reactions", + description: "An emoji reaction to a comment, owned by the user who reacted", + fields: { + id: { + label: "Reaction ID" + }, + comment_id: { + label: "Comment", + help: "The id of the sys_comment this reaction is on. The reaction is readable exactly when that comment is." + }, + emoji: { + label: "Emoji", + help: "The emoji, as the character sequence the client renders (for example 👍)" + }, + user_id: { + label: "User", + help: "The user who reacted. Stamped from the session on create; a client-supplied value is replaced." + }, + created_at: { + label: "Created At" + } + } } }; diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts index def45595778..8c4bfa089f4 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts @@ -20,4 +20,15 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_activity.fields.type.help": "5d8df777db464241", "objects.sys_activity.fields.url.label": "2203b0b9f72534f7", + "objects.sys_comment_reaction.description": "d0fe7e44049573c2", + "objects.sys_comment_reaction.fields.comment_id.help": "054975bf4ebc079c", + "objects.sys_comment_reaction.fields.comment_id.label": "98e6afea98d2698b", + "objects.sys_comment_reaction.fields.created_at.label": "1f02d416befb595b", + "objects.sys_comment_reaction.fields.emoji.help": "3db9cdfe983791c3", + "objects.sys_comment_reaction.fields.emoji.label": "f281bddefc11042a", + "objects.sys_comment_reaction.fields.id.label": "effa90a604eeb22e", + "objects.sys_comment_reaction.fields.user_id.help": "dfde9740ea61d31b", + "objects.sys_comment_reaction.fields.user_id.label": "6c0d22a931e235bd", + "objects.sys_comment_reaction.label": "8139110e90c79f0a", + "objects.sys_comment_reaction.pluralLabel": "d95da4d8f4a703b4", }; diff --git a/packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts b/packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts new file mode 100644 index 00000000000..b9473b85fd3 --- /dev/null +++ b/packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22566] A comment reaction is its reactor's own record, so the platform's + * own-record floor is the whole of its delete rule (ruling A amended on + * #22505: "the platform's ordinary rule, a member writes their own records, + * lets a member add or remove their own reaction on anyone's comment"). + * + * The floor is `member_default`'s `owner_only_deletes` (object `'*'`, + * `created_by == current_user.id`, positions `['org_member']`). This suite + * drives it, as shipped, through the REAL security middleware over a REAL + * engine and driver, with the column it keys on stamped by the REAL + * `ObjectQLPlugin` audit hook: + * + * - a member creates a reaction on another member's comment, and deletes it; + * - another member cannot delete it — by id or by predicate — and the row + * survives; + * - two members reacting at once keep two rows. + * + * ## The object, written out rather than imported + * + * `sys_comment_reaction` is declared by `@objectstack/plugin-audit`, which this + * package does not depend on. The declaration below copies the properties the + * floor reads and nothing else: a `sys_` system object with no `sharingModel` + * (so `owner_only_deletes` is not yielded), audit columns injected (so + * `created_by` exists and is stamped), and the reaction's own columns. Those + * properties are pinned on the real declaration by plugin-audit's + * `sys-comment-reaction.object.test.ts`, so the two cannot drift apart without + * one side going red — the same arrangement `ownership-floor-alternates.test.ts` + * uses for `sys_comment`. The comment itself lives in plugin-audit too; the + * floor never reads it, so here a comment is only the id a reaction names. + * + * ## Who grants the bits + * + * The object-level create / read / delete bits come from an ordinary + * position-distributed set an application ships (as an app grants + * `sys_comment` today), never from the `everyone`-bound baseline, which + * ADR-0090 D5 keeps free of delete bits. Which shipped set — if any — carries + * them is an open decision on #22566; the floor pinned here holds whatever set + * hands the delete bit out. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const REACTION = 'sys_comment_reaction'; + +/** The properties of plugin-audit's declaration the floor reads (see header). */ +const REACTION_OBJECT = { + name: REACTION, + label: 'Comment Reaction', + isSystem: true, + managedBy: 'platform', + fields: { + comment_id: { type: 'text', label: 'Comment', required: true, maxLength: 255 }, + emoji: { type: 'text', label: 'Emoji', required: true, maxLength: 64 }, + user_id: { type: 'text', label: 'User', required: true }, + }, + indexes: [{ fields: ['comment_id', 'emoji', 'user_id'], unique: 'organization' }], + enable: { apiMethods: ['get', 'list', 'create', 'delete', 'bulk'] }, +}; + +/** The app's grant: read, create and delete on reactions — every row. */ +const REACTOR_SET = PermissionSetSchema.parse({ + name: 'qa_comment_reactor', + label: 'Comment reactor (app grant)', + objects: { + [REACTION]: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: true }, + }, +}); + +/** Two rank-and-file members of one organization, holding the app's grant. */ +const member = (userId: string) => ({ + userId, + tenantId: 'org_acme', + positions: ['org_member', 'everyone'], + permissions: [REACTOR_SET.name], + posture: 'MEMBER' as const, +}); +const ALICE = member('usr_alice'); +const BOB = member('usr_bob'); +const SYSTEM = { isSystem: true, userId: 'usr_system' }; + +/** Bob's comment — the id a reaction names. */ +const BOBS_COMMENT = 'cmt_by_bob'; + +function sqliteDriverPlugin(): Plugin { + return { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }; +} + +function objectsPlugin(): Plugin { + return { + name: 'test.objects', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: PluginContext) { + ctx.getService<{ register(m: unknown): unknown }>('manifest').register({ + id: 'test.comment-reaction', + namespace: 'sys', + version: '1.0.0', + type: 'plugin', + name: 'Comment reaction fixture', + // The identity objects the security plugin's authorization store + // reads, as `platform-global-no-organization-column.test.ts` mounts them. + objects: [SysUser, SysMember, SysOrganization, REACTION_OBJECT], + }); + }, + }; +} + +let kernel: ObjectKernel | undefined; +afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; +}); + +async function boot(): Promise { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(objectsPlugin()); + await kernel.use( + new SecurityPlugin({ + defaultPermissionSets: [...defaultPermissionSets, REACTOR_SET], + fallbackPermissionSet: 'member_default', + }), + ); + await kernel.bootstrap(); + return kernel.getService('objectql'); +} + +interface Outcome { ok: boolean; code?: string; status?: number } +const attempt = async (run: () => Promise): Promise => { + try { + await run(); + return { ok: true }; + } catch (e) { + const err = e as { code?: string; status?: number; statusCode?: number }; + return { ok: false, code: err.code, status: err.statusCode ?? err.status }; + } +}; + +const react = (ql: ObjectQL, caller: ReturnType, emoji: string) => + ql.insert(REACTION, { comment_id: BOBS_COMMENT, emoji, user_id: caller.userId }, { context: caller }); +const rowById = (ql: ObjectQL, id: string) => ql.findOne(REACTION, { where: { id }, context: SYSTEM }); + +describe('[#22566] a reaction is its reactor’s own record — the own-record floor, as shipped', () => { + it('a member reacts to another member’s comment and removes the reaction again', async () => { + const ql = await boot(); + const created = await react(ql, ALICE, '👍'); + // The column the floor keys on carries the reactor. + expect((await rowById(ql, created.id))?.created_by).toBe(ALICE.userId); + + const removed = await attempt(() => ql.delete(REACTION, { where: { id: created.id }, context: ALICE })); + expect(removed).toEqual({ ok: true }); + expect(await rowById(ql, created.id)).toBeNull(); + }, 60_000); + + it('another member cannot delete it — by id or by predicate — and the row survives', async () => { + const ql = await boot(); + const created = await react(ql, ALICE, '🎉'); + // Bob can SEE Alice's reaction (the floor narrows writes, never reads)… + expect((await ql.findOne(REACTION, { where: { id: created.id }, context: BOB }))?.id).toBe(created.id); + + // …and may not remove it. + const byId = await attempt(() => ql.delete(REACTION, { where: { id: created.id }, context: BOB })); + expect(byId).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + + await attempt(() => + ql.delete(REACTION, { where: { comment_id: BOBS_COMMENT }, multi: true, context: BOB }), + ); + expect((await rowById(ql, created.id))?.id).toBe(created.id); + }, 60_000); + + it('two members reacting at once keep two rows, each deletable only by its reactor', async () => { + const ql = await boot(); + const [a, b] = await Promise.all([react(ql, ALICE, '❤️'), react(ql, BOB, '❤️')]); + const rows = await ql.find(REACTION, { where: { comment_id: BOBS_COMMENT }, context: SYSTEM }); + expect(rows.map((r: any) => r.created_by).sort()).toEqual([ALICE.userId, BOB.userId].sort()); + + expect(await attempt(() => ql.delete(REACTION, { where: { id: a.id }, context: BOB }))).toMatchObject({ + ok: false, + code: 'PERMISSION_DENIED', + status: 403, + }); + expect(await attempt(() => ql.delete(REACTION, { where: { id: b.id }, context: BOB }))).toEqual({ ok: true }); + const left = await ql.find(REACTION, { where: { comment_id: BOBS_COMMENT }, context: SYSTEM }); + expect(left.map((r: any) => r.id)).toEqual([a.id]); + }, 60_000); +}); + +/** + * The object grants `bulk`, and the batch doors are the protocol's own + * `createManyData` / `deleteManyData` — driven here over the same real engine, + * so what is pinned is the door's real loop, not a re-statement of it. + * `deleteManyData` deletes one id at a time by id, so each row meets the floor; + * a row it may not delete fails ON ITS OWN, with the floor's code, and the row + * survives. + */ +describe('[#22566] the batch doors keep the own-record floor', () => { + interface BatchResult { id: string; success: boolean; errors?: Array<{ code?: string }> } + interface BatchDoor { + createManyData(req: { object: string; records: unknown[]; context?: unknown }): Promise<{ records: Array<{ id: string }> }>; + deleteManyData(req: { + object: string; + ids: string[]; + options?: { continueOnError?: boolean }; + context?: unknown; + }): Promise<{ succeeded: number; failed: number; results: BatchResult[] }>; + } + const doorOver = (ql: ObjectQL): BatchDoor => + new ObjectStackProtocolImplementation(ql as never, () => new Map(), undefined) as unknown as BatchDoor; + + it('createMany then deleteMany: a member removes its own reactions in one batch; another member’s is refused alone and survives', async () => { + const ql = await boot(); + const door = doorOver(ql); + const { records: mine } = await door.createManyData({ + object: REACTION, + records: [ + { comment_id: BOBS_COMMENT, emoji: '👍', user_id: ALICE.userId }, + { comment_id: BOBS_COMMENT, emoji: '🎉', user_id: ALICE.userId }, + ], + context: ALICE, + }); + expect(mine).toHaveLength(2); + const bobs = await react(ql, BOB, '👍'); + + const ids = [mine[0]!.id, bobs.id, mine[1]!.id].map(String); + const res = await door.deleteManyData({ object: REACTION, ids, options: { continueOnError: true }, context: ALICE }); + + expect(res.succeeded).toBe(2); + expect(res.failed).toBe(1); + const refused = res.results.find((r) => r.id === String(bobs.id)); + expect(refused?.success).toBe(false); + expect(refused?.errors?.[0]?.code).toBe('PERMISSION_DENIED'); + // Ground truth past every scope: Bob's row is still there, Alice's are gone. + const left = await ql.find(REACTION, { where: { comment_id: BOBS_COMMENT }, context: SYSTEM }); + expect(left.map((r: any) => String(r.id))).toEqual([String(bobs.id)]); + }, 60_000); +}); diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts index 138b52268aa..9088a876919 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts @@ -350,6 +350,30 @@ describe('sys_comment delete is moderation-shaped, not ownership-shaped (#8839)' }); }); +/** + * [#22566] A comment reaction (`sys_comment_reaction`, plugin-audit) is its + * reactor's own record, so the wildcard own-record floor is the WHOLE of its + * delete rule — the opposite shape from `sys_comment` above, whose delete is + * moderation. A TRIPWIRE again: the behaviour is driven through the real + * middleware in `comment-reaction-own-record-floor.test.ts`. + * + * What it guards is the policy that would quietly undo the ruling ("a member + * cannot delete another member's reaction"): a `sys_comment_moderation`-style + * alternate match on reactions in ANY shipped set would OR a second match into + * the delete class and let every org member remove every reaction. A reaction + * policy appearing here is a decision, not a refactor. + */ +describe('sys_comment_reaction delete is ownership-shaped (#22566)', () => { + it('no shipped set carries a row-level policy on sys_comment_reaction', () => { + expect(defaultPermissionSets.length).toBeGreaterThanOrEqual(6); + for (const set of defaultPermissionSets) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const scoped = (set.rowLevelSecurity ?? []).filter((p: any) => p.object === 'sys_comment_reaction'); + expect(scoped, `${set.name} sys_comment_reaction policies`).toEqual([]); + } + }); +}); + /** * [#11965 / #11663 Choice 6A] platform-admin re-anchor, L1 behaviour-neutrality * pin. diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index ef98b30db7d..1a0c44d5213 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -101,7 +101,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly