From ee42cb53e1b2fc4b7c912d1849dc2e9bc8abdd8e Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:01:03 +0100 Subject: [PATCH 1/9] test(permissions): reproduce read and session races --- .../store/permissionsStoreOwnership.spec.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts new file mode 100644 index 000000000..46ec3e60f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'user-1', + role: 'Owner', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +describe('permissionsStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + vi.clearAllMocks() + }) + + it('does not let a read started before revoke reintroduce the revoked entry', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const viewer = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner, viewer]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.revokeAccess).mockResolvedValue() + + const readRequest = store.fetchBoardAccess('board-1') + await store.revokeAccess('board-1', 'viewer') + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) + + read.resolve([owner, viewer]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) + }) + + it('does not let a read started before a role update restore the old role', async () => { + const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) + store.boardAccess.set('board-1', [oldEntry]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.updateAccess).mockResolvedValue(updatedEntry) + + const readRequest = store.fetchBoardAccess('board-1') + await store.updateAccess('board-1', 'viewer', { role: 'Admin' }) + read.resolve([oldEntry]) + await readRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + }) + + it('keeps the newest same-board read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardAccess('board-1') + const newRequest = store.fetchBoardAccess('board-1') + newer.resolve([access({ id: 'new', role: 'Admin' })]) + await newRequest + older.resolve([access({ id: 'old', role: 'Viewer' })]) + await oldRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['new']) + }) + + it('keeps loading true while independent board reads remain active', async () => { + const boardOne = deferred() + const boardTwo = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(boardOne.promise) + .mockReturnValueOnce(boardTwo.promise) + + const first = store.fetchBoardAccess('board-1') + const second = store.fetchBoardAccess('board-2') + expect(store.loading).toBe(true) + + boardOne.resolve([access({ boardId: 'board-1' })]) + await first + expect(store.loading).toBe(true) + + boardTwo.resolve([access({ id: 'board-2-owner', boardId: 'board-2' })]) + await second + expect(store.loading).toBe(false) + }) + + it('invalidates an old read across logout and login as the same user id', async () => { + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + const request = store.fetchBoardAccess('board-1') + + session.userId = null + session.userId = 'owner-1' + read.resolve([access({ id: 'old-session' })]) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('does not publish a mutation that settles after the session changes', async () => { + const pendingGrant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + + session.userId = null + session.userId = 'other-user' + pendingGrant.resolve(access({ id: 'old-session-grant', userId: 'viewer-1', role: 'Viewer' })) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('suppresses a stale read failure after a confirmed mutation', async () => { + const viewer = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [viewer]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.revokeAccess).mockResolvedValue() + + const readRequest = store.fetchBoardAccess('board-1') + await store.revokeAccess('board-1', 'viewer') + read.reject(new Error('stale failure')) + await expect(readRequest).rejects.toThrow('stale failure') + + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From c43a5ace7f403f773153cf91a7d62e312aab1b9c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:07:44 +0100 Subject: [PATCH 2/9] fix(permissions): bind access state to reads and sessions --- .../2026-09-21-permission-read-ownership.md | 41 +++++ .../src/store/permissionsStore.ts | 171 +++++++++++++++--- .../src/tests/store/permissionsStore.spec.ts | 6 +- .../store/permissionsStoreOwnership.spec.ts | 16 ++ 4 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 docs/analysis/2026-09-21-permission-read-ownership.md diff --git a/docs/analysis/2026-09-21-permission-read-ownership.md b/docs/analysis/2026-09-21-permission-read-ownership.md new file mode 100644 index 000000000..1d65748d1 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-read-ownership.md @@ -0,0 +1,41 @@ +# Board-access read and session ownership + +Status: draft PR #3330, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defects + +A board-access read could settle after a confirmed grant, update or revoke and +replace that newer client state. Same-board reads were last-response-wins, one +shared loading Boolean could clear while other boards still loaded, and pending +reads or mutations retained permission to publish after session replacement. + +A supplemental runner transpiled and executed the actual store module with only +Pinia/Vue/API/session boundaries stubbed. Seven original schedules failed on +`main`: revoke, update, reverse reads, independent loading, same-user session +replacement, old-session mutation and stale failure. The committed Vitest suite +also covers grant settlement. + +## Contract + +- One read owner exists per board; unrelated boards remain concurrent. +- A successful mutation advances that board's generation and retires older reads. +- Session identity/auth/demo transitions synchronously advance an epoch, clear + cached access, retire operations and reset loading/error. +- Success, failure, toast and cache writes require the initiating session epoch. +- Loading is derived from current operation tokens, not whichever call settles. +- A stale call still resolves or rejects to its caller; it loses only permission + to alter the replacement session's UI state. + +Server authorization remains authoritative. This corrects truthful client cache +behavior and does not claim a server-side authorization bypass. Same-board +mutation serialization is outside this slice. + +## Verification and remaining gates + +The actual-module supplemental suite changed from 0/7 ownership cases passing on +`main` to 7/7 after the correction; all twelve integration/permission schedules +pass together. TypeScript syntax transpilation passes. Existing permission-store +tests are adjusted so authenticated fixture state is established before the +session-watching store is created. Canonical lint, typecheck, build, complete +Vitest coverage, exact-head hosted CI and independent review remain required. +No merge, release or deployment qualification is claimed here. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 938eb7296..ff11fe38e 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' +import { ref, computed, watch } from 'vue' import { boardAccessApi } from '../api/boardAccessApi' import { useToastStore } from './toastStore' import { useSessionStore } from './sessionStore' @@ -16,6 +16,100 @@ export const usePermissionsStore = defineStore('permissions', () => { const loading = ref(false) const error = ref(null) + interface OperationOwner { + epoch: number + token: symbol + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + let sessionEpoch = 0 + const activeOperations = new Set() + const activeReadByBoard = new Map() + const mutationGenerationByBoard = new Map() + + function syncLoading() { + loading.value = activeOperations.size > 0 + } + + function beginOperation(label: string): OperationOwner { + const owner = { epoch: sessionEpoch, token: Symbol(label) } + activeOperations.add(owner.token) + error.value = null + syncLoading() + return owner + } + + function ownsSession(owner: OperationOwner): boolean { + return owner.epoch === sessionEpoch + } + + function finishOperation(owner: OperationOwner) { + if (!ownsSession(owner)) return + activeOperations.delete(owner.token) + syncLoading() + } + + function mutationGeneration(boardId: string): number { + return mutationGenerationByBoard.get(boardId) ?? 0 + } + + function beginRead(boardId: string): ReadOwner { + const previous = activeReadByBoard.get(boardId) + if (previous?.epoch === sessionEpoch) activeOperations.delete(previous.token) + + const operation = beginOperation(`read:${boardId}`) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration(boardId), + } + activeReadByBoard.set(boardId, owner) + return owner + } + + function ownsRead(boardId: string, owner: ReadOwner): boolean { + const current = activeReadByBoard.get(boardId) + return ownsSession(owner) + && current?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration(boardId) + } + + function finishRead(boardId: string, owner: ReadOwner) { + if (activeReadByBoard.get(boardId)?.token === owner.token) { + activeReadByBoard.delete(boardId) + } + finishOperation(owner) + } + + function recordMutation(boardId: string) { + mutationGenerationByBoard.set(boardId, mutationGeneration(boardId) + 1) + + const staleRead = activeReadByBoard.get(boardId) + if (staleRead?.epoch === sessionEpoch) { + activeReadByBoard.delete(boardId) + activeOperations.delete(staleRead.token) + syncLoading() + } + } + + function resetForSession() { + sessionEpoch += 1 + activeOperations.clear() + activeReadByBoard.clear() + mutationGenerationByBoard.clear() + boardAccess.value = new Map() + loading.value = false + error.value = null + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -62,86 +156,103 @@ export const usePermissionsStore = defineStore('permissions', () => { loading.value = false return } + + const owner = beginRead(boardId) try { - loading.value = true - error.value = null const access = await boardAccessApi.getAccess(boardId) + if (!ownsRead(boardId, owner)) return boardAccess.value.set(boardId, access) } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board access').message - error.value = msg - toast.error(msg) + if (ownsRead(boardId, owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch board access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead(boardId, owner) } } async function grantAccess(boardId: string, dto: GrantAccessDto) { guardDemoMutation() + const owner = beginOperation(`grant:${boardId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') const access = await boardAccessApi.grantAccess(boardId, dto) + if (!ownsSession(owner)) return access + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] boardAccess.value.set(boardId, [...existing, access]) toast.success('Access granted') return access } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to grant access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to grant access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() + const owner = beginOperation(`update:${boardId}:${accessId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) return updated + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - const index = existing.findIndex(a => a.id === accessId) - if (index !== -1) { - existing[index] = updated - boardAccess.value.set(boardId, [...existing]) + if (existing.some(access => access.id === accessId)) { + boardAccess.value.set( + boardId, + existing.map(access => access.id === accessId ? updated : access), + ) } toast.success('Access updated') return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() + const owner = beginOperation(`revoke:${boardId}:${accessId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) return + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(a => a.id !== accessId)) + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) toast.success('Access revoked') } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } + return { boardAccess, loading, diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts index d579138a3..33499b2d3 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts @@ -40,8 +40,9 @@ describe('permissionsStore', () => { beforeEach(() => { setActivePinia(createPinia()) - store = usePermissionsStore() sessionStore = useSessionStore() + sessionStore.userId = 'user-1' + store = usePermissionsStore() vi.clearAllMocks() }) @@ -186,6 +187,8 @@ describe('permissionsStore', () => { describe('guardrails', () => { it('throws if grantAccess is called without a session user', async () => { + sessionStore.userId = null + await expect(store.grantAccess('board-1', { userId: 'user-2', role: 'Viewer' })) .rejects .toThrow('You must be logged in to use board access management.') @@ -193,6 +196,7 @@ describe('permissionsStore', () => { }) it('returns null role checks when no session user exists', () => { + sessionStore.userId = null store.boardAccess.set('board-1', [makeAccess({ userId: 'user-1', role: 'Owner' })]) expect(store.currentUserRole('board-1')).toBeNull() diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts index 46ec3e60f..aae330eaa 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -85,6 +85,22 @@ describe('permissionsStore async ownership', () => { expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) }) + it('does not let a read started before grant erase the granted entry', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const granted = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.grantAccess).mockResolvedValue(granted) + + const readRequest = store.fetchBoardAccess('board-1') + await store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + read.resolve([owner]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) + }) + it('does not let a read started before a role update restore the old role', async () => { const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) From 40cfd0b9fa485166ff55a5d1c7650a35195ebb85 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:25:31 +0100 Subject: [PATCH 3/9] test(permissions): reproduce same-entry mutation ordering --- .../permissionsStoreMutationOrder.spec.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts new file mode 100644 index 000000000..5cb3a10b6 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation ordering', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + store.boardAccess.set('board-1', [access()]) + vi.clearAllMocks() + }) + + it('serializes two role updates for one access row in intent order', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + first.resolve(access({ role: 'Editor' })) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + second.resolve(access({ role: 'Admin' })) + await secondRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('continues with the queued update after its predecessor fails', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + first.reject(new Error('first failed')) + await expect(firstRequest).rejects.toThrow('first failed') + await flushQueue() + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + + second.resolve(access({ role: 'Admin' })) + await secondRequest + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('orders update before revoke and leaves the row removed', async () => { + const update = deferred() + const revoke = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(update.promise) + vi.mocked(boardAccessApi.revokeAccess).mockReturnValue(revoke.promise) + + const updateRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const revokeRequest = store.revokeAccess('board-1', 'access-1') + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + + update.resolve(access({ role: 'Admin' })) + await updateRequest + await flushQueue() + expect(boardAccessApi.revokeAccess).toHaveBeenCalledTimes(1) + + revoke.resolve() + await revokeRequest + expect(store.boardAccess.get('board-1')).toEqual([]) + }) + + it('does not start queued old-session transport after logout', async () => { + const first = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(first.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + session.userId = null + session.userId = 'owner-1' + + first.resolve(access({ role: 'Editor' })) + await firstRequest + await queuedRequest + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + }) + + it('keeps different access rows concurrent', async () => { + store.boardAccess.set('board-1', [ + access({ id: 'access-1', userId: 'viewer-1' }), + access({ id: 'access-2', userId: 'viewer-2' }), + ]) + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + first.resolve(access({ id: 'access-1', userId: 'viewer-1', role: 'Editor' })) + second.resolve(access({ id: 'access-2', userId: 'viewer-2', role: 'Admin' })) + await Promise.all([firstRequest, secondRequest]) + + expect(store.boardAccess.get('board-1')?.map(item => item.role)).toEqual(['Editor', 'Admin']) + }) +}) From c1fcc1e4dce6aa0147c3021774676c583c31e0e6 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:26:55 +0100 Subject: [PATCH 4/9] fix(permissions): serialize same-entry mutations --- .../2026-09-21-permission-mutation-order.md | 46 +++++++ .../src/store/permissionsStore.ts | 130 +++++++++++------- 2 files changed, 130 insertions(+), 46 deletions(-) create mode 100644 docs/analysis/2026-09-21-permission-mutation-order.md diff --git a/docs/analysis/2026-09-21-permission-mutation-order.md b/docs/analysis/2026-09-21-permission-mutation-order.md new file mode 100644 index 000000000..aa8f03a14 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-mutation-order.md @@ -0,0 +1,46 @@ +# Board-access mutation ordering + +Status: stacked draft PR #3335, 2026-09-21. Parent: PR #3330 at +`c43a5ace7f403f773153cf91a7d62e312aab1b9c`. + +## Reproduced defect + +`updateAccess` and `revokeAccess` started independently for one access row even +though the API accepts no expected revision and the entity has no configured +concurrency token. Two role changes could therefore commit or settle in an order +that differed from the user's clicks. Update/revoke could also overlap, and a +queued pre-logout intent had no transport-time session check. + +A supplemental runner executes the actual production store with only +Pinia/Vue/API/session boundaries stubbed. Against the parent, four ordering and +session schedules fail while the different-access concurrency control passes. +The same five schedules pass after the correction. + +## Contract + +- One queue exists per `{boardId, accessId}`. Update and revoke for that row run + in submission order; different rows remain concurrent. +- The first intent starts transport synchronously. A later intent waits for its + predecessor to finish, regardless of success or failure. +- Each queued operation owns a loading token from submission through settlement, + so loading does not drop between same-entry operations. +- Immediately before transport, queued work rechecks the initiating session + epoch. Session replacement clears queue registration and prevents old intent + from using later credentials. +- A predecessor failure does not cancel the next intent. The next transport + clears the predecessor's shared error before running. +- Existing successful-mutation read invalidation and stale settlement rules from + #3330 remain unchanged. + +The client queue preserves one client's submission order only. It does not solve +cross-device concurrency; the backend currently exposes no revision precondition. + +## Verification and remaining gates + +Actual-module red/green: 1/5 schedules passed on the parent, 5/5 after correction. +The preceding twelve read/session ownership schedules also remain 12/12 green. +Changed TypeScript source/tests transpile without diagnostics. Canonical Pinia/ +Vitest, lint, project typecheck, build, full hosted CI and independent review are +still required. Because this is stacked work, retarget to current `main` and +requalify after #3330 lands. No merge, release or deployment qualification is +claimed. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index ff11fe38e..e0fd36bcd 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -29,6 +29,7 @@ export const usePermissionsStore = defineStore('permissions', () => { const activeOperations = new Set() const activeReadByBoard = new Map() const mutationGenerationByBoard = new Map() + const mutationTails = new Map>() function syncLoading() { loading.value = activeOperations.size > 0 @@ -94,11 +95,40 @@ export const usePermissionsStore = defineStore('permissions', () => { } } + async function enqueueAccessMutation( + boardId: string, + accessId: string, + label: string, + task: (owner: OperationOwner) => Promise, + ): Promise { + const key = `${boardId}:${accessId}` + const predecessor = mutationTails.get(key) + const owner = beginOperation(label) + let release!: () => void + const tail = new Promise((resolve) => { release = resolve }) + mutationTails.set(key, tail) + + try { + if (predecessor) await predecessor + if (!ownsSession(owner)) return undefined + + // A predecessor may have failed after this intent was queued. Clear its + // shared error when this operation becomes the active transport owner. + error.value = null + return await task(owner) + } finally { + finishOperation(owner) + release() + if (mutationTails.get(key) === tail) mutationTails.delete(key) + } + } + function resetForSession() { sessionEpoch += 1 activeOperations.clear() activeReadByBoard.clear() mutationGenerationByBoard.clear() + mutationTails.clear() boardAccess.value = new Map() loading.value = false error.value = null @@ -201,56 +231,64 @@ export const usePermissionsStore = defineStore('permissions', () => { async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() - const owner = beginOperation(`update:${boardId}:${accessId}`) - try { - session.requireUserId('board access management') - const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) - if (!ownsSession(owner)) return updated - - recordMutation(boardId) - const existing = boardAccess.value.get(boardId) ?? [] - if (existing.some(access => access.id === accessId)) { - boardAccess.value.set( - boardId, - existing.map(access => access.id === accessId ? updated : access), - ) - } - toast.success('Access updated') - return updated - } catch (e: unknown) { - if (ownsSession(owner)) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) - } - throw e - } finally { - finishOperation(owner) - } + return await enqueueAccessMutation( + boardId, + accessId, + `update:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) return updated + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + if (existing.some(access => access.id === accessId)) { + boardAccess.value.set( + boardId, + existing.map(access => access.id === accessId ? updated : access), + ) + } + toast.success('Access updated') + return updated + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + error.value = msg + toast.error(msg) + } + throw e + } + }, + ) } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() - const owner = beginOperation(`revoke:${boardId}:${accessId}`) - try { - session.requireUserId('board access management') - await boardAccessApi.revokeAccess(boardId, accessId) - if (!ownsSession(owner)) return - - recordMutation(boardId) - const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) - toast.success('Access revoked') - } catch (e: unknown) { - if (ownsSession(owner)) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) - } - throw e - } finally { - finishOperation(owner) - } + await enqueueAccessMutation( + boardId, + accessId, + `revoke:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) return + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) + toast.success('Access revoked') + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + error.value = msg + toast.error(msg) + } + throw e + } + }, + ) } return { From 216ec3a553c3f45ccc1f415761990f492d1d23c9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:33:43 +0100 Subject: [PATCH 5/9] fix(permissions): deduplicate grant settlement by stable id --- frontend/taskdeck-web/src/store/permissionsStore.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index ff11fe38e..91501b978 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -184,7 +184,9 @@ export const usePermissionsStore = defineStore('permissions', () => { recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, [...existing, access]) + if (!existing.some(entry => entry.id === access.id)) { + boardAccess.value.set(boardId, [...existing, access]) + } toast.success('Access granted') return access } catch (e: unknown) { From 71a20bc8ad9be150b5bebfa949983770eb60ce58 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:34:18 +0100 Subject: [PATCH 6/9] test(permissions): pin grant settlement deduplication --- .../store/permissionsStoreOwnership.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts index aae330eaa..90e277250 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -101,6 +101,22 @@ describe('permissionsStore async ownership', () => { expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) }) + it('does not duplicate a grant already observed by a newer authoritative read', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const granted = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner]) + const grant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(grant.promise) + vi.mocked(boardAccessApi.getAccess).mockResolvedValue([owner, granted]) + + const grantRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + await store.fetchBoardAccess('board-1') + grant.resolve(granted) + await grantRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) + }) + it('does not let a read started before a role update restore the old role', async () => { const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) From cfeb1bffdbd21354dd974c8c6c8e09e468826337 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:14:38 +0100 Subject: [PATCH 7/9] test(permissions): invalidate operations on token rotation --- .../permissionsStoreTokenOwnership.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts new file mode 100644 index 000000000..b44a56bf4 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function token(suffix: string): string { + const body = btoa(JSON.stringify({ exp: 1893456000 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return `header.${body}.${suffix}` +} + +function access(id: string): BoardAccess { + return { + id, + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +describe('permissionsStore token ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + session.token = token('old') + store = usePermissionsStore() + vi.clearAllMocks() + }) + + it('invalidates an old read when the credential rotates for the same user', async () => { + const pending = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(pending.promise) + const request = store.fetchBoardAccess('board-1') + + session.token = token('new') + pending.resolve([access('old-token-read')]) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('suppresses a stale mutation failure after credential rotation', async () => { + const pending = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pending.promise) + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + + session.token = token('new') + pending.reject(new Error('old credential failure')) + await expect(request).rejects.toThrow('old credential failure') + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From 01af1833e8a71604c45fd977759d3233bbbe9753 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:16:09 +0100 Subject: [PATCH 8/9] test(permissions): preserve independent mutation errors --- ...issionsStoreMutationErrorOwnership.spec.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts new file mode 100644 index 000000000..259430539 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function access(id: string, role: BoardAccess['role'] = 'Viewer'): BoardAccess { + return { + id, + boardId: 'board-1', + userId: `${id}-user`, + role, + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation error ownership', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const session = useSessionStore() + session.userId = 'owner-1' + vi.clearAllMocks() + }) + + it('does not erase an independent failure when queued same-entry work starts', async () => { + const store = usePermissionsStore() + store.boardAccess.set('board-1', [access('access-1'), access('access-2')]) + + const first = deferred() + const independent = deferred() + const queued = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(independent.promise) + .mockReturnValueOnce(queued.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const independentRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + independent.reject(new Error('independent access failed')) + await expect(independentRequest).rejects.toThrow('independent access failed') + expect(store.error).toBe('independent access failed') + + first.resolve(access('access-1', 'Editor')) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(3) + expect(store.error).toBe('independent access failed') + + queued.resolve(access('access-1', 'Admin')) + await queuedRequest + expect(store.error).toBe('independent access failed') + }) +}) From 59debd4cbc0b32cc276e169e410207d06af29159 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:26:45 +0100 Subject: [PATCH 9/9] fix(permissions): invalidate ownership on token rotation --- frontend/taskdeck-web/src/store/permissionsStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 91501b978..52932c444 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -105,7 +105,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } watch( - () => [session.userId, session.isAuthenticated, session.isDemo], + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], resetForSession, { flush: 'sync' }, )