Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f963ae5
test(permissions): reproduce read and session races
Chris0Jeky Sep 21, 2026
457c259
fix(permissions): bind access state to reads and sessions
Chris0Jeky Sep 21, 2026
72c1de9
fix(permissions): deduplicate grant settlement by stable id
Chris0Jeky Sep 21, 2026
ed3ffbc
test(permissions): pin grant settlement deduplication
Chris0Jeky Sep 21, 2026
421122b
test(permissions): invalidate operations on token rotation
Chris0Jeky Sep 21, 2026
fa4ee41
fix(permissions): invalidate ownership on token rotation
Chris0Jeky Sep 21, 2026
3b275ee
test(permissions): preserve access cache across token refresh
Chris0Jeky Sep 21, 2026
e00f5d6
fix(permissions): preserve access cache across token refresh
Chris0Jeky Sep 21, 2026
5a86d81
docs(permissions): preserve access cache on token refresh
Chris0Jeky Sep 21, 2026
2676cce
test(permissions): retry unresolved access read after token refresh
Chris0Jeky Sep 21, 2026
1bfe9b7
fix(permissions): retry unresolved reads after token refresh
Chris0Jeky Sep 21, 2026
eb70563
docs(permissions): record unresolved-read retry contract
Chris0Jeky Sep 21, 2026
dc66fd7
fix(permissions): reconcile stale mutations after refresh
Chris0Jeky Sep 21, 2026
00a9bf0
fix(permissions): reconcile stale mutations after active reads
Chris0Jeky Sep 21, 2026
a6fd963
fix(permissions): retry reconciliation after token rotation
Chris0Jeky Sep 21, 2026
0f9cad1
fix: retry cached permissions after token refresh
Chris0Jeky Sep 21, 2026
112585b
Separate token refresh from re-login ownership
Chris0Jeky Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/analysis/2026-09-21-permission-read-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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 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 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 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.
Comment thread
Chris0Jeky marked this conversation as resolved.
- 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.
- 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.

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.

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:

- 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

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.
273 changes: 241 additions & 32 deletions frontend/taskdeck-web/src/store/permissionsStore.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,6 +16,183 @@ export const usePermissionsStore = defineStore('permissions', () => {
const loading = ref(false)
const error = ref<string | null>(null)

type ReadRetry = () => Promise<void>

interface OperationOwner {
epoch: number
token: symbol
userId: string | null
}

interface ReadOwner extends OperationOwner {
observedMutationGeneration: number
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<symbol>()
const activeReadByBoard = new Map<string, ReadOwner>()
const readRetryByBoard = new Map<string, ReadRetry>()
const mutationGenerationByBoard = new Map<string, number>()

function syncLoading() {
loading.value = activeOperations.size > 0
Comment thread
Chris0Jeky marked this conversation as resolved.
}

function beginOperation(label: string): OperationOwner {
const owner = { epoch: sessionEpoch, token: Symbol(label), userId: session.userId }
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,
retry: ReadRetry,
revalidateOnTokenRotation = false,
): 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),
revalidateOnTokenRotation,
}
activeReadByBoard.set(boardId, owner)
readRetryByBoard.set(boardId, retry)
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)
readRetryByBoard.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)
readRetryByBoard.delete(boardId)
activeOperations.delete(staleRead.token)
syncLoading()
}
}

function invalidateOperations(kind: InvalidationKind = 'session-change') {
sessionEpoch += 1
lastInvalidation = { epoch: sessionEpoch, kind }
activeOperations.clear()
activeReadByBoard.clear()
readRetryByBoard.clear()
mutationGenerationByBoard.clear()
loading.value = false
error.value = null
}

function retryMissingActiveReads(isTokenRotation: boolean) {
const retries = Array.from(activeReadByBoard.keys())
.filter(boardId => {
const owner = activeReadByBoard.get(boardId)
return owner?.revalidateOnTokenRotation === true || !boardAccess.value.has(boardId)
Comment thread
Chris0Jeky marked this conversation as resolved.
})
.map(boardId => readRetryByBoard.get(boardId))
.filter((retry): retry is ReadRetry => retry !== undefined)

invalidateOperations(isTokenRotation ? 'token-rotation' : 'session-change')
for (const retry of retries) {
void retry().catch(() => {
// The retried store action owns current error/toast state.
})
}
}

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)
|| lastInvalidation.epoch !== sessionEpoch
|| lastInvalidation.kind !== 'token-rotation'
|| owner.userId === null
|| owner.userId !== session.userId
|| !session.isAuthenticated
|| session.isDemo) {
Comment thread
Chris0Jeky marked this conversation as resolved.
return
}

try {
await fetchBoardAccess(boardId, true)
Comment thread
Chris0Jeky marked this conversation as resolved.
} 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()
}

watch(
() => [session.userId, session.isAuthenticated, session.isDemo],
resetForSession,
{ flush: 'sync' },
Comment thread
Chris0Jeky marked this conversation as resolved.
)

watch(
() => session.token,
(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' },
)

function guardDemoMutation(): never | void {
if (isDemoMode) {
toast.info('This action is view-only in demo mode.')
Expand Down Expand Up @@ -54,94 +231,126 @@ export const usePermissionsStore = defineStore('permissions', () => {
}
})

async function fetchBoardAccess(boardId: string) {
async function fetchBoardAccess(boardId: string, revalidateOnTokenRotation = true) {
if (isDemoMode) {
loading.value = true
error.value = null
boardAccess.value.set(boardId, [])
loading.value = false
return
}

const owner = beginRead(
boardId,
() => fetchBoardAccess(boardId, revalidateOnTokenRotation),
revalidateOnTokenRotation,
)
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)) {
await reconcileStaleMutation(boardId, owner)
return access
}

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) {
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)) {
await reconcileStaleMutation(boardId, 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)) {
await reconcileStaleMutation(boardId, 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,
Expand Down
Loading
Loading