@@ -112,6 +112,15 @@ interface CachedRole {
112112 */
113113const roleCache = new Map < string , CachedRole > ( )
114114
115+ /**
116+ * In-flight resolutions keyed like {@link roleCache}. Concurrent callers for the same
117+ * (user, workflow) share one authorization query instead of racing independent ones, so
118+ * cache writes per key are serialized — a slow, stale pre-revocation read can never
119+ * overwrite a newer recorded decision (e.g. the revocation the eviction sweep just
120+ * cached before kicking the socket).
121+ */
122+ const inFlightRoleResolutions = new Map < string , Promise < string | null > > ( )
123+
115124function purgeExpiredRoles ( now : number ) : void {
116125 for ( const [ key , entry ] of roleCache ) {
117126 if ( entry . expiresAt <= now ) {
@@ -120,35 +129,20 @@ function purgeExpiredRoles(now: number): void {
120129 }
121130}
122131
123- /**
124- * Resolves a user's current workspace role for a workflow, re-reading the `permissions`
125- * table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
126- *
127- * Returns `null` when the user genuinely has no access (removed/revoked). On a transient
128- * DB failure it reuses the last recorded decision for this (user, workflow) — including a
129- * previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
130- * decision has been recorded yet, so a blip neither blocks legitimate editors nor
131- * resurrects already-revoked access.
132- */
133- export async function resolveCurrentWorkflowRole (
132+ async function resolveRoleUncached (
133+ key : string ,
134134 userId : string ,
135135 workflowId : string ,
136136 fallbackRole : string
137137) : Promise < string | null > {
138- const now = Date . now ( )
139- const key = `${ userId } :${ workflowId } `
140- const cached = roleCache . get ( key )
141- if ( cached && cached . expiresAt > now ) {
142- return cached . role
143- }
144-
145138 try {
146139 const authorization = await authorizeWorkflowByWorkspacePermission ( {
147140 workflowId,
148141 userId,
149142 action : 'read' ,
150143 } )
151144 const role = authorization . allowed ? ( authorization . workspacePermission ?? null ) : null
145+ const now = Date . now ( )
152146 if ( roleCache . size >= MAX_ROLE_CACHE_ENTRIES ) {
153147 purgeExpiredRoles ( now )
154148 }
@@ -168,6 +162,41 @@ export async function resolveCurrentWorkflowRole(
168162 }
169163}
170164
165+ /**
166+ * Resolves a user's current workspace role for a workflow, re-reading the `permissions`
167+ * table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod. Concurrent calls for
168+ * the same (user, workflow) coalesce onto a single in-flight query (single-flight), so
169+ * out-of-order cache writes cannot resurrect revoked access.
170+ *
171+ * Returns `null` when the user genuinely has no access (removed/revoked). On a transient
172+ * DB failure it reuses the last recorded decision for this (user, workflow) — including a
173+ * previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
174+ * decision has been recorded yet, so a blip neither blocks legitimate editors nor
175+ * resurrects already-revoked access.
176+ */
177+ export async function resolveCurrentWorkflowRole (
178+ userId : string ,
179+ workflowId : string ,
180+ fallbackRole : string
181+ ) : Promise < string | null > {
182+ const key = `${ userId } :${ workflowId } `
183+ const cached = roleCache . get ( key )
184+ if ( cached && cached . expiresAt > Date . now ( ) ) {
185+ return cached . role
186+ }
187+
188+ const inFlight = inFlightRoleResolutions . get ( key )
189+ if ( inFlight ) {
190+ return inFlight
191+ }
192+
193+ const resolution = resolveRoleUncached ( key , userId , workflowId , fallbackRole ) . finally ( ( ) => {
194+ inFlightRoleResolutions . delete ( key )
195+ } )
196+ inFlightRoleResolutions . set ( key , resolution )
197+ return resolution
198+ }
199+
171200/**
172201 * Live permission gate for mutating socket operations. Re-validates the user's workspace
173202 * role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that
0 commit comments