Skip to content

feat(redis-dep): PR 4/5 — delete permission cache, collapse permissions-cached into permissions - #1041

Merged
2witstudios merged 3 commits into
masterfrom
pu/redis-pr4-permission-cache
Apr 21, 2026
Merged

2witstudios merged 3 commits into
masterfrom
pu/redis-pr4-permission-cache

Conversation

@2witstudios

@2witstudios 2witstudios commented Apr 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

Redis-deprecation PR 4 of 5. Collapses the hybrid L1 memory + L2 Redis permission cache into a single canonical permissions.ts module backed by direct Postgres reads — every request is now a DB round-trip (1 for point lookups, 1 CTE for batches).

  • Deletes packages/lib/src/services/permission-cache.ts (523 lines) and packages/lib/src/permissions/permissions-cached.ts (618 lines); the exported surface moves verbatim into permissions.ts.
  • getBatchPagePermissions becomes a single Drizzle CTE joining pages → drives → drive_members (ADMIN, acceptedAt IS NOT NULL) → page_permissions (unexpired) in one statement — no per-page fan-out. Trashed / expired / inaccessible pageIds resolve to all-false so callers can read map.get(id)?.canView safely.
  • Drops invalidateUserPermissions / invalidateDrivePermissions / getPermissionCacheStats and the GET /api/permissions/batch stats handler. Member invite/remove routes, permission-mutations.ts, and memory-monitor.ts lose their cache-invalidation calls.
  • Updates @pagespace/lib/permissions-cached → @pagespace/lib/permissions in realtime, processor, and their test mocks; drops the subpath from packages/lib/package.json and the alias from apps/processor/vitest.config.ts.

See tasks/redis-deprecation.md § "PR 4" for the wave-level plan.

Review follow-ups (commits 15034c5a, 0635dae5)

Post-initial-review improvements on top of the PR 4 spec:

  • Admin-acceptance divergence fixed. Every ADMIN lookup and drive-membership predicate in permissions.ts (getUserAccessLevel, isDriveOwnerOrAdmin, isUserDriveMember, getUserAccessiblePagesInDrive, getUserAccessiblePagesInDriveWithDetails, getUserDriveAccess, getUserDrivePermissions) now filters on acceptedAt IS NOT NULL, matching the invariant already enforced by getBatchPagePermissions. Single-page and batch paths can no longer disagree on unaccepted invites.
  • bypassCache option dropped entirely from getUserAccessLevel, canUser{View,Edit,Share,Delete}Page, and getUserDriveAccess. 13 call sites and 4 test assertions updated.
  • Response-shape consistency. POST /api/permissions/batch empty-pageIds early return now emits the same { total, accessible, denied, processingTimeMs } shape as the non-empty branch (previously emitted stale cacheHits). Docblock rewritten to spell out that the permissions map only contains viewable entries.
  • Architecture docs refreshed. docs/2.0-architecture/2.2-backend/permissions.md and docs/2.0-architecture/2.3-shared/lib-package.md now describe the single-module, direct-to-Postgres design. docs/security/permission-cache-threat-model.md prefixed with a historical notice.
  • Coverage restored. Added packages/lib/src/permissions/__tests__/drive-permissions.test.ts (18 tests) exercising getUserDrivePermissions and getUserDriveAccess silent-mode branches directly — their previous mock-based unit coverage lived in the deleted permissions-cached.test.ts. Mirrored the test-level integration-test exclude list into the coverage exclude list in packages/lib/vitest.config.ts so integration tests that never execute in the unit run stop counting as 0%-covered files. Global functions coverage climbs from 87.51% → 88.52%, clearing the 88% ratchet threshold.
  • Nitpicks cleared. Removed redundant export type { DrivePermissionLevel } in packages/lib/src/index.ts. Made the slow-path warn test deterministic via vi.spyOn(Date, 'now') (no more 500ms busy-wait on CI). Added loggers.api.error assertion to the getBatchPagePermissions DB-failure test to pin the observability contract.

Pre-merge audit

Acceptance-criteria greps

$ rg 'PermissionCache|permission-cache|permissions-cached|invalidateUserPermissions|invalidateDrivePermissions|getPermissionCacheStats' packages apps
(no matches)

$ ls packages/lib/src/services/permission-cache.ts packages/lib/src/permissions/permissions-cached.ts
ls: ... No such file or directory (both deleted)

$ rg 'bypassCache' packages apps
(no matches — shim removed in review follow-up)

Requirements checklist (per tasks/redis-deprecation.md § PR 4)

  1. PASS — delete permission-cache.ts (523 lines) — file removed
  2. PASS — delete permissions-cached.ts (618 lines) — file removed
  3. PASS — preserve every exported function signature — getUserAccessLevel, canUser{View,Edit,Share,Delete}Page, getUserDriveAccess, getUserDrivePermissions, getBatchPagePermissions, getDriveIdsForUser, isDriveOwnerOrAdmin, isUserDriveMember, PermissionLevel, DrivePermissionLevel all live at packages/lib/src/permissions/permissions.ts. bypassCache? no-op shim dropped in follow-up — unused, not deferrable.
  4. PASS — getBatchPagePermissions single-CTE — one db.select(...).from(pages).leftJoin(drives)...leftJoin(driveMembers)...leftJoin(pagePermissions)...where(inArray(...)) — no per-page fan-out. Regression test asserts db.select is called exactly once and that isNotNull('acceptedAt') is part of the predicate.
  5. PASS — delete invalidation exports + call sites — permission-mutations.ts, members/invite/route.ts, members/[userId]/route.ts, memory-monitor.ts emergency cleanup all cleared.
  6. PASS — delete GET /api/permissions/batch — POST only; stats.accessible now derives from canView === true.
  7. PASS — update @pagespace/lib/server barrel — server.ts re-exports from ./permissions/permissions; packages/lib/package.json drops ./permissions-cached and ./services/permission-cache subpath exports + typesVersions.
  8. PASS — test coverage — batch-page-permissions.test.ts + new drive-permissions.test.ts cover owner, accepted ADMIN member, accepted MEMBER, VIEWER role, unaccepted invite fall-through, unexpired vs expired explicit grant, trashed page, inaccessible pageId, non-existent pageId, mixed batches, fail-closed on DB failure, single-round-trip guard rail, and the shared isNotNull(acceptedAt) invariant.

Pattern propagation

  • from '@pagespace/lib/permissions-cached' / from '../permissions/permissions-cached' → from '@pagespace/lib/permissions' / from '../permissions/permissions'
  • All 7 caller files + all 6 test mocks migrated (realtime index.ts, per-event-auth.ts, 3 realtime test files; processor rbac.ts, authorization.ts, 2 processor test files; repository + service source + tests)
  • rg 'permissions-cached|permission-cache' in packages + apps → zero matches

Deferred items

  • None. No TODO / FIXME / "Phase 2" comments introduced.

Verification

$ pnpm --filter @pagespace/lib --filter web --filter realtime --filter processor typecheck    # all green
$ pnpm --filter @pagespace/lib test:coverage
  All files: 93.56% lines, 94.65% branches, 88.52% functions, 93.56% statements (clears thresholds 85/94/88/85)
  156 test files / 3914 passed / 3 skipped
$ pnpm --filter realtime test                   # 334 passed
$ pnpm --filter processor test                  # 918 passed
$ pnpm --filter web vitest (affected routes)    # 293 passed across permissions/batch, members, channels, pages/*, trash, debug/chat-messages, realtime per-event-auth

Test plan

  • CI green on @pagespace/lib, web, realtime, processor test suites.
  • Unit Tests coverage ratchet (functions ≥ 88%) satisfied.
  • pnpm test:security green post-merge on staging (requires Postgres).
  • Post-merge smoke: batch permission check on a drive with ADMIN member (no explicit page grants) returns canView=true for every page.
  • Post-merge watch p95 on /api/pages/[pageId], /api/inbox, /api/search — per the spec, index-tune Postgres if a hot path regresses (do NOT re-add the cache).

🤖 Generated with Claude Code

…ssions-cached into permissions

Redis deprecation PR 4 of 5. Collapses the hybrid L1/L2 permission cache layer
into a single canonical permissions module that goes straight to Postgres.

- Delete packages/lib/src/services/permission-cache.ts (523 lines) and
  packages/lib/src/permissions/permissions-cached.ts (618 lines).
- Fold getUserAccessLevel, canUser{View,Edit,Share,Delete}Page, getUserDriveAccess,
  getUserDrivePermissions, getBatchPagePermissions, and DrivePermissionLevel into
  packages/lib/src/permissions/permissions.ts with unchanged signatures.
- getBatchPagePermissions becomes a single-CTE Drizzle query joining pages →
  drives → drive_members (ADMIN, acceptedAt IS NOT NULL) → page_permissions
  (unexpired expires_at) in one DB round-trip. Returns an entry for every
  input pageId — trashed / expired / inaccessible rows resolve to all-false.
- Remove invalidateUserPermissions / invalidateDrivePermissions / permissionCache
  call sites in permission-mutations, member invite/remove routes, and
  memory-monitor's emergency cleanup path.
- Delete the GET /api/permissions/batch cache-stats handler (POST keeps working;
  route now derives accessible count from canView instead of map.size since the
  map is no longer pre-filtered).
- Update @pagespace/lib/server, @pagespace/lib, and the permissions index to
  re-export from the canonical module. Drop ./permissions-cached and
  ./services/permission-cache subpath entries from packages/lib/package.json.
- Update @pagespace/lib/permissions-cached → @pagespace/lib/permissions imports
  in realtime, processor, and their test mocks; drop the alias in
  apps/processor/vitest.config.ts.
- Add src/permissions/__tests__/batch-page-permissions.test.ts covering owner,
  accepted ADMIN member, unaccepted invite, unexpired/expired explicit grants,
  trashed pages, inaccessible pageIds, non-existent pageIds, mixed batches, and
  fail-closed on DB failure. Delete the superseded cache-trust-boundaries,
  permissions-cached, and permission-cache test files and their references in
  scripts/test-security.sh, .github/workflows/security.yml, and knip.json.

See tasks/redis-deprecation.md § "PR 4 — Delete the permission cache" for the
full spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
pagespace-master-plan Ready Ready Preview, Comment Apr 20, 2026 9:03pm

@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR removes the cached permissions system (permissions-cached.ts, permission-cache.ts, and related services) and consolidates all permission lookups to use the non-cached permissions module. The change includes updating imports across all applications, removing cache invalidation calls from mutation endpoints, and deleting related test suites.

Changes

Cohort / File(s) Summary
Cached Permissions Module Removal
packages/lib/src/permissions/permissions-cached.ts, packages/lib/src/services/permission-cache.ts, packages/lib/src/permissions/__tests__/permissions-cached.test.ts, packages/lib/src/__tests__/permission-cache.test.ts
Deleted entire cached permissions layer including PermissionCache singleton (524 lines), all permission-cache tests (567 lines), and cached permissions wrapper functions (618 lines) along with their test coverage (1437 lines).
Core Permissions Module Enhancement
packages/lib/src/permissions/permissions.ts
Expanded with new exported types (PermissionLevel, DrivePermissionLevel) and functions (getUserDrivePermissions, getBatchPagePermissions) previously in cached version. Updated existing function signatures to accept bypassCache options for API parity. Added SQL predicate imports (isNotNull, inArray) for batch queries.
Export Surface Updates
packages/lib/src/index.ts, packages/lib/src/permissions/index.ts, packages/lib/src/server.ts, packages/lib/package.json, knip.json
Consolidated exports from permissions-cached to permissions module across all index files. Removed public export subpaths ./permissions-cached and ./services/permission-cache from package exports (16 lines). Updated knip.json entry points.
Application Import Updates - Processor
apps/processor/src/services/authorization.ts, apps/processor/src/services/rbac.ts, apps/processor/src/services/__tests__/authorization.test.ts, apps/processor/src/services/__tests__/rbac-delete.test.ts, apps/processor/vitest.config.ts
Updated permission imports from @pagespace/lib/permissions-cached to @pagespace/lib/permissions. Updated vitest module alias to resolve @pagespace/lib/permissions to packages/lib/src.
Application Import Updates - Realtime
apps/realtime/src/index.ts, apps/realtime/src/per-event-auth.ts, apps/realtime/src/__tests__/index.test.ts, apps/realtime/src/__tests__/per-event-auth.test.ts, apps/realtime/src/__tests__/rooms.test.ts
Updated permission module references in socket event handlers and per-event authorization to use non-cached permissions module. Updated test mocks accordingly.
Cache Invalidation Removal - Member Routes
apps/web/src/app/api/drives/[driveId]/members/[userId]/route.ts, apps/web/src/app/api/drives/[driveId]/members/[userId]/__tests__/route.test.ts, apps/web/src/app/api/drives/[driveId]/members/invite/route.ts, apps/web/src/app/api/drives/[driveId]/members/invite/__tests__/route.test.ts
Removed explicit permission cache invalidation (Promise.all blocks with invalidateUserPermissions/invalidateDrivePermissions) from member update, removal, and invite operations. Removed corresponding test assertions and mocks (29 lines from tests).
Permissions API Endpoint Updates
apps/web/src/app/api/permissions/batch/route.ts, apps/web/src/app/api/permissions/batch/__tests__/route.test.ts
Removed GET /api/permissions/batch endpoint (getPermissionCacheStats). Updated POST response logic to conditionally include only pages with canView: true, recalculating accessible/denied counts (107 lines from tests).
Repository & Service Permission Updates
packages/lib/src/repositories/enforced-file-repository.ts, packages/lib/src/repositories/__tests__/enforced-file-repository.test.ts, packages/lib/src/services/validated-service-token.ts, packages/lib/src/services/__tests__/validated-service-token.test.ts, apps/atlas/src/architecture-data.ts
Updated permission imports from permissions-cached to permissions. Updated type imports for DrivePermissionLevel to use non-cached module. Updated atlas architecture data path references.
Cache-Dependent Code Cleanup
packages/lib/src/permissions/permission-mutations.ts, packages/lib/src/services/memory-monitor.ts
Removed cache invalidation side effects (await Promise.all blocks) from grantPagePermission and revokePagePermission. Removed emergency permission-cache clearing logic from emergencyMemoryCleanup().
Test File Updates & Removal
packages/lib/src/__tests__/cross-tenant-escalation.test.ts, packages/lib/src/__tests__/multi-tenant-isolation.test.ts, packages/lib/src/permissions/__tests__/cache-trust-boundaries.test.ts, packages/lib/src/permissions/__tests__/permission-mutations-unit.test.ts, packages/lib/src/permissions/__tests__/batch-page-permissions.test.ts
Removed PermissionCache imports and clearAll() calls from test setup/teardown. Deleted cache-trust-boundaries integration test (409 lines) and cache-permissions mock setup. Removed permission-cache mocks from mutation unit tests. Added new batch-page-permissions test suite (281 lines).
Configuration & CI Updates
.github/workflows/security.yml, packages/lib/vitest.config.ts, scripts/test-security.sh
Removed permissions-cached.test.ts and permission-cache.test.ts runs from GitHub Actions security workflow. Removed these test files from vitest exclude list and security test script.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Hop, skip, cache-be-gone!
No more redis dreams at dawn,
Permissions flow direct and true,
Simpler paths for me and you!
The rabbit grins at cleanup done, ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: deletion of the permission cache and consolidation of permissions-cached into the non-cached permissions module.
Docstring Coverage ✅ Passed Docstring coverage is 80.95% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/redis-pr4-permission-cache

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.

@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: 2

Caution

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

⚠️ Outside diff range comments (3)
apps/web/src/app/api/permissions/batch/route.ts (1)

50-55: ⚠️ Potential issue | 🟡 Minor

Stale cacheHits field in empty-array response contradicts the new stats shape.

The docblock (Line 31) and the non-empty path (Lines 93-98) now return { total, accessible, denied, processingTimeMs }, but this early-return branch still returns the legacy { total, accessible, cacheHits }. Since this PR removes the permission cache entirely, cacheHits is stale and misleading, and the response shape is now inconsistent between empty and non-empty inputs — clients (and the test at Line 120) will see differing keys.

Proposed fix
     if (pageIds.length === 0) {
       return NextResponse.json({
         permissions: {},
-        stats: { total: 0, accessible: 0, cacheHits: 0 }
+        stats: { total: 0, accessible: 0, denied: 0, processingTimeMs: 0 }
       });
     }

The corresponding assertion in apps/web/src/app/api/permissions/batch/__tests__/route.test.ts (Line 120) should be updated accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/permissions/batch/route.ts` around lines 50 - 55, The
early-return in the batch permissions route returns the old stats shape with
cacheHits which is now removed; update the branch that checks if (pageIds.length
=== 0) in route.ts to return stats matching the new shape used elsewhere ({
total: 0, accessible: 0, denied: 0, processingTimeMs: 0 }) and adjust the
corresponding test assertion in
apps/web/src/app/api/permissions/batch/__tests__/route.test.ts to expect denied
and processingTimeMs instead of cacheHits.
apps/web/src/app/api/permissions/batch/__tests__/route.test.ts (1)

112-122: ⚠️ Potential issue | 🟡 Minor

Test pins the stale cacheHits contract.

This assertion locks in the legacy { total, accessible, cacheHits: 0 } response for the empty-pageIds branch, which is inconsistent with the new { total, accessible, denied, processingTimeMs } shape used everywhere else in this route (see Lines 93-98 of route.ts and the docblock at Line 31). If the route is fixed to emit the new shape consistently, this expectation needs to be updated in lockstep.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/permissions/batch/__tests__/route.test.ts` around lines
112 - 122, The test "should return empty permissions for empty pageIds array"
pins the old stats shape with cacheHits; update the assertion in this test (the
block using createPostRequest and POST) to expect the new stats structure —
include permissions: {} and stats with total: 0, accessible: 0, denied: 0, and
processingTimeMs validated as a number (use the test matcher for any Number),
and remove any expectation for cacheHits.
apps/realtime/src/per-event-auth.ts (1)

11-14: ⚠️ Potential issue | 🟡 Minor

Update the stale cache wording.

The import now targets the canonical non-cached permissions module, but these comments still describe cache stale windows / “cache or DB”. That can confuse future security reviews of the realtime write-auth path.

📝 Proposed wording update
- * Stale-window analysis after bypassCache fix:
- * - Write operations (per-event auth): 0s — always hits DB directly
- * - Room joins: up to 60s (kick handler provides immediate eviction)
- * - API routes: up to 60s (read operations, acceptable risk)
+ * Stale-window analysis after permission-cache removal:
+ * - Write operations (per-event auth): 0s — direct permission read
+ * - Room joins/API routes use the centralized permissions module; kick handling
+ *   remains defense-in-depth for connected sockets.
...
- * This goes directly to the permission system (cache or DB) to verify current access.
+ * This goes through the centralized permissions module to verify current access.

Also applies to: 100-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/realtime/src/per-event-auth.ts` around lines 11 - 14, Update the comment
block that begins "Stale-window analysis after bypassCache fix" and the similar
wording around lines referencing the per-event auth path (mentions "per-event
auth", "cache or DB", and stale windows) to clarify that the import now points
to the canonical non-cached permissions module; remove references to cache
staleness or "cache or DB" ambiguity and explicitly state that write operations
use the non-cached permissions module and hit the DB directly (and adjust the
room joins/API routes wording if present to reflect their separate behavior).
Locate and edit the comment text in per-event-auth.ts (the top "Stale-window
analysis..." block and the similar block at ~100-103) to use precise wording
like "uses canonical non-cached permissions module — write operations hit DB
directly" instead of any cache/stale-window phrasing.
🧹 Nitpick comments (4)
apps/web/src/app/api/permissions/batch/__tests__/route.test.ts (1)

204-223: Busy-wait for >500ms makes this test slow and flaky on CI.

Spinning on Date.now() for 510ms per run adds real wall-clock time and is sensitive to CI scheduler jitter. Prefer stubbing Date.now (or the startTime/endTime readings) via vi.useFakeTimers() / vi.spyOn(Date, 'now') so the slow-path branch is exercised deterministically without actually sleeping.

Sketch
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(0).mockReturnValueOnce(501);
mockGetBatchPagePermissions.mockResolvedValue(new Map());
// ... invoke POST ...
nowSpy.mockRestore();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/permissions/batch/__tests__/route.test.ts` around lines
204 - 223, The test currently busy-waits inside mockGetBatchPagePermissions
which makes CI slow and flaky; instead spy on Date.now (e.g., vi.spyOn(Date,
'now')) and mock its sequential returns to simulate a >500ms duration (first
call = start, second call = start+501), have mockGetBatchPagePermissions resolve
immediately (new Map()) and then call POST; after assertions restore the spy.
Update the test to use vi.spyOn(Date,
'now').mockReturnValueOnce(...).mockReturnValueOnce(...) around the POST
invocation and call .mockRestore() afterwards so the slow-path branch is
exercised deterministically.
packages/lib/src/index.ts (1)

28-30: Nit: redundant named type re-export.

export * from './permissions/permissions' on line 29 already re-exports DrivePermissionLevel, so the explicit export type on line 30 is a no-op. Safe to drop, though harmless to leave for discoverability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/lib/src/index.ts` around lines 28 - 30, The file re-exports
everything from './permissions/permissions' and then redundantly re-exports the
type DrivePermissionLevel; remove the explicit "export type {
DrivePermissionLevel }" line to avoid the no-op duplication. Locate the
re-export block in packages/lib/src/index.ts (the lines exporting from
'./permissions/permissions' and the explicit DrivePermissionLevel type) and
delete the explicit type export, leaving the existing "export * from
'./permissions/permissions';" to provide the type.
packages/lib/src/permissions/__tests__/batch-page-permissions.test.ts (1)

266-280: Optional: also assert the failure is logged.

Fail-closed behavior is well-covered, but asserting loggers.api.error (mocked at line 36) is called on DB rejection would lock in the observability contract alongside the deny-map return.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/lib/src/permissions/__tests__/batch-page-permissions.test.ts` around
lines 266 - 280, The test should also assert that a DB rejection is logged:
after stubbing db.select to return the chain that ultimately rejects (the
existing mocked where/leftJoin chain) and calling getBatchPagePermissions(USER,
['p1','p2']), add an expectation that the mocked logger (loggers.api.error) was
called (or calledWith an Error/DB down message) to ensure the failure is
observable; locate the logger mock from the test setup and add
expect(loggers.api.error).toHaveBeenCalled() (or
toHaveBeenCalledWith(expect.objectContaining({ message: 'DB down' })) as
appropriate) alongside the existing assertions.
packages/lib/src/permissions/permissions.ts (1)

485-572: bypassCache is a pure no-op here — document clearly or drop in the final PR.

getUserDriveAccess accepts bypassCache purely for signature parity; it’s never destructured or consulted. The JSDoc at line 483 does note this, which is good. Consider annotating the param @deprecated so the compatibility shim can be removed cleanly in PR 5/5 once all consumers have migrated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/lib/src/permissions/permissions.ts` around lines 485 - 572, The
options.bypassCache parameter on getUserDriveAccess is a no-op kept only for
signature parity; update the function's JSDoc to mark options.bypassCache as
`@deprecated` and call out it is unused so it can be removed in a future PR, and
(optionally) add a short inline comment next to the options type ({ silent?:
boolean; bypassCache?: boolean }) indicating it's a compatibility shim to make
future removal safe; reference getUserDriveAccess and the options/bypassCache
symbols so reviewers can find and remove the shim later.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/api/permissions/batch/route.ts`:
- Around line 85-91: Update the route.ts docblock for the batch permissions
response to explicitly state that the returned permissions object only includes
entries where canView === true (denied pages are omitted) and that denied pages
are instead counted in stats.denied; reference the variables/structures in the
implementation (permissionsMap, permissions object, accessibleCount and stats)
and enumerate the response shape fields: permissions (map of viewable page
entries) and stats (total, accessible, denied, processingTimeMs) so callers
understand they must check canView before using a permissions entry.

In `@packages/lib/src/permissions/permissions.ts`:
- Around line 582-661: getUserDrivePermissions treats any drive_members row as
active membership and thus grants pending-invite users admin/edit rights; update
the membership query inside getUserDrivePermissions to require acceptedAt not be
null (same filter used in getBatchPagePermissions) by adding an
and(isNotNull(driveMembers.acceptedAt)) to the where clause so only accepted
memberships set isAdmin/canEdit true; ensure the change references
driveMembers.role and driveMembers.acceptedAt in the membership selection and
keeps existing role->isAdmin/isMember logic.

---

Outside diff comments:
In `@apps/realtime/src/per-event-auth.ts`:
- Around line 11-14: Update the comment block that begins "Stale-window analysis
after bypassCache fix" and the similar wording around lines referencing the
per-event auth path (mentions "per-event auth", "cache or DB", and stale
windows) to clarify that the import now points to the canonical non-cached
permissions module; remove references to cache staleness or "cache or DB"
ambiguity and explicitly state that write operations use the non-cached
permissions module and hit the DB directly (and adjust the room joins/API routes
wording if present to reflect their separate behavior). Locate and edit the
comment text in per-event-auth.ts (the top "Stale-window analysis..." block and
the similar block at ~100-103) to use precise wording like "uses canonical
non-cached permissions module — write operations hit DB directly" instead of any
cache/stale-window phrasing.

In `@apps/web/src/app/api/permissions/batch/__tests__/route.test.ts`:
- Around line 112-122: The test "should return empty permissions for empty
pageIds array" pins the old stats shape with cacheHits; update the assertion in
this test (the block using createPostRequest and POST) to expect the new stats
structure — include permissions: {} and stats with total: 0, accessible: 0,
denied: 0, and processingTimeMs validated as a number (use the test matcher for
any Number), and remove any expectation for cacheHits.

In `@apps/web/src/app/api/permissions/batch/route.ts`:
- Around line 50-55: The early-return in the batch permissions route returns the
old stats shape with cacheHits which is now removed; update the branch that
checks if (pageIds.length === 0) in route.ts to return stats matching the new
shape used elsewhere ({ total: 0, accessible: 0, denied: 0, processingTimeMs: 0
}) and adjust the corresponding test assertion in
apps/web/src/app/api/permissions/batch/__tests__/route.test.ts to expect denied
and processingTimeMs instead of cacheHits.

---

Nitpick comments:
In `@apps/web/src/app/api/permissions/batch/__tests__/route.test.ts`:
- Around line 204-223: The test currently busy-waits inside
mockGetBatchPagePermissions which makes CI slow and flaky; instead spy on
Date.now (e.g., vi.spyOn(Date, 'now')) and mock its sequential returns to
simulate a >500ms duration (first call = start, second call = start+501), have
mockGetBatchPagePermissions resolve immediately (new Map()) and then call POST;
after assertions restore the spy. Update the test to use vi.spyOn(Date,
'now').mockReturnValueOnce(...).mockReturnValueOnce(...) around the POST
invocation and call .mockRestore() afterwards so the slow-path branch is
exercised deterministically.

In `@packages/lib/src/index.ts`:
- Around line 28-30: The file re-exports everything from
'./permissions/permissions' and then redundantly re-exports the type
DrivePermissionLevel; remove the explicit "export type { DrivePermissionLevel }"
line to avoid the no-op duplication. Locate the re-export block in
packages/lib/src/index.ts (the lines exporting from './permissions/permissions'
and the explicit DrivePermissionLevel type) and delete the explicit type export,
leaving the existing "export * from './permissions/permissions';" to provide the
type.

In `@packages/lib/src/permissions/__tests__/batch-page-permissions.test.ts`:
- Around line 266-280: The test should also assert that a DB rejection is
logged: after stubbing db.select to return the chain that ultimately rejects
(the existing mocked where/leftJoin chain) and calling
getBatchPagePermissions(USER, ['p1','p2']), add an expectation that the mocked
logger (loggers.api.error) was called (or calledWith an Error/DB down message)
to ensure the failure is observable; locate the logger mock from the test setup
and add expect(loggers.api.error).toHaveBeenCalled() (or
toHaveBeenCalledWith(expect.objectContaining({ message: 'DB down' })) as
appropriate) alongside the existing assertions.

In `@packages/lib/src/permissions/permissions.ts`:
- Around line 485-572: The options.bypassCache parameter on getUserDriveAccess
is a no-op kept only for signature parity; update the function's JSDoc to mark
options.bypassCache as `@deprecated` and call out it is unused so it can be
removed in a future PR, and (optionally) add a short inline comment next to the
options type ({ silent?: boolean; bypassCache?: boolean }) indicating it's a
compatibility shim to make future removal safe; reference getUserDriveAccess and
the options/bypassCache symbols so reviewers can find and remove the shim later.
🪄 Autofix (Beta)

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

Run ID: 67e3a456-1bae-40ca-acb2-17c73f813ec9

📥 Commits

Reviewing files that changed from the base of the PR and between 80f89dd and 15ff1de.

📒 Files selected for processing (42)
  • .github/workflows/security.yml
  • apps/atlas/src/architecture-data.ts
  • apps/processor/src/services/__tests__/authorization.test.ts
  • apps/processor/src/services/__tests__/rbac-delete.test.ts
  • apps/processor/src/services/authorization.ts
  • apps/processor/src/services/rbac.ts
  • apps/processor/vitest.config.ts
  • apps/realtime/src/__tests__/index.test.ts
  • apps/realtime/src/__tests__/per-event-auth.test.ts
  • apps/realtime/src/__tests__/rooms.test.ts
  • apps/realtime/src/index.ts
  • apps/realtime/src/per-event-auth.ts
  • apps/web/src/app/api/drives/[driveId]/members/[userId]/__tests__/route.test.ts
  • apps/web/src/app/api/drives/[driveId]/members/[userId]/route.ts
  • apps/web/src/app/api/drives/[driveId]/members/invite/__tests__/route.test.ts
  • apps/web/src/app/api/drives/[driveId]/members/invite/route.ts
  • apps/web/src/app/api/permissions/batch/__tests__/route.test.ts
  • apps/web/src/app/api/permissions/batch/route.ts
  • knip.json
  • packages/lib/package.json
  • packages/lib/src/__tests__/cross-tenant-escalation.test.ts
  • packages/lib/src/__tests__/multi-tenant-isolation.test.ts
  • packages/lib/src/__tests__/permission-cache.test.ts
  • packages/lib/src/__tests__/permissions-cached.test.ts
  • packages/lib/src/index.ts
  • packages/lib/src/permissions/__tests__/batch-page-permissions.test.ts
  • packages/lib/src/permissions/__tests__/cache-trust-boundaries.test.ts
  • packages/lib/src/permissions/__tests__/permission-mutations-unit.test.ts
  • packages/lib/src/permissions/__tests__/permissions-cached.test.ts
  • packages/lib/src/permissions/index.ts
  • packages/lib/src/permissions/permission-mutations.ts
  • packages/lib/src/permissions/permissions-cached.ts
  • packages/lib/src/permissions/permissions.ts
  • packages/lib/src/repositories/__tests__/enforced-file-repository.test.ts
  • packages/lib/src/repositories/enforced-file-repository.ts
  • packages/lib/src/server.ts
  • packages/lib/src/services/__tests__/validated-service-token.test.ts
  • packages/lib/src/services/memory-monitor.ts
  • packages/lib/src/services/permission-cache.ts
  • packages/lib/src/services/validated-service-token.ts
  • packages/lib/vitest.config.ts
  • scripts/test-security.sh
💤 Files with no reviewable changes (15)
  • apps/web/src/app/api/drives/[driveId]/members/[userId]/route.ts
  • packages/lib/vitest.config.ts
  • packages/lib/src/services/memory-monitor.ts
  • knip.json
  • packages/lib/src/permissions/tests/permission-mutations-unit.test.ts
  • .github/workflows/security.yml
  • apps/web/src/app/api/drives/[driveId]/members/[userId]/tests/route.test.ts
  • packages/lib/package.json
  • packages/lib/src/tests/permissions-cached.test.ts
  • packages/lib/src/permissions/tests/permissions-cached.test.ts
  • packages/lib/src/tests/permission-cache.test.ts
  • packages/lib/src/permissions/tests/cache-trust-boundaries.test.ts
  • scripts/test-security.sh
  • packages/lib/src/services/permission-cache.ts
  • packages/lib/src/permissions/permissions-cached.ts

Comment thread apps/web/src/app/api/permissions/batch/route.ts
Comment thread packages/lib/src/permissions/permissions.ts
Address review feedback on 15ff1de (PR 4/5 of the Redis-deprecation
series) without changing scope:

- Align /api/permissions/batch response shape — empty-pageIds branch
  now returns { total, accessible, denied, processingTimeMs } matching
  the non-empty branch; cacheHits field dropped.
- Fix admin-acceptance divergence: every ADMIN lookup and drive
  membership predicate in permissions.ts now filters on
  acceptedAt IS NOT NULL, matching the invariant already enforced by
  getBatchPagePermissions. Applies to getUserAccessLevel,
  isDriveOwnerOrAdmin, isUserDriveMember, getUserAccessiblePagesInDrive,
  getUserAccessiblePagesInDriveWithDetails, getUserDriveAccess, and
  getUserDrivePermissions. Regression test added to
  batch-page-permissions.test.ts.
- Drop bypassCache option from getUserAccessLevel, canUser*Page, and
  getUserDriveAccess now that there is no cache to bypass. Strip the
  option from 13 call sites across apps/web and apps/realtime plus
  their test assertions; rewrite the stale-window comment in
  apps/realtime/src/per-event-auth.ts for the direct-to-Postgres design.
- Remove redundant export type { DrivePermissionLevel } from
  packages/lib/src/index.ts (already covered by export *).
- Update architecture docs (2.0-architecture/2.2-backend/permissions.md
  and 2.3-shared/lib-package.md) to describe the single-module,
  direct-to-Postgres design. Prefix the old permission-cache threat
  model with a historical notice.

Typecheck clean across @pagespace/lib, web, realtime, processor;
vitest suites all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chet

Addresses remaining review feedback on #1041 and fixes the failing
Unit Tests check.

- Add src/permissions/__tests__/drive-permissions.test.ts covering the
  getUserDrivePermissions function body (owner / accepted ADMIN /
  MEMBER / VIEWER / missing drive / unaccepted invite / silent-mode
  debug logs / DB error fail-closed / acceptedAt filter invariant)
  plus the getUserDriveAccess silent-mode debug branches. These
  functions live in permissions.ts after PR 4 collapsed the cached
  wrapper, but their mock-based unit tests had been deleted along
  with permissions-cached.test.ts — restoring coverage.
- Mirror the test-level integration-test exclude list into the
  coverage `exclude` list in packages/lib/vitest.config.ts. Integration
  tests that never execute in the unit run (cross-tenant-escalation,
  permission-mutations, zero-trust-boundaries, …) were being counted
  as 0%-covered files, pulling the global functions metric below the
  88% ratchet threshold. Global functions coverage now climbs from
  87.51% → 88.52% with no real behavior change.
- Make the slow-path warn test deterministic by spying on Date.now
  instead of busy-waiting >500ms in the mock — removes 0.5s of
  wall-clock time and the CI scheduler flakiness it introduced.
- Assert loggers.api.error is called on getBatchPagePermissions DB
  failure — locks in the observability contract alongside the
  pre-seeded deny-map return.
- Expand the POST /api/permissions/batch docblock to spell out that
  the permissions map only contains viewable entries, that denied
  pages are omitted and reflected in stats.denied, and that the
  empty-input branch returns the same stats shape (processingTimeMs: 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Review follow-ups — comprehensive resolution

Addressing every item from the CodeRabbit review (inline comments + outside-diff potential issues + nitpicks) plus the failing Unit Tests check. Two follow-up commits on the branch: 15034c5 and 0635dae.

Code-change items (potential issues)

Finding Status Where
getUserDrivePermissions missing acceptedAt filter (major, inconsistent with batch path) ✅ Fixed permissions.ts — filter added; all seven admin/membership predicates now share the invariant. Regression tests added for both batch and single-page paths.
cacheHits in empty-pageIds branch contradicts new stats shape ✅ Fixed apps/web/src/app/api/permissions/batch/route.ts:50-55 — now { total: 0, accessible: 0, denied: 0, processingTimeMs: 0 }.
Test pins stale cacheHits contract ✅ Fixed batch/__tests__/route.test.ts:112-122 — expectation updated.
Stale "cache or DB" / "bypassCache" wording in apps/realtime/src/per-event-auth.ts ✅ Fixed Top comment rewritten; (cache or DB) phrase removed; bypassCache option dropped from all 13 call sites + the 4 test assertions that pinned it.

Nitpicks

Finding Status Where
Busy-wait >500ms in slow-path warn test is flaky on CI ✅ Fixed batch/__tests__/route.test.ts:204-228 — switched to vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(510) with a finally restore.
Redundant export type { DrivePermissionLevel } ✅ Fixed packages/lib/src/index.ts — line removed.
DB-failure test should assert loggers.api.error ✅ Fixed batch-page-permissions.test.ts — now pins expect.objectContaining({ userId, pageCount: 2, error: 'DB down' }).
getUserDriveAccess bypassCache no-op param ✅ Fixed (stronger than requested) Dropped the parameter entirely from getUserAccessLevel, canUser*Page, and getUserDriveAccess — no @deprecated shim, just gone. 13 call sites + 4 tests updated.

CI

  • Unit Tests was failing on packages/lib global functions coverage (87.91% < 88% threshold) — caused mechanically by deleting ~1500 lines of cached-permission code while integration test files remained counted as 0% toward the aggregate. Fixed two ways:
    1. Added packages/lib/src/permissions/__tests__/drive-permissions.test.ts (18 tests) exercising getUserDrivePermissions and the getUserDriveAccess silent-mode debug branches directly — brings permissions.ts from 86.27%/92.3% lines/functions to 99.64%/100%.
    2. Mirrored the test-level integration-test exclude list into the coverage exclude list in packages/lib/vitest.config.ts. Integration tests that never execute in the unit run (cross-tenant-escalation, permission-mutations, zero-trust-boundaries, etc.) were being counted as 0% files and dragging the aggregate down.
  • Global functions coverage now: 88.52% (above 88% threshold). 156 test files / 3914 passing / 3 skipped.

Docs

  • Architecture docs refreshed: docs/2.0-architecture/2.2-backend/permissions.md and docs/2.0-architecture/2.3-shared/lib-package.md now describe the single-module, direct-to-Postgres design. docs/security/permission-cache-threat-model.md prefixed with a historical notice.
  • POST /api/permissions/batch docblock expanded to spell out that the response map only contains viewable entries and that denied pages are reflected in stats.denied (not the map).

Verification

pnpm --filter @pagespace/lib --filter web --filter realtime --filter processor typecheck  # all clean
pnpm --filter @pagespace/lib test:coverage                                                  # All files: 93.56% lines, 94.65% branches, 88.52% functions, 93.56% statements — passes thresholds
pnpm --filter @pagespace/lib test                                                           # 156 files / 3914 passed / 3 skipped
apps/realtime vitest                                                                        # 10/334 passing
apps/processor vitest                                                                       # 45/918 passing
apps/web affected vitest                                                                    # 11/293 passing

@2witstudios

Copy link
Copy Markdown
Owner Author

Wave-2 Cross-PR Coordination Audit — PR 4 section

Auditor: point-guard orchestrator. Reviewer: Eric Elliott.
Full audit plan: .claude/plans/the-tasks-related-to-hashed-piglet.md (local).

Static checks — PASS

  • Scope matches spec: 60 files touched, matches tasks/redis-deprecation.md PR 4.
  • Stale-reference grep (permissions-cached|permission-cache|PermissionCache|invalidateUserPermissions|invalidateDrivePermissions|getPermissionCacheStats) across packages/ + apps/ → zero matches.
  • Both permission-cache.ts (524 lines) and permissions-cached.ts (618 lines) deleted from this branch.
  • Did NOT modify shared-redis.ts or security-redis.ts (correct — that's PR 5's scope).

C3 — signature-collision resolution

Master has both permissions.ts:getUserAccessLevel(userId: unknown, ...) (validated via parseUserId) and permissions-cached.ts:getUserAccessLevel(userId: string, ...) (trusted). The barrel at server.ts:9 shadowed the validated variant.

PR 4 picked the validated unknown variant as the post-merge canonical form. Defensive + backwards-compatible (existing string-passing callers still satisfy unknown).

Every one of the six collapsed names (getUserAccessLevel, canUserViewPage/EditPage/SharePage/DeletePage, getUserDriveAccess, getUserDrivePermissions) now has exactly one definition in permissions.ts.

C2 — cross-cutting callers verified

All of permission-mutations.ts (removed permissionCache import + 4 invalidation calls), enforced-file-repository.ts, validated-service-token.ts, memory-monitor.ts (removed dynamic import + clearAll()), apps/web/src/app/api/permissions/batch/route.ts (kept POST, deleted stats GET), apps/realtime/* (6 files), apps/processor/* (6 files), apps/atlas/* — updated inside this PR.

Composed integration — PASS

Local dry-merge master + #1043 + #1042 + #1041:

  • Zero merge conflicts.
  • pnpm --filter @pagespace/{db,lib} build clean.
  • pnpm --filter {web,realtime,processor} typecheck clean.
  • pnpm --filter @pagespace/lib test — 3892 passing, 21 skipped (Postgres-required integration tests).
  • pnpm --filter web test — 6110 passing; 26 failures are pre-existing master issues (admin-role-version.test.ts + gift-subscription/route.security.test.ts — integration tests needing Postgres, fail identically on master today).
  • pnpm lint — all 5 tasks green.

Verdict

PASS — safe to merge as the third and final wave-2 PR (after #1043, #1042).

@2witstudios
2witstudios merged commit e2f5f16 into master Apr 21, 2026
12 checks passed
@2witstudios
2witstudios deleted the pu/redis-pr4-permission-cache branch April 21, 2026 01:12

This branch was previously deployed

1 inactive deployment
Preview — 0635dae5 Deployed Apr 20, 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.

1 participant