From 40f68e95fb7209b6b1e05501281ffe388edc01fe Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:28:58 +0300 Subject: [PATCH] fix(ws): shrink restriction-cache TTL to bound the leak window (#53) invalidateSpaceRestrictionCache has no callers because no restriction-mutation path exists yet (PagePermissionRepo mutators are uncalled; there is no restrict/grant/revoke endpoint), so the 30s spaceHasRestrictions cache could serve a stale 'no restrictions' verdict. Until a mutation endpoint exists to wire the direct invalidation, lower the TTL (30s -> 3s) to bound the worst-case window; the invalidation primitive is kept for that future endpoint. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/ws/ws.service.ts | 27 ++++++++++++++++----------- apps/server/src/ws/ws.utils.ts | 14 +++++++++++++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 75cf598e..5c8303eb 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -23,24 +23,29 @@ export class WsService { } // Drop the cached spaceHasRestrictions verdict for a space. spaceHasRestrictions - // caches "does this space have ANY restricted page" for WS_CACHE_TTL_MS (30s), - // and emitTreeEvent / emitCommentEvent take a room-wide fast path when it is - // false. The FIRST time a space gains a restriction (or loses its last one) - // this cached verdict goes stale for up to the TTL, during which a title/icon- - // bearing tree payload could fan out to the whole room. This MUST be called by - // whatever code creates or removes a page's restriction (the page-access / - // page-permission grant/revoke/restrict path), passing the affected page's - // spaceId, so the next emit re-reads hasRestrictedPagesInSpace. + // caches "does this space have ANY restricted page" for WS_CACHE_TTL_MS, and + // emitTreeEvent / emitCommentEvent take a room-wide fast path when it is false. + // The FIRST time a space gains a restriction (or loses its last one) this cached + // verdict goes stale for up to the TTL, during which a title/icon-bearing tree + // payload could fan out to the whole room. This MUST be called by whatever code + // creates or removes a page's restriction (the page-access / page-permission + // grant/revoke/restrict path), passing the affected page's spaceId, so the next + // emit re-reads hasRestrictedPagesInSpace immediately instead of serving a + // stale cached value. // // NOTE: on this branch there is no permission-mutation site to call this from — // the page-access/page-permission repo mutators (insertPageAccess / // insertPagePermissions / deletePagePermission* / updatePagePermissionRole) // have ZERO callers in apps/server/src; PageAccessService only validates access. - // This primitive is kept (and tested) so that flow, when it lands, has the - // correct hook to invalidate the cache. + // Because there is nothing to wire the invalidation to yet, the documented + // fallback was applied instead: WS_CACHE_TTL_MS was dropped from 30s to 3s (see + // ws.utils.ts) to bound the worst-case stale-leak window. This primitive is kept + // (and tested) so the restriction-mutation flow, when it lands, has the correct + // hook to invalidate the cache. // // TODO: the future restriction-mutation endpoint (restrict/grant/revoke page - // access) MUST call this with the affected page's spaceId. + // access) MUST call this with the affected page's spaceId; once wired, the TTL + // can be raised back to a higher value if desired. async invalidateSpaceRestrictionCache(spaceId: string): Promise { await this.cacheManager.del( `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`, diff --git a/apps/server/src/ws/ws.utils.ts b/apps/server/src/ws/ws.utils.ts index a1eb1569..ebadfd3a 100644 --- a/apps/server/src/ws/ws.utils.ts +++ b/apps/server/src/ws/ws.utils.ts @@ -1,4 +1,16 @@ -export const WS_CACHE_TTL_MS = 30_000; +// TTL for the cached spaceHasRestrictions verdict (see WsService). This cache is +// a read-side fast path: while it is `false`, emitTreeEvent/emitCommentEvent +// broadcast page-bearing payloads to the WHOLE space room. If a space gains its +// first restriction (or loses its last one), the verdict goes stale for up to +// this TTL, during which a title/icon-bearing payload could fan out to +// now-unauthorized sockets. The proper fix is to call +// WsService.invalidateSpaceRestrictionCache(spaceId) from the restriction +// mutation path — but on this branch no such mutation path exists yet (the +// page-permission repo mutators have zero callers), so there is nothing to wire +// the invalidation to. As the documented fallback, the TTL is kept short (3s) +// to bound the worst-case leak window until that endpoint lands and the +// invalidation can be wired directly. +export const WS_CACHE_TTL_MS = 3_000; export const WS_SPACE_RESTRICTION_CACHE_PREFIX = 'ws:space-restrictions:'; export function getSpaceRoomName(spaceId: string): string {