Skip to content

[codex] Add user suspension guard#462

Merged
ross0x01 merged 4 commits into
mainfrom
codex/user-suspension-guard
May 16, 2026
Merged

[codex] Add user suspension guard#462
ross0x01 merged 4 commits into
mainfrom
codex/user-suspension-guard

Conversation

@ross0x01
Copy link
Copy Markdown
Contributor

@ross0x01 ross0x01 commented May 15, 2026

Summary

Adds a first-class Convex-backed suspension guard for payment risk events and wires it into chat and agent request entry points.

Changes

  • Add user_suspensions schema and Convex mutations/queries for active and resolved suspension state.
  • Persist fraud/dispute webhook outcomes into Convex suspension records.
  • Block cost-incurring chat, agent, and agent-long requests when an active suspension exists.
  • Extract shared Stripe customer to WorkOS user resolution for billing webhooks.
  • Rename dispute hold state to dispute_billing_hold so the category describes app policy rather than raw Stripe reason.
  • Add direct tests for suspension messages, Convex suspension behavior, and the server guard.

Impact

Users with active fraud warnings, fraudulent disputes, or disputed-payment billing holds are blocked before model work starts, including when they would otherwise appear as free users. Billing/support paths can still use Stripe metadata and Convex state to understand why an account is held.

Validation

  • Pre-commit hook ran tsc --noEmit
  • Pre-commit hook ran full Jest suite: 67 suites, 969 tests passed
  • Focused suspension tests passed before commit
  • Focused ESLint and Prettier checks passed on touched files

Summary by CodeRabbit

  • New Features

    • Per-user suspension records and automatic gating that blocks cost-incurring actions for suspended users.
    • Improved billing-to-user resolution for webhooks.
  • UI

    • Suspensions show a single "Contact Support" action; error UI now favors informative cause text when present.
  • Tests

    • Added tests for suspension lifecycle, webhook behavior, billing resolution, and request-blocking.

Review Change Stack

@vercel
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

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

Project Deployment Actions Updated (UTC)
hackerai Ready Ready Preview, Comment May 16, 2026 1:03am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e07d4dc2-b158-4157-a5ee-8942650fc75c

📥 Commits

Reviewing files that changed from the base of the PR and between 162a0dc and 9b5d795.

📒 Files selected for processing (1)
  • lib/__tests__/resolve-customer-users.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/tests/resolve-customer-users.test.ts

📝 Walkthrough

Walkthrough

Adds Convex-backed user suspensions, server-side gating for cost-incurring requests, fraud-webhook wiring to upsert suspensions, extracts Stripe→WorkOS customer resolver, and updates UI/error messages and tests.

Changes

User Suspension Infrastructure

Layer / File(s) Summary
Suspension schema and lifecycle endpoints
convex/schema.ts, convex/userSuspensions.ts
Adds user_suspensions table with status (active/resolved), category (early_fraud_warning/dispute_fraudulent/dispute_billing_hold), provenance fields, timestamps, and indexes; implements getActiveByUser, upsertActive, and resolveBySource.
Convex endpoint tests
convex/__tests__/userSuspensions.test.ts
Tests upsert/create, idempotent re-activate behavior, getActive ordering by source_created_at, and resolveBySource lifecycle.

Suspension gating and messaging

Layer / File(s) Summary
Server gating + messages
lib/suspensions.ts, lib/suspensionMessage.ts, lib/__tests__/suspensions.test.ts, lib/__tests__/suspensionMessage.test.ts
Adds service-role Convex client helpers: getActiveSuspensionForUser and assertUserCanMakeCostIncurringRequest that throws a ChatSDKError with composed message/metadata when suspended; extends label mapping for dispute_billing_hold; tests validate blocking behavior and message safety.

Fraud Webhook and Customer Resolution

Layer / File(s) Summary
Customer-to-user/org resolver extraction
lib/billing/resolve-customer-users.ts, lib/__tests__/resolve-customer-users.test.ts, app/api/subscription/webhook/route.ts
Moves Stripe customer → WorkOS membership resolution into resolveUserIdsFromCustomer and updates subscription webhook to delegate to it (removes direct WorkOS import).
Fraud webhook suspension integration
app/api/fraud/webhook/route.ts
Adds suspendCustomerUsers helper that resolves user IDs and upserts Convex suspensions; refactors blockFraudulentUser to accept structured suspension metadata and wires suspension creation into early fraud warnings, fraudulent disputes, and billing-hold disputes.

Request Gating Integration

Layer / File(s) Summary
Gating checks in API routes and triggers
app/api/agent-long/route.ts, lib/api/chat-handler.ts, trigger/agent-long.ts
Inserts assertUserCanMakeCostIncurringRequest(userId) immediately after user identification in agent-long POST, chat-handler setup, and agent-long streaming execution to block suspended users before rate-limiting, usage accounting, or streaming begins.
UI error surface
app/components/MessageErrorState.tsx, app/components/chat.tsx
Shows "Contact Support" only for suspension errors, prefers error.cause in toasts, and uses safe window.open options.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ChatHandler as Chat Handler
  participant assertGate as assertUserCanMakeCostIncurringRequest
  participant Convex as Convex Service
  participant ErrorSDK as ChatSDKError

  Client->>ChatHandler: POST /api/chat
  ChatHandler->>ChatHandler: getUserIDAndPro(userId)
  ChatHandler->>assertGate: assertUserCanMakeCostIncurringRequest(userId)
  assertGate->>Convex: api.userSuspensions.getActiveByUser(userId)
  
  alt User is Suspended
    Convex-->>assertGate: suspension record
    assertGate->>ErrorSDK: create forbidden:chat error with message+metadata
    ErrorSDK-->>ChatHandler: throw 403 ChatSDKError
    ChatHandler-->>Client: 403 Forbidden
  else User is Not Suspended
    Convex-->>assertGate: null
    assertGate-->>ChatHandler: allow
    ChatHandler->>ChatHandler: checkRateLimit()
    ChatHandler->>ChatHandler: Stream response
    ChatHandler-->>Client: 200 OK stream
  end
Loading
sequenceDiagram
  participant Stripe as Stripe Webhook
  participant FraudRoute as Fraud Webhook Route
  participant blockUser as blockFraudulentUser
  participant suspendHelper as suspendCustomerUsers
  participant ResolveCust as resolveUserIdsFromCustomer
  participant Convex as Convex userSuspensions

  Stripe->>FraudRoute: early_fraud_warning / dispute_created
  FraudRoute->>blockUser: construct suspension metadata
  blockUser->>blockUser: cancel subscriptions + detach payments + mark blocked
  alt billing hold / non-fraudulent dispute
    blockUser->>suspendHelper: invoke with category="dispute_billing_hold"
  else fraudulent dispute or early warning
    blockUser->>suspendHelper: invoke with appropriate category
  end
  suspendHelper->>ResolveCust: resolveUserIdsFromCustomer(stripeCustomerId)
  ResolveCust-->>suspendHelper: {userIds, orgId}
  suspendHelper->>Convex: upsertActive(userId, suspension data)
  Convex-->>suspendHelper: inserted/patched suspension
  suspendHelper-->>blockUser: done
  blockUser-->>FraudRoute: blocking complete
  FraudRoute->>Stripe: finalize webhook
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through code fields and planted a gate,

Convex keeps notes while the rabbit sips tea,
Suspensions say "pause" when disputes roam late,
Support opens doors so users can plea,
Happy hops and safe builds for you and me.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '[codex] Add user suspension guard' directly and accurately summarizes the main change: adding a new user suspension guard feature with Convex backing to block cost-incurring requests. It is concise, clear, and specific.
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.

✏️ 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 codex/user-suspension-guard

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

@ross0x01 ross0x01 marked this pull request as ready for review May 16, 2026 00:36
Copy link
Copy Markdown

@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

🧹 Nitpick comments (1)
lib/suspensions.ts (1)

8-14: ⚡ Quick win

Fail fast on missing CONVEX_SERVICE_ROLE_KEY.

Using a non-null assertion here defers misconfiguration to runtime request handling. Prefer explicit startup-time validation so failures are immediate and diagnosable.

Suggested change
-const serviceKey = process.env.CONVEX_SERVICE_ROLE_KEY!;
+const serviceKey = process.env.CONVEX_SERVICE_ROLE_KEY;
+if (!serviceKey) {
+  throw new Error("Missing CONVEX_SERVICE_ROLE_KEY");
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/suspensions.ts` around lines 8 - 14, The code uses a non-null assertion
on process.env.CONVEX_SERVICE_ROLE_KEY (serviceKey) inside
getActiveSuspensionForUser which delays misconfiguration errors to runtime;
instead validate and fail fast at module initialization: read and check
CONVEX_SERVICE_ROLE_KEY once (e.g., throw a clear Error if missing) before
exporting getActiveSuspensionForUser, and keep the function using that validated
serviceKey when calling
getConvexClient().query(api.userSuspensions.getActiveByUser, { serviceKey,
userId }); Ensure the thrown error message names CONVEX_SERVICE_ROLE_KEY for
easy diagnosis.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@convex/userSuspensions.ts`:
- Around line 19-31: The getActiveByUser implementation loads all active
suspensions into memory and sorts them; change the query to return a bounded
result: use ctx.db.query("user_suspensions").withIndex("by_user_and_status",
...) and apply .order("desc", "source_created_at") (or equivalent) then .take(1)
instead of .collect(), or if the index lacks source_created_at, add
source_created_at to the by_user_and_status index and then use
.order("desc").take(1) to fetch the latest suspension without in-memory sorting.

In `@lib/billing/resolve-customer-users.ts`:
- Around line 22-34: The current call to
workos.userManagement.listOrganizationMemberships in resolve-customer-users.ts
only reads the first page; change the logic to iterate all pages (e.g., use
memberships.autoPagination) to collect every membership.userId into userIds
before returning, using logPrefix and orgId as before; if userIds is empty log
the same error and return { userIds, orgId }. Ensure you reference
memberships.autoPagination from the listOrganizationMemberships result (or
implement manual pagination with list_metadata.after) so no active members are
missed.

---

Nitpick comments:
In `@lib/suspensions.ts`:
- Around line 8-14: The code uses a non-null assertion on
process.env.CONVEX_SERVICE_ROLE_KEY (serviceKey) inside
getActiveSuspensionForUser which delays misconfiguration errors to runtime;
instead validate and fail fast at module initialization: read and check
CONVEX_SERVICE_ROLE_KEY once (e.g., throw a clear Error if missing) before
exporting getActiveSuspensionForUser, and keep the function using that validated
serviceKey when calling
getConvexClient().query(api.userSuspensions.getActiveByUser, { serviceKey,
userId }); Ensure the thrown error message names CONVEX_SERVICE_ROLE_KEY for
easy diagnosis.
🪄 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: 89425ba6-0aa9-4c1d-98fa-ec2ec93b60e8

📥 Commits

Reviewing files that changed from the base of the PR and between c64b144 and ae755d5.

⛔ Files ignored due to path filters (1)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (13)
  • app/api/agent-long/route.ts
  • app/api/fraud/webhook/route.ts
  • app/api/subscription/webhook/route.ts
  • convex/__tests__/userSuspensions.test.ts
  • convex/schema.ts
  • convex/userSuspensions.ts
  • lib/__tests__/suspensionMessage.test.ts
  • lib/__tests__/suspensions.test.ts
  • lib/api/chat-handler.ts
  • lib/billing/resolve-customer-users.ts
  • lib/suspensionMessage.ts
  • lib/suspensions.ts
  • trigger/agent-long.ts

Comment thread convex/userSuspensions.ts Outdated
Comment thread lib/billing/resolve-customer-users.ts Outdated
Copy link
Copy Markdown

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

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

Inline comments:
In `@app/components/MessageErrorState.tsx`:
- Around line 145-150: The Button's onClick opens an external support page with
window.open without protecting window.opener; update the handler in the
component using the Button (the onClick passed to the Button in
MessageErrorState) to open the URL with noopener and noreferrer protection —
e.g. call window.open("https://help.hackerai.co/", "_blank",
"noopener,noreferrer") or replace the Button with an anchor element that has
target="_blank" and rel="noopener noreferrer" to ensure the new tab cannot
access window.opener.
🪄 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: bd6a26b1-211e-4ad2-a078-48aaf354e06b

📥 Commits

Reviewing files that changed from the base of the PR and between ae755d5 and 4d76162.

📒 Files selected for processing (2)
  • app/components/MessageErrorState.tsx
  • app/components/chat.tsx

Comment thread app/components/MessageErrorState.tsx
Copy link
Copy Markdown

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

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

Inline comments:
In `@lib/__tests__/resolve-customer-users.test.ts`:
- Around line 23-26: The test setup spies on console.error in the beforeEach
block but never restores it, which can leak mocked global state; add an
afterEach that restores the spy (either call jest.restoreAllMocks() in afterEach
or keep a reference to the spy returned by jest.spyOn(console, "error") and call
mockRestore() on it) so console.error is reset after each test; update the
beforeEach/afterEach surrounding the jest.spyOn(console, "error") invocation to
perform this restore.
🪄 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: 395758b2-ec11-414f-a433-298236ebb05a

📥 Commits

Reviewing files that changed from the base of the PR and between 4d76162 and 162a0dc.

📒 Files selected for processing (6)
  • app/components/MessageErrorState.tsx
  • convex/__tests__/userSuspensions.test.ts
  • convex/schema.ts
  • convex/userSuspensions.ts
  • lib/__tests__/resolve-customer-users.test.ts
  • lib/billing/resolve-customer-users.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • convex/schema.ts
  • app/components/MessageErrorState.tsx
  • convex/userSuspensions.ts
  • lib/billing/resolve-customer-users.ts
  • convex/tests/userSuspensions.test.ts

Comment thread lib/__tests__/resolve-customer-users.test.ts
@ross0x01 ross0x01 merged commit 885e140 into main May 16, 2026
5 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.

1 participant