Skip to content

feat(auth): accept pending drive invites on login - #1236

Merged
2witstudios merged 6 commits into
masterfrom
pu/epic-3-post-login-hook
May 4, 2026
Merged

2witstudios merged 6 commits into
masterfrom
pu/epic-3-post-login-hook

Conversation

@2witstudios

@2witstudios 2witstudios commented May 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds the post-login pending invitation acceptance hook — Epic 3 of the pu/invites redo. After Epic 1 hardened authz to filter on acceptedAt IS NOT NULL, any pending drive_members row is invisible to drive queries. This PR wires every successful login to flip pending → accepted so an invitee actually reaches the drive they were invited to.

  • Repository seam (driveInviteRepository) gains findPendingMembersForUser + race-safe acceptPendingMember.
  • Pure helper acceptUserPendingInvitations orchestrates accept + best-effort broadcast. Broadcast/recipient-resolution failures are caught and logged — they never abort login. (The original PR coupled broadcast errors to session revocation; review flagged that as a flaw and this PR fixes it.) Genuine acceptance-write errors do propagate so the caller can revoke the just-created session.
  • The just-accepted user is filtered out of getDriveRecipientUserIds() before fan-out so they don't receive their own member_added echo (they appear in the recipient list because acceptedAt was already written when recipients are queried).
  • Wired into all 9 session-creating auth routes: magic-link verify, passkey authenticate, signup-passkey, google callback/native/one-tap, apple callback/native, mobile google exchange. On acceptance failure each route revokes only the just-created session via sessionService.revokeSession(sessionToken, …) — a revokeAllUserSessions call would have logged the user out on every device.
  • Magic-link verify also honours an optional ?inviteDriveId redirect hint via the new shared resolvePostLoginRedirectPath() helper (single source of truth for desktop and web flows).
  • Telemetry hardening: trackAuthEvent calls in apple/callback, apple/native, google/native, and mobile/oauth/google/exchange now mask emails (same pattern google/callback already had); google/one-tap no longer fires trackAuthEvent('login') or resets rate-limit counters until after acceptance succeeds, so a failed acceptance no longer emits a misleading success-login event before the rollback path runs. The inline regex maskEmail in one-tap was replaced with the shared maskEmail utility (the regex would emit raw email for 1-character local parts).
  • Coverage gate test (post-login-acceptance-coverage.test.ts) asserts every future session-creating route under /api/auth/**/route.ts either calls the helper or appears on a justified allow-list (currently ws-token, device/refresh, mobile/refresh — these don't transition unauthenticated → authenticated).

Why this epic ships now

Wave 2 of the redo. Wave 1 (#1233 repo seam, #1234 authz gate) merged. Wave 3 (Epic 4 email branch) creates pending rows that no login flow currently resolves — this hook is the prerequisite.

Out of scope (per Epic 3 brief)

  • Legacy POST /api/drives/[driveId]/members route (Epic 4)
  • Email-payload branch (Epic 4)
  • members/[userId]/resend route (Epic 6)
  • UI components (Epic 5)

Test plan

  • pnpm --filter web typecheck — clean
  • pnpm --filter web lint — clean (only pre-existing warning in unrelated file)
  • pnpm exec vitest run src/app/api/auth/ + src/lib/auth/__tests__/post-login-pending-acceptance.test.ts + src/lib/repositories/ — 939/939 pass
  • pnpm exec vitest run src/app/api/drives/ — 549/549 pass (no regression in drive routes that share the websocket helper)
  • Coverage gate test green for current state of repo
  • CI: all required checks green (Unit Tests, Lint & TypeScript Check, Security Test Suite, CodeQL, CodeRabbit)
  • Mergeable: CLEAN, no conflicts with master

🤖 Generated with Claude Code

Adds the post-login pending invitation acceptance hook (Epic 3 of the
pu/invites redo) so a user with a pending drive_members row gains drive
access on their next successful login via any auth flow.

Why: Epic 1 hardened authz on acceptedAt IS NOT NULL, which made any
pending row invisible to drive queries. Without this hook, an invitee
could authenticate but never reach the drive they were invited to.

Implementation:
- driveInviteRepository gains findPendingMembersForUser +
  acceptPendingMember (race-safe conditional UPDATE).
- Pure helper acceptUserPendingInvitations orchestrates accept + best-
  effort broadcast. Acceptance writes propagate so callers can revoke
  the session; broadcast/recipient-resolution failures log and continue
  (the original PR coupled them, which review flagged as a flaw).
- Wired into all 9 session-creating auth routes: magic-link verify,
  passkey authenticate, signup-passkey, google callback/native/one-tap,
  apple callback/native, and mobile google exchange. Magic-link verify
  also honours an optional ?inviteDriveId redirect hint.
- Coverage gate test asserts every future session-creating route under
  /api/auth either calls the helper or appears on a justified allow-list
  (currently: ws-token, device/refresh, mobile/refresh).

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

coderabbitai Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 54 minutes and 2 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 70bbc349-9b84-4c7d-8b0b-79861bf60008

📥 Commits

Reviewing files that changed from the base of the PR and between c73ed0b and 4bd6eaf.

📒 Files selected for processing (7)
  • apps/web/.claude/ralph-loop.local.md
  • apps/web/src/app/api/auth/google/one-tap/route.ts
  • apps/web/src/app/api/auth/magic-link/verify/route.ts
  • apps/web/src/app/api/auth/signup-passkey/__tests__/route.test.ts
  • apps/web/src/app/api/auth/signup-passkey/route.ts
  • apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts
  • apps/web/src/lib/websocket/__tests__/socket-utils.test.ts
📝 Walkthrough

Walkthrough

Adds a post-login step that accepts pending drive invitations after session creation across OAuth, passkey, magic-link, and signup flows; introduces repository support, websocket fan-out helpers, tests, and a Vitest coverage gate enforcing acceptance presence in session-creating routes. Error paths revoke the newly-created session and return/redirect with server error.

Changes

Post-Login Pending Invitation Acceptance

Layer / File(s) Summary
Data / Repository
apps/web/src/lib/repositories/drive-invite-repository.ts, apps/web/src/lib/repositories/__tests__/drive-invite-repository.test.ts
Adds findPendingMembersForUser(userId) and acceptPendingMember(memberId) to query pending drive-membership rows and perform conditional atomic acceptance; tests cover NULL filtering and concurrent-update semantics.
Core Business Logic
apps/web/src/lib/auth/post-login-pending-acceptance.ts, apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts
New exported AcceptedInvitation type and acceptUserPendingInvitations(userId) which accepts pending invites, accumulates accepted rows, resolves/generates per-drive websocket payloads, filters self from recipients, and logs (but swallows) broadcast/recipient-resolution errors. Tests assert acceptance, broadcasting, error handling, and propagation of DB failures.
Websocket Utilities
apps/web/src/lib/websocket/socket-utils.ts
Adds broadcastDriveMemberEventToRecipients(payload, recipientUserIds) performing best-effort per-recipient POSTs with Promise.allSettled, logging on partial failures and skipping when no recipients or realtime URL missing.
Auth Route Integrations
apps/web/src/app/api/auth/.../route.ts (Google, Apple, One-Tap, native, passkey, signup-passkey, magic-link, mobile exchange)
Each affected auth handler imports and calls acceptUserPendingInvitations(user.id) after session creation / CSRF generation. On helper throw, handlers log the error, revoke the newly-created session via sessionService.revokeSession(sessionToken, 'pending_invite_acceptance_failed'), and return/redirect with server error (500 or signin redirect). Tracking payloads now mask emails in several flows.
Auth Tests / Mocks
apps/web/src/app/api/auth/**/__tests__/*.test.ts (many files), apps/web/src/app/api/auth/__tests__/post-login-acceptance-coverage.test.ts
Many test suites add module mocks for @/lib/auth/post-login-pending-acceptance and new contract tests asserting call order (after createSession), success/no-op behavior, and failure behavior (500 + revokeSession called). A new coverage gate enforces that routes invoking sessionService.createSession also invoke acceptUserPendingInvitations unless explicitly allow-listed; allow-list entries are validated to exist.
Magic-Link Redirect Behavior
apps/web/src/app/api/auth/magic-link/verify/route.ts, .../__tests__/route.test.ts
When an inviteDriveId query param matches an accepted pending invitation, redirect targets /dashboard/<driveId>; desktop/web redirect logic prioritizes matched invite drive before new-user provisioning. Failure revokes session and redirects to signin with server_error.
Misc Test Stubs
apps/web/src/app/api/auth/__tests__/*, other auth test files
Added lightweight mocks for acceptUserPendingInvitations in multiple isolated test suites to prevent flakiness and to assert new behavior where relevant.

Sequence Diagram

sequenceDiagram
    actor User
    participant AuthRoute as Auth Route
    participant SessionSvc as Session Service
    participant AcceptLogic as acceptUserPendingInvitations
    participant InviteRepo as Drive Invite Repo
    participant DriveRecipients as getDriveRecipientUserIds
    participant WebSocket as WebSocket Broadcast

    User->>AuthRoute: Request (callback/exchange/verify/authenticate)
    AuthRoute->>SessionSvc: createSession(user)
    SessionSvc-->>AuthRoute: session created (token)
    AuthRoute->>AuthRoute: generate CSRF / prepare response
    AuthRoute->>AcceptLogic: acceptUserPendingInvitations(user.id)
    AcceptLogic->>InviteRepo: findPendingMembersForUser(user.id)
    InviteRepo-->>AcceptLogic: pending invitations
    loop per pending invitation
        AcceptLogic->>InviteRepo: acceptPendingMember(memberId)
        InviteRepo-->>AcceptLogic: accepted? (true/false)
        alt accepted
            AcceptLogic->>DriveRecipients: getDriveRecipientUserIds(driveId)
            DriveRecipients-->>AcceptLogic: recipient user IDs
            AcceptLogic->>WebSocket: broadcastDriveMemberEventToRecipients(payload, recipients)
            WebSocket-->>AcceptLogic: broadcast settled
        else skipped (race)
            AcceptLogic-->>AcceptLogic: skip broadcast
        end
    end
    alt acceptance threw
        AcceptLogic-->>AuthRoute: error
        AuthRoute->>SessionSvc: revokeSession(sessionToken, "pending_invite_acceptance_failed")
        AuthRoute-->>User: error response / redirect (500 or signin?error=server_error)
    else accepted successfully
        AcceptLogic-->>AuthRoute: AcceptedInvitation[]
        AuthRoute-->>User: continue with normal successful login response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through sessions, tiny and swift,

Accepted invites as a joyful gift.
I nudged the broadcasts, skipped echo to me,
Masked the emails and kept logs tidy.
Hooray — new invites, delivered with glee!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(auth): accept pending drive invites on login' directly and clearly summarizes the main change: implementing functionality to accept pending drive invitations as part of the post-login flow.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/epic-3-post-login-hook

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.

@2witstudios

Copy link
Copy Markdown
Owner Author

Rubric self-review

Scoring per Appendix (0–2 each):

File Contract stability Mock quality Assertion meaning Flake risk Spec clarity
post-login-pending-acceptance.test.ts 2 2 2 2 2
drive-invite-repository.test.ts (new tests) 2 2 2 2 2
magic-link/verify/route.test.ts (new tests) 2 2 2 2 2
Per-route post-login describe blocks (8 routes) 2 2 2 2 2
post-login-acceptance-coverage.test.ts 2 2 2 2 2

Notes

  • §3 — non-negotiable rules: route tests mock the repository / helper seams (@/lib/auth/post-login-pending-acceptance, @/lib/repositories/drive-invite-repository), never the ORM chain. The single exception is drive-invite-repository.test.ts itself, which is the seam-under-test (allowed per §4).
  • §6 — boundary assertions verify payloads: the helper test asserts the broadcast was called with the correct member_added payload (driveId, userId, operation, role, driveName) and the correct recipient list, not just "was called". See post-login-pending-acceptance.test.ts lines for the "successful UPDATE" case and the "fans out to each drive's recipients independently" case.
  • §3 — no order-dependent mock ladders: invocation-order assertions use mock.invocationCallOrder (a deterministic counter) for the "called after createSession" check, not mockReturnValueOnce chains.
  • Broadcast-coupling fix (the explicit Epic 3 callout): the helper logs and continues on broadcast or recipient-resolution failure. Two dedicated tests assert this:
    • "given the broadcast helper throws, logs the error and continues without propagating"
    • "given getDriveRecipientUserIds throws, logs the error and continues without propagating"
      Both verify the accepted row still appears in the returned list (durable acceptance, recoverable nudge).
  • Coverage gate: file-system inspection of apps/web/src/app/api/auth/**/route.ts. Allow-list (ws-token, device/refresh, mobile/refresh) is justified inline. A second test asserts no stale allow-list entries.
  • No @scaffold labels needed — all tests are contract tests against stable observable behavior.
  • No REVIEW flags — the broadcast-error semantics were the only ambiguity, and the spec called it out explicitly.

Pre-submission checklist (§12)

  • All tests pass (924/924 auth + lib + new tests)
  • Route tests mock helper/repository seams, not ORM
  • No order-dependent mock ladders
  • No suite-shape tripwires (the coverage gate asserts a contract about future routes, not a count)
  • Error cases assert consistent error shape (500 JSON for JSON routes, redirect to /auth/signin?error=server_error for HTML)
  • Boundary side effects assert payloads (broadcast and revokeAllUserSessions)
  • No sleeps; mock invocation order is deterministic

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

Caution

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

⚠️ Outside diff range comments (1)
apps/web/src/app/api/auth/apple/callback/route.ts (1)

234-239: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

trackAuthEvent receives raw unmasked email — PII leak.

email at Line 235 is the raw value from verificationResult.userInfo. maskEmail is already imported (Line 16) and correctly used elsewhere in this file (e.g. Lines 145, 158). The parallel google/callback/route.ts uses maskEmail(email) in its equivalent trackAuthEvent call.

🛡️ Proposed fix
-    trackAuthEvent(user.id, 'login', {
-      email,
-      ip: clientIP,
-      provider: 'apple',
-      userAgent: req.headers.get('user-agent')
-    });
+    trackAuthEvent(user.id, 'login', {
+      email: maskEmail(email),
+      ip: clientIP,
+      provider: 'apple',
+      userAgent: req.headers.get('user-agent')
+    });

Based on learnings: "In auth route handlers (e.g., under apps/web/src/app/api/auth/**), ensure trackAuthEvent does not receive raw email (PII). Before calling trackAuthEvent, mask the email using the existing maskEmail utility from packages/lib/src/audit/index.ts."

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

In `@apps/web/src/app/api/auth/apple/callback/route.ts` around lines 234 - 239,
The trackAuthEvent call is passing raw PII (email) from
verificationResult.userInfo; replace that with the masked value by applying the
existing maskEmail utility before invoking trackAuthEvent. Concretely, ensure
the local variable used in the trackAuthEvent payload (currently email) is set
to maskEmail(email) or pass maskEmail(email) directly to trackAuthEvent so the
provider 'apple' login event never receives the unmasked email; update the call
in route.ts where trackAuthEvent(user.id, 'login', {...}) is invoked.
🤖 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/auth/google/one-tap/route.ts`:
- Around line 246-253: The rate-limit reset and success telemetry are currently
executed before acceptUserPendingInvitations(), so a failure there can still
appear as a successful login; move the success-only side effects (the earlier
rate-limit reset call and the trackAuthEvent(...) invocation) to execute only
after acceptUserPendingInvitations() completes successfully — keep the try/catch
around acceptUserPendingInvitations() and keep
sessionService.revokeAllUserSessions(...) in the catch path, and place the
rate-limit reset and trackAuthEvent calls after the try block so they never run
when acceptUserPendingInvitations fails.

In `@apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts`:
- Around line 270-277: The rollback currently calls
sessionService.revokeAllUserSessions(user.id,
'pending_invite_acceptance_failed') which will log the user out everywhere;
instead revoke only the session created by this request. Replace the
revokeAllUserSessions call with a targeted revoke that uses the session
object/ID returned when creating the session (e.g., newSession or session —
whatever variable holds the created session), for example
sessionService.revokeSession(createdSession.id,
'pending_invite_acceptance_failed') or
sessionService.revokeUserSession(session.id, ...), and keep the error log and
response behavior the same.

In `@apps/web/src/app/api/auth/signup-passkey/route.ts`:
- Around line 213-223: The current signup-passkey route revokes the newly
created session and returns 500 if acceptUserPendingInvitations(userId) fails,
which leaves users stranded; change this to treat invitation-acceptance failures
as non-fatal for the signup flow: in the catch block for
acceptUserPendingInvitations(userId) (same block that calls
sessionService.revokeAllUserSessions and NextResponse.json), remove the
revoke-and-500 behavior and instead log the error with loggers.auth.error
(including the error and userId), keep the session intact, return the normal
successful signup response, and (optionally) enqueue a background retry or set a
persistent flag so invitations can be retried on next login—this keeps behavior
local to the signup-passkey route while preserving diagnostics and recovery
paths.

In `@apps/web/src/lib/auth/post-login-pending-acceptance.ts`:
- Around line 49-57: getDriveRecipientUserIds is called after acceptedAt is
written so the newly accepted user will appear in driveRecipients; before
calling broadcastDriveMemberEventToRecipients(filter) remove userId from the
recipients array (e.g., driveRecipients = driveRecipients.filter(id => id !==
userId)), and if the filtered list is empty skip the broadcast to avoid sending
the member_added event to the accepter; keep using createDriveMemberEventPayload
and broadcastDriveMemberEventToRecipients but pass the filtered recipient list.

---

Outside diff comments:
In `@apps/web/src/app/api/auth/apple/callback/route.ts`:
- Around line 234-239: The trackAuthEvent call is passing raw PII (email) from
verificationResult.userInfo; replace that with the masked value by applying the
existing maskEmail utility before invoking trackAuthEvent. Concretely, ensure
the local variable used in the trackAuthEvent payload (currently email) is set
to maskEmail(email) or pass maskEmail(email) directly to trackAuthEvent so the
provider 'apple' login event never receives the unmasked email; update the call
in route.ts where trackAuthEvent(user.id, 'login', {...}) is invoked.
🪄 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: a391a95b-2a31-44ae-8364-db8eb2d9cba2

📥 Commits

Reviewing files that changed from the base of the PR and between bb6eb46 and 5884fe2.

📒 Files selected for processing (30)
  • apps/web/src/app/api/auth/__tests__/google-callback-redirect.test.ts
  • apps/web/src/app/api/auth/__tests__/mobile-oauth-google-exchange.test.ts
  • apps/web/src/app/api/auth/__tests__/post-login-acceptance-coverage.test.ts
  • apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/callback/route.ts
  • apps/web/src/app/api/auth/apple/native/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/native/route.ts
  • apps/web/src/app/api/auth/google/__tests__/google-callback-redirect.test.ts
  • apps/web/src/app/api/auth/google/__tests__/one-tap.test.ts
  • apps/web/src/app/api/auth/google/__tests__/open-redirect-protection.test.ts
  • apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/callback/route.ts
  • apps/web/src/app/api/auth/google/native/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/native/route.ts
  • apps/web/src/app/api/auth/google/one-tap/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/one-tap/route.ts
  • apps/web/src/app/api/auth/magic-link/verify/__tests__/desktop-verify.test.ts
  • apps/web/src/app/api/auth/magic-link/verify/__tests__/route.test.ts
  • apps/web/src/app/api/auth/magic-link/verify/route.ts
  • apps/web/src/app/api/auth/mobile/oauth/google/exchange/__tests__/route.test.ts
  • apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts
  • apps/web/src/app/api/auth/passkey/authenticate/__tests__/route.test.ts
  • apps/web/src/app/api/auth/passkey/authenticate/route.ts
  • apps/web/src/app/api/auth/signup-passkey/__tests__/route.test.ts
  • apps/web/src/app/api/auth/signup-passkey/route.ts
  • apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts
  • apps/web/src/lib/auth/post-login-pending-acceptance.ts
  • apps/web/src/lib/repositories/__tests__/drive-invite-repository.test.ts
  • apps/web/src/lib/repositories/drive-invite-repository.ts
  • apps/web/src/lib/websocket/socket-utils.ts

Comment thread apps/web/src/app/api/auth/google/one-tap/route.ts
Comment thread apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts
Comment thread apps/web/src/app/api/auth/signup-passkey/route.ts
Comment thread apps/web/src/lib/auth/post-login-pending-acceptance.ts
Four reviewer-flagged issues plus an adjacent PII fix:

1. Post-login helper no longer echoes `member_added` to the just-accepted user.
   `getDriveRecipientUserIds()` now runs after `acceptedAt` is written, so the
   acceptee was previously included in their own broadcast. Filter them out
   before fan-out and skip the broadcast entirely when no other recipients
   remain (e.g. solo drive). New tests cover both branches.

2. Scope the rollback to the just-created session via
   `sessionService.revokeSession(sessionToken, …)` instead of
   `revokeAllUserSessions`. On routes that did not pre-revoke (mobile OAuth
   exchange, google/apple callbacks, one-tap) the prior code would log the
   user out on every device whenever invitation acceptance failed. Tests now
   assert the targeted revoke and that revokeAllUserSessions is NOT called
   with the new reason.

3. Move the rate-limit reset and `trackAuthEvent('login')` calls in
   google/one-tap to AFTER the acceptance hook, so a thrown
   `acceptUserPendingInvitations()` no longer clears failure counters or
   emits a misleading success-login event before the rollback path runs.

4. Mask the email passed to `trackAuthEvent` in apple/callback (the original
   CodeRabbit flag) and the parallel apple/native, google/native, and
   mobile/oauth/google/exchange handlers — same PII leak pattern that
   google/callback already guarded against. Existing route tests updated to
   assert masked-only emails.

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

Copy link
Copy Markdown
Owner Author

CodeRabbit review responses (commit c73ed0b)

Replying inline since the per-thread reply API was 502'ing — happy to also resolve threads once stable.

1. apps/web/src/app/api/auth/google/one-tap/route.ts (rate-limit reset + trackAuthEvent before acceptance)

Fixed. Moved resetDistributedRateLimit and trackAuthEvent('login', …) to run after the acceptUserPendingInvitations try/catch. On failure we now roll back without clearing rate-limit counters or emitting a misleading success-login event. revokeSessionsForLogin is left in its original spot — it's a session-fixation guard, not success telemetry, so revoking prior sessions is correct even on a flow that later rolls back.

2. apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts (revokeAllUserSessions logs out every device)

Fixed. All nine session-creating routes now use sessionService.revokeSession(sessionToken, 'pending_invite_acceptance_failed') instead of revokeAllUserSessions(userId, …). This kills only the just-created session and leaves the user's other devices alone. Tests now assert the targeted call AND that revokeAllUserSessions(_, 'pending_invite_acceptance_failed') is never invoked, so a regression on this would be caught by the unit suite.

3. apps/web/src/app/api/auth/signup-passkey/route.ts (treat acceptance failure as non-fatal for signup)

Discussed, kept the spec behavior. Epic 3's slice 3.4 spec is explicit: every session-creating route revokes the new session and returns 500 when acceptUserPendingInvitations throws. The coverage gate test relies on that uniform contract — weakening signup-passkey would create a hole the gate cannot catch and would diverge from the rest of the auth surface.

UX-wise, the targeted-revoke fix from #2 makes this much less harmful: we now revoke only the just-created session, not all of the user's sessions. The user can immediately retry the signup flow on the next attempt; the underlying account/passkey records persist (they were committed before our hook). If the recovery surface should be richer (background retry, persistent flag) that would be a follow-up that applies uniformly to all nine session-creating routes, not signup-passkey alone.

4. apps/web/src/lib/auth/post-login-pending-acceptance.ts (echo member_added to the acceptee)

Fixed. The helper now filters userId out of the recipient list returned by getDriveRecipientUserIds (which would otherwise include the just-accepted user since acceptedAt was already written). When the filtered list is empty (e.g. the acceptee is the only member of a tiny drive) we skip the broadcast entirely. Two new test cases cover both branches.

Outside-diff: apple/callback trackAuthEvent unmasked email

Fixed in apple/callback plus the parallel apple/native, google/native, and mobile/oauth/google/exchange handlers — same PII pattern, same fix. Existing route tests updated to assert the masked form (te***@example.com) and explicitly reject the raw form.

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

🧹 Nitpick comments (2)
apps/web/src/app/api/auth/magic-link/verify/route.ts (1)

164-176: ⚡ Quick win

Extract the redirect-path resolution into one helper.

The matchedInviteDrive / isNewUser / provisionGettingStartedDriveIfNeeded branch now exists twice. Keeping it duplicated makes the desktop and web post-login flows easy to drift apart on the next redirect-rule change.

♻️ Possible extraction
+async function resolvePostLoginRedirectPath({
+  matchedInviteDriveId,
+  isNewUser,
+  userId,
+}: {
+  matchedInviteDriveId: string | null;
+  isNewUser: boolean;
+  userId: string;
+}): Promise<string> {
+  if (matchedInviteDriveId) {
+    return `/dashboard/${matchedInviteDriveId}`;
+  }
+
+  if (!isNewUser) {
+    return '/dashboard';
+  }
+
+  try {
+    const provisionedDrive = await provisionGettingStartedDriveIfNeeded(userId);
+    if (provisionedDrive) {
+      return `/dashboard/${provisionedDrive.driveId}`;
+    }
+  } catch (error) {
+    loggers.auth.error('Failed to provision Getting Started drive', error as Error, { userId });
+  }
+
+  return '/dashboard';
+}
+
-          let desktopRedirectPath = '/dashboard';
-          if (matchedInviteDrive) {
-            desktopRedirectPath = `/dashboard/${matchedInviteDrive.driveId}`;
-          } else if (isNewUser) {
-            try {
-              const provisionedDrive = await provisionGettingStartedDriveIfNeeded(userId);
-              if (provisionedDrive) {
-                desktopRedirectPath = `/dashboard/${provisionedDrive.driveId}`;
-              }
-            } catch (error) {
-              loggers.auth.error('Failed to provision Getting Started drive', error as Error, { userId });
-            }
-          }
+          const desktopRedirectPath = await resolvePostLoginRedirectPath({
+            matchedInviteDriveId: matchedInviteDrive?.driveId ?? null,
+            isNewUser,
+            userId,
+          });
...
-    let redirectPath = '/dashboard';
-
-    if (matchedInviteDrive) {
-      redirectPath = `/dashboard/${matchedInviteDrive.driveId}`;
-    } else if (isNewUser) {
-      try {
-        const provisionedDrive = await provisionGettingStartedDriveIfNeeded(userId);
-        if (provisionedDrive) {
-          redirectPath = `/dashboard/${provisionedDrive.driveId}`;
-        }
-      } catch (error) {
-        loggers.auth.error('Failed to provision Getting Started drive', error as Error, {
-          userId,
-        });
-        // Continue with default dashboard redirect
-      }
-    }
+    const redirectPath = await resolvePostLoginRedirectPath({
+      matchedInviteDriveId: matchedInviteDrive?.driveId ?? null,
+      isNewUser,
+      userId,
+    });

Also applies to: 228-244

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

In `@apps/web/src/app/api/auth/magic-link/verify/route.ts` around lines 164 - 176,
Duplicate redirect-path resolution logic (matchedInviteDrive / isNewUser /
provisionGettingStartedDriveIfNeeded) appears in multiple places (e.g., the
block setting desktopRedirectPath and again around lines 228-244); extract that
logic into a single helper function (e.g., resolvePostLoginRedirect(userId,
matchedInviteDrive, isNewUser)) that returns the final redirect path string,
replace both inline branches with calls to that helper, and preserve existing
error handling (loggers.auth.error) inside the helper when provisioning fails.
apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts (1)

115-129: ⚡ Quick win

Add invocation-order assertions to verify acceptPendingMember is called before getDriveRecipientUserIds.

Test 3 exists precisely because the implementation accepts the member first (writing acceptedAt) and then queries recipients — so the acceptee is already a member and appears in the recipient list, requiring the filter. The mock returns the same fixed recipient list regardless of call timing, so the test passes even if the order were reversed in a future refactor, giving a false green for a semantic regression.

The PR's own testing rules call for mock.invocationCallOrder for exactly this scenario. The same gap exists in Tests 2 and 6.

♻️ Suggested addition to Test 3 (and equivalently Tests 2/6)
     await acceptUserPendingInvitations('user_x');

+    // Critical: accept must be written before recipients are queried, so the
+    // acceptee appears in the list and can then be filtered out.
+    const acceptOrder =
+      vi.mocked(driveInviteRepository.acceptPendingMember).mock.invocationCallOrder[0];
+    const recipientsOrder =
+      vi.mocked(getDriveRecipientUserIds).mock.invocationCallOrder[0];
+    expect(acceptOrder).toBeLessThan(recipientsOrder!);
+
     expect(broadcastDriveMemberEventToRecipients).toHaveBeenCalledTimes(1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts` around
lines 115 - 129, Add invocation-order assertions to ensure the implementation
calls acceptPendingMember before querying recipients: after invoking
acceptUserPendingInvitations('user_x') in this test, capture invocation orders
for vi.mocked(driveInviteRepository.acceptPendingMember) and
vi.mocked(getDriveRecipientUserIds) using mock.invocationCallOrder and assert
the acceptPendingMember call order is less than the getDriveRecipientUserIds
call order; apply the same pattern to the other two failing tests (the ones
labelled Test 2 and Test 6) to prevent a future refactor from reversing the
semantic order.
🤖 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/auth/google/one-tap/route.ts`:
- Around line 247-254: The inline regex masking for maskedEmail can fail for
1-char local parts and may leak raw email; replace the manual regex with the
already-imported maskEmail utility (use maskedEmail = maskEmail(email)) before
calling trackAuthEvent(user.id, isNewUser ? 'signup' : 'login', ...), keeping
the same payload keys (email: maskedEmail, ip: clientIP, provider:
'google-one-tap', userAgent: req.headers.get('user-agent')) so all auth handlers
consistently pass masked emails.

---

Nitpick comments:
In `@apps/web/src/app/api/auth/magic-link/verify/route.ts`:
- Around line 164-176: Duplicate redirect-path resolution logic
(matchedInviteDrive / isNewUser / provisionGettingStartedDriveIfNeeded) appears
in multiple places (e.g., the block setting desktopRedirectPath and again around
lines 228-244); extract that logic into a single helper function (e.g.,
resolvePostLoginRedirect(userId, matchedInviteDrive, isNewUser)) that returns
the final redirect path string, replace both inline branches with calls to that
helper, and preserve existing error handling (loggers.auth.error) inside the
helper when provisioning fails.

In `@apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts`:
- Around line 115-129: Add invocation-order assertions to ensure the
implementation calls acceptPendingMember before querying recipients: after
invoking acceptUserPendingInvitations('user_x') in this test, capture invocation
orders for vi.mocked(driveInviteRepository.acceptPendingMember) and
vi.mocked(getDriveRecipientUserIds) using mock.invocationCallOrder and assert
the acceptPendingMember call order is less than the getDriveRecipientUserIds
call order; apply the same pattern to the other two failing tests (the ones
labelled Test 2 and Test 6) to prevent a future refactor from reversing the
semantic order.
🪄 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: 38da952f-37a0-4d89-be90-f6b2b751c7c9

📥 Commits

Reviewing files that changed from the base of the PR and between 5884fe2 and c73ed0b.

📒 Files selected for processing (21)
  • apps/web/src/app/api/auth/__tests__/mobile-oauth-google-exchange.test.ts
  • apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/callback/route.ts
  • apps/web/src/app/api/auth/apple/native/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/native/route.ts
  • apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/callback/route.ts
  • apps/web/src/app/api/auth/google/native/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/native/route.ts
  • apps/web/src/app/api/auth/google/one-tap/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/one-tap/route.ts
  • apps/web/src/app/api/auth/magic-link/verify/__tests__/route.test.ts
  • apps/web/src/app/api/auth/magic-link/verify/route.ts
  • apps/web/src/app/api/auth/mobile/oauth/google/exchange/__tests__/route.test.ts
  • apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts
  • apps/web/src/app/api/auth/passkey/authenticate/__tests__/route.test.ts
  • apps/web/src/app/api/auth/passkey/authenticate/route.ts
  • apps/web/src/app/api/auth/signup-passkey/__tests__/route.test.ts
  • apps/web/src/app/api/auth/signup-passkey/route.ts
  • apps/web/src/lib/auth/__tests__/post-login-pending-acceptance.test.ts
  • apps/web/src/lib/auth/post-login-pending-acceptance.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/app/api/auth/tests/mobile-oauth-google-exchange.test.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • apps/web/src/app/api/auth/signup-passkey/tests/route.test.ts
  • apps/web/src/app/api/auth/apple/callback/tests/route.test.ts
  • apps/web/src/app/api/auth/google/one-tap/tests/route.test.ts
  • apps/web/src/app/api/auth/passkey/authenticate/tests/route.test.ts
  • apps/web/src/lib/auth/post-login-pending-acceptance.ts
  • apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts
  • apps/web/src/app/api/auth/google/callback/route.ts
  • apps/web/src/app/api/auth/magic-link/verify/tests/route.test.ts

Comment thread apps/web/src/app/api/auth/google/one-tap/route.ts Outdated
- Replace inline regex maskEmail in google/one-tap with the shared
  maskEmail utility — the regex form would emit raw email when the
  pattern did not match (e.g. 1-char local parts).
- Extract resolvePostLoginRedirectPath helper in magic-link/verify so
  the desktop exchange and web cookie redirect flows share one
  source of truth and cannot drift on the next redirect-rule change.
- Add invocation-order assertions in post-login-pending-acceptance
  tests so the load-bearing acceptance-then-recipients-then-broadcast
  ordering is locked. Without these, a future refactor pulling
  recipients before acceptance would silently make the self-filter
  dead code and re-introduce the member_added echo to the acceptee.

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

Copy link
Copy Markdown
Owner Author

CodeRabbit round 2 responses (commit 83cb037)

Actionable: google/one-tap regex maskEmail

Fixed. Replaced the inline email.replace(/(.{2}).*(@.*)/, '$1***$2') with maskEmail(email) (already imported at the top of the file). The regex variant would have emitted the raw email for 1-character local parts because the pattern would not match — maskEmail always returns ***@*** or a properly masked form. All other route handlers were already on the utility; one-tap is now consistent.

Nitpick: extract redirect-path resolution helper in magic-link/verify

Done. New private helper resolvePostLoginRedirectPath({ matchedInviteDriveId, isNewUser, userId }) co-located in the same file. Both the desktop exchange branch and the web cookie branch now call the helper instead of duplicating the matched-invite / new-user / provisioning ladder. Provisioning errors are still logged inside the helper.

Nitpick: invocation-order assertions in helper tests

Done. Added mock.invocationCallOrder assertions in three tests:

  • "broadcasts member_added with the correct payload" — accept < recipients < broadcast
  • "filters them out before broadcasting" — accept < recipients (locks the load-bearing ordering that makes the self-filter meaningful)
  • "fans out to each drive's recipients independently" — per-row accept[i] < recipients[i]

These guard against a future refactor that pulls recipients before acceptance, which would silently make the self-filter dead code and re-introduce the member_added echo regression.

@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Triggering an explicit re-review on commit 83cb037 (round 2 nitpicks: maskEmail utility, redirect-path helper extraction, invocation-order assertions). All prior threads have been resolved.

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@2witstudios, kicking off a fresh review of commit 83cb037. The round-2 fixes look well-scoped — maskEmail unification in one-tap, the extracted resolvePostLoginRedirectPath helper in magic-link/verify, and the mock.invocationCallOrder guards on the accept→recipients→broadcast ordering are all solid improvements. Reviewing now.

[review]

2witstudios and others added 3 commits May 4, 2026 11:30
Tighten the acceptedInvitations local type binding from a structural
{driveId, driveName, role: string} to the exported AcceptedInvitation
type so the role field stays narrowed to 'OWNER' | 'ADMIN' | 'MEMBER'
instead of widening to string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add unit tests for the new fan-out helper:
- multi-recipient broadcasts route to each user:{userId}:drives channel
- empty recipient list short-circuits before fetch
- absent INTERNAL_REALTIME_URL short-circuits before fetch
- partial fetch failures do not propagate (best-effort contract;
  broadcast must never abort the calling operation, e.g. login)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same class of issue as the google/one-tap regex maskEmail: the inline
form (email.substring(0, 3) + '***@' + domain) leaks domain characters
into the local segment for short addresses. For example
'a@example.com' → 'a@e***@example.com'. Use the shared maskEmail
utility (already imported) which always emits a clean
{firstTwoChars}***@{domain} form. Test assertion updated to the
canonical 2-char prefix.

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

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Re-triggering review on latest HEAD (4bd6eaf). Recent commits since the last review pass:

  • 83cb037: maskEmail utility unification, redirect-helper extraction, invocation-order test assertions
  • ca40e8a: tighten AcceptedInvitation typing in magic-link/verify
  • 4c336b0: unit tests for the new broadcastDriveMemberEventToRecipients helper in socket-utils
  • 4bd6eaf: replace inline maskEmail with utility in signup-passkey trackAuthEvent

All prior CodeRabbit threads are resolved. Local validation: 939/939 tests, typecheck/lint green.

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@2witstudios, kicking off a fresh review of the latest HEAD (4bd6eaf41). The recent commits look well-scoped:

  • 83cb037 — maskEmail utility unification, resolvePostLoginRedirectPath extraction, and invocationCallOrder guards
  • ca40e8a — tighter AcceptedInvitation typing in magic-link/verify
  • 4c336b0 — unit tests for the new broadcastDriveMemberEventToRecipients helper in socket-utils
  • 4bd6eaf — maskEmail utility replacing the inline regex in signup-passkey's trackAuthEvent

All prior threads resolved and 939/939 tests green — reviewing now.

[review]

@2witstudios
2witstudios merged commit 7e96137 into master May 4, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/epic-3-post-login-hook branch May 5, 2026 02:57
2witstudios added a commit that referenced this pull request May 5, 2026
* fix(realtime): fan out member_removed to recipients

Switch the DELETE handler from broadcastDriveMemberEvent (single-recipient,
sent to the just-removed user) to broadcastDriveMemberEventToRecipients
fanned out across getDriveRecipientUserIds(driveId). Other admins watching
the members page now see the row disappear in realtime instead of having
to refresh, matching the parity already in place for member_added.

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

* fix(realtime): inspect Response.ok in fan-out tally

broadcastDriveEvent and broadcastDriveMemberEventToRecipients used
Promise.allSettled to track per-recipient success but only counted
status === 'rejected'. fetch resolves on HTTP 4xx/5xx (it does not
throw — only network errors throw), so silent broadcast failures were
being logged as successes.

Surface !response.ok inside the map callback so it shows up as a
rejected settled-result and the failed/total counters in the warn log
become accurate. Network errors continue to count as failures unchanged.

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

* chore: remove stray ralph-loop.local.md

The file was accidentally committed in PR #1236; *.local.md is agent-loop
state that doesn't belong in source control. Add a recursive
**/.claude/*.local.md gitignore rule so future stray files in nested
.claude/ directories (e.g. apps/web/.claude/) are ignored as well — the
existing top-level .claude/*.local.md rule only matched the repo root.

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

* fix(realtime): make post-commit broadcast non-fatal in member-removed

Codex review flagged that getDriveRecipientUserIds runs after the
membership-delete transaction but inside the main request try/catch, so
a transient DB blip during recipient lookup would surface as a 500 even
though the member was already removed — a false failure signal that can
trigger client retries against already-applied state.

Wrap the recipient lookup + broadcast in an inner try/catch so any
post-commit failure is logged as best-effort and the handler still
returns 200. Mirrors the pattern already used in
post-login-pending-acceptance for member_added.

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

* fix(realtime): make post-commit kicks non-fatal in member-removed

Apply the same best-effort contract to the page-room kick block as the
broadcast block above. The pre-existing db.select for drivePages also
runs after the membership-delete commit; without a wrap, a transient DB
blip there would mask a successful removal as a 500 — the very same
bug shape Codex flagged for the broadcast.

Now both post-commit side-effect blocks (broadcast + kicks) log on
failure and return 200, so the post-commit semantics of this handler
are uniform.

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

* chore(tests): align createDriveMemberEventPayload mock with production shape

CodeRabbit nitpick: the mocked factory returned { event, data } but the
real helper from socket-utils.ts returns { operation, ...options }. The
existing assertions rely only on call args (not return shape), so this
is purely a fidelity fix — no test behavior change.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2witstudios added a commit that referenced this pull request May 15, 2026
* feat(auth): accept pending drive invites on login

Adds the post-login pending invitation acceptance hook (Epic 3 of the
pu/invites redo) so a user with a pending drive_members row gains drive
access on their next successful login via any auth flow.

Why: Epic 1 hardened authz on acceptedAt IS NOT NULL, which made any
pending row invisible to drive queries. Without this hook, an invitee
could authenticate but never reach the drive they were invited to.

Implementation:
- driveInviteRepository gains findPendingMembersForUser +
  acceptPendingMember (race-safe conditional UPDATE).
- Pure helper acceptUserPendingInvitations orchestrates accept + best-
  effort broadcast. Acceptance writes propagate so callers can revoke
  the session; broadcast/recipient-resolution failures log and continue
  (the original PR coupled them, which review flagged as a flaw).
- Wired into all 9 session-creating auth routes: magic-link verify,
  passkey authenticate, signup-passkey, google callback/native/one-tap,
  apple callback/native, and mobile google exchange. Magic-link verify
  also honours an optional ?inviteDriveId redirect hint.
- Coverage gate test asserts every future session-creating route under
  /api/auth either calls the helper or appears on a justified allow-list
  (currently: ws-token, device/refresh, mobile/refresh).

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

* fix(auth): address CodeRabbit review feedback on Epic 3

Four reviewer-flagged issues plus an adjacent PII fix:

1. Post-login helper no longer echoes `member_added` to the just-accepted user.
   `getDriveRecipientUserIds()` now runs after `acceptedAt` is written, so the
   acceptee was previously included in their own broadcast. Filter them out
   before fan-out and skip the broadcast entirely when no other recipients
   remain (e.g. solo drive). New tests cover both branches.

2. Scope the rollback to the just-created session via
   `sessionService.revokeSession(sessionToken, …)` instead of
   `revokeAllUserSessions`. On routes that did not pre-revoke (mobile OAuth
   exchange, google/apple callbacks, one-tap) the prior code would log the
   user out on every device whenever invitation acceptance failed. Tests now
   assert the targeted revoke and that revokeAllUserSessions is NOT called
   with the new reason.

3. Move the rate-limit reset and `trackAuthEvent('login')` calls in
   google/one-tap to AFTER the acceptance hook, so a thrown
   `acceptUserPendingInvitations()` no longer clears failure counters or
   emits a misleading success-login event before the rollback path runs.

4. Mask the email passed to `trackAuthEvent` in apple/callback (the original
   CodeRabbit flag) and the parallel apple/native, google/native, and
   mobile/oauth/google/exchange handlers — same PII leak pattern that
   google/callback already guarded against. Existing route tests updated to
   assert masked-only emails.

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

* refactor(auth): apply CodeRabbit round 2 nitpicks

- Replace inline regex maskEmail in google/one-tap with the shared
  maskEmail utility — the regex form would emit raw email when the
  pattern did not match (e.g. 1-char local parts).
- Extract resolvePostLoginRedirectPath helper in magic-link/verify so
  the desktop exchange and web cookie redirect flows share one
  source of truth and cannot drift on the next redirect-rule change.
- Add invocation-order assertions in post-login-pending-acceptance
  tests so the load-bearing acceptance-then-recipients-then-broadcast
  ordering is locked. Without these, a future refactor pulling
  recipients before acceptance would silently make the self-filter
  dead code and re-introduce the member_added echo to the acceptee.

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

* refactor(auth): use AcceptedInvitation type in magic-link verify

Tighten the acceptedInvitations local type binding from a structural
{driveId, driveName, role: string} to the exported AcceptedInvitation
type so the role field stays narrowed to 'OWNER' | 'ADMIN' | 'MEMBER'
instead of widening to string.

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

* test(socket-utils): cover broadcastDriveMemberEventToRecipients

Add unit tests for the new fan-out helper:
- multi-recipient broadcasts route to each user:{userId}:drives channel
- empty recipient list short-circuits before fetch
- absent INTERNAL_REALTIME_URL short-circuits before fetch
- partial fetch failures do not propagate (best-effort contract;
  broadcast must never abort the calling operation, e.g. login)

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

* fix(auth): replace inline maskEmail with utility in signup-passkey

Same class of issue as the google/one-tap regex maskEmail: the inline
form (email.substring(0, 3) + '***@' + domain) leaks domain characters
into the local segment for short addresses. For example
'a@example.com' → 'a@e***@example.com'. Use the shared maskEmail
utility (already imported) which always emits a clean
{firstTwoChars}***@{domain} form. Test assertion updated to the
canonical 2-char prefix.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2witstudios added a commit that referenced this pull request May 15, 2026
* fix(realtime): fan out member_removed to recipients

Switch the DELETE handler from broadcastDriveMemberEvent (single-recipient,
sent to the just-removed user) to broadcastDriveMemberEventToRecipients
fanned out across getDriveRecipientUserIds(driveId). Other admins watching
the members page now see the row disappear in realtime instead of having
to refresh, matching the parity already in place for member_added.

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

* fix(realtime): inspect Response.ok in fan-out tally

broadcastDriveEvent and broadcastDriveMemberEventToRecipients used
Promise.allSettled to track per-recipient success but only counted
status === 'rejected'. fetch resolves on HTTP 4xx/5xx (it does not
throw — only network errors throw), so silent broadcast failures were
being logged as successes.

Surface !response.ok inside the map callback so it shows up as a
rejected settled-result and the failed/total counters in the warn log
become accurate. Network errors continue to count as failures unchanged.

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

* chore: remove stray ralph-loop.local.md

The file was accidentally committed in PR #1236; *.local.md is agent-loop
state that doesn't belong in source control. Add a recursive
**/.claude/*.local.md gitignore rule so future stray files in nested
.claude/ directories (e.g. apps/web/.claude/) are ignored as well — the
existing top-level .claude/*.local.md rule only matched the repo root.

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

* fix(realtime): make post-commit broadcast non-fatal in member-removed

Codex review flagged that getDriveRecipientUserIds runs after the
membership-delete transaction but inside the main request try/catch, so
a transient DB blip during recipient lookup would surface as a 500 even
though the member was already removed — a false failure signal that can
trigger client retries against already-applied state.

Wrap the recipient lookup + broadcast in an inner try/catch so any
post-commit failure is logged as best-effort and the handler still
returns 200. Mirrors the pattern already used in
post-login-pending-acceptance for member_added.

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

* fix(realtime): make post-commit kicks non-fatal in member-removed

Apply the same best-effort contract to the page-room kick block as the
broadcast block above. The pre-existing db.select for drivePages also
runs after the membership-delete commit; without a wrap, a transient DB
blip there would mask a successful removal as a 500 — the very same
bug shape Codex flagged for the broadcast.

Now both post-commit side-effect blocks (broadcast + kicks) log on
failure and return 200, so the post-commit semantics of this handler
are uniform.

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

* chore(tests): align createDriveMemberEventPayload mock with production shape

CodeRabbit nitpick: the mocked factory returned { event, data } but the
real helper from socket-utils.ts returns { operation, ...options }. The
existing assertions rely only on call args (not return shape), so this
is purely a fidelity fix — no test behavior change.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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