@@ -102,9 +102,34 @@ const MAX_ROLE_CACHE_ENTRIES = 5_000
102102interface CachedRole {
103103 /** Authoritative workspace role, or `null` when the user has no access. */
104104 role : string | null
105+ /**
106+ * The {@link beginRoomPermissionRead} ticket of the query this decision came
107+ * from — i.e. when its DB read STARTED, not when it was written.
108+ */
109+ readSeq : number
105110 expiresAt : number
106111}
107112
113+ /**
114+ * Monotonic ticket counter establishing a total order over authorization READS.
115+ *
116+ * Ordering by write time is not sufficient: two readers can start their queries in
117+ * one order and finish in the other, so the decision written last can be derived
118+ * from the older read. A join that authorized before a revocation but returned
119+ * after the sweep's denial would then win — leaving a revoked user with another
120+ * full TTL of access. Every writer therefore takes a ticket before it queries and
121+ * a decision only yields to one from a strictly later-started read.
122+ */
123+ let roleReadSeqCounter = 0
124+
125+ /**
126+ * Takes a read ticket. Call immediately BEFORE issuing an authorization query, and
127+ * pass the result to {@link commitRoomPermission} once it returns.
128+ */
129+ export function beginRoomPermissionRead ( ) : number {
130+ return ++ roleReadSeqCounter
131+ }
132+
108133/**
109134 * Per-pod cache of authoritative workspace roles, keyed by
110135 * `${userId}:${roomName(room)}`.
@@ -142,19 +167,31 @@ function purgeExpiredRoles(now: number): void {
142167 }
143168}
144169
145- /**
146- * Records a freshly-read authoritative decision into the role cache. Every
147- * successful DB read of a user's workspace role goes through this — including
148- * the join-time {@link verifyWorkflowAccess} — so a stale cached revocation
149- * never outlives a newer authoritative read (e.g. a re-granted user re-joining
150- * within the TTL of the sweep's recorded `null`).
151- */
152- function recordRoleDecision ( key : string , role : string | null ) : void {
170+ function recordRoleDecision ( key : string , role : string | null , readSeq : number ) : void {
153171 const now = Date . now ( )
154172 if ( roleCache . size >= MAX_ROLE_CACHE_ENTRIES ) {
155173 purgeExpiredRoles ( now )
156174 }
157- roleCache . set ( key , { role, expiresAt : now + ROLE_REVALIDATION_TTL_MS } )
175+ roleCache . set ( key , { role, readSeq, expiresAt : now + ROLE_REVALIDATION_TTL_MS } )
176+ }
177+
178+ /**
179+ * Commits a freshly-read authoritative decision, unless a decision from a
180+ * strictly later-started read is already cached — in which case that one stands
181+ * and is returned instead.
182+ *
183+ * Every successful DB read of a user's workspace role goes through this: the
184+ * cached resolver, the join-time {@link verifyWorkflowAccess}, and the room join
185+ * authorizer. That is what keeps a stale cached revocation from outliving a newer
186+ * authoritative read (a re-granted user re-joining inside the sweep's recorded
187+ * `null` TTL) while equally keeping a stale ALLOW from burying the sweep's fresher
188+ * denial. Returns the decision that ends up in force.
189+ */
190+ function commitRoleDecision ( key : string , role : string | null , readSeq : number ) : string | null {
191+ const existing = roleCache . get ( key )
192+ if ( existing !== undefined && existing . readSeq > readSeq ) return existing . role
193+ recordRoleDecision ( key , role , readSeq )
194+ return role
158195}
159196
160197/**
@@ -195,19 +232,13 @@ async function resolveRoleUncached(
195232 room : RoomRef ,
196233 fallbackRole : string
197234) : Promise < string | null > {
198- const entryBeforeQuery = roleCache . get ( key )
235+ const readSeq = beginRoomPermissionRead ( )
199236 try {
200237 const role = await readAuthoritativeRoomPermission ( userId , room )
201- // A fresh authoritative read (e.g. a join-time verifyWorkflowAccess) may
202- // have recorded a decision while this query was in flight. That write is
203- // newer than this query's read snapshot, so prefer it instead of
204- // overwriting it with a potentially stale result.
205- const entryAfterQuery = roleCache . get ( key )
206- if ( entryAfterQuery !== undefined && entryAfterQuery !== entryBeforeQuery ) {
207- return entryAfterQuery . role
208- }
209- recordRoleDecision ( key , role )
210- return role
238+ // Yields only to a decision from a later-STARTED read (see commitRoleDecision).
239+ // Comparing write order instead would let a join whose authorize began before
240+ // this one — but returned after it — bury this result.
241+ return commitRoleDecision ( key , role , readSeq )
211242 } catch ( error ) {
212243 logger . warn (
213244 `Failed to re-validate role for user ${ userId } on ${ roomName ( room ) } ; using last known role` ,
@@ -291,50 +322,35 @@ export function peekRoomPermission(userId: string, room: RoomRef): string | null
291322}
292323
293324/**
294- * Records an authoritative decision read outside this module (the room join
325+ * Commits an authoritative decision read outside this module (the room join
295326 * authorizer) into the shared cache, so the sweep and the per-frame gates start
296- * warm and a re-granted user is never held out by a stale cached revocation .
327+ * warm on the room a socket just joined .
297328 *
298- * Unconditional: the caller's read is taken as the newest word. Use
299- * {@link recordRoomPermissionIfUnchanged} when the read was slow enough that a
300- * newer decision could have landed meanwhile.
329+ * `readSeq` must come from a {@link beginRoomPermissionRead} taken BEFORE the
330+ * caller's query: the decision is discarded if a later-started read already
331+ * committed one, so neither a stale ALLOW can bury the sweep's fresher denial nor
332+ * a stale revocation outlive a re-grant.
301333 */
302- export function recordRoomPermission (
334+ export function commitRoomPermission (
303335 userId : string ,
304336 room : RoomRef ,
305- permission : PermissionType | null
337+ permission : PermissionType | null ,
338+ readSeq : number
306339) : void {
307- recordRoleDecision ( roleCacheKey ( userId , room ) , permission )
308- }
309-
310- /** Opaque token identifying the cache entry a caller observed before its query. */
311- export type RoomPermissionSnapshot = object | undefined
312-
313- /** Captures the current cache entry for a (user, room), for {@link recordRoomPermissionIfUnchanged}. */
314- export function snapshotRoomPermission ( userId : string , room : RoomRef ) : RoomPermissionSnapshot {
315- return roleCache . get ( roleCacheKey ( userId , room ) )
340+ commitRoleDecision ( roleCacheKey ( userId , room ) , permission , readSeq )
316341}
317342
318343/**
319- * Records an authoritative decision only if no other decision landed since
320- * `snapshot` was taken.
321- *
322- * Without this, a join whose authorization query started before a revocation but
323- * returned after the sweep recorded that revocation would overwrite it with its own
324- * stale "allowed", handing the socket another full TTL of access. Entry identity
325- * (not a timestamp) is the comparison, mirroring the in-flight ordering guard the
326- * cached resolver already applies to its own queries — so it is exact regardless of
327- * how many writes land inside one millisecond.
344+ * Records a decision as the newest word, taking its read ticket at write time.
345+ * For callers that have just observed the authoritative state with no query of
346+ * their own to order against.
328347 */
329- export function recordRoomPermissionIfUnchanged (
348+ export function recordRoomPermission (
330349 userId : string ,
331350 room : RoomRef ,
332- permission : PermissionType | null ,
333- snapshot : RoomPermissionSnapshot
351+ permission : PermissionType | null
334352) : void {
335- const key = roleCacheKey ( userId , room )
336- if ( roleCache . get ( key ) !== snapshot ) return
337- recordRoleDecision ( key , permission )
353+ recordRoleDecision ( roleCacheKey ( userId , room ) , permission , beginRoomPermissionRead ( ) )
338354}
339355
340356/**
@@ -376,6 +392,9 @@ export async function verifyWorkflowAccess(
376392 userId : string ,
377393 workflowId : string
378394) : Promise < { hasAccess : boolean ; role ?: string ; workspaceId ?: string } > {
395+ // Taken before the reads below, so this decision yields to any authorization
396+ // that started later (e.g. the sweep's denial) instead of by write order.
397+ const readSeq = beginRoomPermissionRead ( )
379398 try {
380399 const workflowData = await db
381400 . select ( {
@@ -398,9 +417,10 @@ export async function verifyWorkflowAccess(
398417 action : 'read' ,
399418 } )
400419
401- recordRoleDecision (
420+ commitRoleDecision (
402421 roleCacheKey ( userId , { type : ROOM_TYPES . WORKFLOW , id : workflowId } ) ,
403- authorization . allowed ? ( authorization . workspacePermission ?? null ) : null
422+ authorization . allowed ? ( authorization . workspacePermission ?? null ) : null ,
423+ readSeq
404424 )
405425
406426 if ( ! authorization . allowed || ! authorization . workspacePermission ) {
0 commit comments