Skip to content

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Feb 12, 2026

Summary

This PR adds security validation to the gift subscription admin endpoints to prevent unauthorized access through stale admin sessions after role demotion. It implements verifyAdminAuth() which validates both the user's current role and adminRoleVersion to detect when an admin user has been demoted.

Key Changes

  • New security function: Added verifyAdminAuth() in auth.ts that validates admin access by checking both role and adminRoleVersion against the database
  • Security event logging: Logs admin_role_version_mismatch events when a stale admin session is detected, capturing details like the claimed version and actual version mismatch
  • Updated gift subscription endpoints: Replaced authenticateRequestWithOptions() with verifyAdminAuth() in both POST and DELETE handlers for /api/admin/users/[userId]/gift-subscription
  • Comprehensive test suite: Added 456 lines of security tests covering:
    • Valid admin authentication scenarios
    • Stale token rejection after admin demotion
    • Stale token rejection after admin re-promotion
    • Multiple role change scenarios
    • Security event logging verification

Implementation Details

  • The verifyAdminAuth() function calls validateAdminAccess() which compares the adminRoleVersion in the user's session token against the current adminRoleVersion in the database
  • When a version mismatch is detected, a security event is logged with context about the failed validation
  • This prevents the "stale admin session window" vulnerability where a demoted admin could still perform privileged operations using their old session token
  • The solution handles edge cases like multiple rapid role changes by using an incrementing version counter

https://claude.ai/code/session_01WNEyTFTTg1P1Ko3Wip2GJd

Summary by CodeRabbit

  • New Features

    • Standardized admin-auth wrapper for admin routes and richer admin-auth error signaling.
  • Security

    • CSRF protection for admin state-changing actions and stronger defenses against stale/changed admin sessions; security event logging expanded for role-version mismatches.
  • Tests

    • Extensive security tests for admin gift-subscription flows covering auth, CSRF, role-version changes and logging.
  • Chores

    • Session tracking extended to include admin role version.

…dmin routes

Fixes #550

## Changes
- Migrated gift subscription POST/DELETE handlers to use `verifyAdminAuth()`
- Added security event logging for admin role version mismatches
- Added comprehensive security tests for role demotion race conditions

## Security Impact
Previously, gift subscription admin endpoints checked `auth.role === 'admin'`
without validating `adminRoleVersion`, creating a stale admin window where
demoted admins could still use existing sessions.

Now uses `verifyAdminAuth()` which:
1. Validates the role claim in the session token
2. Re-validates against database to check current `adminRoleVersion`
3. Logs security events when validation fails due to version mismatch

This follows the zero-trust principle and matches the pattern used by 8 other
admin routes in the codebase.

## Testing
- Added 10 security tests covering:
  - Valid admin access scenarios
  - Role demotion race conditions
  - Multiple role changes with stale tokens
  - Security event logging verification

Resolves P2 security issue from 2026-02-11 security posture assessment.

https://claude.ai/code/session_M9lh1
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 12, 2026

Warning

Rate limit exceeded

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

⌛ 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.

📝 Walkthrough

Walkthrough

Replaces in-handler admin checks with a new withAdminAuth wrapper, adds CSRF validation and structured AdminValidationResult for adminRoleVersion checks, logs admin_role_version_mismatch, propagates adminRoleVersion through sessions/DB, and adds comprehensive security tests for gift-subscription admin endpoints.

Changes

Cohort / File(s) Summary
Gift-Subscription Route
apps/web/src/app/api/admin/users/[userId]/gift-subscription/route.ts
Switched POST/DELETE to use withAdminAuth, derive adminUser.id, added tier/user/subscription validation and unified error handling.
Gift-Subscription Security Tests
apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts
Added extensive security scenarios: adminRoleVersion demotion/repromotion, CSRF missing/invalid, missing auth; heavy mocking (Stripe, logging) and assertions on security events.
Auth Layer & Helpers
apps/web/src/lib/auth/auth.ts, apps/web/src/lib/auth/admin-role.ts, apps/web/src/lib/auth/index.ts, apps/web/src/lib/auth/__tests__/*.test.ts
Added AdminValidationResult type, changed validateAdminAccess to return it, updated verifyAdminAuth to return `VerifiedUser
Admin Routes (wrapper adoption)
apps/web/src/app/api/admin/... (e.g. audit-logs/*, contact/route.ts, global-prompt/route.ts, schema/route.ts, users/route.ts, monitoring/[metric]/route.ts)
Replaced inline verifyAdminAuth checks with withAdminAuth-wrapped handlers and adjusted signatures to receive adminUser from the wrapper.
Logging Types
packages/lib/src/logging/logger-config.ts
Extended logSecurityEvent event union to include admin_role_version_mismatch.
Sessions / DB Schema / Session Service
packages/db/drizzle/0082_common_maverick.sql, packages/db/src/schema/sessions.ts, packages/lib/src/auth/session-service.ts
Added admin_role_version column (default 0) and adminRoleVersion schema field; propagate adminRoleVersion through session creation/lookup and SessionClaims.
Tests / Mocks / Manifests
apps/web/src/app/api/admin/audit-logs/__tests__/*, package.json, packages/db/drizzle/meta/_journal.json
Adjusted tests to mock withAdminAuth/verifyAdminAuth patterns and isAdminAuthError, added CSRF/logging mocks, and updated migration journal entry.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Route as GiftSubscriptionRoute
    participant Auth as withAdminAuth / verifyAdminAuth
    participant CSRF as validateCSRF
    participant DB as validateAdminAccess / SessionStore
    participant Logger as logSecurityEvent
    participant Stripe

    Client->>Route: POST/DELETE /api/admin/users/{userId}/gift-subscription + session + csrf
    Route->>Auth: withAdminAuth -> verifyAdminAuth(request)
    Auth->>CSRF: validateCSRF(request)
    CSRF-->>Auth: ok / error
    Auth->>DB: validateAdminAccess(claimedAdminVersion)
    DB-->>Auth: AdminValidationResult (isValid / reason / actualAdminRoleVersion)
    alt CSRF fail or role version mismatch
        Auth->>Logger: logSecurityEvent("login_csrf_invalid" / "admin_role_version_mismatch")
        Logger-->>Auth: logged
        Auth-->>Route: NextResponse (403/CSRF error)
        Route-->>Client: 403 + error
    else valid admin
        Route->>Stripe: create or expire gift subscription
        Stripe-->>Route: success
        Route-->>Client: 200/204 success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • Issue #550: Implements verifyAdminAuth usage, adminRoleVersion mismatch logging, and tests for stale admin sessions — directly related.

Possibly related PRs

  • PR #236: Modifies the same admin auth surface (admin-role/version validation and withAdminAuth changes).
  • PR #507: Related changes to logging configuration/types that overlap logSecurityEvent updates.
  • PR #159: Related session/schema work that interacts with session claim fields and session-service propagation.

Poem

🐰 I hopped through gates with careful paws,
Counting role versions, checking laws,
Old tokens fade, new logs sing loud,
No stale paws slipping in the crowd,
Subscriptions safe — the rabbit’s proud.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (46 files):

⚔️ .env.example (content)
⚔️ apps/processor/src/api/ingest.ts (content)
⚔️ apps/processor/src/api/optimize.ts (content)
⚔️ apps/processor/src/api/serve.ts (content)
⚔️ apps/processor/src/middleware/auth.ts (content)
⚔️ apps/processor/src/middleware/rate-limit.ts (content)
⚔️ apps/processor/src/server.ts (content)
⚔️ apps/processor/src/services/rbac.ts (content)
⚔️ apps/realtime/src/index.ts (content)
⚔️ apps/web/src/app/admin/layout.tsx (content)
⚔️ apps/web/src/app/api/activities/[activityId]/rollback-to-point/route.ts (content)
⚔️ apps/web/src/app/api/activities/[activityId]/rollback/route.ts (content)
⚔️ apps/web/src/app/api/admin/audit-logs/__tests__/search-security.test.ts (content)
⚔️ apps/web/src/app/api/admin/audit-logs/export/route.ts (content)
⚔️ apps/web/src/app/api/admin/audit-logs/integrity/route.ts (content)
⚔️ apps/web/src/app/api/admin/audit-logs/route.ts (content)
⚔️ apps/web/src/app/api/admin/contact/route.ts (content)
⚔️ apps/web/src/app/api/admin/global-prompt/route.ts (content)
⚔️ apps/web/src/app/api/admin/schema/route.ts (content)
⚔️ apps/web/src/app/api/admin/users/[userId]/gift-subscription/route.ts (content)
⚔️ apps/web/src/app/api/admin/users/route.ts (content)
⚔️ apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts (content)
⚔️ apps/web/src/app/api/drives/[driveId]/restore/route.ts (content)
⚔️ apps/web/src/app/api/drives/[driveId]/route.ts (content)
⚔️ apps/web/src/app/api/drives/route.ts (content)
⚔️ apps/web/src/app/api/mcp/drives/route.ts (content)
⚔️ apps/web/src/app/api/monitoring/[metric]/route.ts (content)
⚔️ apps/web/src/app/api/trash/drives/[driveId]/route.ts (content)
⚔️ apps/web/src/hooks/useGlobalDriveSocket.ts (content)
⚔️ apps/web/src/lib/ai/tools/drive-tools.ts (content)
⚔️ apps/web/src/lib/ai/tools/page-write-tools.ts (content)
⚔️ apps/web/src/lib/auth/__tests__/admin-role-version.test.ts (content)
⚔️ apps/web/src/lib/auth/__tests__/auth.test.ts (content)
⚔️ apps/web/src/lib/auth/admin-role.ts (content)
⚔️ apps/web/src/lib/auth/auth.ts (content)
⚔️ apps/web/src/lib/auth/index.ts (content)
⚔️ apps/web/src/lib/websocket/__tests__/socket-utils.test.ts (content)
⚔️ apps/web/src/lib/websocket/socket-utils.ts (content)
⚔️ packages/db/drizzle/meta/_journal.json (content)
⚔️ packages/db/src/schema/sessions.ts (content)
⚔️ packages/lib/package.json (content)
⚔️ packages/lib/src/auth/session-service.ts (content)
⚔️ packages/lib/src/config/__tests__/env-validation.test.ts (content)
⚔️ packages/lib/src/config/env-validation.ts (content)
⚔️ packages/lib/src/logging/logger-config.ts (content)
⚔️ packages/lib/src/services/drive-member-service.ts (content)

These conflicts must be resolved before merging into master.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the PR: adding admin role version validation to prevent stale session attacks. It directly relates to the core security feature introduced across the authentication and gift-subscription endpoint changes.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/fix-gift-subscription-auth-M9lh1

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.

Zero-trust defense-in-depth improvements:
- Add explicit CSRF validation for state-changing admin operations
- Enhance validateAdminAccess to return detailed validation results
- Include actualAdminRoleVersion and currentRole in security logs
- Fix lint errors: remove unused imports, add explicit Error type
- Add defensive cleanup in test afterEach

Review feedback addressed:
- CodeRabbit: Add actual adminRoleVersion from DB for richer audit context
- CodeRabbit: Fix implicit any type in mock callback
- CodeRabbit: Wrap cleanup in try/catch to prevent cascading failures
- Security review: Re-add CSRF for defense-in-depth (zero-trust)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios
Copy link
Owner Author

Review Feedback Addressed

I've addressed all the review feedback and added security hardening improvements:

CodeRabbit Nitpicks Fixed

  1. Add actual adminRoleVersion from DB (auth.ts:43-51)

    • Enhanced validateAdminAccess to return AdminValidationResult with detailed context
    • Security logs now include actualAdminRoleVersion and currentRole for forensic analysis
    • Commit: 5d14275
  2. Fix implicit any type (route.security.test.ts:54-57)

    • Changed (error) to (error: Error) in mock callback
    • Commit: 5d14275
  3. Wrap cleanup in try/catch (route.security.test.ts:132-137)

    • Added try/catch around afterEach cleanup to prevent cascading failures
    • Commit: 5d14275

Security Hardening (Zero-Trust Defense-in-Depth)

  1. Re-added CSRF validation (auth.ts)
    • Added explicit CSRF validation for state-changing admin operations (POST, PUT, PATCH, DELETE)
    • Even though SameSite=Strict cookies provide protection, explicit CSRF validation adds defense-in-depth
    • Logs admin_csrf_validation_failed security events when CSRF validation fails
    • Commit: 5d14275

Test Updates

  1. Updated test expectations to match enhanced security logging:
    • Tests now verify actualAdminRoleVersion and currentRole in security events
    • Tests use specific reason values (not_admin, version_mismatch) instead of generic message
    • Added CSRF validation mock to keep tests focused on admin role version validation

Files Changed

  • apps/web/src/lib/auth/auth.ts - CSRF validation + enhanced logging
  • apps/web/src/lib/auth/admin-role.ts - AdminValidationResult interface
  • apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts - Fixed all issues

CI should pass now. Please re-review when checks are green.

2witstudios and others added 7 commits February 12, 2026 12:16
- Update validateAdminAccess mock to return AdminValidationResult instead of boolean
- Add mock for CSRF validation (allows all requests by default)
- Add mock for security event logging
- Import AdminValidationResult type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update admin-role-version.test.ts to check isValid property
  instead of boolean return value from validateAdminAccess
- Add enhanced assertions for reason, actualAdminRoleVersion,
  and currentRole in validation result
- Add additional CSRF mock path to ensure mock is applied
  correctly for relative imports in auth.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of trying to mock CSRF validation (which wasn't working due
to module resolution issues), use real CSRF tokens in test requests.
This approach:
- Is more realistic and tests actual production behavior
- Generates CSRF tokens from the session ID after session creation
- Includes X-CSRF-Token header in all POST/DELETE requests
- Removes non-working CSRF mocks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The test verifyAdminAuth mock now:
- Skips CSRF validation (which was causing path resolution issues)
- Still validates adminRoleVersion against database (the core security check)
- Logs security events for forensic analysis on validation failures

This isolates the test to focus on admin role version validation
without the complexity of CSRF token generation and validation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The import is now dynamically imported inside the mock function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of mocking verifyAdminAuth to skip CSRF, use real CSRF tokens:
- Generate valid CSRF tokens bound to session in beforeEach
- Include X-CSRF-Token header in all authenticated requests
- Add explicit CSRF protection tests (missing token, invalid token)

This ensures full defense-in-depth security testing:
- CSRF protection is enforced
- adminRoleVersion validation prevents stale session attacks
- Security events are logged for forensic analysis

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The cookie name should be 'session' not 'ps-session' to match
COOKIE_CONFIG.session.name in cookie-config.ts.

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

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@apps/web/src/app/api/admin/users/`[userId]/gift-subscription/__tests__/route.security.test.ts:
- Around line 302-354: The tests expect specific CSRF error codes but
verifyAdminAuth is swallowing validateCSRF responses and returning null; change
verifyAdminAuth (or the POST route handler) to propagate validateCSRF's
NextResponse errors instead of converting them to null so the route returns the
original 403 responses with the code field; locate verifyAdminAuth in auth.ts
and the validateCSRF call in csrf-validation.ts and update verifyAdminAuth to
check the return value from validateCSRF and if it is a NextResponse error,
return that response (or throw/forward it) so the POST handler can return it
unchanged.

In `@apps/web/src/lib/auth/auth.ts`:
- Around line 44-60: The CSRF validation branch discards the NextResponse from
validateCSRF() and returns null; instead, capture and return the csrfError
NextResponse so callers see the specific error codes. In the isStateChanging
branch where csrfError is set from validateCSRF(request), keep the
logSecurityEvent call (including user.id, method, authType: 'session', action)
but change the final return from null to returning csrfError (the value returned
by validateCSRF) so the function propagates the original NextResponse error.
🧹 Nitpick comments (3)
apps/web/src/lib/auth/__tests__/auth.test.ts (1)

124-125: Test expectations correctly align with AdminValidationResult interface.

The valid and invalid result shapes match the interface definition in admin-role.ts (lines 40–45). Consider adding an assertion on logSecurityEvent in the version-mismatch test (line 170) for completeness, though this is already well-covered in the route security tests.

Also applies to: 180-187

apps/web/src/lib/auth/__tests__/admin-role-version.test.ts (1)

54-58: Consider wrapping afterEach cleanup in try/catch for consistency.

The route.security.test.ts wraps its cleanup in try/catch (lines 150–156) to prevent cascading failures, but this file does not. Since these are integration tests hitting a real database, a cleanup failure here could mask subsequent test failures.

♻️ Suggested change
   afterEach(async () => {
-    await db.delete(sessions).where(eq(sessions.userId, adminUserId));
-    await db.delete(sessions).where(eq(sessions.userId, regularUserId));
-    await db.delete(users).where(eq(users.id, adminUserId));
-    await db.delete(users).where(eq(users.id, regularUserId));
+    try {
+      await db.delete(sessions).where(eq(sessions.userId, adminUserId));
+      await db.delete(sessions).where(eq(sessions.userId, regularUserId));
+      await db.delete(users).where(eq(users.id, adminUserId));
+      await db.delete(users).where(eq(users.id, regularUserId));
+    } catch {
+      // Swallow cleanup errors to avoid masking test failures
+    }
   });
apps/web/src/lib/auth/admin-role.ts (1)

44-44: Consider using UserRole type instead of string for currentRole.

currentRole is typed as string, but the UserRole type ('user' | 'admin') is already defined on line 3. Using it here would provide stronger type safety and make the interface self-documenting.

♻️ Suggested change
 export interface AdminValidationResult {
   isValid: boolean;
   reason?: 'user_not_found' | 'not_admin' | 'version_mismatch';
   actualAdminRoleVersion?: number;
-  currentRole?: string;
+  currentRole?: UserRole;
 }

2witstudios and others added 2 commits February 12, 2026 13:52
The adminRoleVersion validation was fundamentally broken because
validateSession() returned the CURRENT adminRoleVersion from the user
table instead of the value stored when the session was created. This
meant role changes were never detected.

Changes:
- Add adminRoleVersion column to sessions table schema
- Store adminRoleVersion at session creation time
- Return stored adminRoleVersion in session claims (not current)

This enables verifyAdminAuth() to properly detect when a user's admin
role has changed since their session was created, preventing stale
admin session attacks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Changes:
- verifyAdminAuth now returns NextResponse on error instead of null
- CSRF errors are propagated with their original error codes (CSRF_TOKEN_MISSING, etc.)
- validateAdminAccess is called for ALL authenticated users (not just role=admin from claims)
- Added isAdminAuthError type guard for callers to check error responses
- Updated all admin route handlers to use new return type
- Updated unit tests for new behavior

This ensures:
1. CSRF error codes are preserved in responses
2. Security events are logged for all admin access denials
3. Role changes are properly detected via validateAdminAccess

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

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
apps/web/src/app/api/admin/users/[userId]/gift-subscription/route.ts (1)

99-110: ⚠️ Potential issue | 🟡 Minor

User email included in Stripe coupon name.

Line 104 embeds targetUser.email in the coupon name field, which is visible in the Stripe dashboard. This is low-risk since Stripe already stores customer PII, but worth noting for GDPR/data-minimization compliance if the project tracks PII propagation to third-party systems.

apps/web/src/app/api/admin/audit-logs/export/route.ts (1)

159-168: ⚠️ Potential issue | 🟡 Minor

Pre-existing LIKE pattern injection — search input is not escaped here, unlike the main audit-logs route.

The main audit-logs/route.ts (lines 85-89) escapes %, _, and \ before building the LIKE pattern and uses Drizzle's ilike() function. This export route uses raw SQL with unescaped search directly interpolated into the LIKE pattern. An attacker with admin access could use % or _ wildcards to broaden the export beyond intended results.

Consider applying the same escaping logic used in the main route, and switching to Drizzle's ilike() for consistency.

🧹 Nitpick comments (4)
apps/web/src/lib/auth/__tests__/auth.test.ts (1)

181-206: Consider asserting that logSecurityEvent was called for the version mismatch case.

The admin_role_version_mismatch security event is a key forensic signal. Adding an assertion here would guard against accidental removal of the logging call.

💡 Suggested assertion addition
      expect(isAdminAuthError(result)).toBe(true);
      expect(result).toBeInstanceOf(NextResponse);
      expect(mockValidateAdminAccess).toHaveBeenCalledWith('admin-123', 0);
+
+     const { logSecurityEvent } = await import('@pagespace/lib/server');
+     expect(logSecurityEvent).toHaveBeenCalledWith('admin_role_version_mismatch', expect.objectContaining({
+       reason: 'version_mismatch',
+       userId: 'admin-123',
+       claimedAdminRoleVersion: 0,
+       actualAdminRoleVersion: 1,
+     }));
apps/web/src/lib/auth/auth.ts (2)

80-96: Semantic mismatch: admin_role_version_mismatch event logged for all validation failures, including not_admin.

When validateAdminAccess returns reason: 'not_admin' (e.g., a regular user attempting admin access), the event type admin_role_version_mismatch is misleading — there's no version mismatch, the user simply isn't an admin. Consider using the more general unauthorized event for non-version-mismatch reasons, reserving admin_role_version_mismatch for actual version divergence.

💡 Suggested refinement
  const validationResult: AdminValidationResult = await validateAdminAccess(user.id, user.adminRoleVersion);
  if (!validationResult.isValid) {
-   logSecurityEvent('admin_role_version_mismatch', {
+   const eventType = validationResult.reason === 'version_mismatch'
+     ? 'admin_role_version_mismatch'
+     : 'unauthorized';
+   logSecurityEvent(eventType, {
      reason: validationResult.reason ?? 'admin_role_version_validation_failed',
      userId: user.id,
      claimedAdminRoleVersion: user.adminRoleVersion,
      actualAdminRoleVersion: validationResult.actualAdminRoleVersion,
      currentRole: validationResult.currentRole,
      authType: 'session',
      action: 'deny_access',
    });

48-55: Authentication failure returns 403 instead of 401.

When verifyAuth returns null (the user is unauthenticated — no valid session), the response is a 403 Forbidden with the message "Admin access required." While a blanket 403 avoids leaking endpoint information, returning 401 Unauthorized for genuinely unauthenticated requests is more semantically correct per HTTP standards and helps clients (e.g., redirecting to login). Consider differentiating: 401 for unauthenticated, 403 for authenticated-but-not-admin.

apps/web/src/app/api/admin/global-prompt/route.ts (1)

57-65: Auth check sits outside try/catch, unlike schema/route.ts.

In schema/route.ts, verifyAdminAuth is called inside the try block (Line 8), but here it's called before it (Line 59). If verifyAdminAuth were to throw an unexpected error, this route would surface an uncontrolled framework-level 500 instead of the structured error response at Line 410.

Consider moving the auth check inside the try block for consistency and resilience:

Suggested fix
 export async function GET(request: Request) {
-  // Verify admin authentication
-  const adminAuthResult = await verifyAdminAuth(request);
-  if (isAdminAuthError(adminAuthResult)) {
-    return adminAuthResult;
-  }
-  const adminUser = adminAuthResult;
-
   try {
+    // Verify admin authentication
+    const adminAuthResult = await verifyAdminAuth(request);
+    if (isAdminAuthError(adminAuthResult)) {
+      return adminAuthResult;
+    }
+    const adminUser = adminAuthResult;
+
     // Parse query params for context selection

2witstudios and others added 2 commits February 12, 2026 14:17
Rename adminUser to _adminUser in routes that only need auth check
but don't use the user data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add withAdminAuth higher-order function that wraps admin route handlers
with authentication, eliminating the repetitive 3-line auth pattern
across all admin routes.

Changes:
- Add withAdminAuth HOF to apps/web/src/lib/auth/auth.ts
- Export withAdminAuth and AdminRouteContext from auth index
- Migrate 9 route files (11 handlers) to use the new pattern
- Update search-security test mocks to support withAdminAuth

Benefits:
- DRY: Auth logic in one place instead of 25+ call sites
- Type-safe: Handlers receive VerifiedUser, not union type
- Pit of success: Impossible to forget auth check

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

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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/admin/contact/route.ts (1)

35-42: ⚠️ Potential issue | 🟡 Minor

LIKE pattern injection: searchTerm is not escaped before use with ilike.

The audit-logs route (Lines 76-82 in audit-logs/route.ts) explicitly escapes %, _, and \ before building the LIKE pattern. This route passes searchTerm directly into ilike patterns, allowing users to inject wildcards like % or _ for broader matching than intended.

This is a pre-existing issue (not introduced by this PR), but it's worth aligning with the sanitization pattern already established in the audit-logs route.

Suggested fix
     const searchConditions = searchTerm
-      ? or(
-          ilike(contactSubmissions.name, `%${searchTerm}%`),
-          ilike(contactSubmissions.email, `%${searchTerm}%`),
-          ilike(contactSubmissions.subject, `%${searchTerm}%`),
-          ilike(contactSubmissions.message, `%${searchTerm}%`)
+      ? (() => {
+          const escaped = searchTerm
+            .replace(/\\/g, '\\\\')
+            .replace(/%/g, '\\%')
+            .replace(/_/g, '\\_');
+          const pattern = `%${escaped}%`;
+          return or(
+            ilike(contactSubmissions.name, pattern),
+            ilike(contactSubmissions.email, pattern),
+            ilike(contactSubmissions.subject, pattern),
+            ilike(contactSubmissions.message, pattern)
+          );
+        })()
🧹 Nitpick comments (3)
apps/web/src/lib/auth/auth.ts (1)

33-52: Orphaned JSDoc block — the docstring for verifyAdminAuth is separated from the function by AdminRouteContext and withAdminAuth.

The JSDoc starting at Line 33 documents verifyAdminAuth but the function itself doesn't appear until Line 82. The intervening type alias and withAdminAuth HOF make it easy to misattribute this doc. Consider moving it directly above verifyAdminAuth at Line 82.

apps/web/src/app/api/admin/users/[userId]/gift-subscription/route.ts (2)

86-101: Coupon ID incorporates Date.now() — low collision risk but non-deterministic.

The coupon ID GIFT_${targetUserId}_${Date.now()} is technically vulnerable to collisions if two admins gift the same user within the same millisecond. This is extremely unlikely for a manual admin action, but if you ever want to make this bulletproof, consider appending a short random suffix. The Date.now() usage here is for Stripe's external coupon identifier, so the CUID2 convention for internal IDs doesn't apply.


157-157: request parameter is unused in the DELETE handler.

The request parameter is passed by the withAdminAuth wrapper but never referenced in the DELETE handler body. This is harmless but could be prefixed with an underscore for clarity, consistent with the pattern in other routes (e.g., _request in users/route.ts).

Suggested change
-export const DELETE = withAdminAuth<RouteContext>(async (adminUser, request, context) => {
+export const DELETE = withAdminAuth<RouteContext>(async (adminUser, _request, context) => {

Use function overloads to properly type withAdminAuth:
- Routes without context get a single-arg wrapper function
- Routes with context get a two-arg wrapper function

This fixes the CI TypeScript error where AdminRouteContext including
undefined was incompatible with Next.js route handler signatures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios 2witstudios merged commit 93228ae into master Feb 12, 2026
10 checks passed
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.

2 participants