From f303beb7b77dd954d9c7a5976ddacd1ca3c615a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:46:47 +0000 Subject: [PATCH 01/11] feat(plugin-audit): sys_comment_reaction, the reactor's own record of an emoji reaction A reaction is one (comment, emoji, user) row owned by the reactor. The comment gate's two installers answer for it with the same handler: a reaction is readable exactly when its comment is (one caller-scoped read of sys_comment), and created only on a comment the caller can read, with user_id stamped from the session. Delete is the platform's own-record floor; update is not offered. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../scripts/i18n-extract.config.ts | 3 +- .../plugins/plugin-audit/src/audit-plugin.ts | 21 +- .../plugin-audit/src/comment-access-hooks.ts | 250 ++++++++++++++---- .../plugins/plugin-audit/src/objects/index.ts | 4 +- .../objects/sys-comment-reaction.object.ts | 136 ++++++++++ 5 files changed, 354 insertions(+), 60 deletions(-) create mode 100644 packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts 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.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/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.ts b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts new file mode 100644 index 00000000000..2a1c0d63211 --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts @@ -0,0 +1,136 @@ +// 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. + * + * ## 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 imported in bulk. + userActions: { edit: false, import: false }, + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'delete'], + // Not a record anyone discusses, and its CRUD is not a timeline event. + feeds: false, + activities: false, + clone: false, + }, +}); From 96bbfafd14678e7ce3508e828119375d1ad26e8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:51:43 +0000 Subject: [PATCH 02/11] test(plugin-audit): pin the reaction read rule, create gate, stamping and uniqueness Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- ...omment-reaction-access.integration.test.ts | 344 ++++++++++++++++++ .../sys-comment-reaction.object.test.ts | 79 ++++ 2 files changed, 423 insertions(+) create mode 100644 packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts create mode 100644 packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts 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..55bcdabc39e --- /dev/null +++ b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts @@ -0,0 +1,344 @@ +// 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`; + * - 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 } as any); + const readableCommentIds = async (caller: Caller): Promise => + (await engine.find(COMMENT, { context: caller } as any)).map((r: Row) => String(r.id)).sort(); + const allReactions = async (): Promise => engine.find(REACTION, { context: SYS } as any); + + 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 } as any); + expect(row?.user_id).toBe(MEMBER.userId); + expect(row?.created_by).toBe(MEMBER.userId); + await engine.delete(REACTION, { where: { id: created.id }, context: SYS } as any); + }); + + 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 } as any)).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 } as any); + 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 } as any)).toBe(3); + expect(await engine.count(REACTION, {}, { context: OTHER } as any)).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 } as any)).toBeNull(); + expect((await engine.findOne(REACTION, { where: { id: shown.id }, context: MEMBER } as any))?.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 } as any, + ); + 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 } as any); + 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 } as any); + expect(rows).toEqual([]); + }); + + it('a system read is not narrowed', async () => { + const rows = await engine.find(REACTION, { context: SYS } as any); + 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 } as any); + }); + }); + + 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 } as any)); + expect(deleted).toMatchObject({ ok: true }); + for (const caller of [MEMBER, OTHER, ADMIN]) { + const rows = await engine.find(REACTION, { where: { comment_id: doomed }, context: caller } as any); + expect(rows, caller.userId).toEqual([]); + } + expect((await engine.find(REACTION, { where: { comment_id: doomed }, context: SYS } as any)).length).toBe(1); + }); + }); +}); 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..df9227b15bb --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts @@ -0,0 +1,79 @@ +// 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 — never update', () => { + expect(SysCommentReaction.enable?.apiMethods).toEqual(['get', 'list', 'create', 'delete']); + 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 }); + }); +}); From 8361ea8d822b43fcca3f623462a279278e6299fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:54:29 +0000 Subject: [PATCH 03/11] test(plugin-security): a reaction's delete is the own-record floor, as shipped Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../comment-reaction-own-record-floor.test.ts | 211 ++++++++++++++++++ .../objects/default-permission-sets.test.ts | 24 ++ 2 files changed, 235 insertions(+) create mode 100644 packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts 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..0304d13c52e --- /dev/null +++ b/packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts @@ -0,0 +1,211 @@ +// 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 { 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'] }, +}; + +/** 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', +}); +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 } as any); +const rowById = (ql: ObjectQL, id: string) => ql.findOne(REACTION, { where: { id }, context: SYSTEM } as any); + +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 } as any)); + 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 } as any))?.id).toBe(created.id); + + // …and may not remove it. + const byId = await attempt(() => ql.delete(REACTION, { where: { id: created.id }, context: BOB } as any)); + expect(byId).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + + await attempt(() => + ql.delete(REACTION, { where: { comment_id: BOBS_COMMENT }, multi: true, context: BOB } as any), + ); + 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 } as any); + 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 } as any))).toMatchObject({ + ok: false, + code: 'PERMISSION_DENIED', + status: 403, + }); + expect(await attempt(() => ql.delete(REACTION, { where: { id: b.id }, context: BOB } as any))).toEqual({ ok: true }); + const left = await ql.find(REACTION, { where: { comment_id: BOBS_COMMENT }, context: SYSTEM } as any); + expect(left.map((r: any) => r.id)).toEqual([a.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. From 8fb4570d2466ddaf819dc74f2241e45616660d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:57:47 +0000 Subject: [PATCH 04/11] chore(census): sys_comment_reaction enters the platform-object tenancy census Regenerated with node scripts/platform-object-tenancy-census.mjs --write. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- scripts/platform-object-tenancy-census.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index d829ab1b999..bfea2ed6d67 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 83, - "inReach": 48, + "registered": 84, + "inReach": 49, "outOfReach": 35 }, "reasonTotals": { @@ -154,6 +154,13 @@ "tenantField": "organization_id", "reasons": [] }, + { + "name": "sys_comment_reaction", + "file": "packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_device_code", "file": "packages/platform-objects/src/identity/sys-device-code.object.ts", From 91e3f1b46bca5de74086fd215cc45325d1a2ee30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:58:50 +0000 Subject: [PATCH 05/11] =?UTF-8?q?chore(changeset):=20plugin-audit=20minor?= =?UTF-8?q?=20=E2=80=94=20sys=5Fcomment=5Freaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .changeset/22566-comment-reaction-record.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/22566-comment-reaction-record.md diff --git a/.changeset/22566-comment-reaction-record.md b/.changeset/22566-comment-reaction-record.md new file mode 100644 index 00000000000..c4352397cd3 --- /dev/null +++ b/.changeset/22566-comment-reaction-record.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-audit': 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` and `delete` on it. It offers no `update`: a reaction is removed and made again, not edited. +- **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. +- **`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. From 4e449a197497a1b53bdd0e584c7b3df625ef5838 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:03:18 +0000 Subject: [PATCH 06/11] chore(i18n): regenerate plugin-audit bundles for sys_comment_reaction node scripts/check-i18n-bundles.mjs --write --filter=audit Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/translations/en.objects.generated.ts | 25 +++++++++++++++++++ .../translations/es-ES.objects.generated.ts | 25 +++++++++++++++++++ .../es-ES.source-hashes.generated.ts | 11 ++++++++ .../translations/ja-JP.objects.generated.ts | 25 +++++++++++++++++++ .../ja-JP.source-hashes.generated.ts | 11 ++++++++ .../translations/zh-CN.objects.generated.ts | 25 +++++++++++++++++++ .../zh-CN.source-hashes.generated.ts | 11 ++++++++ 7 files changed, 133 insertions(+) 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", }; From cdd0198080f65903e535cc69d3d3a2147693830b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:20:10 +0000 Subject: [PATCH 07/11] test(plugin-audit): the provisioning and bundle-ownership pins name sys_comment_reaction Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- packages/plugins/plugin-audit/src/audit-plugin.test.ts | 4 ++++ .../plugin-audit/src/translations/bundle-ownership.test.ts | 1 + 2 files changed, 5 insertions(+) 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/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)', () => { From 32c30d8034a948c80023c6a6e48f4ee5020f1905 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:35:43 +0000 Subject: [PATCH 08/11] test: type the reaction tests' query options instead of erasing them Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- ...omment-reaction-access.integration.test.ts | 38 +++++++++---------- .../comment-reaction-own-record-floor.test.ts | 22 +++++------ 2 files changed, 30 insertions(+), 30 deletions(-) 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 index 55bcdabc39e..d2b07859a39 100644 --- a/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts +++ b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts @@ -106,10 +106,10 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { 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 } as any); + engine.insert(REACTION, { comment_id: commentId, emoji, ...extra }, { context: caller }); const readableCommentIds = async (caller: Caller): Promise => - (await engine.find(COMMENT, { context: caller } as any)).map((r: Row) => String(r.id)).sort(); - const allReactions = async (): Promise => engine.find(REACTION, { context: SYS } as any); + (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' } }); @@ -190,10 +190,10 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { 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 } as any); + 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 } as any); + 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 () => { @@ -231,13 +231,13 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { ] 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 } as any)).map((r: Row) => String(r.comment_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 } as any); + 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}:👍`, @@ -248,21 +248,21 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { }); it('count: the total is narrowed identically to the rows', async () => { - expect(await engine.count(REACTION, {}, { context: MEMBER } as any)).toBe(3); - expect(await engine.count(REACTION, {}, { context: OTHER } as any)).toBe(3); + 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 } as any)).toBeNull(); - expect((await engine.findOne(REACTION, { where: { id: shown.id }, context: MEMBER } as any))?.id).toBe(shown.id); + 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 } as any, + { 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 }); @@ -271,7 +271,7 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { 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 } as any); + 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); @@ -287,12 +287,12 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { }); 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 } as any); + 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 } as any); + const rows = await engine.find(REACTION, { context: SYS }); expect(rows.map((r: Row) => r.comment_id)).toEqual(expect.arrayContaining([comments.onTheirs, comments.onMine])); }); }); @@ -320,7 +320,7 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { 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 } as any); + await engine.delete(REACTION, { where: { id: created.id }, context: SYS }); }); }); @@ -332,13 +332,13 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { 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 } as any)); + 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 } as any); + 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 } as any)).length).toBe(1); + expect((await engine.find(REACTION, { where: { comment_id: doomed }, context: SYS })).length).toBe(1); }); }); }); 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 index 0304d13c52e..20336762b5a 100644 --- 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 @@ -81,7 +81,7 @@ const member = (userId: string) => ({ tenantId: 'org_acme', positions: ['org_member', 'everyone'], permissions: [REACTOR_SET.name], - posture: 'MEMBER', + posture: 'MEMBER' as const, }); const ALICE = member('usr_alice'); const BOB = member('usr_bob'); @@ -162,8 +162,8 @@ const attempt = async (run: () => Promise): Promise => { }; const react = (ql: ObjectQL, caller: ReturnType, emoji: string) => - ql.insert(REACTION, { comment_id: BOBS_COMMENT, emoji, user_id: caller.userId }, { context: caller } as any); -const rowById = (ql: ObjectQL, id: string) => ql.findOne(REACTION, { where: { id }, context: SYSTEM } as any); + 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 () => { @@ -172,7 +172,7 @@ describe('[#22566] a reaction is its reactor’s own record — the own-record f // 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 } as any)); + 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); @@ -181,14 +181,14 @@ describe('[#22566] a reaction is its reactor’s own record — the own-record f 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 } as any))?.id).toBe(created.id); + 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 } as any)); + 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 } as any), + ql.delete(REACTION, { where: { comment_id: BOBS_COMMENT }, multi: true, context: BOB }), ); expect((await rowById(ql, created.id))?.id).toBe(created.id); }, 60_000); @@ -196,16 +196,16 @@ describe('[#22566] a reaction is its reactor’s own record — the own-record f 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 } as any); + 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 } as any))).toMatchObject({ + 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 } as any))).toEqual({ ok: true }); - const left = await ql.find(REACTION, { where: { comment_id: BOBS_COMMENT }, context: SYSTEM } as any); + 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); }); From 1e36d614fdd4f40c672f698b768343ae8c304fb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:14:49 +0000 Subject: [PATCH 09/11] fix(plugin-audit,spec): sys_comment_reaction grants bulk, and joins plugin-audit's platform object registry A whitelist that grants create and delete grants bulk too (api-methods-batch-conformance); the batch doors still admit only children allowed one at a time, so updateMany stays refused. PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit'] lists the object the package now registers. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/objects/sys-comment-reaction.object.test.ts | 6 ++++-- .../src/objects/sys-comment-reaction.object.ts | 10 ++++++++-- .../spec/src/system/constants/platform-object-names.ts | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) 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 index df9227b15bb..e75a10fd2bf 100644 --- 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 @@ -65,8 +65,10 @@ describe('sys_comment_reaction declaration', () => { expect((SysCommentReaction as any).systemFields?.audit).not.toBe(false); }); - it('offers create, read and delete over the data API — never update', () => { - expect(SysCommentReaction.enable?.apiMethods).toEqual(['get', 'list', 'create', 'delete']); + 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. 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 index 2a1c0d63211..d9f1d29c2a5 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts @@ -36,6 +36,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * - **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 * @@ -120,14 +126,14 @@ export const SysCommentReaction = ObjectSchema.create({ { fields: ['comment_id', 'emoji', 'user_id'], unique: 'organization' }, ], - // A reaction is made and removed, never edited, and never imported in bulk. + // 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'], + apiMethods: ['get', 'list', 'create', 'delete', 'bulk'], // Not a record anyone discusses, and its CRUD is not a timeline event. feeds: false, activities: false, 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 Date: Sat, 10 Oct 2026 04:25:45 +0000 Subject: [PATCH 10/11] =?UTF-8?q?test:=20pin=20the=20batch=20doors=20?= =?UTF-8?q?=E2=80=94=20createMany=20and=20insertMany=20run=20the=20create?= =?UTF-8?q?=20gate=20per=20row,=20deleteMany=20keeps=20the=20own-record=20?= =?UTF-8?q?floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- ...omment-reaction-access.integration.test.ts | 64 +++++++++++++++++++ .../comment-reaction-own-record-floor.test.ts | 53 ++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) 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 index d2b07859a39..46592fc9cf1 100644 --- a/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts +++ b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts @@ -18,6 +18,8 @@ * - 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. * @@ -324,6 +326,68 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { }); }); + // 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( 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 index 20336762b5a..b9473b85fd3 100644 --- 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 @@ -45,6 +45,7 @@ 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'; @@ -63,7 +64,7 @@ const REACTION_OBJECT = { user_id: { type: 'text', label: 'User', required: true }, }, indexes: [{ fields: ['comment_id', 'emoji', 'user_id'], unique: 'organization' }], - enable: { apiMethods: ['get', 'list', 'create', 'delete'] }, + enable: { apiMethods: ['get', 'list', 'create', 'delete', 'bulk'] }, }; /** The app's grant: read, create and delete on reactions — every row. */ @@ -209,3 +210,53 @@ describe('[#22566] a reaction is its reactor’s own record — the own-record f 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); +}); From 11e9afaac5b35731fe59343537d0e3d8a2edb625 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:27:27 +0000 Subject: [PATCH 11/11] =?UTF-8?q?chore(changeset):=20spec=20minor=20?= =?UTF-8?q?=E2=80=94=20sys=5Fcomment=5Freaction=20in=20the=20platform=20ob?= =?UTF-8?q?ject=20registry;=20the=20batch=20doors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .changeset/22566-comment-reaction-record.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/22566-comment-reaction-record.md b/.changeset/22566-comment-reaction-record.md index c4352397cd3..d5ba5fd69f4 100644 --- a/.changeset/22566-comment-reaction-record.md +++ b/.changeset/22566-comment-reaction-record.md @@ -1,15 +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` and `delete` on it. It offers no `update`: a reaction is removed and made again, not edited. +- **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.