From 5a107614c93c370066d088eaa45cf0117523db90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:00:22 +0000 Subject: [PATCH 1/4] fix(app-shell): take back an own reaction through the unreact door Claude-Session: https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8 Co-authored-by: Claude --- .../RecordDetailView.feedGrant-12082.test.tsx | 98 ++++++++++++++++--- .../app-shell/src/views/RecordDetailView.tsx | 42 +++++--- .../core/src/utils/affordanceGrants.ts | 10 +- .../src/affordanceGrantMap-12082.test.tsx | 8 +- 4 files changed, 127 insertions(+), 31 deletions(-) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx index f5b48628d5f..5526c191051 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx @@ -16,8 +16,10 @@ * * - the composer: `feedComment`, the create grant on `sys_comment`; * - the reaction control: `feedReactionAdd`, the create grant on - * `sys_comment_reaction`, with a take-back asking `feedReactionRemove`, its - * delete grant, per click; + * `sys_comment_reaction`, with a take-back asking `feedReactionRemove` per + * click: the same create grant, since the take-back goes through the + * unreact door (`DELETE /api/v1/comments/reactions/:id`), which removes the + * caller's own row with no `delete` grant; * - on a deployment with no `sys_comment_reaction` (the column store), the * reaction control: `feedReactionColumn`, the update grant on `sys_comment`. * @@ -36,10 +38,13 @@ import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/pe import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail'; import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data'; +/** The page's authenticated fetch: the unreact door is called through it. */ +const authFetch = vi.hoisted(() => vi.fn()); + vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), useAuth: () => ({ user: { id: 'u1', name: 'u1', image: null }, activeOrganization: null }), - createAuthenticatedFetch: () => vi.fn(), + createAuthenticatedFetch: () => authFetch, })); vi.mock('@object-ui/collaboration', async (importOriginal) => ({ @@ -69,6 +74,7 @@ vi.mock('./MetadataInspector', () => ({ })); import { RecordDetailView } from './RecordDetailView'; +import { toast } from 'sonner'; const COMMENT = 'sys_comment'; const REACTION = 'sys_comment_reaction'; @@ -127,8 +133,11 @@ function answer(feed: { [COMMENT]?: Bits; [REACTION]?: Bits }): MePermissionsRes } as MePermissionsResponse; } -/** One comment by another member; the caller (`u1`) has already reacted 👍 on it. */ -function makeDataSource() { +/** + * One comment by another member; `reactor` (the caller `u1` unless named) has + * already reacted 👍 on it. + */ +function makeDataSource(reactor = 'u1') { const comment = { id: COMMENT_ID, thread_id: THREAD, @@ -136,9 +145,9 @@ function makeDataSource() { author_name: 'u3', body: COMMENT_BODY, created_at: '2026-10-10T08:00:00.000Z', - reactions: JSON.stringify({ '👍': ['u1'] }), + reactions: JSON.stringify({ '👍': [reactor] }), }; - const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: 'u1', created_at: '2026-10-10T09:00:00.000Z' }; + const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: reactor, created_at: '2026-10-10T09:00:00.000Z' }; return { find: vi.fn(async (objectName: string) => { if (objectName === COMMENT) return { data: [{ ...comment }] }; @@ -210,7 +219,23 @@ const thumbsChip = (view: View) => (b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '👍', ); +/** The unreact door's answer to a removal of the caller's own row. */ +const unreacted = (id: string) => + new Response(JSON.stringify({ success: true, data: { id, deleted: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +/** The calls that reached the unreact door: `[url, init]`. */ +const unreactCalls = () => + authFetch.mock.calls.filter(([url]) => String(url).includes('/api/v1/comments/reactions/')); + beforeEach(() => { + authFetch.mockImplementation(async (url: string) => + String(url).includes('/api/v1/comments/reactions/') + ? unreacted(String(url).split('/').pop() as string) + : new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ); vi.stubGlobal( 'fetch', vi.fn(async () => @@ -267,19 +292,22 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio expect((thumbsChip(view) as HTMLButtonElement).disabled).toBe(!offered); }); - it('a take-back asks the delete grant: with create but not delete, clicking the own reaction writes nothing and keeps it shown', async () => { + it('a take-back reads no delete grant: with create but not delete, clicking the own reaction removes it through the unreact door', async () => { const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly })); fireEvent.click(thumbsChip(view)!); await act(async () => {}); + expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/r1', { method: 'DELETE', credentials: 'include' }]]); expect(dataSource.delete).not.toHaveBeenCalled(); - expect(thumbsChip(view)!.children[1].textContent).toBe('1'); + expect(thumbsChip(view)).toBeUndefined(); }); - it('CONTROL — with the delete grant the same click takes the reaction back by deleting the own row', async () => { + it('CONTROL — with the delete grant the same click takes the reaction back through the same door, never the data door', async () => { const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.full })); fireEvent.click(thumbsChip(view)!); await act(async () => {}); - expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]); + expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/r1', { method: 'DELETE', credentials: 'include' }]]); + expect(dataSource.delete).not.toHaveBeenCalled(); + expect(thumbsChip(view)).toBeUndefined(); }); it('adding through the picker creates the own row where create is held', async () => { @@ -291,6 +319,51 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio }); }); +describe("a take-back goes through the unreact door, and only on the caller's own reaction (objectstack#26346)", () => { + it('a member with no grant beyond create on sys_comment_reaction adds a reaction and takes it back', async () => { + const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.readOnly, [REACTION]: SHAPE.createOnly })); + fireEvent.click(addReaction(view)!); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: '🎉' })); + await act(async () => {}); + const party = () => + (view.queryAllByRole('button') as HTMLElement[]).find( + (b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '🎉', + ); + expect(party()).toBeDefined(); + fireEvent.click(party()!); + await act(async () => {}); + expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '🎉' }]]); + expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/new-row', { method: 'DELETE', credentials: 'include' }]]); + expect(dataSource.delete).not.toHaveBeenCalled(); + expect(party()).toBeUndefined(); + }); + + it("another member's reaction offers no take-back: a click on their chip adds the caller's own row and reaches no door that removes", async () => { + const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.full }), makeDataSource('u2')); + fireEvent.click(thumbsChip(view)!); + await act(async () => {}); + expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '👍' }]]); + expect(unreactCalls()).toEqual([]); + expect(dataSource.delete).not.toHaveBeenCalled(); + expect(thumbsChip(view)!.children[1].textContent).toBe('2'); + }); + + it('a take-back the door refuses puts the reaction back and says it was not saved', async () => { + authFetch.mockImplementation(async () => + new Response(JSON.stringify({ success: false, error: { code: 'PERMISSION_DENIED' } }), { + status: 403, + headers: { 'content-type': 'application/json' }, + }), + ); + const { view } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly })); + fireEvent.click(thumbsChip(view)!); + await act(async () => {}); + expect(unreactCalls().map(([url]) => url)).toEqual(['/api/v1/comments/reactions/r1']); + expect(thumbsChip(view)!.children[1].textContent).toBe('1'); + expect(toast.error).toHaveBeenCalledTimes(1); + }); +}); + /** * The two feed objects as objectstack registers them (`plugin-audit`): their * policy keys (`managedBy`, `userActions`) and the `enable` block the server @@ -336,7 +409,8 @@ describe('against the registered feed schemas in the page objects (objectui#1208 expect(addReaction(view)).not.toBeNull(); fireEvent.click(thumbsChip(view)!); await act(async () => {}); - expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]); + expect(unreactCalls().map(([url]) => url)).toEqual(['/api/v1/comments/reactions/r1']); + expect(dataSource.delete).not.toHaveBeenCalled(); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index d375ca2b3b7..5081fb31954 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -368,8 +368,9 @@ interface ReactionLedger { * ruling A amended on objectstack-ai/objectstack#22505; objectui#12078). * * A member adds a reaction by creating their row (`comment_id`, `emoji`; the - * server stamps `user_id` from the session) and takes it back by deleting that - * row. Nobody's write touches anyone else's reaction, so two members reacting + * server stamps `user_id` from the session) and takes it back by removing that + * row through the unreact door ({@link COMMENT_REACTION_REMOVE_PATH}). Nobody's + * write touches anyone else's reaction, so two members reacting * at the same moment both stay stored, which a whole-set write to * `sys_comment.reactions` could not do: the later write replaced the earlier. * @@ -380,6 +381,18 @@ interface ReactionLedger { */ const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; +/** + * The unreact door, `DELETE` + `/:id` of a `sys_comment_reaction` row + * (objectstack-ai/objectstack#26161): it removes the caller's own reaction + * row and refuses anyone else's (`403 PERMISSION_DENIED`). A take-back goes + * through it and not through the data door's `delete`, because that one needs + * an object-level `delete` grant, which no every-member set may carry + * (ADR-0090 D5): a member who reacts on the every-member grants alone could + * add a reaction and never take it back. The door reads no body and refuses + * any query parameter. + */ +const COMMENT_REACTION_REMOVE_PATH = '/api/v1/comments/reactions'; + /** The object the record feed's comments and replies are rows of. */ const COMMENT_OBJECT = 'sys_comment'; @@ -2869,9 +2882,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri * All of the above is the `column` store, which runs only where the * deployment has no `sys_comment_reaction`. On the `records` store * (objectui#12078) a click writes nothing but the clicker's own row: it - * creates that row, or deletes it, so no write can replace another member's + * creates that row, or removes it through the unreact door, so no write can replace another member's * reaction and two members reacting at once both stay stored. The writes of - * one comment and emoji run one after another (a take-back deletes the row an + * one comment and emoji run one after another (a take-back removes the row an * earlier click is still creating). Once the last of them answers, the row * must show the clicker's reaction exactly when the server holds their row; * if it does not, a write was refused, and the clicker's own id on that emoji @@ -2900,11 +2913,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const userId = currentUser.id; const want = !holdsOwnReaction(row.reactions, emoji, userId); // The control is offered on the add row (`feedReactionAdd`, where it is - // handed over below); taking a reaction back is the delete of the - // caller's own row, its own row of the map. A click that would take - // back with that row closed writes nothing and shows nothing changed, - // rather than showing a take-back the server then refuses - // (objectui#12082). + // handed over below); taking a reaction back is the removal of the + // caller's own row through the unreact door, its own row of the map + // (`feedReactionRemove`, which reads no `delete` grant). A click that + // would take back with that row closed writes nothing and shows + // nothing changed, rather than showing a take-back the server then + // refuses (objectui#12082). if (!want && !reactionRemoveGranted) return; const commentKey = JSON.stringify([key, String(itemId)]); const writeKey = JSON.stringify([key, String(itemId), emoji]); @@ -2944,9 +2958,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } ownRows().set(emoji, String(created.id)); } else if (!want && rowId !== undefined) { - if ((await dataSource.delete(COMMENT_REACTION_OBJECT, rowId)) === false) { - throw new Error(`The ${emoji} reaction was not deleted.`); - } + const res = await authFetch( + `${import.meta.env.VITE_SERVER_URL || ''}${COMMENT_REACTION_REMOVE_PATH}/${encodeURIComponent(rowId)}`, + { method: 'DELETE', credentials: 'include' }, + ); + if (!res.ok) throw new Error(`The ${emoji} reaction was not removed (HTTP ${res.status}).`); ownRows().delete(emoji); } }; @@ -3041,7 +3057,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri () => settle('failed'), ); }, - [currentUser.id, dataSource, feedRecordKey, feedItemsByRecord, reactionStore, reactionRemoveGranted, t], + [authFetch, currentUser.id, dataSource, feedRecordKey, feedItemsByRecord, reactionStore, reactionRemoveGranted, t], ); useEffect(() => { diff --git a/ui/packages/core/src/utils/affordanceGrants.ts b/ui/packages/core/src/utils/affordanceGrants.ts index fa811ebffa7..2669402633c 100644 --- a/ui/packages/core/src/utils/affordanceGrants.ts +++ b/ui/packages/core/src/utils/affordanceGrants.ts @@ -208,9 +208,15 @@ export const AFFORDANCE_GRANTS = { feedReactionAdd: { crud: 'create', grant: 'create' }, /** * The same control taking the caller's reaction back, asked of - * `sys_comment_reaction`: it deletes the caller's own reaction row. + * `sys_comment_reaction`: it removes the caller's own reaction row through + * the unreact door (`DELETE /api/v1/comments/reactions/:id`), not the data + * door, so no CRUD bit is read. That door admits exactly the caller's own + * row and needs no `delete` grant, which no every-member set may carry + * (ADR-0090 D5), so the take-back asks the grant the reaction was added + * under: whoever may react may take their own reaction back. Whose reaction + * it is decides which chips offer it; the door's own rule decides the write. */ - feedReactionRemove: { crud: 'delete', grant: 'delete' }, + feedReactionRemove: { crud: null, grant: 'create' }, /** * The same control on a deployment with no `sys_comment_reaction` (the * reactions column store), asked of `sys_comment`: a click updates the diff --git a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index efc54c0ae19..4931e06aa8a 100644 --- a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -121,10 +121,12 @@ const EXPECTED_GRANT: Record = { kanbanCardMove: 'update', // The record feed's writes (the census backlog's leg B), each asked of the // object it writes: a comment or reply, a reaction added or taken back, and - // a reaction on the column store. + // a reaction on the column store. The take-back removes the caller's own + // row through the unreact door, which needs no delete grant, so it asks the + // grant the reaction was added under (objectstack#26346). feedComment: 'create', feedReactionAdd: 'create', - feedReactionRemove: 'delete', + feedReactionRemove: 'create', feedReactionColumn: 'update', // objectui#12268: the author's Edit and Delete on their own feed comment, // asked of `sys_comment`. @@ -695,8 +697,6 @@ const WRITE_SITES: Record = { mapped(['feedReactionColumn'], RECORD_PAGE), 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: create': mapped(['feedReactionAdd'], RECORD_PAGE), - 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: delete': - mapped(['feedReactionRemove'], RECORD_PAGE), 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > onClick :: delete': mapped(['recordDelete'], RECORD_PAGE), 'packages/app-shell/src/views/RelatedRecordActionsBridge.tsx :: RelatedRecordActionsBridge > value > resolve :: delete': From 9f62e061c3b6f888b39f0f1113d5af6c4c7b322d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:01:09 +0000 Subject: [PATCH 2/4] docs(changeset): reaction take-back through the unreact door Claude-Session: https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8 Co-authored-by: Claude --- .changeset/26346-reaction-unreact-door.md | 15 +++++++++++++++ ui/content/docs/plugins/plugin-detail.mdx | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .changeset/26346-reaction-unreact-door.md diff --git a/.changeset/26346-reaction-unreact-door.md b/.changeset/26346-reaction-unreact-door.md new file mode 100644 index 00000000000..3e64de8f4a2 --- /dev/null +++ b/.changeset/26346-reaction-unreact-door.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/core': patch +--- + +fix(app-shell): a member takes back their own comment reaction through the unreact door, with no object delete grant + +A member whose grants are the every-member ones could add a reaction on the record feed and never take it back. The reaction chip removed through the generic data door's `DELETE /api/v1/data/sys_comment_reaction/:id`, which needs an object-level `delete` grant that no every-member set may carry (ADR-0090 D5), and the `feedReactionRemove` row of the affordance-to-grant map read that grant, so the chip offered no take-back at all. + +Clause-②: no +Metadata-protocol: none + +- The record page now removes the caller's own reaction row through the unreact door, `DELETE /api/v1/comments/reactions/:id`, which `@objectstack/plugin-audit` mounts. The door removes only the caller's own row; a refused take-back puts the reaction back on screen and says it was not saved, as before. +- `feedReactionRemove` in `@object-ui/core`'s `AFFORDANCE_GRANTS` now reads `{ crud: null, grant: 'create' }`: its write no longer goes through the data door, so no CRUD bit is read, and it asks the grant the reaction was added under. Whose reaction a chip shows still decides which chips offer the take-back: a click on another member's reaction adds the caller's own. +- A member who holds the `delete` grant sees the same behaviour: the take-back goes through the same door. diff --git a/ui/content/docs/plugins/plugin-detail.mdx b/ui/content/docs/plugins/plugin-detail.mdx index 68a49ffab0d..acf26b22fc6 100644 --- a/ui/content/docs/plugins/plugin-detail.mdx +++ b/ui/content/docs/plugins/plugin-detail.mdx @@ -222,7 +222,10 @@ Three sources, in precedence order: hands each handler over only where the caller may make that write (objectui#12082): the composer and the reply input need the **create** grant on `sys_comment`; the reaction control needs the **create** grant on - `sys_comment_reaction`, and taking a reaction back its **delete** grant; on a + `sys_comment_reaction`, and taking the caller's own reaction back needs the + same grant: it goes through the unreact door + (`DELETE /api/v1/comments/reactions/:id`), which removes only the caller's + own row and asks no **delete** grant. On a deployment with no `sys_comment_reaction` the control needs the **update** grant on `sys_comment`. These are the `feedComment`, `feedReactionAdd`, `feedReactionRemove` and `feedReactionColumn` rows of the affordance-to-grant From ba54f83a4448f8391a644c71bdc4caabb5a89a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:09:40 +0000 Subject: [PATCH 3/4] test(app-shell): the reaction records pin removes through the unreact door Claude-Session: https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8 Co-authored-by: Claude --- ...dDetailView.reactionRecords-12078.test.tsx | 56 +++++++++++++++---- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx index 6bc86b10132..195a8138cb3 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx @@ -10,7 +10,8 @@ * left only the later write stored. Now, where the deployment has the object, * the chatter reads every comment's reaction rows in one batched `comment_id` * `$in` read, groups them into the shape the panel already renders, and a click - * creates or deletes the clicker's own row. Where the deployment has no such + * creates the clicker's own row, or removes it through the unreact door + * (`DELETE /api/v1/comments/reactions/:id`, objectstack#26346). Where the deployment has no such * object (a framework that predates it), the column path stays, unchanged: the * `objectui#11019` / `objectui#10899` / `objectui#11035` pins beside this file * run on that path. @@ -20,7 +21,8 @@ * source is a fake SERVER shared by every mounted view: each view's adapter is * bound to its signed-in member, and the server keeps the object's declared * rules: `user_id` stamped from that member, one row per (comment, emoji, - * user), and a member deletes only their own row. Which store the page uses is + * user), and a member removes only their own row, through the unreact door + * the page's authenticated fetch reaches. Which store the page uses is * read from the object registry the page's metadata context serves. */ @@ -36,6 +38,9 @@ import { toast } from 'sonner'; /** The signed-in member is read from a context, so two views can be two members at once. */ const signedIn = vi.hoisted(() => ({ ctx: null as unknown as React.Context })); +/** The unreact door of the member a view is being mounted as: the page's authenticated fetch reaches it. */ +const mounting = vi.hoisted(() => ({ door: null as unknown as (url: string, init?: RequestInit) => Promise })); + vi.mock('@object-ui/auth', async (importOriginal) => { const ReactModule = await import('react'); signedIn.ctx = ReactModule.createContext('u1'); @@ -45,7 +50,12 @@ vi.mock('@object-ui/auth', async (importOriginal) => { const id = ReactModule.useContext(signedIn.ctx); return { user: { id, name: id, image: null }, activeOrganization: null }; }, - createAuthenticatedFetch: () => vi.fn(), + // Only the unreact door is answered; every other call answers nothing, as before. + createAuthenticatedFetch: () => { + const door = mounting.door; + return (url: string, init?: RequestInit) => + String(url).includes('/api/v1/comments/reactions/') ? door(url, init) : undefined; + }, }; }); @@ -136,7 +146,8 @@ const refusal = (status: number, code: string) => /** * One server shared by every view a case mounts. `as(member)` is that member's * adapter: what they create is stamped as theirs, and they delete only rows - * that are theirs. + * that are theirs. Its `unreact` is that member's unreact door: it removes the + * member's own row and refuses anyone else's, as the server's door does. */ function makeServer(init: { comments?: CommentRow[]; reactions?: Omit[] } = {}) { const comments: CommentRow[] = init.comments ?? [ @@ -192,6 +203,18 @@ function makeServer(init: { comments?: CommentRow[]; reactions?: Omit { + const answer = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + const id = decodeURIComponent(String(url).split('/api/v1/comments/reactions/')[1]); + if (init?.method !== 'DELETE') return answer(404, { success: false }); + const at = reactions.findIndex((r) => r.id === id); + if (at < 0) return answer(404, { success: false, error: { code: 'RECORD_NOT_FOUND' } }); + // The door's own rule: the row's reactor is the caller. + if (reactions[at].user_id !== member) return answer(403, { success: false, error: { code: 'PERMISSION_DENIED' } }); + reactions.splice(at, 1); + return answer(200, { success: true, data: { id, deleted: true } }); + }), }); return { comments, reactions, as }; @@ -234,6 +257,7 @@ function tree(member: string, dataSource: any, metadata: any) { /** Mounts the record page as `member` and waits for the comment to show. */ async function mountAs(member: string, dataSource: any, objects: unknown[] = REGISTRY.present) { + mounting.door = dataSource.unreact; const view = render(tree(member, dataSource, metadataFor(objects))); await within(view.container).findByText(COMMENT_BODY); // Let the reaction read that follows the comment read land. @@ -283,6 +307,11 @@ async function click(run: () => void) { await act(async () => {}); } +/** The row ids a member's take-backs removed through the unreact door, in order. */ +function unreacted(dataSource: any): string[] { + return dataSource.unreact.mock.calls.map(([url]: [string]) => decodeURIComponent(url.split('/').pop() as string)); +} + /** Every `sys_comment_reaction` read a data source was asked, as its `$in` id lists. */ function reactionReads(dataSource: any): string[][] { return dataSource.find.mock.calls @@ -332,7 +361,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor expect(isOwn(again, '👍')).toBe(true); }); - it('a second click removes it: the clicker deletes their own row, by the id the create returned and by the id the read returned', async () => { + it('a second click removes it: the clicker removes their own row through the unreact door, by the id the create returned and by the id the read returned', async () => { const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '👍', user_id: 'u2' }] }); const u1 = server.as('u1'); const view = await mountAs('u1', u1); @@ -342,11 +371,12 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor expect(chips(view)).toEqual(['👍 1', '🎉 1']); await click(() => fireEvent.click(chip(view, '🎉'))); - expect(u1.delete.mock.calls).toEqual([[REACTION, created.id]]); + expect(unreacted(u1)).toEqual([created.id]); + expect(u1.delete).not.toHaveBeenCalled(); expect(server.reactions.map((r) => [r.emoji, r.user_id])).toEqual([['👍', 'u2']]); expect(chips(view)).toEqual(['👍 1']); - // A row the read found: its id is the one the take-back deletes. + // A row the read found: its id is the one the take-back removes. cleanup(); const before = makeServer({ reactions: [ @@ -359,7 +389,8 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor const read = await mountAs('u1', reader); expect(chips(read)).toEqual(['👍 2']); await click(() => fireEvent.click(chip(read, '👍'))); - expect(reader.delete.mock.calls).toEqual([[REACTION, mine.id]]); + expect(unreacted(reader)).toEqual([mine.id]); + expect(reader.delete).not.toHaveBeenCalled(); expect(before.reactions.map((r) => r.user_id)).toEqual(['u2']); expect(chips(read)).toEqual(['👍 1']); expect(toast.error).not.toHaveBeenCalled(); @@ -452,7 +483,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor expect(server.reactions.map((r) => r.user_id)).toEqual(['u2']); }); - it('a take-back clicked before the create answers deletes the row that create made', async () => { + it('a take-back clicked before the create answers removes the row that create made', async () => { const server = makeServer(); const u1 = server.as('u1'); let answer!: () => void; @@ -471,11 +502,12 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor expect(chips(view)).toEqual([]); await act(async () => {}); // The take-back waits for the row it takes back. - expect(u1.delete).not.toHaveBeenCalled(); + expect(u1.unreact).not.toHaveBeenCalled(); await act(async () => answer()); - await waitFor(() => expect(u1.delete).toHaveBeenCalledTimes(1)); - expect(u1.delete.mock.calls[0]).toEqual([REACTION, 'r1']); + await waitFor(() => expect(u1.unreact).toHaveBeenCalledTimes(1)); + expect(unreacted(u1)).toEqual(['r1']); + expect(u1.delete).not.toHaveBeenCalled(); expect(server.reactions).toEqual([]); expect(chips(view)).toEqual([]); expect(toast.error).not.toHaveBeenCalled(); From 0a064d80eb07e3b394d9c5b8bedf1b1415b6baec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:13:00 +0000 Subject: [PATCH 4/4] chore(sdui): re-record the core package record Claude-Session: https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8 Co-authored-by: Claude --- scripts/sdui-manifest.sources/core.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 8fe6fe5a4f1..72a3f5fe575 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":112,"sha256":"810fe0e8f6db868fd443540cb2e30a85f691a48a08d58ba7ba8db0c577d579e7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":112,"sha256":"efff82df58673728f7eb1904a1a24633f981cba071d1f24541529b3b314f9da7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}