Skip to content

Protect collaborative rooms with capability access - #33

Merged
donovan-yohan merged 3 commits into
mainfrom
feat/capability-room-access
Aug 8, 2026
Merged

donovan-yohan merged 3 commits into
mainfrom
feat/capability-room-access

Conversation

@donovan-yohan

@donovan-yohan donovan-yohan commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

What changed

  • makes protected capability rooms the only supported session type
  • exchanges #roomKey links for signed HttpOnly browser cookies before the workspace mounts
  • scopes MCP to one room with Authorization: Bearer <sessionId>.<roomKey> and removes session enumeration
  • authorizes JSON, WebSocket, and MCP ingress before session-manager access
  • adds key rotation with live browser/MCP revocation, exact-origin credentialed CORS, bounded brute-force controls, and a bounded/coalesced successful-bearer proof cache
  • adds canonical Fly/Vercel deployment configuration and a guarded production smoke test
  • improves landing-page spacing and preserves the canvas camera across source/render updates

Architecture and security boundary

The room verifier and access version are server-private LevelDB state, separate from Yjs, history, and activity. Raw keys are returned once, remain in the URL fragment or current-page memory only, and are never accepted in query parameters or tool arguments. The MCP request context is bound to the bearer-authorized room; mutations retain the existing fresh-read/revision checks.

This is lightweight capability access, not identity or RBAC. Anyone holding the full share link or MCP bearer is intentionally a full collaborator.

User impact

A bare room URL now shows an access gate without starting protected document, presence, history, or WebSocket work. A complete share link opens the room, clears the fragment after a successful exchange, and enables realtime browser/MCP collaboration. Transient exchange failures preserve the fragment for retry. Settings can copy the private link or agent prompt and rotate the room key.

The canvas also fits only on initial diagram mount or explicit user action, preventing local, remote, or renderer-kind source updates from unexpectedly changing zoom or position.

Validation

Exact head: 1e14d76c99741be2c617328a5595eee12f00da5e

  • pnpm lint
  • pnpm typecheck
  • pnpm test — 7 shared, 63 server, 129 web tests
  • pnpm build
  • strict isolated canvas smoke — all 22 checks, 2px drag/drop offset, camera stable, durable Yjs position
  • pnpm test:e2e-collaboration — two browsers + MCP convergence, stale-write rejection, cross-room isolation, fragment handling, and rotation revocation
  • pnpm test:e2e-workspace-ux — 21 checks, including stable local/remote/renderer camera transitions and explicit Fit behavior
  • focused adversarial rereview of the review-fix delta — no P0/P1/P2 findings

Deployment follow-through

The main-only Fly job requires repository secrets FLY_API_TOKEN and ROOM_COOKIE_SECRET. Production verification also requires the api.arielcharts.donovanyohan.com Fly certificate/DNS mapping and the Vercel web environment to point at that API origin.

Closes #30

@vercel

vercel Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
arielcharts Ready Ready Preview Aug 8, 2026 9:13pm

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request adds protected room creation and access using room keys, signed cookies, and room-scoped MCP bearers. It gates HTTP, WebSocket, and MCP routes, supports key rotation, updates the web workspace flow, expands E2E coverage, and adds deployment checks.

Changes

Protected room access

Layer / File(s) Summary
Room access foundation
apps/server/src/lib/{types,env,http,origin,persistence,room-access,session-manager}.*, apps/server/.env.example
Adds private room records, protected session creation, signed browser cookies, bearer authentication, key rotation, rate limits, environment validation, and room-access persistence.
Authenticated server ingress
apps/server/src/index.ts, apps/server/src/lib/{mcp,mcp-server,websocket}.*, apps/server/src/index.test.ts
Requires room credentials for diagram routes, MCP discovery and tools, and WebSocket upgrades. Removes session enumeration and closes sockets after rotation.
Web room creation and authorization
apps/web/src/app/*, apps/web/src/components/{room-gate,landing-page-client,session-workspace,workspace-settings}.*, apps/web/src/lib/{room-access-api,history-api}.*, apps/web/src/app/globals.css
Creates private rooms, exchanges URL-fragment keys for cookies, prevents workspace mounting before authorization, supports sharing and key reset, and sends browser credentials with history requests.
Protected collaboration validation
e2e-*.ts, e2e/support/*
Updates collaboration suites and helpers to use room credentials. Adds isolation, revocation, cookie, WebSocket, MCP, and production smoke coverage.
Camera fitting policy
apps/web/src/components/diagram-canvas.tsx, apps/web/src/lib/renderer-camera-policy.*, e2e-workspace-ux-validate.ts
Replaces renderer-transition fitting with one-time initial fitting and camera-preservation assertions.
Deployment and runtime wiring
.github/workflows/ci.yml, fly.toml, package.json, README.md, docs/*, apps/web/next.config.*
Adds protected collaboration CI, gated Fly deployment, production settings, canonical host redirects, runtime documentation, and smoke-test commands.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • donovan-yohan/arielcharts#17 — The changes share the same SessionManager, persistence, MCP, WebSocket, and history revision seams.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The camera-policy, renderer-transition, and landing-page spacing changes are unrelated to the room-protection requirements in issue #30. Move the camera-policy and landing-page spacing changes into a separate pull request, or link them to explicit requirements.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation addresses issue #30 requirements for protected rooms, credential-bound access, isolation, rotation, throttling, CORS, and integration coverage.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's primary change: adding capability-based protection for collaborative rooms.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/capability-room-access

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.

@donovan-yohan
donovan-yohan marked this pull request as ready for review August 8, 2026 20:45

@coderabbitai coderabbitai 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.

Actionable comments posted: 13

Caution

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

⚠️ Outside diff range comments (3)
apps/server/src/lib/session-manager.ts (1)

828-833: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Confirm that a new protected session always persists its room-access record.

loadSession passes initialRoomAccess to persistSession only when repairedOnLoad || persisted is true. For createProtectedSession, persisted is always null. The record is therefore written only if repairCatalogAndNames returns true for a fresh empty Y.Doc.

If that function ever returns false for an empty document, the room-access record is never stored. getRoomAccess then returns null, every authentication path returns 401, and the room becomes permanently unreachable while its Yjs state still exists. Make the write unconditional when initialRoomAccess is present rather than depending on the repair result.

🐛 Proposed fix
-    if (repairedOnLoad || persisted) {
+    if (repairedOnLoad || persisted || options.initialRoomAccess) {
       await this.persistSession(state, { recovery: true, initialRoomAccess: options.initialRoomAccess });
     }
#!/bin/bash
# Description: Inspect repairCatalogAndNames to confirm its return value for an empty document.
rg -nP -B3 -A40 'function repairCatalogAndNames' apps/server/src/lib/session-manager.ts
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/lib/session-manager.ts` around lines 828 - 833, Update the
persistence condition in loadSession so persistSession runs whenever
options.initialRoomAccess is present, regardless of repairedOnLoad or persisted.
Preserve the existing recovery payload and conditional behavior for sessions
without initialRoomAccess.
apps/web/src/components/landing-page-client.tsx (1)

67-86: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Associate the validation message with the input.

When parseRoomReference fails, the help text at Line 82 turns red and the submit button at Line 85 becomes disabled. Neither change is exposed to assistive technology. The input has no aria-invalid and no aria-describedby, so a screen-reader user receives no reason for the disabled button.

Add an ID to the help text and reference it from the input.

♿ Proposed fix
           <input
             data-testid="join-session-input"
             id="session-id-input"
+            aria-describedby="session-id-help"
+            aria-invalid={!joinValueIsValid}
             autoCapitalize="none"
             autoComplete="off"
             autoCorrect="off"
             className="text-input"
             inputMode="text"
             onChange={(event) => {
               setJoinValue(event.target.value);
             }}
             placeholder="a7x9k2mn or https://…/s/a7x9k2mn#roomKey=…"
             spellCheck={false}
             value={joinValue}
           />
-          <p className={`field-help${joinValueIsValid ? '' : ' error-text'}`}>
+          <p id="session-id-help" className={`field-help${joinValueIsValid ? '' : ' error-text'}`}>

This is a browser and layout change. Run npx tsx e2e-validate.ts and use pnpm test:e2e-workspace-ux as the production-mode browser gate for focus and layout stability.

Based on learnings: "use pnpm test:e2e-workspace-ux as the production-mode browser gate for theme, flyout, focus, responsive, toolbar, Fit, and layout stability."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/landing-page-client.tsx` around lines 67 - 86, Add a
stable unique ID to the validation help <p> and set the join-session input’s
aria-describedby to that ID so assistive technology receives the validation
message. Also expose the invalid state through aria-invalid based on
joinValueIsValid, while preserving the existing validation styling and
submit-button behavior.

Sources: Coding guidelines, Learnings

e2e/support/mcp.ts (1)

94-103: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not report HTTP errors as timeouts.

Line 97 throws for a non-ok response inside the same try that line 100 catches. The catch rewraps every failure as MCP ${name} request failed within 15s. The status text from line 97 survives only in cause, which most harness output does not print.

This PR introduces many 401 responses from the new room authentication. A failing bearer now reports a timeout, which points a reader at the wrong subsystem.

Move the status check out of the try so the HTTP message reaches the caller unchanged.

🐛 Proposed fix
+    let response: Response;
     try {
-      const response = await postModernMcp(this.endpoint, this.origin, this.room, name, args, this.nextId++);
-      if (!response.ok) {
-        throw new Error(`MCP ${name} returned HTTP ${response.status}: ${await response.text()}`);
-      }
-      return response.json() as Promise<McpPayload>;
+      response = await postModernMcp(this.endpoint, this.origin, this.room, name, args, this.nextId++);
     } catch (error) {
       throw new Error(`MCP ${name} request failed within ${MCP_FETCH_TIMEOUT_MS / 1_000}s`, { cause: error });
     }
+    if (!response.ok) {
+      throw new Error(`MCP ${name} returned HTTP ${response.status}: ${await response.text()}`);
+    }
+    return response.json() as Promise<McpPayload>;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/support/mcp.ts` around lines 94 - 103, Update the request flow around
postModernMcp so the non-ok HTTP status check and its descriptive error occur
outside the timeout-catching try block. Keep only transport or response parsing
failures wrapped with the timeout message, allowing HTTP errors such as 401 to
reach callers unchanged.
🧹 Nitpick comments (12)
apps/server/src/lib/room-access.ts (2)

260-270: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Build the rotated record once.

rotateExclusive constructs the same record object twice. If the two literals drift, the persisted record and the returned record diverge.

♻️ Proposed refactor
     const next = await this.createGrant();
-    await this.store.setRoomAccess(sessionId, {
-      ...next.record,
-      accessVersion: current.accessVersion + 1,
-      createdAt: current.createdAt,
-    });
-    return { roomKey: next.roomKey, record: { ...next.record, accessVersion: current.accessVersion + 1, createdAt: current.createdAt } };
+    const record: RoomAccessRecord = {
+      ...next.record,
+      accessVersion: current.accessVersion + 1,
+      createdAt: current.createdAt,
+    };
+    await this.store.setRoomAccess(sessionId, record);
+    return { roomKey: next.roomKey, record };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/lib/room-access.ts` around lines 260 - 270, Update
rotateExclusive to construct the rotated access record once after createGrant,
including the incremented accessVersion and preserved createdAt, then reuse that
same record for both setRoomAccess and the returned RoomAccessGrant.

227-240: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Room cookies accumulate per session and are sent on every request.

browserCookieHeaders sets one cookie per room with Path=/. A user who opens many rooms accumulates one cookie per room. The browser then sends all of them on every request to the server origin, including the WebSocket upgrade. Browsers cap cookies per domain and servers cap request header size, so heavy users can eventually break requests.

Consider scoping the cookie to the room paths it is used on, or storing one cookie that holds a bounded map of authorized rooms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/lib/room-access.ts` around lines 227 - 240, Update
browserCookieHeaders so room cookies are not sent on every server-origin
request: scope each cookie’s Path to the specific room route(s) that consume it,
or replace the per-room cookie format with one bounded authorization map while
preserving session, accessVersion, expiry, and signature validation.
apps/server/src/lib/room-access.test.ts (1)

49-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit coverage for rotation revocation.

The suite tests cookie signing, expiry, and bearer validation, but it does not test rotate. Rotation is a stated requirement of the linked issue: it must invalidate the previous room key and the previously issued cookie. Add a case that rotates the grant, then asserts that the old cookie and the old bearer both fail with 401 and that the new key succeeds.

💚 Proposed test
+  it('revokes prior cookies and bearers after rotation', async () => {
+    const access = new RoomAccessService(store, { cryptoProfile: 'test', cookieSecret: 'test-secret' });
+    const grant = await access.createGrant();
+    await store.setRoomAccess('abc123de', grant.record);
+    const staleCookie = (access.browserCookieHeaders('abc123de', 1)['set-cookie'] as string).split(';')[0]!;
+
+    const rotated = await access.rotate('abc123de', 1);
+    expect(rotated.record.accessVersion).toBe(2);
+    await expect(access.authenticateBrowserCookie('abc123de', request({ cookie: staleCookie }))).rejects.toMatchObject({ status: 401 });
+    await expect(access.authenticateBearer(request({ authorization: `Bearer abc123de.${grant.roomKey}` }))).rejects.toMatchObject({ status: 401 });
+    await expect(access.authenticateBearer(request({ authorization: `Bearer abc123de.${rotated.roomKey}` }))).resolves.toEqual({ sessionId: 'abc123de', accessVersion: 2 });
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/lib/room-access.test.ts` around lines 49 - 71, Extend the
existing RoomAccessService test around createGrant and cookie authentication to
cover rotate: issue and retain the original room key, bearer, and signed cookie,
rotate the grant, then assert the old cookie and bearer reject with status 401
while authentication using the rotated key succeeds.
apps/web/src/lib/room-access-api.test.ts (1)

30-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The hardcoded server origin couples the test to one environment.

The assertion pins http://localhost:4000. If the API base URL is read from an environment variable, this test fails whenever that variable differs, for example in CI or in a production-mode run. Derive the expected prefix from the same source the client uses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/room-access-api.test.ts` around lines 30 - 34, Update the
request assertion in the room access API test to derive the expected server
origin from the same environment-backed configuration used by the client, rather
than hardcoding localhost:4000. Keep the existing path, method, credentials, and
signal expectations unchanged.
apps/web/src/components/landing-page-client.tsx (1)

16-29: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider aborting the in-flight room creation on unmount.

createRoom accepts an AbortSignal, but handleCreate passes none. If the user leaves the landing page after clicking the button, the request still completes and router.push at Line 24 runs. The user is then pulled to /s/<id>#roomKey=… from whatever page they moved to.

The server performs a memory-hard derivation during createGrant, so the response is not instantaneous and this window is reachable.

♻️ Proposed refactor
+  const abortRef = useRef<AbortController | null>(null);
+  useEffect(() => () => { abortRef.current?.abort(); }, []);
+
   const handleCreate = async () => {
     if (createPending) {
       return;
     }
     setCreatePending(true);
     setCreateFailed(false);
+    const controller = new AbortController();
+    abortRef.current = controller;
     try {
-      const room = await createRoom();
+      const room = await createRoom(controller.signal);
+      if (controller.signal.aborted) return;
       router.push(getRoomSharePath(room.sessionId, room.roomKey));
     } catch {
+      if (controller.signal.aborted) return;
       setCreateFailed(true);
       setCreatePending(false);
     }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/landing-page-client.tsx` around lines 16 - 29, Update
handleCreate to use an AbortController whose signal is passed to createRoom, and
abort it when the landing-page component unmounts. Ensure the aborted request
cannot trigger router.push or update creation state after unmount, while
preserving existing failure handling for non-abort errors.
apps/server/src/index.ts (1)

98-105: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Do not report internal failures as 401 on /access and /rotate.

sendRoomAccessError converts every non-RoomAccessError into 401 "Room access denied.". The /api/rooms handler (Line 173) and the /mcp handler (Line 341) narrow to RoomAccessError before calling it, so they keep 5xx fidelity. The /access catch (Line 207) and the /rotate catch (Line 240) do not narrow.

A LevelDB failure inside roomAccess.rotate or store.getRoomAccess therefore reaches the browser as an authentication failure. The user sees "wrong key" during an infrastructure outage, and the server emits no log line.

Keep the generic 401 body for real authentication failures. Log and return 500 for the rest.

♻️ Proposed refactor
 function sendRoomAccessError(response: ServerResponse, error: unknown, headers: Record<string, unknown> = {}): void {
-  const roomError = error instanceof RoomAccessError ? error : new RoomAccessError(401);
+  if (!(error instanceof RoomAccessError)) {
+    console.error('Room access request failed:', error);
+    sendJson(response, 500, { error: 'Internal server error.' }, { ...headers, 'cache-control': 'no-store' });
+    return;
+  }
+  const roomError = error;
   sendJson(response, roomError.status, { error: 'Room access denied.' }, {
     ...headers,
     ...roomAccessErrorHeaders(roomError),
     'cache-control': 'no-store',
   });
 }

Keep assertValidSessionId failures as 401. Convert them explicitly at the call sites:

-        assertValidSessionId(accessSessionId);
+        if (!isValidSessionId(accessSessionId)) throw new RoomAccessError(401);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/index.ts` around lines 98 - 105, Update sendRoomAccessError
and the /access and /rotate catch paths to preserve 401 responses for genuine
authentication failures, including assertValidSessionId failures, while treating
unexpected errors from roomAccess.rotate or store.getRoomAccess as internal
failures: log them and return a 500 response instead of the generic
room-access-denied 401. Keep the existing RoomAccessError handling and response
headers intact.
apps/server/src/lib/websocket.test.ts (1)

176-177: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add negative WebSocket authentication coverage and a rotation-revocation test.

apps/server/src/lib/websocket.test.ts only uses the current valid room cookie. Add denial cases for no cookie header, a cookie belonging to a different session, and a cookie with a stale accessVersion after rotation. Also assert that an already-upgraded WebSocket socket is terminated after roomAccess.rotate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/lib/websocket.test.ts` around lines 176 - 177, Add negative
authentication coverage in the WebSocket tests around room creation and
roomCookie setup: verify connections are denied without a cookie, with a cookie
from another session, and with the pre-rotation accessVersion after
roomAccess.rotate. Also verify an already-upgraded WebSocket is terminated when
rotate is called, while preserving the existing valid-cookie success path.

Source: Coding guidelines

apps/web/src/components/room-gate.test.tsx (1)

39-42: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer asserting only the props argument.

The assertion pins the component call arity to two arguments. The second argument is a React internal detail. React 19 removed legacy context and forwardRef wrapping, and the renderer call signature for function components is not a public contract. A future React patch release can change the argument count and break this test without any product regression.

Use toHaveBeenCalledWith on the props alone through mock.calls[0]?.[0], or keep objectContaining and drop the trailing undefined.

♻️ Proposed assertion change
-    expect(vi.mocked(SessionWorkspace)).toHaveBeenCalledWith(
-      expect.objectContaining({ initialRoomKey: 'raw-key', sessionId: 'abc123de' }),
-      undefined,
-    );
+    expect(vi.mocked(SessionWorkspace).mock.calls[0]?.[0]).toEqual(
+      expect.objectContaining({ initialRoomKey: 'raw-key', sessionId: 'abc123de' }),
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/room-gate.test.tsx` around lines 39 - 42, Update the
SessionWorkspace assertion in the room-gate test to validate only the first
props argument, using mock.calls[0]?.[0] with the existing objectContaining
check, or remove the trailing undefined from toHaveBeenCalledWith. Do not assert
the component’s internal call arity.
e2e/support/room-access.ts (1)

24-30: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Prefer Headers.getSetCookie() for cookie extraction.

Line 25 reads set-cookie through Headers.get. When a response carries more than one Set-Cookie header, undici returns them joined by a comma. Line 27 then splits on ; and returns the first segment, which would be a corrupted concatenation rather than the room cookie.

The current server responses set exactly one cookie, so this works today. It breaks silently if a second cookie is ever added to the access or rotate responses. Headers.getSetCookie() is available in Node 20 and later and returns each header as a separate array entry.

♻️ Proposed change
 function cookieFrom(response: Response, description: string): string {
-  const setCookie = response.headers.get('set-cookie');
-  assert(setCookie, `${description} did not set a room access cookie.`);
-  const cookie = setCookie.split(';', 1)[0];
+  const setCookies = response.headers.getSetCookie();
+  assert(setCookies.length === 1,
+    `${description} set ${setCookies.length} cookies; expected exactly one room access cookie.`);
+  const cookie = setCookies[0]!.split(';', 1)[0];
   assert(cookie && cookie.includes('='), `${description} returned an invalid room access cookie.`);
   return cookie;
 }

Run the following script to confirm the Node target supports getSetCookie:

#!/bin/bash
# Check the declared Node engine and TypeScript lib for the E2E harness.
fd -H -t f 'package.json' -d 2 --exec sh -c 'echo "== $1"; jq -r ".engines // empty, .devDependencies[\"`@types/node`\"] // empty" "$1"' _ {}
fd -H -t f 'tsconfig*.json' -d 2 --exec sh -c 'echo "== $1"; grep -n "\"lib\"\|\"target\"" "$1"' _ {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/support/room-access.ts` around lines 24 - 30, Update cookieFrom to use
response.headers.getSetCookie() and select the room access cookie from the
returned individual header values instead of reading the combined set-cookie
string. Preserve the existing validation and return behavior, while ensuring
multiple Set-Cookie headers cannot corrupt extraction.
e2e/support/yjs-session.ts (1)

318-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the cookie-bearing WebSocket polyfill into one shared helper.

connectAgentPresence in e2e-workspace-ux-validate.ts (lines 202-206) builds the same CookieWebSocket subclass. Export one factory, for example cookieWebSocket(cookie, origin), from this module and use it in both places. This keeps the authenticated-header contract in one location.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/support/yjs-session.ts` around lines 318 - 324, Extract the
cookie-bearing WebSocket subclass creation into an exported helper such as
cookieWebSocket(cookie, origin) in this module, preserving the existing cookie
and optional origin headers. Replace the local WebSocketPolyfill construction
here and the duplicate CookieWebSocket implementation in connectAgentPresence
with this shared factory.
e2e-validate.ts (1)

90-100: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the new durable observer and the browser with try/finally.

validate() closes the browser at line 583 and destroys durableObserver at line 584 only on the success path. Any throw between them leaves both open. The observer now holds a live WebSocket and a Y.Doc, so a failed run can hang instead of exiting. Line 396 throws explicitly inside this unguarded body.

Wrap the body in try { … } finally { await browser.close(); durableObserver.destroy(); }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-validate.ts` around lines 90 - 100, Wrap the main validation body in
validate(), including the page-loading and subsequent assertions, with a
try/finally block. Move browser.close() and durableObserver.destroy() into the
finally block so both resources are released on success and every throw,
including the explicit failure path; preserve their existing cleanup order.
e2e-workspace-ux-validate.ts (1)

1644-1651: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the cookie-only precondition explicit.

The second expectAgentConnectionModal call asserts that no raw room key remains in memory. That state holds only because expectThemeContract reloads the page. The dependency is implicit, so reordering or changing the theme helper silently breaks this assertion.

Add an explicit await page.reload({ waitUntil: 'domcontentloaded' }) (and the workspace wait) before the cookie-only run, or add a short comment that states the reload requirement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-workspace-ux-validate.ts` around lines 1644 - 1651, Make the cookie-only
precondition explicit before the second expectAgentConnectionModal call: reload
the page with domcontentloaded, then perform the existing workspace wait, or add
a concise comment documenting that expectThemeContract must reload first. Keep
the cookie-only assertion after this explicit precondition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/index.ts`:
- Around line 389-400: In the WebSocket authentication flow after
isValidSessionId, add a one-time error listener to the raw socket before calling
roomAccess.authenticateBrowserCookie, then remove that listener when
authentication settles before invoking websocketServer.upgrade. Ensure the
listener also remains effective if authentication rejects and socket.destroy()
runs, without changing the existing validation or upgrade behavior.

In `@apps/server/src/lib/env.ts`:
- Around line 13-16: Update parseBoolean to accept only the exact strings 'true'
and 'false', returning the corresponding boolean and preserving fallback only
for undefined; throw an error for any other value so invalid environment
configuration is rejected consistently with the other parsers in the file.

In `@apps/server/src/lib/mcp-server.ts`:
- Around line 384-388: Add an MCP-aware end-to-end test covering sequential
requests with different bearer tokens, verifying each request uses its current
authorized session rather than a stale closure value. Assert that the returned
roomSessionId differs or that a cross-room operation is denied, and anchor the
test around the createMcpHandler flow and authorizedSessionId handling.

In `@apps/server/src/lib/room-access.ts`:
- Around line 105-111: Update authenticateBearer and the bearer-grant flow to
avoid running the full scryptProfile derivation on every MCP request: cache
successful bearer verification briefly in memory keyed by the verified bearer,
or derive and validate a cheap HMAC token once when the bearer is granted. Keep
the existing high-cost scrypt path for one-time room-key exchange and preserve
bearer expiration and revocation checks.

In `@apps/web/src/app/globals.css`:
- Around line 1040-1050: Update the `.visually-hidden` rule in
apps/web/src/app/globals.css lines 1040-1050 by replacing the deprecated `clip`
declaration with `clip-path: inset(50%)`. In the rule at
apps/web/src/app/globals.css lines 1970-1978, delete the redundant deprecated
`clip` declaration because `clip-path: inset(50%)` is already present.

In `@apps/web/src/components/room-gate.tsx`:
- Around line 98-117: Update the exchangeRoomKey error handling in the room-gate
flow to clear the fragment only when the caught error is a RoomAccessApiError
with an authentication-failure status. Import and use RoomAccessApiError for
this check; preserve the fragment for network errors and 5xx responses while
keeping the existing access-check and gate-state behavior.

In `@apps/web/src/components/session-workspace.tsx`:
- Around line 1219-1225: Update resetRoomKey to catch rotateRoomKey failures,
set room-key-related feedback to the existing error state, and announce the
failure through setRoomKeyAnnouncement so the live region reports it. Ensure the
callback handles both RoomAccessApiError and network rejections without
returning an unhandled rejected promise, while preserving the existing success
updates.

In `@apps/web/src/components/workspace-settings.tsx`:
- Line 312: Update the keyResetMessage status region in the workspace settings
component to render unconditionally with aria-live and its existing styling,
changing only the text content based on keyResetMessage; preserve the current
empty state when no message exists.

In `@docs/arielcharts-spec.md`:
- Line 240: Update the non-flowchart editing description near the sequence,
class, and state diagram scope in the documentation to remove “read-only” and
instead exclude only structural canvas editing, while preserving source editing
support.

In `@docs/context-map.md`:
- Around line 66-73: Renumber the stale-revision ingress item following the new
item 4 to 5, preserving its existing text and numbering sequence.

In `@e2e-collaboration-validate.ts`:
- Around line 354-360: Ensure resources are acquired within the scopes protected
by their existing cleanup blocks: in e2e-collaboration-validate.ts lines
354-360, declare activeSocket before the try, assign it after opening the
socket, and terminate it with optional cleanup in the finally at line 383; in
lines 476-493, begin the try immediately after chromium.launch so createRoom and
exchangeRoomAccess failures still reach the browser.close() finally at line 758.
- Around line 388-406: Remove the duplicated local ModernMcpClient
implementation and import or extend the shared ModernMcpClient from
./e2e/support/mcp so requests retain timeout-aware postModernMcp behavior and
shared members. Preserve only the suite-specific createDiagramWithLatestRevision
stale-revision retry, implemented in a thin subclass or added to the shared
client as appropriate, while keeping the existing constructor and bound-room
behavior.

In `@e2e-validate.ts`:
- Around line 90-100: Move room provisioning into the existing resource-cleanup
scopes: in e2e-validate.ts lines 90-100, wrap the flow in try/finally and always
call browser.close() and durableObserver.destroy(); in e2e-sequence-validate.ts
lines 281-285, move createRoom inside the existing try that closes the context
and browser; in e2e-workspace-ux-validate.ts lines 1629-1635, move createRoom
and exchangeRoomAccess inside the try whose finally calls browser.close().

---

Outside diff comments:
In `@apps/server/src/lib/session-manager.ts`:
- Around line 828-833: Update the persistence condition in loadSession so
persistSession runs whenever options.initialRoomAccess is present, regardless of
repairedOnLoad or persisted. Preserve the existing recovery payload and
conditional behavior for sessions without initialRoomAccess.

In `@apps/web/src/components/landing-page-client.tsx`:
- Around line 67-86: Add a stable unique ID to the validation help <p> and set
the join-session input’s aria-describedby to that ID so assistive technology
receives the validation message. Also expose the invalid state through
aria-invalid based on joinValueIsValid, while preserving the existing validation
styling and submit-button behavior.

In `@e2e/support/mcp.ts`:
- Around line 94-103: Update the request flow around postModernMcp so the non-ok
HTTP status check and its descriptive error occur outside the timeout-catching
try block. Keep only transport or response parsing failures wrapped with the
timeout message, allowing HTTP errors such as 401 to reach callers unchanged.

---

Nitpick comments:
In `@apps/server/src/index.ts`:
- Around line 98-105: Update sendRoomAccessError and the /access and /rotate
catch paths to preserve 401 responses for genuine authentication failures,
including assertValidSessionId failures, while treating unexpected errors from
roomAccess.rotate or store.getRoomAccess as internal failures: log them and
return a 500 response instead of the generic room-access-denied 401. Keep the
existing RoomAccessError handling and response headers intact.

In `@apps/server/src/lib/room-access.test.ts`:
- Around line 49-71: Extend the existing RoomAccessService test around
createGrant and cookie authentication to cover rotate: issue and retain the
original room key, bearer, and signed cookie, rotate the grant, then assert the
old cookie and bearer reject with status 401 while authentication using the
rotated key succeeds.

In `@apps/server/src/lib/room-access.ts`:
- Around line 260-270: Update rotateExclusive to construct the rotated access
record once after createGrant, including the incremented accessVersion and
preserved createdAt, then reuse that same record for both setRoomAccess and the
returned RoomAccessGrant.
- Around line 227-240: Update browserCookieHeaders so room cookies are not sent
on every server-origin request: scope each cookie’s Path to the specific room
route(s) that consume it, or replace the per-room cookie format with one bounded
authorization map while preserving session, accessVersion, expiry, and signature
validation.

In `@apps/server/src/lib/websocket.test.ts`:
- Around line 176-177: Add negative authentication coverage in the WebSocket
tests around room creation and roomCookie setup: verify connections are denied
without a cookie, with a cookie from another session, and with the pre-rotation
accessVersion after roomAccess.rotate. Also verify an already-upgraded WebSocket
is terminated when rotate is called, while preserving the existing valid-cookie
success path.

In `@apps/web/src/components/landing-page-client.tsx`:
- Around line 16-29: Update handleCreate to use an AbortController whose signal
is passed to createRoom, and abort it when the landing-page component unmounts.
Ensure the aborted request cannot trigger router.push or update creation state
after unmount, while preserving existing failure handling for non-abort errors.

In `@apps/web/src/components/room-gate.test.tsx`:
- Around line 39-42: Update the SessionWorkspace assertion in the room-gate test
to validate only the first props argument, using mock.calls[0]?.[0] with the
existing objectContaining check, or remove the trailing undefined from
toHaveBeenCalledWith. Do not assert the component’s internal call arity.

In `@apps/web/src/lib/room-access-api.test.ts`:
- Around line 30-34: Update the request assertion in the room access API test to
derive the expected server origin from the same environment-backed configuration
used by the client, rather than hardcoding localhost:4000. Keep the existing
path, method, credentials, and signal expectations unchanged.

In `@e2e-validate.ts`:
- Around line 90-100: Wrap the main validation body in validate(), including the
page-loading and subsequent assertions, with a try/finally block. Move
browser.close() and durableObserver.destroy() into the finally block so both
resources are released on success and every throw, including the explicit
failure path; preserve their existing cleanup order.

In `@e2e-workspace-ux-validate.ts`:
- Around line 1644-1651: Make the cookie-only precondition explicit before the
second expectAgentConnectionModal call: reload the page with domcontentloaded,
then perform the existing workspace wait, or add a concise comment documenting
that expectThemeContract must reload first. Keep the cookie-only assertion after
this explicit precondition.

In `@e2e/support/room-access.ts`:
- Around line 24-30: Update cookieFrom to use response.headers.getSetCookie()
and select the room access cookie from the returned individual header values
instead of reading the combined set-cookie string. Preserve the existing
validation and return behavior, while ensuring multiple Set-Cookie headers
cannot corrupt extraction.

In `@e2e/support/yjs-session.ts`:
- Around line 318-324: Extract the cookie-bearing WebSocket subclass creation
into an exported helper such as cookieWebSocket(cookie, origin) in this module,
preserving the existing cookie and optional origin headers. Replace the local
WebSocketPolyfill construction here and the duplicate CookieWebSocket
implementation in connectAgentPresence with this shared factory.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 45a06240-9c40-4e32-8876-fedf3e1fbe55

📥 Commits

Reviewing files that changed from the base of the PR and between 4dbf830 and 3fdf33b.

📒 Files selected for processing (53)
  • .github/workflows/ci.yml
  • README.md
  • apps/server/.env.example
  • apps/server/AGENTS.md
  • apps/server/src/index.test.ts
  • apps/server/src/index.ts
  • apps/server/src/lib/env.ts
  • apps/server/src/lib/http.ts
  • apps/server/src/lib/mcp-server.ts
  • apps/server/src/lib/mcp.test.ts
  • apps/server/src/lib/mcp.ts
  • apps/server/src/lib/origin.ts
  • apps/server/src/lib/persistence.ts
  • apps/server/src/lib/room-access.test.ts
  • apps/server/src/lib/room-access.ts
  • apps/server/src/lib/session-manager.test.ts
  • apps/server/src/lib/session-manager.ts
  • apps/server/src/lib/types.ts
  • apps/server/src/lib/websocket.test.ts
  • apps/server/src/lib/websocket.ts
  • apps/web/AGENTS.md
  • apps/web/next.config.test.ts
  • apps/web/next.config.ts
  • apps/web/src/app/globals.css
  • apps/web/src/app/page.tsx
  • apps/web/src/app/s/[id]/page.tsx
  • apps/web/src/components/diagram-canvas.tsx
  • apps/web/src/components/landing-page-client.tsx
  • apps/web/src/components/room-gate.test.tsx
  • apps/web/src/components/room-gate.tsx
  • apps/web/src/components/session-workspace.tsx
  • apps/web/src/components/workspace-settings.tsx
  • apps/web/src/lib/history-api.test.ts
  • apps/web/src/lib/history-api.ts
  • apps/web/src/lib/renderer-camera-policy.test.ts
  • apps/web/src/lib/renderer-camera-policy.ts
  • apps/web/src/lib/room-access-api.test.ts
  • apps/web/src/lib/room-access-api.ts
  • apps/web/src/lib/session.test.ts
  • docs/arielcharts-spec.md
  • docs/context-map.md
  • e2e-collaboration-validate.ts
  • e2e-production-smoke.ts
  • e2e-sequence-validate.ts
  • e2e-validate.ts
  • e2e-workspace-ux-validate.ts
  • e2e/support/mcp.ts
  • e2e/support/owned-services.ts
  • e2e/support/room-access.ts
  • e2e/support/workspace.ts
  • e2e/support/yjs-session.ts
  • fly.toml
  • package.json

Comment thread apps/server/src/index.ts
Comment thread apps/server/src/lib/env.ts
Comment thread apps/server/src/lib/mcp-server.ts
Comment thread apps/server/src/lib/room-access.ts
Comment thread apps/web/src/app/globals.css
Comment thread docs/arielcharts-spec.md
Comment thread docs/context-map.md
Comment thread e2e-collaboration-validate.ts Outdated
Comment thread e2e-collaboration-validate.ts Outdated
Comment thread e2e-validate.ts
@donovan-yohan
donovan-yohan merged commit 4da027e into main Aug 8, 2026
7 checks passed
@donovan-yohan
donovan-yohan deleted the feat/capability-room-access branch August 9, 2026 04:15

This branch was successfully deployed

1 active deployment
Preview — 1e14d76c Deployed Aug 8, 2026 by vercel[bot]
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.

Protect shared rooms with capability links and room-code access

1 participant