Skip to content

feat(realtime): add real-time permission revocation with zero-trust security - #256

Merged
2witstudios merged 7 commits into
masterfrom
critical-real-time-revocation-continuity-check-once-trust-forever-fix
Jan 27, 2026
Merged

2witstudios merged 7 commits into
masterfrom
critical-real-time-revocation-continuity-check-once-trust-forever-fix

Conversation

@2witstudios

@2witstudios 2witstudios commented Jan 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Add SocketRegistry for bidirectional user→socket→room tracking to enable immediate socket ejection on permission revocation
  • Add /api/kick endpoint with HMAC signature verification for server-side socket removal
  • Add per-event authorization module for defense-in-depth on sensitive write operations
  • Integrate kick calls into all permission revocation paths (member removal, page permissions, rollbacks)
  • Add useAccessRevocation hook for client-side toast notifications and automatic redirects

Context

This closes the "check once, trust forever" security gap where users who joined a Socket.IO room would continue receiving real-time updates even after their access was revoked. This is critical for enterprise security requirements including SOC 2 compliance and employee offboarding scenarios.

Architecture

Server-side enforcement (primary security layer):

  • SocketRegistry tracks all connections and room memberships
  • Kick API immediately ejects sockets when permissions change
  • Per-event re-authorization for sensitive write operations

Client-side UX (graceful handling):

  • useAccessRevocation hook listens for access_revoked events
  • Shows toast notification explaining what happened
  • Redirects away from revoked resources if currently viewing them

Test plan

  • Run cd apps/realtime && pnpm vitest run to verify socket-registry, kick-api, and per-event-auth tests pass
  • Run cd apps/web && pnpm vitest run src/hooks/__tests__/useAccessRevocation.test.ts to verify client hook tests
  • Manual test: Remove a user from a drive while they have a page open → verify they see toast and get redirected
  • Manual test: Revoke page permission while user is viewing → verify immediate redirect
  • Verify no regressions in normal real-time functionality (cursor sync, document updates)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Real-time access revocation: Kick API plus socket registry to immediately remove users from affected rooms; server and web endpoints trigger revocations.
    • Per-event authorization: revalidation for sensitive/write events.
    • Client-side hook and layout integration to handle access_revoked events with contextual toasts and redirects.
  • Tests

    • Extensive test suites for kick flows, socket registry, per-event auth, and client hook behavior.
  • Documentation

    • New design doc detailing real-time revocation architecture and workflows.

✏️ Tip: You can customize this high-level summary in your review settings.

…ecurity

Implements enterprise-grade real-time access revocation to close the
"check once, trust forever" security gap. When permissions are revoked,
connected sockets are immediately removed from rooms.

Key changes:
- Add SocketRegistry for tracking user->socket->room mappings
- Add /api/kick endpoint for server-side socket ejection
- Add per-event authorization module for sensitive write operations
- Integrate kick calls into permission revocation API routes
- Add useAccessRevocation hook for client-side notification/redirect
- Add comprehensive test coverage for all new modules

This ensures sub-5-second revocation latency for enterprise security
requirements (SOC 2 compliance, employee offboarding, etc).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jan 26, 2026 •

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a real-time permission-revocation system: a SocketRegistry, Kick API/handler with signature verification, per-event reauthorization, server integration to track sockets/rooms and expose /api/kick, client hook to handle access_revoked events, web helpers to invoke kicks, route integrations, tests, and design documentation.

Changes

Cohort / File(s) Summary
Socket Registry Infrastructure
apps/realtime/src/socket-registry.ts, apps/realtime/src/__tests__/socket-registry.test.ts
New in-memory registry mapping users↔sockets and socket↔rooms; registration, room tracking, lookups, cleanup, and comprehensive unit tests.
Kick Handler & API
apps/realtime/src/kick-handler.ts, apps/realtime/src/__tests__/kick-api.test.ts
New KickPayload/KickResult types, parse/validate, pattern matching (exact and trailing-*), executeKick to remove sockets from rooms and emit access_revoked, plus HTTP handler and tests.
Per-Event Authorization
apps/realtime/src/per-event-auth.ts, apps/realtime/src/__tests__/per-event-auth.test.ts
New classification of sensitive vs read-only events, logic to decide when to reauthorize, and async page access recheck with tests.
Realtime Server Integration
apps/realtime/src/index.ts
Integrates socketRegistry and kick handler, registers/unregisters sockets on connect/disconnect, tracks room joins/leaves, adds /api/kick endpoint and shared signature verification.
Web API Kick Utilities
apps/web/src/lib/websocket/socket-utils.ts
New public Kick API types and helpers (generic kickUserFromRooms plus kickUserFromDrive/DriveActivity/Page/PageActivity) that POST to realtime /api/kick.
Permission Route Handlers
apps/web/src/app/api/drives/[driveId]/members/[userId]/route.ts, apps/web/src/app/api/pages/[pageId]/permissions/route.ts, apps/web/src/app/api/activities/[activityId]/rollback/route.ts
After membership/permission removals, invoke kick helpers to revoke realtime access across drive/page/activity rooms; added imports and conditional parallel kicks.
Client-Side Revocation Hook
apps/web/src/hooks/useAccessRevocation.ts, apps/web/src/hooks/__tests__/useAccessRevocation.test.ts
New hook subscribing to access_revoked, deduplicating events, showing contextual toasts, redirecting when appropriate; tests validate lifecycle and behavior.
Layout Integration
apps/web/src/components/layout/Layout.tsx
Layout initializes socket connection and access-revocation hook.
Tests & Mocks
apps/web/src/app/api/pages/[pageId]/permissions/__tests__/route.test.ts
Tests updated to mock websocket kick helpers to simulate revocation side effects.
Design Documentation
docs/plans/2026-01-26-real-time-revocation-design.md
New design doc describing architecture, flows, components (Socket Registry, Kick API, per-event auth, client handling), and testing/ops notes.

Sequence Diagram(s)

sequenceDiagram
    participant Admin as Admin/API
    participant WebAPI as Web API
    participant Realtime as Realtime Server
    participant Registry as Socket Registry
    participant Client as Client App

    Admin->>WebAPI: Remove member / Revoke permission
    WebAPI->>WebAPI: Update DB, broadcast member_removed
    WebAPI->>Realtime: POST /api/kick (HMAC-signed)
    Realtime->>Realtime: verifySignature, parse & validate payload
    Realtime->>Registry: getSocketsForUser / getSocketsForUserInRoom
    Registry-->>Realtime: matching socketIds
    Realtime->>Realtime: executeKick (leave rooms, emit access_revoked)
    Realtime->>Client: emit access_revoked to affected sockets
    Client->>Client: show toast / redirect or silent handling
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly Related PRs

Poem

🐰 I hopped through rooms with a tiny drum,
I nudged the sockets — now access is numb.
A little kick, a gentle zap, a beaming light,
Clients hush, toasts flash, and ducks take flight.
Hooray — real-time tidy, in soft moonlight.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change—adding real-time permission revocation with zero-trust security—which directly corresponds to the substantial additions of SocketRegistry, Kick API, per-event authorization, and integration across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e1e8032f9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +318 to +320
await Promise.all([
kickUserFromDrive(driveId, targetUserId, 'member_removed', access.drive.name),
kickUserFromDriveActivity(driveId, targetUserId, 'member_removed'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Kick removed members out of page rooms too

When a drive member is removed, this code only calls kickUserFromDrive and kickUserFromDriveActivity, which ejects sockets from drive:{id} and activity:drive:{id} rooms, but page rooms are joined by raw pageId (see join_channel in apps/realtime/src/index.ts) and are not matched by the drive room pattern. That means a removed user who already joined one or more page rooms will continue receiving real‑time page updates for those pages until they disconnect, despite their permissions being deleted. Consider kicking from each page room in the drive (or using a room naming scheme that lets you match all pages in a drive) to fully revoke access.

Useful? React with 👍 / 👎.

Comment on lines +139 to +143
useEffect(() => {
const socket = getSocket();
if (!socket) return;

socket.on('access_revoked', handleAccessRevoked);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reattach access_revoked listener after socket replacement

The effect subscribes once using getSocket() and never reruns when the socket instance changes, so if the store replaces the socket (e.g., on forced reconnect after auth refresh), the new socket will not have the access_revoked handler attached and revocation events will be missed. Subscribing to useSocketStore(state => state.socket) (or similar) and using the socket object as a dependency would ensure the listener is re-registered on reconnect.

Useful? React with 👍 / 👎.

2witstudios and others added 2 commits January 26, 2026 16:37
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use vi.hoisted() to ensure mock variables are available before mock factories run
- Add missing websocket mock to permissions route test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/realtime/src/__tests__/per-event-auth.test.ts`:
- Around line 68-83: The test suite is missing coverage for three
SensitiveEventType values and for the async authorization logic in
reauthorizePageAccess; update the SensitiveEventType test (calling
isSensitiveEvent) to include 'task_create', 'task_update', and 'task_delete',
and add unit tests for reauthorizePageAccess that mock getUserAccessLevel to
assert behavior for allowed access, denied access (throws or returns false as
appropriate), and error propagation; ensure tests exercise the path where
reauthorizePageAccess resolves when getUserAccessLevel grants permission,
rejects/throws when it denies, and properly handles/propagates unexpected errors
from getUserAccessLevel.

In `@apps/realtime/src/kick-handler.ts`:
- Around line 47-54: The JSON parse in parseKickRequest currently uses a blind
type assertion to KickPayload; instead parse to unknown (or any) and then call
the existing validateKickPayload(payload) to perform runtime checks (including
adding/ensuring validation for reason being a string and metadata being an
object if not already checked). Update parseKickRequest to catch JSON parse
errors, call validateKickPayload on the parsed value, and return { success:
false, error: ... } when validation fails; reference the types KickPayload and
ParseResult and the validator function validateKickPayload to locate and
implement the change.

In `@docs/plans/2026-01-26-real-time-revocation-design.md`:
- Around line 22-52: The fenced ASCII diagram in the docs/plans file is missing
a language specifier; update the code fence that opens the diagram (the triple
backticks before the box starting with "┌────────────────...") to include a
language tag such as text (i.e., change ``` to ```text) so the block renders
correctly and satisfies markdown linting; locate the fenced block by the ASCII
diagram header "Permission Change Flow" and modify only the opening fence.
🧹 Nitpick comments (12)
apps/web/src/lib/websocket/socket-utils.ts (1)

85-101: Consider importing types from a shared location to avoid duplication.

KickPayload and KickResult are defined identically in both apps/web/src/lib/websocket/socket-utils.ts and apps/realtime/src/kick-handler.ts. This duplication can lead to drift if one is updated without the other.

Consider extracting these interfaces to a shared package (e.g., @pagespace/lib/types/realtime) and importing from there in both locations to ensure type consistency.

apps/realtime/src/__tests__/kick-api.test.ts (1)

121-139: Room pattern matching tests don't exercise the actual implementation.

These tests use inline string comparison (room === exactPattern) and a local regex (/^drive:/) rather than testing the actual roomMatchesPattern function from kick-handler.ts. This means the production room-matching logic isn't being validated.

♻️ Suggested improvement

Import and test the actual function:

 import {
   parseKickRequest,
   validateKickPayload,
   KickPayload,
   KickResult,
+  roomMatchesPattern,
 } from '../kick-handler';

 describe('room pattern matching', () => {
   it('given exact room pattern, should match only that room', () => {
-    const exactPattern = 'drive:drive-123';
-    const room = 'drive:drive-123';
-    expect(room === exactPattern).toBe(true);
+    expect(roomMatchesPattern('drive:drive-123', 'drive:drive-123')).toBe(true);
+    expect(roomMatchesPattern('drive:drive-456', 'drive:drive-123')).toBe(false);
   });
apps/realtime/src/per-event-auth.ts (2)

62-86: Unused resourceId parameter in shouldReauthorize.

The resourceId property in ReauthorizeParams is never used within the function body. Either remove it from the interface or document its intended future use.

♻️ Proposed fix

If not needed:

 interface ReauthorizeParams {
   eventType: string;
   roomType: 'page' | 'drive' | 'activity' | 'dm' | 'notification';
-  resourceId: string;
 }

Or add a TODO comment explaining future use.


42-53: Type and Set definitions can drift.

SensitiveEventType (lines 19-29) and SENSITIVE_EVENTS (lines 42-53) duplicate the same values. If one is updated without the other, they'll become inconsistent.

♻️ Suggested pattern to keep them in sync
const SENSITIVE_EVENTS = [
  'document_update',
  'page_content_change',
  'page_delete',
  'page_move',
  'file_upload',
  'comment_create',
  'comment_delete',
  'task_create',
  'task_update',
  'task_delete',
] as const;

export type SensitiveEventType = typeof SENSITIVE_EVENTS[number];

const SENSITIVE_EVENTS_SET = new Set<string>(SENSITIVE_EVENTS);

export function isSensitiveEvent(eventType: string): boolean {
  return SENSITIVE_EVENTS_SET.has(eventType);
}
apps/realtime/src/__tests__/per-event-auth.test.ts (1)

9-10: Unused imports: beforeEach and vi.

These are imported but not used in the test file.

♻️ Proposed fix
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect } from 'vitest';
apps/web/src/app/api/pages/[pageId]/permissions/route.ts (1)

191-195: Consider handling kick failures for observability.

The kick operations are critical for zero-trust security but failures are silently swallowed. While it's acceptable to not fail the HTTP response (permission was already revoked in DB), logging failures would aid debugging and security auditing.

🔧 Proposed improvement for error visibility
     // CRITICAL: Kick user from real-time rooms immediately (zero-trust revocation)
-    await Promise.all([
-      kickUserFromPage(pageId, userId, 'permission_revoked'),
-      kickUserFromPageActivity(pageId, userId, 'permission_revoked'),
-    ]);
+    try {
+      await Promise.all([
+        kickUserFromPage(pageId, userId, 'permission_revoked'),
+        kickUserFromPageActivity(pageId, userId, 'permission_revoked'),
+      ]);
+    } catch (kickError) {
+      // Log but don't fail - permission is already revoked in DB
+      loggers.api.warn('Failed to kick user from real-time rooms', {
+        pageId,
+        userId,
+        error: kickError instanceof Error ? kickError.message : String(kickError),
+      });
+    }
apps/web/src/app/api/pages/[pageId]/permissions/__tests__/route.test.ts (1)

72-76: Consider adding test coverage for kick function calls.

The mock is correctly set up, but no test explicitly verifies that kickUserFromPage and kickUserFromPageActivity are called during permission deletion. This would strengthen the test contract for the real-time revocation feature.

🧪 Add test to verify kick calls

Add to the DELETE describe block:

import { kickUserFromPage, kickUserFromPageActivity } from '@/lib/websocket';

// In describe('side effects (notifications)'):
it('kicks user from real-time rooms on successful deletion', async () => {
  await DELETE(
    createRequest({ userId: 'user_456' }),
    { params: mockParams }
  );

  expect(kickUserFromPage).toHaveBeenCalledWith(
    mockPageId,
    'user_456',
    'permission_revoked'
  );
  expect(kickUserFromPageActivity).toHaveBeenCalledWith(
    mockPageId,
    'user_456',
    'permission_revoked'
  );
});

it('does NOT kick user when authorization fails', async () => {
  (permissionManagementService.canUserManagePermissions as Mock).mockResolvedValue(false);

  await DELETE(
    createRequest({ userId: 'user_456' }),
    { params: mockParams }
  );

  expect(kickUserFromPage).not.toHaveBeenCalled();
  expect(kickUserFromPageActivity).not.toHaveBeenCalled();
});
apps/web/src/app/api/drives/[driveId]/members/[userId]/route.ts (1)

316-321: Consider handling kick failures for observability (consistent with permissions route).

Same concern as the permissions route - kick failures are silently swallowed. For consistency and auditability, consider logging failures without failing the request.

🔧 Proposed improvement for error visibility
     // CRITICAL: Kick user from real-time rooms immediately (zero-trust revocation)
     // This ensures the user stops receiving updates even if their socket is still connected
-    await Promise.all([
-      kickUserFromDrive(driveId, targetUserId, 'member_removed', access.drive.name),
-      kickUserFromDriveActivity(driveId, targetUserId, 'member_removed'),
-    ]);
+    try {
+      await Promise.all([
+        kickUserFromDrive(driveId, targetUserId, 'member_removed', access.drive.name),
+        kickUserFromDriveActivity(driveId, targetUserId, 'member_removed'),
+      ]);
+    } catch (kickError) {
+      // Log but don't fail - member is already removed from DB
+      loggers.api.warn('Failed to kick user from real-time rooms', {
+        driveId,
+        targetUserId,
+        error: kickError instanceof Error ? kickError.message : String(kickError),
+      });
+    }
apps/web/src/hooks/useAccessRevocation.ts (2)

30-56: Potential false positive in pathname matching.

The pathname.includes(metadata.driveId) and pathname.includes(metadata.pageId) checks (lines 40-42, 50-52) could match unintended routes if the ID happens to appear elsewhere in the URL (e.g., as part of another ID or query param). The primary checks using /drives/ and /pages/ prefixes are more precise.

🔧 Consider removing the loose includes checks
 function shouldRedirect(pathname: string, payload: AccessRevokedPayload): boolean {
   const { room, metadata } = payload;

   // Check if user is in the affected drive
   if (room.startsWith('drive:') && metadata?.driveId) {
     // User is viewing something in this drive
     if (pathname.includes(`/drives/${metadata.driveId}`)) {
       return true;
     }
-    // Also check if pathname includes the drive ID in any format
-    if (pathname.includes(metadata.driveId)) {
-      return true;
-    }
   }

   // Check if user is viewing the affected page
   if (metadata?.pageId) {
     if (pathname.includes(`/pages/${metadata.pageId}`)) {
       return true;
     }
-    if (pathname.includes(metadata.pageId)) {
-      return true;
-    }
   }

   return false;
 }

115-116: Consider removing or conditionalizing debug logging.

The console.log statement will appear in production browser consoles. For security events, this may be acceptable for debugging, but consider using a debug flag or removing it for cleaner production output.

apps/web/src/hooks/__tests__/useAccessRevocation.test.ts (1)

76-148: Consider adding test coverage for edge cases.

The test suite covers the main scenarios well but could be strengthened with:

  1. Deduplication test: Verify that duplicate events for the same room within 5 seconds don't trigger multiple toasts
  2. Session revocation redirect: Verify session_revoked reason redirects to /auth/login instead of /dashboard
🧪 Additional test cases
it('given duplicate revocation events for same room, should show toast only once', () => {
  renderHook(() => useAccessRevocation());

  const handlerCall = mockSocket.on.mock.calls.find(
    (call) => call[0] === 'access_revoked'
  );
  const handler = handlerCall?.[1];

  const payload = {
    room: 'drive:test-drive-id',
    reason: 'member_removed' as const,
    metadata: { driveId: 'test-drive-id', driveName: 'Test Drive' },
  };

  // Trigger same event twice
  act(() => {
    handler(payload);
    handler(payload);
  });

  // Should only show one toast
  expect(mockToast.error).toHaveBeenCalledTimes(1);
});

it('given session_revoked reason, should redirect to /auth/login', () => {
  renderHook(() => useAccessRevocation());

  const handlerCall = mockSocket.on.mock.calls.find(
    (call) => call[0] === 'access_revoked'
  );
  const handler = handlerCall?.[1];

  act(() => {
    handler({
      room: 'drive:test-drive-id',
      reason: 'session_revoked',
      metadata: { driveId: 'test-drive-id' },
    });
  });

  expect(mockPush).toHaveBeenCalledWith('/auth/login');
});
apps/realtime/src/socket-registry.ts (1)

149-153: Consider minor optimization for high-traffic scenarios.

The current implementation creates intermediate arrays. For very high-traffic scenarios with many sockets per user/room, this could be optimized to iterate directly over Sets. However, for typical usage patterns, this implementation is acceptable.

♻️ Optional: Avoid intermediate array allocation
  getSocketsForUserInRoom(userId: string, room: string): string[] {
-   const userSockets = this.getSocketsForUser(userId);
-   const roomSockets = new Set(this.getSocketsInRoom(room));
-   return userSockets.filter(socketId => roomSockets.has(socketId));
+   const userSockets = this.userToSockets.get(userId);
+   const roomSockets = this.roomToSockets.get(room);
+   if (!userSockets || !roomSockets) return [];
+   const result: string[] = [];
+   for (const socketId of userSockets) {
+     if (roomSockets.has(socketId)) {
+       result.push(socketId);
+     }
+   }
+   return result;
  }

Comment on lines +68 to +83
describe('SensitiveEventType', () => {
it('should include all write operations', () => {
const sensitiveEvents: SensitiveEventType[] = [
'document_update',
'page_content_change',
'page_delete',
'page_move',
'file_upload',
'comment_create',
'comment_delete',
];

sensitiveEvents.forEach(event => {
expect(isSensitiveEvent(event)).toBe(true);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test coverage is incomplete.

  1. Missing reauthorizePageAccess tests: The main async authorization function isn't tested at all. This is a significant gap since it contains the actual permission checking logic.

  2. Incomplete SensitiveEventType array: The test checks only 7 of the 10 sensitive events. Missing: task_create, task_update, task_delete.

💚 Proposed fix for complete type coverage
   it('should include all write operations', () => {
     const sensitiveEvents: SensitiveEventType[] = [
       'document_update',
       'page_content_change',
       'page_delete',
       'page_move',
       'file_upload',
       'comment_create',
       'comment_delete',
+      'task_create',
+      'task_update',
+      'task_delete',
     ];

Consider adding tests for reauthorizePageAccess that mock getUserAccessLevel and verify authorization logic for granted/denied/error cases.

🤖 Prompt for AI Agents
In `@apps/realtime/src/__tests__/per-event-auth.test.ts` around lines 68 - 83, The
test suite is missing coverage for three SensitiveEventType values and for the
async authorization logic in reauthorizePageAccess; update the
SensitiveEventType test (calling isSensitiveEvent) to include 'task_create',
'task_update', and 'task_delete', and add unit tests for reauthorizePageAccess
that mock getUserAccessLevel to assert behavior for allowed access, denied
access (throws or returns false as appropriate), and error propagation; ensure
tests exercise the path where reauthorizePageAccess resolves when
getUserAccessLevel grants permission, rejects/throws when it denies, and
properly handles/propagates unexpected errors from getUserAccessLevel.

Comment on lines +47 to +54
export function parseKickRequest(body: string): ParseResult {
try {
const payload = JSON.parse(body) as KickPayload;
return { success: true, payload };
} catch {
return { success: false, error: 'Invalid JSON' };
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type assertion without runtime validation could allow malformed payloads.

The as KickPayload assertion trusts the parsed JSON structure. While validateKickPayload checks userId and roomPattern, malformed reason or metadata fields would pass through unchecked.

🔧 Suggested enhancement to validate reason field
+const VALID_REASONS = ['member_removed', 'role_changed', 'permission_revoked', 'session_revoked'] as const;
+
 export function validateKickPayload(payload: KickPayload): ValidationResult {
   if (!payload.userId || typeof payload.userId !== 'string' || payload.userId.trim() === '') {
     return { valid: false, error: 'Missing or invalid userId' };
   }

   if (!payload.roomPattern || typeof payload.roomPattern !== 'string' || payload.roomPattern.trim() === '') {
     return { valid: false, error: 'Missing or invalid roomPattern' };
   }

+  if (!payload.reason || !VALID_REASONS.includes(payload.reason)) {
+    return { valid: false, error: 'Missing or invalid reason' };
+  }
+
   return { valid: true };
 }
🤖 Prompt for AI Agents
In `@apps/realtime/src/kick-handler.ts` around lines 47 - 54, The JSON parse in
parseKickRequest currently uses a blind type assertion to KickPayload; instead
parse to unknown (or any) and then call the existing
validateKickPayload(payload) to perform runtime checks (including
adding/ensuring validation for reason being a string and metadata being an
object if not already checked). Update parseKickRequest to catch JSON parse
errors, call validateKickPayload on the parsed value, and return { success:
false, error: ... } when validation fails; reference the types KickPayload and
ParseResult and the validator function validateKickPayload to locate and
implement the change.

Comment thread docs/plans/2026-01-26-real-time-revocation-design.md Outdated
2witstudios and others added 2 commits January 26, 2026 16:58
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- P1: Kick users from page rooms when removed from drive (not just drive room)
- P2: Reattach access_revoked listener when socket reconnects
- Add missing task events (task_create, task_update, task_delete) to tests
- Validate reason field in kick payload
- Add language specifier to markdown code block

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/app/api/drives/[driveId]/members/[userId]/route.ts (1)

318-341: Prevent kick failures from skipping cache invalidation or returning false errors.

If any kick call throws, the handler returns 500 even though the member was already removed, and the permission cache invalidation is skipped. That can leave stale access in cache and creates a confusing client retry path. Make kicks best-effort and ensure invalidation runs in a finally block.

✅ Suggested fix (best-effort kicks + always-invalidate)
-    // CRITICAL: Kick user from real-time rooms immediately (zero-trust revocation)
-    // This ensures the user stops receiving updates even if their socket is still connected
-
-    // First, kick from drive-level rooms
-    await Promise.all([
-      kickUserFromDrive(driveId, targetUserId, 'member_removed', access.drive.name),
-      kickUserFromDriveActivity(driveId, targetUserId, 'member_removed'),
-    ]);
-
-    // Also kick from all page rooms in this drive (page rooms use pageId, not drive pattern)
-    const drivePages = await db.select({ id: pages.id }).from(pages).where(eq(pages.driveId, driveId));
-    if (drivePages.length > 0) {
-      const pageKickPromises = drivePages.flatMap((page) => [
-        kickUserFromPage(page.id, targetUserId, 'member_removed'),
-        kickUserFromPageActivity(page.id, targetUserId, 'member_removed'),
-      ]);
-      await Promise.all(pageKickPromises);
-    }
-
-    // Invalidate permission caches so removed user loses access immediately
-    await Promise.all([
-      invalidateUserPermissions(targetUserId),
-      invalidateDrivePermissions(driveId),
-    ]);
+    try {
+      // CRITICAL: Kick user from real-time rooms immediately (zero-trust revocation)
+      // This ensures the user stops receiving updates even if their socket is still connected
+
+      // First, kick from drive-level rooms
+      await Promise.allSettled([
+        kickUserFromDrive(driveId, targetUserId, 'member_removed', access.drive.name),
+        kickUserFromDriveActivity(driveId, targetUserId, 'member_removed'),
+      ]);
+
+      // Also kick from all page rooms in this drive (page rooms use pageId, not drive pattern)
+      const drivePages = await db.select({ id: pages.id }).from(pages).where(eq(pages.driveId, driveId));
+      if (drivePages.length > 0) {
+        const pageKickPromises = drivePages.flatMap((page) => [
+          kickUserFromPage(page.id, targetUserId, 'member_removed'),
+          kickUserFromPageActivity(page.id, targetUserId, 'member_removed'),
+        ]);
+        await Promise.allSettled(pageKickPromises);
+      }
+    } catch (error) {
+      loggers.api.error('Kick failed during member removal:', error as Error);
+    } finally {
+      // Invalidate permission caches so removed user loses access immediately
+      await Promise.all([
+        invalidateUserPermissions(targetUserId),
+        invalidateDrivePermissions(driveId),
+      ]);
+    }
🧹 Nitpick comments (2)
apps/realtime/src/__tests__/per-event-auth.test.ts (2)

9-9: Remove unused imports.

beforeEach and vi are imported but never used in this test file.

🧹 Proposed fix
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect } from 'vitest';

35-65: Consider adding test coverage for notification room type.

The shouldReauthorize function explicitly handles notification rooms (returning false), but there's no test covering this branch. Adding a test would improve branch coverage.

💚 Suggested test case
it('given notification room event, should NOT require re-auth (user-specific room)', () => {
  const result = shouldReauthorize({
    eventType: 'document_update',
    roomType: 'notification',
    resourceId: 'user-123',
  });

  expect(result).toBe(false);
});

- Remove unused beforeEach and vi imports from per-event-auth.test.ts
- Add test coverage for notification room type in shouldReauthorize

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios
2witstudios merged commit fda7189 into master Jan 27, 2026
7 of 8 checks passed
@2witstudios
2witstudios deleted the critical-real-time-revocation-continuity-check-once-trust-forever-fix branch January 29, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant