Skip to content

feat: Agent Signup & Verify endpoints#396

Open
recoup-coding-agent wants to merge 1 commit intotestfrom
feat/agent-signup-flow
Open

feat: Agent Signup & Verify endpoints#396
recoup-coding-agent wants to merge 1 commit intotestfrom
feat/agent-signup-flow

Conversation

@recoup-coding-agent
Copy link
Copy Markdown
Collaborator

@recoup-coding-agent recoup-coding-agent commented Apr 6, 2026

Summary

  • Add POST /api/agents/signup — unauthenticated endpoint for agent registration
    • agent+ prefix emails get instant API key on first signup
    • All other cases (new normal email or existing account) send a 6-digit verification code via Resend
    • Uniform response shape prevents email enumeration
  • Add POST /api/agents/verify — unauthenticated endpoint for email verification
    • Validates code against hash stored in Privy custom_metadata
    • Rate-limited to 5 attempts, 24h expiry
    • Returns API key on success, clears metadata
  • New Privy REST helpers: createPrivyUser, getPrivyUserByEmail, setPrivyCustomMetadata
  • Zod validation for both request bodies
  • 23 tests across 5 test files (validators, handlers, utility)

Test plan

  • All 23 unit tests pass (pnpm test lib/agents/__tests__/)
  • POST /api/agents/signup with agent+ email returns api_key immediately
  • POST /api/agents/signup with normal email returns api_key: null and sends verification email
  • POST /api/agents/signup with existing account email sends verification code
  • POST /api/agents/verify with correct code returns api_key
  • POST /api/agents/verify with wrong code increments attempts and returns 400
  • POST /api/agents/verify after 5 failures returns 429
  • POST /api/agents/verify with expired code returns 400
  • Returned api_key works on authenticated endpoints (e.g. GET /api/artists)

🤖 Generated with Claude Code


Summary by cubic

Adds unauthenticated agent signup and email verification endpoints to enable programmatic onboarding. agent+ emails get an instant API key; all others verify via a 6‑digit code with consistent responses to avoid email enumeration.

  • New Features
    • POST /api/agents/signup: registers by email; agent+ gets an immediate API key; otherwise stores a 6‑digit code in Privy and sends it via Resend. Uniform response shape and CORS enabled.
    • POST /api/agents/verify: checks the code hash in Privy, enforces 24h expiry and 5-attempt limit; on success issues an API key and clears metadata.
    • Helpers and validation: createPrivyUser, getPrivyUserByEmail, setPrivyCustomMetadata; Zod validators for request bodies; route handlers with tests.

Written for commit 0ac00a5. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added agent signup endpoint enabling email-based registration and account creation
    • Implemented agent email verification flow with verification codes
    • Added API key generation for verified agent accounts
    • Implemented security measures including verification code expiration and attempt limiting (max 5 failed attempts)

Add POST /api/agents/signup and POST /api/agents/verify for
programmatic agent onboarding without auth. Agent+ prefix emails
get instant API keys; all others require email verification via
6-digit code stored in Privy custom_metadata.

New files:
- lib/agents/ — validators, handlers, email sender, prefix check
- lib/privy/ — createPrivyUser, getPrivyUserByEmail, setPrivyCustomMetadata
- app/api/agents/signup/route.ts and verify/route.ts
- 5 test files (23 tests passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 6, 2026

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

Project Deployment Actions Updated (UTC)
recoup-api Ready Ready Preview Apr 6, 2026 3:02am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

A new agent onboarding system is implemented, introducing POST endpoints for signup and email verification. The flow includes request validation, Privy user account provisioning, verification code delivery via email, and API key generation for authenticated agents.

Changes

Cohort / File(s) Summary
API Routes
app/api/agents/signup/route.ts, app/api/agents/verify/route.ts
New unauthenticated POST endpoints with request parsing, validation delegation, and CORS preflight handlers. Both configured with force-dynamic caching behavior.
Signup Flow
lib/agents/agentSignupHandler.ts, lib/agents/validateAgentSignupBody.ts, lib/agents/sendVerificationEmail.ts
Signup handler implements branching logic: existing accounts receive verification codes, agent-prefixed emails provision immediate API keys, and new standard emails trigger code delivery. Includes Zod schema validation and Resend email integration.
Verification Flow
lib/agents/agentVerifyHandler.ts, lib/agents/validateAgentVerifyBody.ts
Verify handler enforces code matching with attempt rate-limiting (max 5 failures), retrieves accounts, generates API keys, and clears verification metadata on success. Includes Zod validation for email and code fields.
Utility Functions
lib/agents/isAgentPrefixEmail.ts
Simple email utility determining whether an address uses the agent+ prefix for conditional signup branching.
Privy Integration
lib/privy/createPrivyUser.ts, lib/privy/getPrivyUserByEmail.ts, lib/privy/setPrivyCustomMetadata.ts
Privy API wrappers handling user creation, email-based lookup, and custom metadata mutations (verification codes, expiry, attempt counts). Include Basic Auth headers and error handling.

Sequence Diagrams

sequenceDiagram
    actor Client
    participant Server as Server<br/>(signup route)
    participant DB as Database
    participant Privy as Privy API
    participant Email as Email Service

    Client->>Server: POST /api/agents/signup<br/>(email)
    Server->>Server: Validate email format
    
    Server->>DB: selectAccountByEmail(email)
    alt Account Exists
        Server->>Privy: getPrivyUserByEmail(email)
        alt Privy User Exists
            Privy-->>Server: user {id}
        else No Privy User
            Server->>Privy: createPrivyUser(email)
            Privy-->>Server: {id}
        end
        
        Server->>Server: Generate verification code
        Server->>Privy: setPrivyCustomMetadata(userId,<br/>code_hash, expiry, attempts)
        Privy-->>Server: metadata updated
        
        Server->>Email: sendVerificationEmail(email, code)
        Email-->>Server: sent
        
        Server-->>Client: {account_id, api_key: null}
    else New Email, agent+ prefix
        Server->>DB: createAccount(email)
        DB-->>Server: account {id}
        
        Server->>Privy: createPrivyUser(email)
        Privy-->>Server: user {id}
        
        Server->>Server: generateApiKey("recoup_sk")
        Server->>Server: Hash API key
        Server->>DB: insertApiKey(account_id, hashed_key)
        DB-->>Server: key stored
        
        Server-->>Client: {account_id, api_key}
    else New Email, standard
        Server->>DB: createAccount(email)
        DB-->>Server: account {id}
        
        Server->>Privy: createPrivyUser(email)
        Privy-->>Server: user {id}
        
        Server->>Server: Generate verification code
        Server->>Privy: setPrivyCustomMetadata(userId, code_hash, expiry, attempts)
        Privy-->>Server: metadata updated
        
        Server->>Email: sendVerificationEmail(email, code)
        Email-->>Server: sent
        
        Server-->>Client: {account_id, api_key: null}
    end
Loading
sequenceDiagram
    actor Client
    participant Server as Server<br/>(verify route)
    participant Privy as Privy API
    participant DB as Database

    Client->>Server: POST /api/agents/verify<br/>(email, code)
    Server->>Server: Validate email & code format
    
    Server->>Privy: getPrivyUserByEmail(email)
    Privy-->>Server: user {id, custom_metadata}
    
    Server->>Server: Extract code_hash, expires_at,<br/>attempts from metadata
    
    alt Metadata Missing or Expired
        Server-->>Client: 400 Error
    else Attempts ≥ 5
        Server-->>Client: 429 Rate Limited
    else Code Mismatch
        Server->>Privy: setPrivyCustomMetadata(userId,<br/>increment attempts)
        Privy-->>Server: metadata updated
        Server-->>Client: 400 Error
    else Code Matches
        Server->>DB: selectAccountByEmail(email)
        DB-->>Server: account {id}
        
        Server->>Server: generateApiKey("recoup_sk")
        Server->>Server: Hash API key
        Server->>DB: insertApiKey(account_id, hashed_key)
        DB-->>Server: key stored
        
        Server->>Privy: setPrivyCustomMetadata(userId,<br/>clear metadata)
        Privy-->>Server: metadata cleared
        
        Server-->>Client: {account_id, api_key,<br/>message: "Verified"}
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

✨ From signup's email dance to verification's code,
Agents find their way down a well-lit road,
Privy guards the secrets, keys are born anew,
Branching logic branches, each path rings true! 🔑

🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning Code violates SOLID principles with unhandled insertApiKey errors, conditional expiry validation allowing indefinite verification codes, and 21 repeated getCorsHeaders() calls across 8 files creating DRY violations. Add error checking for insertApiKey in both handlers; make expiry validation mandatory; extract Privy headers into reusable utility; create shared validation error response utility; implement environment variable validator at startup.

✏️ 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 feat/agent-signup-flow

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.

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: 3

🧹 Nitpick comments (5)
app/api/agents/verify/route.ts (1)

27-27: Redundant type assertion.

The as AgentVerifyBody cast is unnecessary. Since validated is not a NextResponse at this point (checked on line 23), TypeScript already narrows the type to AgentVerifyBody.

♻️ Cleaner version
-  return agentVerifyHandler(validated as AgentVerifyBody);
+  return agentVerifyHandler(validated);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/agents/verify/route.ts` at line 27, The return statement uses an
unnecessary type assertion "as AgentVerifyBody"; remove the redundant cast and
directly pass validated to agentVerifyHandler since validated has already been
narrowed away from NextResponse (see validated and agentVerifyHandler
references) — update the return to simply return agentVerifyHandler(validated)
so TypeScript infers the correct AgentVerifyBody type without the explicit cast.
lib/agents/sendVerificationEmail.ts (1)

18-20: Use NextResponse for consistency with codebase patterns.

The codebase convention (see lib/emails/processAndSendEmail.ts) uses instanceof NextResponse rather than instanceof Response. While both work since NextResponse extends Response, using the more specific type improves consistency and clarity.

♻️ Suggested fix
+import { NextResponse } from "next/server";
 import { sendEmailWithResend } from "@/lib/emails/sendEmail";
 import { RECOUP_FROM_EMAIL } from "@/lib/const";
-  if (result instanceof Response) {
+  if (result instanceof NextResponse) {
     throw new Error("Failed to send verification email");
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/agents/sendVerificationEmail.ts` around lines 18 - 20, Change the
type-check to use NextResponse instead of Response for consistency: replace the
"result instanceof Response" check in sendVerificationEmail (the block that
throws "Failed to send verification email") with "result instanceof
NextResponse" and add the appropriate NextResponse import (e.g., from
'next/server' or matching project imports) if it's not already imported.
lib/agents/validateAgentVerifyBody.ts (1)

5-8: Consider stricter validation for the verification code format.

The PR description mentions a "6-digit verification code," but the schema only validates that code is a non-empty string. Adding format validation here would reject malformed codes earlier and provide clearer error messages.

♻️ Suggested stricter validation
 export const agentVerifyBodySchema = z.object({
   email: z.string({ message: "email is required" }).email("email must be a valid email address"),
-  code: z.string({ message: "code is required" }).min(1, "code cannot be empty"),
+  code: z.string({ message: "code is required" }).regex(/^\d{6}$/, "code must be a 6-digit number"),
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/agents/validateAgentVerifyBody.ts` around lines 5 - 8, The
agentVerifyBodySchema currently only checks that code is a non-empty string;
update agentVerifyBodySchema to enforce the expected 6-digit numeric format by
replacing the loose validation on the code field with a stricter rule (e.g., a
length or regex-based constraint that ensures exactly six digits) and provide a
clear error message like "code must be a 6-digit numeric code" so malformed
codes are rejected during schema validation.
lib/privy/getPrivyUserByEmail.ts (1)

10-18: Consider adding a request timeout for resilience.

External HTTP calls without timeouts can hang indefinitely if the Privy API becomes unresponsive, potentially blocking resources. Consider using AbortController with a timeout.

♻️ Example with timeout
 export async function getPrivyUserByEmail(
   email: string,
 ): Promise<{ id: string; custom_metadata?: Record<string, unknown> } | null> {
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), 10000);
+
-  const response = await fetch("https://api.privy.io/v1/users/email/address", {
+  const response = await fetch("https://api.privy.io/v1/users/email/address", {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
       "privy-app-id": process.env.PRIVY_APP_ID!,
       Authorization: `Basic ${btoa(process.env.PRIVY_APP_ID! + ":" + process.env.PRIVY_PROJECT_SECRET!)}`,
     },
     body: JSON.stringify({ email }),
+    signal: controller.signal,
-  });
+  }).finally(() => clearTimeout(timeoutId));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/privy/getPrivyUserByEmail.ts` around lines 10 - 18, Add an
AbortController-based request timeout around the fetch call in
getPrivyUserByEmail: create an AbortController, pass controller.signal to fetch,
start a timer (e.g., setTimeout) that calls controller.abort() after a
configurable timeout (e.g., 5s), and clear the timer once the response is
received; handle the abort/timeout case (catch AbortError or check
signal.aborted) and throw or return a clear timeout error so callers can handle
it. Ensure the Authorization/header logic and body remain unchanged and the
controller is cleaned up to avoid leaks.
lib/agents/agentSignupHandler.ts (1)

61-67: Sequential operations without transaction handling.

createAccountWithEmail performs four sequential database/service operations. If any operation fails midway (e.g., insertCreditsUsage fails after insertAccountEmail succeeds), you'll have orphaned partial state.

For a signup flow, this could leave accounts in an inconsistent state. Consider:

  1. Using database transactions if Supabase supports them for these operations
  2. Adding compensating cleanup in the catch block
  3. Making the flow idempotent so retries are safe

This isn't a blocker for this PR, but worth tracking for reliability improvements.

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

In `@lib/agents/agentSignupHandler.ts` around lines 61 - 67, The
createAccountWithEmail function performs multiple DB/service calls
(insertAccount, insertAccountEmail, insertCreditsUsage, assignAccountToOrg)
without transaction or rollback, so if a step fails you can leave partial state;
update createAccountWithEmail to run these operations in a single DB transaction
(or a transactional wrapper) and/or add a try/catch that performs compensating
cleanup (delete account, delete email, revoke credits, remove org assignment) on
error, and ensure the operations are idempotent (safe to retry) by checking for
existing records before re-creating them; reference the createAccountWithEmail
function and the called helpers (insertAccount, insertAccountEmail,
insertCreditsUsage, assignAccountToOrg) when implementing the
transaction/cleanup logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/agents/agentSignupHandler.ts`:
- Around line 75-81: The function generateAndStoreApiKey uses a non-null
assertion on process.env.PROJECT_SECRET which can throw silently if the env var
is missing; replace the assertion with a guarded lookup (e.g., const
projectSecret = process.env.PROJECT_SECRET) and if it's falsy throw a clear
Error("PROJECT_SECRET environment variable is required") before calling
hashApiKey, and apply the same pattern to the other occurrence referenced (the
usage at line ~111) so both hashApiKey calls validate the secret at runtime
instead of using the ! operator.

In `@lib/agents/agentVerifyHandler.ts`:
- Line 95: The call to insertApiKey is unchecked and may return an error while
you still return the raw API key to the client; change the flow around
insertApiKey in agentVerifyHandler (the call with keyHash and
insertApiKey({...})) so you destructure its result ({ data, error }), verify
error is null and data exists before returning the plain key, and on failure
abort (throw or return a proper error response) so you never expose a key that
wasn't persisted; ensure you reference insertApiKey and the local key/keyHash
variables when implementing the guard.
- Around line 47-56: The expiry check in agentVerifyHandler.ts currently skips
validation when metadata.verification_expires_at is missing, allowing codes to
be accepted indefinitely; update the verify flow in the handler that reads
metadata (use the same metadata object and the existing verification_expires_at
symbol) to require presence of metadata.verification_expires_at and return the
same NextResponse.json({ error: GENERIC_ERROR }, { status: 400, headers:
getCorsHeaders() }) if it is absent or unparsable, or alternatively set a strict
default expiry and treat missing/unparsable values as expired—ensure the code
paths around the existing Date parsing/compare use this enforced behavior so
missing fields cannot bypass expiry checks.

---

Nitpick comments:
In `@app/api/agents/verify/route.ts`:
- Line 27: The return statement uses an unnecessary type assertion "as
AgentVerifyBody"; remove the redundant cast and directly pass validated to
agentVerifyHandler since validated has already been narrowed away from
NextResponse (see validated and agentVerifyHandler references) — update the
return to simply return agentVerifyHandler(validated) so TypeScript infers the
correct AgentVerifyBody type without the explicit cast.

In `@lib/agents/agentSignupHandler.ts`:
- Around line 61-67: The createAccountWithEmail function performs multiple
DB/service calls (insertAccount, insertAccountEmail, insertCreditsUsage,
assignAccountToOrg) without transaction or rollback, so if a step fails you can
leave partial state; update createAccountWithEmail to run these operations in a
single DB transaction (or a transactional wrapper) and/or add a try/catch that
performs compensating cleanup (delete account, delete email, revoke credits,
remove org assignment) on error, and ensure the operations are idempotent (safe
to retry) by checking for existing records before re-creating them; reference
the createAccountWithEmail function and the called helpers (insertAccount,
insertAccountEmail, insertCreditsUsage, assignAccountToOrg) when implementing
the transaction/cleanup logic.

In `@lib/agents/sendVerificationEmail.ts`:
- Around line 18-20: Change the type-check to use NextResponse instead of
Response for consistency: replace the "result instanceof Response" check in
sendVerificationEmail (the block that throws "Failed to send verification
email") with "result instanceof NextResponse" and add the appropriate
NextResponse import (e.g., from 'next/server' or matching project imports) if
it's not already imported.

In `@lib/agents/validateAgentVerifyBody.ts`:
- Around line 5-8: The agentVerifyBodySchema currently only checks that code is
a non-empty string; update agentVerifyBodySchema to enforce the expected 6-digit
numeric format by replacing the loose validation on the code field with a
stricter rule (e.g., a length or regex-based constraint that ensures exactly six
digits) and provide a clear error message like "code must be a 6-digit numeric
code" so malformed codes are rejected during schema validation.

In `@lib/privy/getPrivyUserByEmail.ts`:
- Around line 10-18: Add an AbortController-based request timeout around the
fetch call in getPrivyUserByEmail: create an AbortController, pass
controller.signal to fetch, start a timer (e.g., setTimeout) that calls
controller.abort() after a configurable timeout (e.g., 5s), and clear the timer
once the response is received; handle the abort/timeout case (catch AbortError
or check signal.aborted) and throw or return a clear timeout error so callers
can handle it. Ensure the Authorization/header logic and body remain unchanged
and the controller is cleaned up to avoid leaks.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a56bb2d5-dcf0-4969-8fdd-3ba9d88c5d12

📥 Commits

Reviewing files that changed from the base of the PR and between e5d1265 and 0ac00a5.

⛔ Files ignored due to path filters (5)
  • lib/agents/__tests__/agentSignupHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/agents/__tests__/agentVerifyHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/agents/__tests__/isAgentPrefixEmail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/agents/__tests__/validateAgentSignupBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/agents/__tests__/validateAgentVerifyBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (11)
  • app/api/agents/signup/route.ts
  • app/api/agents/verify/route.ts
  • lib/agents/agentSignupHandler.ts
  • lib/agents/agentVerifyHandler.ts
  • lib/agents/isAgentPrefixEmail.ts
  • lib/agents/sendVerificationEmail.ts
  • lib/agents/validateAgentSignupBody.ts
  • lib/agents/validateAgentVerifyBody.ts
  • lib/privy/createPrivyUser.ts
  • lib/privy/getPrivyUserByEmail.ts
  • lib/privy/setPrivyCustomMetadata.ts

Comment on lines +75 to +81
async function generateAndStoreApiKey(accountId: string): Promise<string> {
const today = new Date().toISOString().slice(0, 10);
const rawKey = generateApiKey("recoup_sk");
const keyHash = hashApiKey(rawKey, process.env.PROJECT_SECRET!);
await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
return rawKey;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Non-null assertion on PROJECT_SECRET could cause silent failures.

The process.env.PROJECT_SECRET! assertion on line 78 (and similarly on line 111) will throw a runtime error if the environment variable is missing. Consider validating this at application startup or adding a guard:

const projectSecret = process.env.PROJECT_SECRET;
if (!projectSecret) {
  throw new Error("PROJECT_SECRET environment variable is required");
}
const keyHash = hashApiKey(rawKey, projectSecret);

Alternatively, if this is validated elsewhere at startup, this is acceptable—but explicit checks make the code more defensive.

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

In `@lib/agents/agentSignupHandler.ts` around lines 75 - 81, The function
generateAndStoreApiKey uses a non-null assertion on process.env.PROJECT_SECRET
which can throw silently if the env var is missing; replace the assertion with a
guarded lookup (e.g., const projectSecret = process.env.PROJECT_SECRET) and if
it's falsy throw a clear Error("PROJECT_SECRET environment variable is
required") before calling hashApiKey, and apply the same pattern to the other
occurrence referenced (the usage at line ~111) so both hashApiKey calls validate
the secret at runtime instead of using the ! operator.

Comment on lines +47 to +56
// Expired
if (metadata.verification_expires_at) {
const expiresAt = new Date(metadata.verification_expires_at).getTime();
if (Date.now() > expiresAt) {
return NextResponse.json(
{ error: GENERIC_ERROR },
{ status: 400, headers: getCorsHeaders() },
);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how verification metadata is set in agentSignupHandler to ensure expiry is always included
rg -n "verification_code_hash|verification_expires_at" --type=ts -C 3

Repository: recoupable/api

Length of output: 5178


Make expiry validation mandatory, not conditional.

The signup handler correctly always sets both verification_code_hash and verification_expires_at together. However, in the verify handler (lines 48-50), the expiry check is conditional—if verification_expires_at is missing, the entire validation is skipped without error. This allows codes to remain valid indefinitely if the metadata is incomplete, manually created, or corrupted. Either require the field to always be present and fail early if missing, or enforce a default expiry behavior to prevent silent security bypasses.

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

In `@lib/agents/agentVerifyHandler.ts` around lines 47 - 56, The expiry check in
agentVerifyHandler.ts currently skips validation when
metadata.verification_expires_at is missing, allowing codes to be accepted
indefinitely; update the verify flow in the handler that reads metadata (use the
same metadata object and the existing verification_expires_at symbol) to require
presence of metadata.verification_expires_at and return the same
NextResponse.json({ error: GENERIC_ERROR }, { status: 400, headers:
getCorsHeaders() }) if it is absent or unparsable, or alternatively set a strict
default expiry and treat missing/unparsable values as expired—ensure the code
paths around the existing Date parsing/compare use this enforced behavior so
missing fields cannot bypass expiry checks.

const today = new Date().toISOString().slice(0, 10);
const rawKey = generateApiKey("recoup_sk");
const keyHash = hashApiKey(rawKey, process.env.PROJECT_SECRET!);
await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Unchecked error from insertApiKey could return API key that wasn't persisted.

Per the insertApiKey signature (context snippet 2), the function returns { data, error }. If the insert fails, this code proceeds to return the raw API key to the user even though it wasn't stored in the database. The user would receive an invalid key.

🐛 Proposed fix
-    await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
+    const { error: insertError } = await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
+    if (insertError) {
+      console.error("[ERROR] agentVerifyHandler: Failed to insert API key:", insertError);
+      return NextResponse.json(
+        { error: "Internal server error" },
+        { status: 500, headers: getCorsHeaders() },
+      );
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
const { error: insertError } = await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
if (insertError) {
console.error("[ERROR] agentVerifyHandler: Failed to insert API key:", insertError);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: getCorsHeaders() },
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/agents/agentVerifyHandler.ts` at line 95, The call to insertApiKey is
unchecked and may return an error while you still return the raw API key to the
client; change the flow around insertApiKey in agentVerifyHandler (the call with
keyHash and insertApiKey({...})) so you destructure its result ({ data, error
}), verify error is null and data exists before returning the plain key, and on
failure abort (throw or return a proper error response) so you never expose a
key that wasn't persisted; ensure you reference insertApiKey and the local
key/keyHash variables when implementing the guard.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

6 issues found across 16 files

Confidence score: 2/5

  • High-confidence, user-facing failures are likely in lib/agents/agentVerifyHandler.ts: the insertApiKey result error is not handled, so users can be given API keys that were never persisted and will fail on authenticated requests.
  • lib/agents/agentVerifyHandler.ts also has a fail-open verification path: if verification_expires_at is missing, expiry checks are skipped and codes may remain valid indefinitely.
  • Given two concrete high-severity logic issues (7-8/10, confidence 9/10), this is above normal merge risk; the remaining findings in lib/agents/agentSignupHandler.ts and lib/agents/validateAgentVerifyBody.ts are mostly quality/validation improvements but still worth cleaning up.
  • Pay close attention to lib/agents/agentVerifyHandler.ts, lib/agents/validateAgentVerifyBody.ts, lib/agents/agentSignupHandler.ts - auth token persistence and verification-expiry correctness are the main risk drivers, with input/code-generation edge cases behind them.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/agents/agentSignupHandler.ts">

<violation number="1" location="lib/agents/agentSignupHandler.ts:1">
P1: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

At 158 lines this file exceeds the 100-line limit and bundles multiple concerns. Extract `createAccountWithEmail`, `generateAndStoreApiKey`, and `storeVerificationCode` into separate files under `lib/agents/` — each is an independent unit with its own responsibility.</violation>

<violation number="2" location="lib/agents/agentSignupHandler.ts:70">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

Every private helper has a full JSDoc block that restates the function name and typed parameters (e.g. `@param email - The email address` on a parameter already named `email: string`). These are narrating comments that add no information beyond what the signatures already express. Remove or condense them — a one-line `//` comment is enough where the name isn't self-evident.</violation>

<violation number="3" location="lib/agents/agentSignupHandler.ts:110">
P2: `randomInt` upper bound is exclusive, so `999999` is never generated. Use `randomInt(100000, 1000000)` to cover all 6-digit codes.</violation>
</file>

<file name="lib/agents/validateAgentVerifyBody.ts">

<violation number="1" location="lib/agents/validateAgentVerifyBody.ts:7">
P2: The `code` schema should enforce the expected 6-digit format (e.g., `.regex(/^\d{6}$/)`) so obviously invalid codes are rejected at the validation layer without consuming rate-limited verification attempts.</violation>
</file>

<file name="lib/agents/agentVerifyHandler.ts">

<violation number="1" location="lib/agents/agentVerifyHandler.ts:48">
P1: Missing `verification_expires_at` causes the expiry check to be skipped entirely, making the code valid forever. Treat a missing expiry as expired to fail-safe.</violation>

<violation number="2" location="lib/agents/agentVerifyHandler.ts:95">
P1: `insertApiKey` returns `{ data, error }` but the error is not checked. If the insert fails, the user receives an API key that was never persisted and will not work on any authenticated endpoint.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant Signup as NEW: /api/agents/signup
    participant Verify as NEW: /api/agents/verify
    participant Privy as NEW: Privy API
    participant DB as Supabase
    participant Email as Resend

    Note over Client,Email: Agent Registration Flow

    Client->>Signup: POST { email }
    Signup->>DB: selectAccountByEmail()
    
    alt NEW: Email starts with 'agent+' (Instant Access)
        Signup->>DB: createAccountWithEmail()
        Signup->>Privy: NEW: createPrivyUser()
        Signup->>DB: generateAndStoreApiKey()
        Signup-->>Client: 200 OK { api_key: "recoup_sk_..." }
    
    else Standard Email (Requires Verification)
        opt Account doesn't exist
            Signup->>DB: createAccountWithEmail()
        end
        Signup->>Privy: NEW: getPrivyUserByEmail() / createPrivyUser()
        Signup->>Privy: NEW: setPrivyCustomMetadata() (code hash + expiry)
        Signup->>Email: NEW: sendVerificationEmail(code)
        Signup-->>Client: 200 OK { api_key: null }
    end

    Note over Client,Email: Agent Verification Flow (standard email path)

    Client->>Verify: POST { email, code }
    Verify->>Privy: NEW: getPrivyUserByEmail() (fetch metadata)
    
    alt NEW: Invalid Code / Expired / Max Attempts
        Verify->>Privy: NEW: setPrivyCustomMetadata() (increment attempts)
        Verify-->>Client: 400/429 Error
    else Valid Code
        Verify->>DB: selectAccountByEmail()
        Verify->>DB: generateAndStoreApiKey()
        Verify->>Privy: NEW: setPrivyCustomMetadata() (clear metadata)
        Verify-->>Client: 200 OK { api_key: "recoup_sk_..." }
    end
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

@@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P1: Custom agent: Enforce Clear Code Style and Maintainability Practices

At 158 lines this file exceeds the 100-line limit and bundles multiple concerns. Extract createAccountWithEmail, generateAndStoreApiKey, and storeVerificationCode into separate files under lib/agents/ — each is an independent unit with its own responsibility.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agents/agentSignupHandler.ts, line 1:

<comment>At 158 lines this file exceeds the 100-line limit and bundles multiple concerns. Extract `createAccountWithEmail`, `generateAndStoreApiKey`, and `storeVerificationCode` into separate files under `lib/agents/` — each is an independent unit with its own responsibility.</comment>

<file context>
@@ -0,0 +1,158 @@
+import { NextResponse } from "next/server";
+import { randomInt } from "crypto";
+import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
</file context>
Fix with Cubic

}

// Expired
if (metadata.verification_expires_at) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P1: Missing verification_expires_at causes the expiry check to be skipped entirely, making the code valid forever. Treat a missing expiry as expired to fail-safe.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agents/agentVerifyHandler.ts, line 48:

<comment>Missing `verification_expires_at` causes the expiry check to be skipped entirely, making the code valid forever. Treat a missing expiry as expired to fail-safe.</comment>

<file context>
@@ -0,0 +1,111 @@
+    }
+
+    // Expired
+    if (metadata.verification_expires_at) {
+      const expiresAt = new Date(metadata.verification_expires_at).getTime();
+      if (Date.now() > expiresAt) {
</file context>
Fix with Cubic

const today = new Date().toISOString().slice(0, 10);
const rawKey = generateApiKey("recoup_sk");
const keyHash = hashApiKey(rawKey, process.env.PROJECT_SECRET!);
await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P1: insertApiKey returns { data, error } but the error is not checked. If the insert fails, the user receives an API key that was never persisted and will not work on any authenticated endpoint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agents/agentVerifyHandler.ts, line 95:

<comment>`insertApiKey` returns `{ data, error }` but the error is not checked. If the insert fails, the user receives an API key that was never persisted and will not work on any authenticated endpoint.</comment>

<file context>
@@ -0,0 +1,111 @@
+    const today = new Date().toISOString().slice(0, 10);
+    const rawKey = generateApiKey("recoup_sk");
+    const keyHash = hashApiKey(rawKey, process.env.PROJECT_SECRET!);
+    await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash });
+
+    // Clear custom_metadata
</file context>
Fix with Cubic

}

/**
* Generates a new API key, hashes it, and stores it in the database.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P2: Custom agent: Flag AI Slop and Fabricated Changes

Every private helper has a full JSDoc block that restates the function name and typed parameters (e.g. @param email - The email address on a parameter already named email: string). These are narrating comments that add no information beyond what the signatures already express. Remove or condense them — a one-line // comment is enough where the name isn't self-evident.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agents/agentSignupHandler.ts, line 70:

<comment>Every private helper has a full JSDoc block that restates the function name and typed parameters (e.g. `@param email - The email address` on a parameter already named `email: string`). These are narrating comments that add no information beyond what the signatures already express. Remove or condense them — a one-line `//` comment is enough where the name isn't self-evident.</comment>

<file context>
@@ -0,0 +1,158 @@
+}
+
+/**
+ * Generates a new API key, hashes it, and stores it in the database.
+ *
+ * @param accountId - The account ID to associate the key with
</file context>
Fix with Cubic


export const agentVerifyBodySchema = z.object({
email: z.string({ message: "email is required" }).email("email must be a valid email address"),
code: z.string({ message: "code is required" }).min(1, "code cannot be empty"),
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P2: The code schema should enforce the expected 6-digit format (e.g., .regex(/^\d{6}$/)) so obviously invalid codes are rejected at the validation layer without consuming rate-limited verification attempts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agents/validateAgentVerifyBody.ts, line 7:

<comment>The `code` schema should enforce the expected 6-digit format (e.g., `.regex(/^\d{6}$/)`) so obviously invalid codes are rejected at the validation layer without consuming rate-limited verification attempts.</comment>

<file context>
@@ -0,0 +1,37 @@
+
+export const agentVerifyBodySchema = z.object({
+  email: z.string({ message: "email is required" }).email("email must be a valid email address"),
+  code: z.string({ message: "code is required" }).min(1, "code cannot be empty"),
+});
+
</file context>
Fix with Cubic

* @param email - The email address to send the code to
*/
async function storeVerificationCode(email: string): Promise<void> {
const code = randomInt(100000, 999999).toString();
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P2: randomInt upper bound is exclusive, so 999999 is never generated. Use randomInt(100000, 1000000) to cover all 6-digit codes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agents/agentSignupHandler.ts, line 110:

<comment>`randomInt` upper bound is exclusive, so `999999` is never generated. Use `randomInt(100000, 1000000)` to cover all 6-digit codes.</comment>

<file context>
@@ -0,0 +1,158 @@
+ * @param email - The email address to send the code to
+ */
+async function storeVerificationCode(email: string): Promise<void> {
+  const code = randomInt(100000, 999999).toString();
+  const codeHash = hashApiKey(code, process.env.PROJECT_SECRET!);
+  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
</file context>
Fix with Cubic

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