From f963ae5a8b5e7a609bb37fe0e3e37398185ae705 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 01/17] 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 457c259a2e5ac97528429d630b98e2a48d3069e7 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 02/17] 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 72c1de90e340c8d209ba935ec8a67f3282cb3d57 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 03/17] 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 ed3ffbc01e54a4dee96b83d4d971f006c80a5856 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 04/17] 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 421122b23ee7b064cf9bec3840d9b20cdf714b64 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 05/17] 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 fa4ee4149339d045c43068e7320c23ee6d981e7f 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 06/17] 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' }, ) From 3b275ee08ae08c1f7860d54ba5033e978befa182 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:52:58 +0100 Subject: [PATCH 07/17] test(permissions): preserve access cache across token refresh --- .../store/permissionsStoreTokenOwnership.spec.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index b44a56bf4..5faa1a751 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -76,21 +76,28 @@ describe('permissionsStore token ownership', () => { vi.clearAllMocks() }) - it('invalidates an old read when the credential rotates for the same user', async () => { + it('preserves loaded access while invalidating an old read after same-user token rotation', async () => { + store.boardAccess.set('board-1', [access('existing')]) const pending = deferred() vi.mocked(boardAccessApi.getAccess).mockReturnValue(pending.promise) const request = store.fetchBoardAccess('board-1') session.token = token('new') + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + pending.resolve([access('old-token-read')]) await request - expect(store.boardAccess.size).toBe(0) + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) expect(store.loading).toBe(false) expect(store.error).toBeNull() }) - it('suppresses a stale mutation failure after credential rotation', async () => { + it('preserves loaded access while suppressing an old-token mutation failure', async () => { + store.boardAccess.set('board-1', [access('existing')]) const pending = deferred() vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pending.promise) const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) @@ -99,7 +106,7 @@ describe('permissionsStore token ownership', () => { pending.reject(new Error('old credential failure')) await expect(request).rejects.toThrow('old credential failure') - expect(store.boardAccess.size).toBe(0) + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) expect(store.loading).toBe(false) expect(store.error).toBeNull() expect(toastMocks.error).not.toHaveBeenCalled() From e00f5d6f2f0462ce077fcca476b274e4eaa6b94a Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:18:03 +0100 Subject: [PATCH 08/17] fix(permissions): preserve access cache across token refresh --- .../taskdeck-web/src/store/permissionsStore.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 52932c444..711f79676 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -94,22 +94,32 @@ export const usePermissionsStore = defineStore('permissions', () => { } } - function resetForSession() { + function invalidateOperations() { sessionEpoch += 1 activeOperations.clear() activeReadByBoard.clear() mutationGenerationByBoard.clear() - boardAccess.value = new Map() loading.value = false error.value = null } + function resetForSession() { + invalidateOperations() + boardAccess.value = new Map() + } + watch( - () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + () => [session.userId, session.isAuthenticated, session.isDemo], resetForSession, { flush: 'sync' }, ) + watch( + () => session.token, + invalidateOperations, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') From 5a86d817629c86b09ed68a50c9e691b2dd5e6b8b Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:21:36 +0100 Subject: [PATCH 09/17] docs(permissions): preserve access cache on token refresh --- .../2026-09-21-permission-read-ownership.md | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/docs/analysis/2026-09-21-permission-read-ownership.md b/docs/analysis/2026-09-21-permission-read-ownership.md index 1d65748d1..f1f3314b2 100644 --- a/docs/analysis/2026-09-21-permission-read-ownership.md +++ b/docs/analysis/2026-09-21-permission-read-ownership.md @@ -4,38 +4,35 @@ Status: draft PR #3330, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4 ## 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 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 account 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. +The initial lifecycle correction then treated every token refresh as a full cache reset. A successful same-user session extension could blank the unchanged Board Access route even though its board selection and authorization identity had not changed. ## 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. +- User identity, authentication, or demo-session replacement advances the epoch, clears cached access, retires operations, and resets loading/error. +- Token-only rotation advances the same operation epoch and clears transient loading/error ownership, but preserves the loaded board-access cache for the unchanged user and route. +- Success, failure, toast, and cache writes require the initiating lifecycle 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. +- A stale call still resolves or rejects to its caller; it loses only permission to alter current UI state. + +Server authorization remains authoritative. This corrects truthful client cache behavior and does not claim a server-side authorization bypass. Same-entry mutation serialization remains in stacked PR #3335. + +## Test-first evidence + +The original ownership suite covers read-versus-grant/update/revoke races, reverse reads, independent loading, same-user logout/login, replacement-session mutation settlement, stale failures, and stable-ID grant deduplication. + +Review-regression head `6bbf8d04139bef2dca91d290b910e1c07a8e76aa` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build, and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 2 failures, 0 errors**; both failures were the new token-refresh preservation cases: + +1. preserve loaded access while suppressing an old-token read; +2. preserve loaded access while suppressing an old-token mutation failure. + +No unrelated frontend test failed. + +## Remaining gates + +The production correction splits token-only operation invalidation from full identity reset. Exact-head canonical tests, complete Required CI, Extended, Self-Test, and repeat independent review remain required. The stacked mutation-order PR must later be reconciled to this corrected parent and requalified. + +No merge, release, or deployment qualification is claimed. From 2676cce7da4ef18e5912221427aa19d257adc084 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:30:54 +0100 Subject: [PATCH 10/17] test(permissions): retry unresolved access read after token refresh --- .../permissionsStoreTokenOwnership.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 5faa1a751..240f1f96f 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -111,4 +111,32 @@ describe('permissionsStore token ownership', () => { expect(store.error).toBeNull() expect(toastMocks.error).not.toHaveBeenCalled() }) + + it('retries an unresolved board-access read after same-user token rotation', async () => { + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardAccess('board-1') + session.token = token('new') + + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(2) + expect(store.boardAccess.has('board-1')).toBe(false) + expect(store.loading).toBe(true) + + oldRead.resolve([access('old-token-read')]) + await request + + expect(store.boardAccess.has('board-1')).toBe(false) + expect(store.loading).toBe(true) + + freshRead.resolve([access('fresh-token-read')]) + await vi.waitFor(() => { + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-token-read']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + }) }) From 1bfe9b70c279cc724c00092969e09953005c06cc Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:43:25 +0100 Subject: [PATCH 11/17] fix(permissions): retry unresolved reads after token refresh --- .../src/store/permissionsStore.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 711f79676..cfe9bcf4c 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -16,6 +16,8 @@ export const usePermissionsStore = defineStore('permissions', () => { const loading = ref(false) const error = ref(null) + type ReadRetry = () => Promise + interface OperationOwner { epoch: number token: symbol @@ -28,6 +30,7 @@ export const usePermissionsStore = defineStore('permissions', () => { let sessionEpoch = 0 const activeOperations = new Set() const activeReadByBoard = new Map() + const readRetryByBoard = new Map() const mutationGenerationByBoard = new Map() function syncLoading() { @@ -56,7 +59,7 @@ export const usePermissionsStore = defineStore('permissions', () => { return mutationGenerationByBoard.get(boardId) ?? 0 } - function beginRead(boardId: string): ReadOwner { + function beginRead(boardId: string, retry: ReadRetry): ReadOwner { const previous = activeReadByBoard.get(boardId) if (previous?.epoch === sessionEpoch) activeOperations.delete(previous.token) @@ -66,6 +69,7 @@ export const usePermissionsStore = defineStore('permissions', () => { observedMutationGeneration: mutationGeneration(boardId), } activeReadByBoard.set(boardId, owner) + readRetryByBoard.set(boardId, retry) return owner } @@ -79,6 +83,7 @@ export const usePermissionsStore = defineStore('permissions', () => { function finishRead(boardId: string, owner: ReadOwner) { if (activeReadByBoard.get(boardId)?.token === owner.token) { activeReadByBoard.delete(boardId) + readRetryByBoard.delete(boardId) } finishOperation(owner) } @@ -89,6 +94,7 @@ export const usePermissionsStore = defineStore('permissions', () => { const staleRead = activeReadByBoard.get(boardId) if (staleRead?.epoch === sessionEpoch) { activeReadByBoard.delete(boardId) + readRetryByBoard.delete(boardId) activeOperations.delete(staleRead.token) syncLoading() } @@ -98,11 +104,26 @@ export const usePermissionsStore = defineStore('permissions', () => { sessionEpoch += 1 activeOperations.clear() activeReadByBoard.clear() + readRetryByBoard.clear() mutationGenerationByBoard.clear() loading.value = false error.value = null } + function retryMissingActiveReads() { + const retries = Array.from(activeReadByBoard.keys()) + .filter(boardId => !boardAccess.value.has(boardId)) + .map(boardId => readRetryByBoard.get(boardId)) + .filter((retry): retry is ReadRetry => retry !== undefined) + + invalidateOperations() + for (const retry of retries) { + void retry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + function resetForSession() { invalidateOperations() boardAccess.value = new Map() @@ -116,7 +137,7 @@ export const usePermissionsStore = defineStore('permissions', () => { watch( () => session.token, - invalidateOperations, + retryMissingActiveReads, { flush: 'sync' }, ) @@ -167,7 +188,7 @@ export const usePermissionsStore = defineStore('permissions', () => { return } - const owner = beginRead(boardId) + const owner = beginRead(boardId, () => fetchBoardAccess(boardId)) try { const access = await boardAccessApi.getAccess(boardId) if (!ownsRead(boardId, owner)) return From eb7056375f30a71e7c1615315433c4d3c515ee4f Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:50:54 +0100 Subject: [PATCH 12/17] docs(permissions): record unresolved-read retry contract --- .../2026-09-21-permission-read-ownership.md | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/analysis/2026-09-21-permission-read-ownership.md b/docs/analysis/2026-09-21-permission-read-ownership.md index f1f3314b2..4b2d0f700 100644 --- a/docs/analysis/2026-09-21-permission-read-ownership.md +++ b/docs/analysis/2026-09-21-permission-read-ownership.md @@ -4,35 +4,41 @@ Status: draft PR #3330, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4 ## 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 account replacement. +A board-access read could settle after a confirmed grant, update or revoke and replace 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 account replacement. -The initial lifecycle correction then treated every token refresh as a full cache reset. A successful same-user session extension could blank the unchanged Board Access route even though its board selection and authorization identity had not changed. +The first lifecycle correction treated every token refresh as a full cache reset and could blank an unchanged Board Access route. The preservation correction then exposed a second boundary: when refresh happened during an unresolved first read, the old owner was retired but the unchanged board route did not refetch. ## Contract - One read owner exists per board; unrelated boards remain concurrent. - A successful mutation advances that board's generation and retires older reads. -- User identity, authentication, or demo-session replacement advances the epoch, clears cached access, retires operations, and resets loading/error. -- Token-only rotation advances the same operation epoch and clears transient loading/error ownership, but preserves the loaded board-access cache for the unchanged user and route. -- Success, failure, toast, and cache writes require the initiating lifecycle epoch. +- User identity, authentication or demo-session replacement advances the epoch, clears cached access, retires operations and resets loading/error. +- Token-only rotation preserves settled board caches, suppresses old-token UI settlement and restarts only active board reads that do not yet have a cache entry. +- An empty array is a settled authoritative cache and is not retried merely because it is empty. +- The retry retains the exact board ID captured by the active read. +- Success, failure, toast and cache writes require the initiating lifecycle 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 current UI state. +- Grant, update and revoke mutations are never replayed. Server authorization remains authoritative. This corrects truthful client cache behavior and does not claim a server-side authorization bypass. Same-entry mutation serialization remains in stacked PR #3335. ## Test-first evidence -The original ownership suite covers read-versus-grant/update/revoke races, reverse reads, independent loading, same-user logout/login, replacement-session mutation settlement, stale failures, and stable-ID grant deduplication. +The original ownership suite covers read-versus-grant/update/revoke races, reverse reads, independent loading, same-user logout/login, replacement-session mutation settlement, stale failures and stable-ID grant deduplication. -Review-regression head `6bbf8d04139bef2dca91d290b910e1c07a8e76aa` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build, and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 2 failures, 0 errors**; both failures were the new token-refresh preservation cases: +Review-regression head `6bbf8d04139bef2dca91d290b910e1c07a8e76aa` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 2 failures, 0 errors**, both loaded-cache preservation cases. -1. preserve loaded access while suppressing an old-token read; -2. preserve loaded access while suppressing an old-token mutation failure. +Issue #3352 added test-only head `d28697ede737ec42ae3e696c7a7cedcb753de347`, covering token rotation while `board-1` has an unresolved first read and no cache entry. A dependency-free runner transpiled and executed the actual production module: -No unrelated frontend test failed. +- before the retry correction: `getAccess` was called once and loading became false after rotation; +- after the correction: `getAccess` was called twice for the same board, old-token settlement was suppressed and the fresh-token result populated the cache. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. ## Remaining gates -The production correction splits token-only operation invalidation from full identity reset. Exact-head canonical tests, complete Required CI, Extended, Self-Test, and repeat independent review remain required. The stacked mutation-order PR must later be reconciled to this corrected parent and requalified. +Current production correction: `3c7746ee0330406a4e6b84c1a5bc253b02a7aa08` before this documentation commit. + +Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and repeat independent review remain required. Stacked mutation-order PR #3335 must later be reconciled to this corrected parent and requalified. -No merge, release, or deployment qualification is claimed. +No merge, release or deployment qualification is claimed. From dc66fd7c3ea10d9260f5d679c82a8808807e3b8e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 22:27:57 +0100 Subject: [PATCH 13/17] fix(permissions): reconcile stale mutations after refresh --- .../src/store/permissionsStore.ts | 42 +++++++++++++++++-- .../permissionsStoreTokenOwnership.spec.ts | 17 ++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index cfe9bcf4c..fb0a2fc22 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -21,6 +21,7 @@ export const usePermissionsStore = defineStore('permissions', () => { interface OperationOwner { epoch: number token: symbol + userId: string | null } interface ReadOwner extends OperationOwner { @@ -38,7 +39,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } function beginOperation(label: string): OperationOwner { - const owner = { epoch: sessionEpoch, token: Symbol(label) } + const owner = { epoch: sessionEpoch, token: Symbol(label), userId: session.userId } activeOperations.add(owner.token) error.value = null syncLoading() @@ -124,6 +125,30 @@ export const usePermissionsStore = defineStore('permissions', () => { } } + async function reconcileStaleMutation(boardId: string, owner: OperationOwner) { + // A same-user token rotation retires the mutation owner, but the server may + // already have committed it. Re-read under the replacement credential so a + // successful mutation cannot disappear from the access cache. Identity + // changes and logout must not read a board on behalf of the old session. + if (ownsSession(owner) + || owner.userId === null + || owner.userId !== session.userId + || !session.isAuthenticated + || session.isDemo) { + return + } + + if (activeReadByBoard.has(boardId)) return + + try { + await fetchBoardAccess(boardId) + } catch { + // The read owns its error/toast state. The mutation itself already + // settled successfully, so do not turn a reconciliation failure into a + // second, misleading mutation failure. + } + } + function resetForSession() { invalidateOperations() boardAccess.value = new Map() @@ -211,7 +236,10 @@ export const usePermissionsStore = defineStore('permissions', () => { try { session.requireUserId('board access management') const access = await boardAccessApi.grantAccess(boardId, dto) - if (!ownsSession(owner)) return access + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return access + } recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] @@ -238,7 +266,10 @@ export const usePermissionsStore = defineStore('permissions', () => { try { session.requireUserId('board access management') const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) - if (!ownsSession(owner)) return updated + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return updated + } recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] @@ -268,7 +299,10 @@ export const usePermissionsStore = defineStore('permissions', () => { try { session.requireUserId('board access management') await boardAccessApi.revokeAccess(boardId, accessId) - if (!ownsSession(owner)) return + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return + } recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 240f1f96f..3c71b6357 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -112,6 +112,23 @@ describe('permissionsStore token ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('reconciles a successful old-token grant after same-user token rotation', async () => { + const existing = access('existing') + const granted = access('fresh-grant') + store.boardAccess.set('board-1', [existing]) + const pendingGrant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + vi.mocked(boardAccessApi.getAccess).mockResolvedValue([existing, granted]) + + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.token = token('new') + pendingGrant.resolve(granted) + + await expect(request).resolves.toEqual(granted) + expect(boardAccessApi.getAccess).toHaveBeenCalledWith('board-1') + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing', 'fresh-grant']) + }) + it('retries an unresolved board-access read after same-user token rotation', async () => { const oldRead = deferred() const freshRead = deferred() From 00a9bf0df9cc325dcc447bff68ca6120435372fc Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:01:04 +0100 Subject: [PATCH 14/17] fix(permissions): reconcile stale mutations after active reads --- .../src/store/permissionsStore.ts | 2 -- .../permissionsStoreTokenOwnership.spec.ts | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index fb0a2fc22..0cb5b55b1 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -138,8 +138,6 @@ export const usePermissionsStore = defineStore('permissions', () => { return } - if (activeReadByBoard.has(boardId)) return - try { await fetchBoardAccess(boardId) } catch { diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 3c71b6357..02aaa408b 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -129,6 +129,41 @@ describe('permissionsStore token ownership', () => { expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing', 'fresh-grant']) }) + it('supersedes an active replacement read after a stale mutation settles', async () => { + const oldRead = deferred() + const replacementRead = deferred() + const reconciledRead = deferred() + const pendingGrant = deferred() + const granted = access('fresh-grant') + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(replacementRead.promise) + .mockReturnValueOnce(reconciledRead.promise) + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + + const readRequest = store.fetchBoardAccess('board-1') + const mutationRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.token = token('new') + + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(2) + + pendingGrant.resolve(granted) + await vi.waitFor(() => { + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(3) + }) + + reconciledRead.resolve([granted]) + await expect(mutationRequest).resolves.toEqual(granted) + + replacementRead.resolve([access('stale-replacement-read')]) + oldRead.resolve([access('stale-old-read')]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-grant']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + it('retries an unresolved board-access read after same-user token rotation', async () => { const oldRead = deferred() const freshRead = deferred() From a6fd96360264540d2714e1063d1929e1bad83664 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:14:07 +0100 Subject: [PATCH 15/17] fix(permissions): retry reconciliation after token rotation --- .../src/store/permissionsStore.ts | 23 +++++++++--- .../permissionsStoreTokenOwnership.spec.ts | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 0cb5b55b1..88a93d5f5 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -26,6 +26,7 @@ export const usePermissionsStore = defineStore('permissions', () => { interface ReadOwner extends OperationOwner { observedMutationGeneration: number + revalidateOnTokenRotation: boolean } let sessionEpoch = 0 @@ -60,7 +61,11 @@ export const usePermissionsStore = defineStore('permissions', () => { return mutationGenerationByBoard.get(boardId) ?? 0 } - function beginRead(boardId: string, retry: ReadRetry): ReadOwner { + function beginRead( + boardId: string, + retry: ReadRetry, + revalidateOnTokenRotation = false, + ): ReadOwner { const previous = activeReadByBoard.get(boardId) if (previous?.epoch === sessionEpoch) activeOperations.delete(previous.token) @@ -68,6 +73,7 @@ export const usePermissionsStore = defineStore('permissions', () => { const owner = { ...operation, observedMutationGeneration: mutationGeneration(boardId), + revalidateOnTokenRotation, } activeReadByBoard.set(boardId, owner) readRetryByBoard.set(boardId, retry) @@ -113,7 +119,10 @@ export const usePermissionsStore = defineStore('permissions', () => { function retryMissingActiveReads() { const retries = Array.from(activeReadByBoard.keys()) - .filter(boardId => !boardAccess.value.has(boardId)) + .filter(boardId => { + const owner = activeReadByBoard.get(boardId) + return owner?.revalidateOnTokenRotation === true || !boardAccess.value.has(boardId) + }) .map(boardId => readRetryByBoard.get(boardId)) .filter((retry): retry is ReadRetry => retry !== undefined) @@ -139,7 +148,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } try { - await fetchBoardAccess(boardId) + await fetchBoardAccess(boardId, true) } catch { // The read owns its error/toast state. The mutation itself already // settled successfully, so do not turn a reconciliation failure into a @@ -202,7 +211,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } }) - async function fetchBoardAccess(boardId: string) { + async function fetchBoardAccess(boardId: string, revalidateOnTokenRotation = false) { if (isDemoMode) { loading.value = true error.value = null @@ -211,7 +220,11 @@ export const usePermissionsStore = defineStore('permissions', () => { return } - const owner = beginRead(boardId, () => fetchBoardAccess(boardId)) + const owner = beginRead( + boardId, + () => fetchBoardAccess(boardId, revalidateOnTokenRotation), + revalidateOnTokenRotation, + ) try { const access = await boardAccessApi.getAccess(boardId) if (!ownsRead(boardId, owner)) return diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 02aaa408b..81a4c6f2b 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -164,6 +164,41 @@ describe('permissionsStore token ownership', () => { expect(store.error).toBeNull() }) + it('retries an in-flight reconciliation read after another token rotation', async () => { + store.boardAccess.set('board-1', [access('existing')]) + const pendingGrant = deferred() + const firstReconciliation = deferred() + const secondReconciliation = deferred() + const granted = access('fresh-grant') + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(firstReconciliation.promise) + .mockReturnValueOnce(secondReconciliation.promise) + + const mutationRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.token = token('new') + pendingGrant.resolve(granted) + + await vi.waitFor(() => { + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(1) + }) + + session.token = token('newer') + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(2) + + secondReconciliation.resolve([granted]) + await vi.waitFor(() => { + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-grant']) + }) + + firstReconciliation.resolve([access('stale-reconciliation')]) + await expect(mutationRequest).resolves.toEqual(granted) + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-grant']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + it('retries an unresolved board-access read after same-user token rotation', async () => { const oldRead = deferred() const freshRead = deferred() From 0f9cad14ce057413fb44661b0e36f77c47d19439 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 00:18:26 +0100 Subject: [PATCH 16/17] fix: retry cached permissions after token refresh --- frontend/taskdeck-web/src/store/permissionsStore.ts | 2 +- .../store/permissionsStoreTokenOwnership.spec.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 88a93d5f5..25b1c2acf 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -211,7 +211,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } }) - async function fetchBoardAccess(boardId: string, revalidateOnTokenRotation = false) { + async function fetchBoardAccess(boardId: string, revalidateOnTokenRotation = true) { if (isDemoMode) { loading.value = true error.value = null diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 81a4c6f2b..bae804008 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -79,21 +79,28 @@ describe('permissionsStore token ownership', () => { it('preserves loaded access while invalidating an old read after same-user token rotation', async () => { store.boardAccess.set('board-1', [access('existing')]) const pending = deferred() - vi.mocked(boardAccessApi.getAccess).mockReturnValue(pending.promise) + const freshRead = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValueOnce(pending.promise).mockReturnValueOnce(freshRead.promise) const request = store.fetchBoardAccess('board-1') session.token = token('new') expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) - expect(store.loading).toBe(false) + expect(store.loading).toBe(true) expect(store.error).toBeNull() pending.resolve([access('old-token-read')]) await request expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) - expect(store.loading).toBe(false) + expect(store.loading).toBe(true) expect(store.error).toBeNull() + + freshRead.resolve([access('fresh-token-read')]) + await vi.waitFor(() => { + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-token-read']) + expect(store.loading).toBe(false) + }) }) it('preserves loaded access while suppressing an old-token mutation failure', async () => { From 112585b7227e55c9083e263a0d49d7faf9673f58 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 01:38:09 +0100 Subject: [PATCH 17/17] Separate token refresh from re-login ownership --- .../src/store/permissionsStore.ts | 28 ++++++++++++++++--- .../permissionsStoreTokenOwnership.spec.ts | 18 ++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 25b1c2acf..05d4136b3 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -29,7 +29,13 @@ export const usePermissionsStore = defineStore('permissions', () => { revalidateOnTokenRotation: boolean } + type InvalidationKind = 'session-change' | 'token-rotation' + let sessionEpoch = 0 + let lastInvalidation: { epoch: number; kind: InvalidationKind } = { + epoch: 0, + kind: 'session-change', + } const activeOperations = new Set() const activeReadByBoard = new Map() const readRetryByBoard = new Map() @@ -107,8 +113,9 @@ export const usePermissionsStore = defineStore('permissions', () => { } } - function invalidateOperations() { + function invalidateOperations(kind: InvalidationKind = 'session-change') { sessionEpoch += 1 + lastInvalidation = { epoch: sessionEpoch, kind } activeOperations.clear() activeReadByBoard.clear() readRetryByBoard.clear() @@ -117,7 +124,7 @@ export const usePermissionsStore = defineStore('permissions', () => { error.value = null } - function retryMissingActiveReads() { + function retryMissingActiveReads(isTokenRotation: boolean) { const retries = Array.from(activeReadByBoard.keys()) .filter(boardId => { const owner = activeReadByBoard.get(boardId) @@ -126,7 +133,7 @@ export const usePermissionsStore = defineStore('permissions', () => { .map(boardId => readRetryByBoard.get(boardId)) .filter((retry): retry is ReadRetry => retry !== undefined) - invalidateOperations() + invalidateOperations(isTokenRotation ? 'token-rotation' : 'session-change') for (const retry of retries) { void retry().catch(() => { // The retried store action owns current error/toast state. @@ -140,6 +147,8 @@ export const usePermissionsStore = defineStore('permissions', () => { // successful mutation cannot disappear from the access cache. Identity // changes and logout must not read a board on behalf of the old session. if (ownsSession(owner) + || lastInvalidation.epoch !== sessionEpoch + || lastInvalidation.kind !== 'token-rotation' || owner.userId === null || owner.userId !== session.userId || !session.isAuthenticated @@ -169,7 +178,18 @@ export const usePermissionsStore = defineStore('permissions', () => { watch( () => session.token, - retryMissingActiveReads, + (token, previousToken) => { + // A valid token replacement with the same authenticated identity is the + // only session transition where a committed stale mutation is safe to + // reconcile. Logout followed by a quick same-user login must not let an + // old lifecycle read or mutate the replacement session. + const isTokenRotation = token !== null + && previousToken !== null + && session.userId !== null + && session.isAuthenticated + && !session.isDemo + retryMissingActiveReads(isTokenRotation) + }, { flush: 'sync' }, ) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index bae804008..0ff0d50d8 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -136,6 +136,24 @@ describe('permissionsStore token ownership', () => { expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing', 'fresh-grant']) }) + it('does not reconcile a stale mutation after logout and same-user re-login', async () => { + store.boardAccess.set('board-1', [access('existing')]) + const pendingGrant = deferred() + const granted = access('stale-grant') + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.clearSession() + session.token = token('new-login') + session.userId = 'owner-1' + pendingGrant.resolve(granted) + + await expect(request).resolves.toEqual(granted) + expect(boardAccessApi.getAccess).not.toHaveBeenCalled() + expect(store.boardAccess.has('board-1')).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + it('supersedes an active replacement read after a stale mutation settles', async () => { const oldRead = deferred() const replacementRead = deferred()