Skip to content

Conversation

enemyrr
Copy link

@enemyrr enemyrr commented Aug 22, 2025

Summary

  • Adds domain-based access control for API keys to enhance security
  • API keys can now be restricted to specific domains or granted access to all domains
  • Maintains backward compatibility with existing API keys

Changes

  • Database: Added domainId field to ApiKey model with foreign key relation to Domain
  • Frontend: Added domain selection dropdown in API key creation form
  • Frontend: Updated API keys table to display domain access information
  • Backend: Enhanced API key validation to check domain access permissions
  • API: Updated public API endpoints to respect domain-based access controls

Features

  • Domain Selection: When creating an API key, users can choose "All Domains" or select a specific verified domain
  • Access Control: API requests are validated against the API key's domain permissions
  • Backward Compatibility: Existing API keys automatically have access to all domains (domainId = null)
  • Security: API keys with domain restrictions can only send emails from their assigned domain
  • Error Handling: Clear error messages when API key lacks domain access

Test plan

  • Database migration runs successfully
  • API key creation with domain selection works
  • API keys table shows domain access correctly
  • Domain validation works in email sending
  • Public API respects domain restrictions
  • Backward compatibility maintained for existing keys

Summary by CodeRabbit

  • New Features

    • Restrict API keys to a specific domain or allow access to all domains.
    • Domain Access selector when creating API keys.
    • API keys list shows each key’s domain access.
    • Database migration adds optional domain association to API keys.
  • Behavior Changes

    • Public API domain listing and email endpoints now respect an API key’s domain scope.
    • Email sending and email operations enforce domain-level access and return clearer errors when not permitted.

Add functionality to limit API key usage to specific domains for enhanced security:

- Add domainId field to ApiKey model with foreign key to Domain
- Update API key creation to support domain selection (defaults to "All Domains")
- Add domain access dropdown in frontend with verified domains list
- Display domain access in API keys table
- Implement domain validation for API requests
- Filter domain endpoints based on API key access
- Maintain backward compatibility (existing keys have access to all domains)

🤖 Generated with [Claude Code](https://claude.ai/code)

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

coderabbitai bot commented Aug 22, 2025

Walkthrough

Adds optional domain scoping for API keys: DB migration adds nullable ApiKey.domainId with FK (ON DELETE SET NULL); Prisma schema and server services/routers propagate and validate domain restrictions; public API and runtime env include API key domain info; dashboard UI gains domain select and list column; email flows enforce domain access.

Changes

Cohort / File(s) Summary
Database migration & Prisma schema
apps/web/prisma/migrations/20250822125136_add_domain_access_to_api_keys/migration.sql, apps/web/prisma/schema.prisma
Adds nullable ApiKey.domainId column and FK ApiKey_domainId_fkeyDomain(id) (ON DELETE SET NULL, ON UPDATE CASCADE). Updates Prisma models: Domain.apiKeys added and ApiKey.domainId (Int?) plus ApiKey.domain relation added.
Dashboard UI — create & list
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx, apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
Adds Domain access select to API key creation form (includes "All Domains"); domainId wired into creation payload only when specific domain chosen. Adds Domain Access column to API keys table and adjusts colspans.
tRPC router / API endpoints
apps/web/src/server/api/routers/api.ts
createToken input accepts optional domainId (number) and forwards it to addApiKey; getApiKeys selects domainId and nested domain.name. Permission enum switched to Prisma native enum.
Server service logic
apps/web/src/server/service/api-service.ts, apps/web/src/server/service/domain-service.ts, apps/web/src/server/service/email-service.ts
addApiKey signature accepts optional domainId and persists it after ownership/verification checks (throws DOMAIN_NOT_FOUND / related errors). New validateApiKeyDomainAccess(...) enforces API-key domain restrictions. sendEmail branches to validate domain via API key when apiKeyId present. getTeamAndApiKey includes domain relation.
Public API auth, types & runtime env
apps/web/src/server/public-api/auth.ts, apps/web/src/server/public-api/hono.ts
getTeamFromToken now includes apiKey: { domainId } in returned team/context. AppEnv.Variables.team typing extended to carry API key domain info; ApiKey type imported.
Public API routes & utilities
apps/web/src/server/public-api/api/domains/get-domains.ts, apps/web/src/server/public-api/api/domains/verify-domain.ts, apps/web/src/server/public-api/api/emails/*, apps/web/src/server/public-api/api-utils.ts
Domain listing and verify endpoints now respect API-key domain scoping: if team.apiKey.domainId set, routes restrict to that domain; verify-domain adds access checks and specific error responses. New exported helper checkIsValidEmailIdWithDomainRestriction(...) added and used across email routes (get-email, list-emails filtering precedence updated, update-email, cancel-email) to enforce domain-scoped email access.
Public API router usages / selections
apps/web/src/server/api/routers/api.ts, other server handlers
Several handlers now include domainId in selects/returns and pass domainId through request handling where relevant.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as Dashboard UI
  participant API as tRPC Router
  participant SVC as Api Service
  participant DB as Prisma/DB

  User->>UI: Submit Create API Key (name, permission, domainId?)
  UI->>API: createToken({ name, permission, domainId? })
  API->>SVC: addApiKey({ teamId, name, permission, domainId? })
  SVC->>DB: INSERT ApiKey { teamId, name, permission, domainId? }
  DB-->>SVC: Created ApiKey (incl. domain relation)
  SVC-->>API: ApiKey
  API-->>UI: Created key (with domain info if present)
Loading
sequenceDiagram
  autonumber
  actor Client
  participant Pub as Public API
  participant Dom as Domains Route
  participant DB as Prisma/DB

  Client->>Pub: GET /domains (with API key)
  Pub->>DB: resolve team + apiKey (domainId?)
  Pub->>Dom: getDomains(team with apiKey)
  alt apiKey has domainId
    Dom->>DB: findMany({ teamId, id: domainId })
  else no domain restriction
    Dom->>DB: findMany({ teamId })
  end
  DB-->>Dom: Domains
  Dom-->>Client: Domain list (scoped if restricted)
Loading
sequenceDiagram
  autonumber
  actor Client
  participant Email as Public Email Endpoint
  participant Svc as Domain Service
  participant DB as Prisma/DB

  Client->>Email: POST /send (from, teamId, apiKeyId?)
  alt apiKeyId provided
    Email->>DB: getTeamAndApiKey(apiKeyId) (includes domain)
    opt API key not found
      Email-->>Client: 400 BAD_REQUEST (Invalid API key)
    end
    Email->>Svc: validateApiKeyDomainAccess(from, teamId, apiKey)
  else no apiKeyId
    Email->>Svc: validateDomainFromEmail(from, teamId)
  end
  Svc-->>Email: Resolved domain
  Email-->>Client: Proceed with send using domain.id
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

I nibble at migrations and hop through the trees,
Linking keys with domains on a soft summer breeze.
I check each envelope, nose twitching with pride,
One domain or many — the rules now decide.
A carrot, a hop, and a tidy schema stride. 🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

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

Caution

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

⚠️ Outside diff range comments (3)
apps/web/src/server/service/email-service.ts (2)

490-499: Use domain-aware validation when recording suppressed bulk emails.

Even when all TOs are suppressed, we still create an Email row. Use the same domain resolution rules (respecting apiKeyId) to avoid creating records under the wrong domain.

-    // Validate domain for suppressed email too
-    const domain = await validateDomainFromEmail(from, teamId);
+    // Validate domain for suppressed email with API key awareness
+    let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
+    if (apiKeyId) {
+      const apiKey = await db.apiKey.findUnique({
+        where: { id: apiKeyId },
+        include: { domain: true },
+      });
+      if (!apiKey) {
+        throw new UnsendApiError({ code: "BAD_REQUEST", message: "Invalid API key" });
+      }
+      domain = await validateApiKeyDomainAccess(from, teamId, apiKey);
+    } else {
+      domain = await validateDomainFromEmail(from, teamId);
+    }

Also applies to: 522-554


1-10: Fix invalid findUnique call in validateDomainFromEmail

The call in apps/web/src/server/service/domain-service.ts at line 37 uses:

const domain = await db.domain.findUnique({
  where: { name: fromDomain, teamId },
});

However, your Prisma schema defines name as globally unique (String @unique) and does not define a composite unique constraint on (teamId, name). As a result, findUnique({ where: { name, teamId } }) is an invalid call and will fail at runtime.

Choose one of the following resolutions:

• Quick fix with runtime check
– Change to
ts const domain = await db.domain.findUnique({ where: { name: fromDomain }, }); if (!domain || domain.teamId !== teamId) { throw new UnsendApiError("Domain not found or access denied"); }
– No schema changes required.

• Schema-backed composite unique

  1. In apps/web/prisma/schema.prisma, update the Domain model:
    model Domain {
      id     Int    @id @default(autoincrement())
      name   String @unique
      teamId Int
    + @@unique([teamId, name])
      // …other fields…
    }
  2. Run prisma migrate dev to add the composite index.
  3. Use a named unique input for findUnique, e.g.:
    const domain = await db.domain.findUnique({
      where: { teamId_name: { teamId, name: fromDomain } },
    });

Alternatively, you can opt for findFirst({ where: { name: fromDomain, teamId } }) if you prefer not to modify the schema—though without a composite index this may be less efficient.

Impacted locations:

  • apps/web/src/server/service/domain-service.ts @ line 37 (findUnique usage in validateDomainFromEmail)
  • (If choosing the schema-backed approach) apps/web/prisma/schema.prisma – add @@unique([teamId, name])
apps/web/src/server/service/domain-service.ts (1)

13-55: Critical: Prisma Domain model missing composite unique on (name, teamId)

I inspected apps/web/prisma/schema.prisma and found that the Domain model declares name String @unique but does not include a composite unique constraint on (name, teamId). As written, the call:

await db.domain.findUnique({
  where: { name: fromDomain, teamId },
});

will fail to compile under Prisma’s generated typings (and error at runtime), since DomainWhereUniqueInput only accepts unique fields (id or name), not both name and teamId.

Please address this by either:

  • Adding a composite unique index to the Domain model:

    model Domain {
      id            Int          @id @default(autoincrement())
      name          String
      teamId        Int
      …  

Then run `prisma migrate dev` (or equivalent) to apply the change.

- **Or** changing the query to use a non-unique finder, for example:

```ts
const domain = await db.domain.findFirst({
  where: { name: fromDomain, teamId },
});

Whichever approach you choose, ensure the Prisma schema and client typings align with the intended lookup.

🧹 Nitpick comments (16)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (2)

56-58: Handle deleted or missing domains explicitly; keep wording consistent.

  • With ON DELETE SET NULL, a formerly restricted key can display “All Domains” after its domain is deleted, which may mask a privilege broadening. Prefer showing an explicit state.
  • Consistency: either “All domains” or “All Domains” across the UI.

Apply a safer display fallback:

-                  <TableCell>
-                    {apiKey.domainId ? apiKey.domain?.name : "All Domains"}
-                  </TableCell>
+                  <TableCell>
+                    {apiKey.domainId
+                      ? apiKey.domain?.name ?? "Domain removed"
+                      : "All domains"}
+                  </TableCell>

Also consider a small tooltip: “All domains: this key can send from any verified domain.”


60-62: Consistent relative time formatting.

createdAt uses addSuffix; lastUsed doesn’t. Align for consistency.

-                    {apiKey.lastUsed
-                      ? formatDistanceToNow(apiKey.lastUsed)
-                      : "Never"}
+                    {apiKey.lastUsed
+                      ? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
+                      : "Never"}
apps/web/src/server/service/email-service.ts (1)

73-94: Type the domain variable to satisfy strict TS and prevent implicit any.

With let domain; TypeScript infers any. Annotate with the Prisma Domain type (or the return type of the validator) for safety.

-import { EmailRenderer } from "@unsend/email-editor/src/renderer";
+import { EmailRenderer } from "@unsend/email-editor/src/renderer";
+import type { Domain as PrismaDomain } from "@prisma/client";
@@
-  let domain;
+  let domain: PrismaDomain;

Alternatively:

-  let domain;
+  let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
apps/web/src/server/public-api/hono.ts (1)

25-38: Minor typing cleanup: avoid casting to any.

c as any hides type errors. getTeamFromToken can accept Context and return the sanitized type so the cast isn’t needed.

Proposed adjustment (sketch):

  • Change getTeamFromToken signature to accept Context.
  • Here: const team = await getTeamFromToken(c);
apps/web/src/server/service/api-service.ts (1)

51-53: Avoid over-fetching: select only needed domain fields

You only use domain metadata downstream; fetching the entire domain object is unnecessary. Narrow the include to selected fields.

-    include: {
-      domain: true,
-    },
+    include: {
+      domain: {
+        select: { id: true, name: true },
+      },
+    },
apps/web/src/server/api/routers/api.ts (1)

13-17: Harden input schema: use native Prisma enum and stronger number constraints

  • Replace string literal enum with z.nativeEnum(ApiPermission) to avoid drift if enum values change in Prisma.
  • Constrain domainId as an integer and positive.
-import { z } from "zod";
+import { z } from "zod";
+import { ApiPermission } from "@prisma/client";
@@
-      z.object({ 
-        name: z.string(), 
-        permission: z.enum(["FULL", "SENDING"]),
-        domainId: z.number().optional()
-      })
+      z.object({
+        name: z.string(),
+        permission: z.nativeEnum(ApiPermission),
+        domainId: z.number().int().positive().optional(),
+      })
apps/web/prisma/migrations/20250822125136_add_domain_access_to_api_keys/migration.sql (1)

1-5: Consider indexing ApiKey.domainId

Queries that filter or join by domainId (e.g., audits, admin views) will benefit from an index. Postgres doesn’t auto-index FKs.

 ALTER TABLE "ApiKey" ADD COLUMN     "domainId" INTEGER;
@@
 ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+-- Optional: add index for faster filters/joins on domainId
+CREATE INDEX IF NOT EXISTS "ApiKey_domainId_idx" ON "ApiKey" ("domainId");
apps/web/src/server/service/domain-service.ts (1)

9-9: Minor: Narrow the parameter type or use the embedded domain

You don’t use apiKey.domain here. Either drop it from the type to just ApiKey, or incorporate it in the error message (e.g., to show the key’s allowed domain on mismatch). Keeping the surface minimal reduces confusion.

apps/web/src/server/public-api/api/domains/get-domains.ts (2)

26-38: Simplify branching and avoid implicit any on domains

let domains; infers any under strict settings. You can keep strong typing and readability by computing a single const with a conditional.

-    // If API key is restricted to a specific domain, only return that domain
-    let domains;
-    if (team.apiKey.domainId) {
-      domains = await db.domain.findMany({ 
-        where: { 
-          teamId: team.id,
-          id: team.apiKey.domainId 
-        } 
-      });
-    } else {
-      // If API key has access to all domains, return all team domains
-      domains = await db.domain.findMany({ where: { teamId: team.id } });
-    }
+    // If API key is restricted to a specific domain, only return that domain; else return all team domains
+    const domains = team.apiKey.domainId
+      ? await db.domain.findMany({
+          where: { teamId: team.id, id: team.apiKey.domainId },
+        })
+      : await db.domain.findMany({ where: { teamId: team.id } });

7-20: Minor: OpenAPI description mismatch

The 200 response description says “Retrieve the user”. This route returns domains. Update the description to avoid confusing SDK generators or consumers.

apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (6)

30-36: Reorder imports to follow repo import grouping/alphabetization guidelines.

Per guidelines, group external libs (react, zod, react-hook-form, lucide-react), then internal packages (e.g., @unsend/ui, ~/trpc/*), and alphabetize within groups. This improves readability and reduces churn in future diffs.


42-42: Tighten schema: restrict domainId to 'all' or a numeric ID string.

Strengthens validation against unexpected values while keeping the form’s string-based Select model intact.

Apply this diff:

-  domainId: z.string().optional(),
+  // "all" means unrestricted; otherwise a numeric id (as string)
+  domainId: z
+    .union([z.literal("all"), z.string().regex(/^\d+$/, "Invalid domain id")])
+    .optional(),

52-53: Handle domains query loading/error and confirm verified-domain filtering.

  • UX: When the domains query is loading or errors, the Select should reflect that state (disabled + inline item).
  • Data: PR objective mentions “specific verified domain.” Verify this query only surfaces verified domains (or filter client-side if the API doesn’t yet). Also consider empty-state messaging when there are no domains.

Would you confirm whether api.domain.domains.useQuery() returns only verified domains? If not, should we filter to verified in the UI until the API adds a filter?


69-69: Safer conversion and guard for undefined values.

Prevents accidental NaN if domainId is ever undefined and makes the intent explicit.

Apply this diff:

-        domainId: values.domainId === "all" ? undefined : Number(values.domainId),
+        domainId:
+          values.domainId && values.domainId !== "all"
+            ? Number.parseInt(values.domainId, 10)
+            : undefined,

64-79: Add error handling for API key creation to surface failures to the user.

Currently only onSuccess is handled; add onError to show a toast and keep the dialog open. This improves UX and aligns with the error-handling guideline.

You can update the mutation call like this (illustrative snippet outside the changed lines):

createApiKeyMutation.mutate(
  {
    name: values.name,
    permission: "FULL",
    domainId:
      values.domainId && values.domainId !== "all"
        ? Number.parseInt(values.domainId, 10)
        : undefined,
  },
  {
    onSuccess: (data) => {
      utils.apiKey.invalidate();
      setApiKey(data);
      apiKeyForm.reset();
    },
    onError: (err) => {
      toast.error(err?.message ?? "Failed to create API key");
    },
  }
);

81-87: Handle clipboard write errors to avoid silent failures.

navigator.clipboard.writeText can reject (permissions, insecure context). Catch and notify the user.

You can wrap it as:

async function handleCopy() {
  try {
    await navigator.clipboard.writeText(apiKey);
    setIsCopied(true);
    setTimeout(() => setIsCopied(false), 2000);
  } catch {
    toast.error("Could not copy to clipboard. Please copy manually.");
  }
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a895db7 and 80a2118.

📒 Files selected for processing (11)
  • apps/web/prisma/migrations/20250822125136_add_domain_access_to_api_keys/migration.sql (1 hunks)
  • apps/web/prisma/schema.prisma (2 hunks)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (4 hunks)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (4 hunks)
  • apps/web/src/server/api/routers/api.ts (2 hunks)
  • apps/web/src/server/public-api/api/domains/get-domains.ts (1 hunks)
  • apps/web/src/server/public-api/auth.ts (1 hunks)
  • apps/web/src/server/public-api/hono.ts (1 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
  • apps/web/src/server/service/domain-service.ts (2 hunks)
  • apps/web/src/server/service/email-service.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports, and ensure proper naming of key components.

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
{apps,packages}/**/*.{jsx,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{jsx,tsx}: Use functional React components with hooks and group related hooks together
In React components, structure code with props at the top, hooks next, helper functions, then JSX

Files:

  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
🧠 Learnings (1)
📚 Learning: 2025-08-09T10:37:58.146Z
Learnt from: CR
PR: unsend-dev/unsend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-09T10:37:58.146Z
Learning: Applies to apps/smtp-server/**/*.{ts,tsx} : Use Hono for public API endpoints

Applied to files:

  • apps/web/src/server/public-api/hono.ts
🧬 Code graph analysis (6)
apps/web/src/server/service/email-service.ts (2)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/service/domain-service.ts (2)
  • validateApiKeyDomainAccess (58-80)
  • validateDomainFromEmail (13-56)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (1)
packages/ui/src/table.tsx (2)
  • TableHead (113-113)
  • TableCell (115-115)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-42)
apps/web/src/server/service/domain-service.ts (1)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (2)
packages/ui/src/form.tsx (5)
  • FormField (176-176)
  • FormItem (171-171)
  • FormLabel (172-172)
  • FormControl (173-173)
  • FormDescription (174-174)
packages/ui/src/select.tsx (5)
  • Select (150-150)
  • SelectTrigger (153-153)
  • SelectValue (152-152)
  • SelectContent (154-154)
  • SelectItem (156-156)
🔇 Additional comments (5)
apps/web/prisma/schema.prisma (1)

193-194: Relation back-reference looks correct.

Domain.apiKeys establishes the expected 1:N link to ApiKey and aligns with the PR’s intent.

apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (2)

28-28: Good UX addition.

The “Domain Access” column provides clear visibility into scoping.


37-37: Colspan updates are correct.

colSpan=7 matches the new column count in the header.

Also applies to: 46-46

apps/web/src/server/api/routers/api.ts (1)

40-45: LGTM: exposing domain scope in list payload

Selecting domainId and domain.name enables the UI to render “All Domains” vs the specific domain cleanly. This aligns with the PR objective and keeps sensitive fields out.

apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (1)

60-60: Good default: preserves backward compatibility.

Defaulting to "all" aligns with the objective that existing keys remain unrestricted by domain.

Comment on lines 208 to 215
domainId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
}
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Prevent cross-team association and clarify deletion semantics for security.

  • Today, ApiKey.domainId references Domain(id) without asserting the same team across both sides. An ApiKey could (accidentally) reference a Domain from another team, and validateApiKeyDomainAccess only compares IDs, not team membership. Enforce the invariant teamId(apiKey) === teamId(domain) at DB or service layer.
  • ON DELETE SET NULL silently broadens a restricted key to “All Domains” if its domain is deleted. That’s a surprising privilege escalation.

Actionable suggestions:

  • If you can accept a composite relation, model the relation on [domainId, teamId] → [id, teamId] and check team membership at the DB level. Otherwise, add a strict service-level check in key creation/update to ensure selected domain belongs to the same team.
  • Consider ON DELETE RESTRICT (or disabling the key) instead of SET NULL to avoid unexpectedly broadening access. If SET NULL is desired, surface this clearly in the UI and audit logs.

Apply an index for query performance and prepare for service-side checks:

 model ApiKey {
   id           Int           @id @default(autoincrement())
   clientId     String        @unique
   tokenHash    String
   partialToken String
   name         String
   permission   ApiPermission @default(SENDING)
   domainId     Int?
+  @@index([domainId])
   createdAt    DateTime      @default(now())
   updatedAt    DateTime      @updatedAt
   lastUsed     DateTime?
   teamId       Int
   team         Team          @relation(fields: [teamId], references: [id], onDelete: Cascade)
-  domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: SetNull)
+  domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: SetNull)
 }

If you prefer DB-level invariants and can tolerate a composite optional relation, we can draft the exact Prisma schema changes for [domainId, teamId][id, teamId].


🏁 Script executed:

#!/bin/bash
set -e

echo "=== Finding ApiKey model in Prisma schema ==="
rg -n -C3 "model ApiKey" apps/web/prisma/schema.prisma || true

echo
echo "=== Displaying lines 180–260 around ApiKey definition ==="
sed -n '180,260p' apps/web/prisma/schema.prisma || true

echo
echo "=== Searching for validateApiKeyDomainAccess usage ==="
rg -n -C3 "validateApiKeyDomainAccess" -g "*.ts" || true

echo
echo "=== Searching for any domain.teamId equality checks ==="
rg -n -C3 "domain.*teamId" -g "*.ts" || true

Length of output: 8491


🏁 Script executed:

#!/bin/bash
set -e

echo "=== Domain model definition ==="
rg -n -C5 "model Domain" apps/web/prisma/schema.prisma || true

echo
echo "=== validateDomainFromEmail implementation ==="
sed -n '1,200p' apps/web/src/server/service/domain-service.ts || true

echo
echo "=== Listing public API endpoints ==="
find apps/web/src/server/public-api/api -type f -maxdepth 2 || true

echo
echo "=== Searching for ApiKey in public API endpoints ==="
rg -n -C5 "ApiKey" -g "apps/web/src/server/public-api/api/**" || true

echo
echo "=== Searching for domainId in ApiKey endpoints ==="
rg -n -C5 "domainId" -g "apps/web/src/server/public-api/api/**" || true

Length of output: 14073


Mandatory: Enforce domain-team invariant and tighten deletion semantics for ApiKey

The current schema and service code still allow three gaps:

  1. DB allows an ApiKey’s domainId to be set to any Domain.id (even from another team).
  2. ON DELETE SET NULL silently lifts a formerly restricted key to unrestricted if its domain is deleted.
  3. No index on domainId means domain-scoped lookups won’t use an index.

To address these:

• schema.prisma (apps/web/prisma/schema.prisma):
– Add an index on domainId for lookup performance.
– Change the referential action on domain from SetNull to Restrict (or NoAction) to avoid privilege escalation.
(Optional, if you can tolerate the complexity) introduce a composite relation on [domainId, teamId] → [id, teamId] to enforce at the DB level that every ApiKey.domain must belong to the same team.

 model ApiKey {
   id           Int           @id @default(autoincrement())
   clientId     String        @unique
   tokenHash    String
   partialToken String
   name         String
   permission   ApiPermission @default(SENDING)
   domainId     Int?
+    @@index([domainId])
   createdAt    DateTime      @default(now())
   updatedAt    DateTime      @updatedAt
   lastUsed     DateTime?
   teamId       Int
   team         Team          @relation(fields: [teamId], references: [id], onDelete: Cascade)
-    domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: SetNull)
+    domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: Restrict)
 }

• Service layer (in your ApiKey-creation/​update endpoints):
Before persisting a new or updated ApiKey, explicitly verify domainId, if provided, belongs to the same teamId. For example, reuse validateDomainFromEmail (or add a similar check) in createApiKey/updateApiKey to throw if the selected domain’s teamId mismatches the ApiKey’s teamId.

• Runtime enforcement (already in place):
validateApiKeyDomainAccess calls validateDomainFromEmail(email, teamId) to ensure team membership, then checks apiKey.domainId === domain.id. This is good, but it only runs when sending mail—creation and other operations still need safeguards.

Please update the schema and service code accordingly to close these security gaps.

📝 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
domainId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
}
model ApiKey {
id Int @id @default(autoincrement())
clientId String @unique
tokenHash String
partialToken String
name String
permission ApiPermission @default(SENDING)
domainId Int?
@@index([domainId])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Restrict)
}
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around lines 208 to 215, the ApiKey model
currently allows domainId from any Domain, uses onDelete: SetNull (which can
escalate privileges), and lacks an index on domainId; update the schema to add
an index on domainId, change the domain relation's onDelete from SetNull to
Restrict (or NoAction) to prevent silent privilege escalation, and optionally
add a composite relation/constraint tying [domainId, teamId] → [id, teamId] to
enforce at the DB level that the domain belongs to the same team; additionally,
update the ApiKey create/update service endpoints to explicitly validate that
the provided domainId (when present) belongs to the same teamId before
persisting (reuse validateDomainFromEmail or add an equivalent check) so
mismatched domain/team combinations are rejected.

Comment on lines +195 to +221
<FormField
control={apiKeyForm.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>Domain access</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">All Domains</SelectItem>
{domainsQuery.data?.map((domain: { id: number; name: string }) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose which domain this API key can send emails from.
</FormDescription>
</FormItem>
)}
/>
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Make Select controlled (not defaultValue) to ensure form resets work reliably.

Using defaultValue makes the Select uncontrolled; after apiKeyForm.reset() the UI can show a stale value, confusing users. Control it via value, disable during loading/error, and add basic loading/empty states.

Apply this diff:

-                      <Select onValueChange={field.onChange} defaultValue={field.value}>
+                      <Select
+                        onValueChange={field.onChange}
+                        value={field.value ?? "all"}
+                        disabled={domainsQuery.isLoading || domainsQuery.isError}
+                      >
                         <FormControl>
                           <SelectTrigger>
                             <SelectValue placeholder="Select domain access" />
                           </SelectTrigger>
                         </FormControl>
-                        <SelectContent>
-                          <SelectItem value="all">All Domains</SelectItem>
-                          {domainsQuery.data?.map((domain: { id: number; name: string }) => (
-                            <SelectItem key={domain.id} value={domain.id.toString()}>
-                              {domain.name}
-                            </SelectItem>
-                          ))}
-                        </SelectContent>
+                        <SelectContent>
+                          <SelectItem value="all">All Domains</SelectItem>
+                          {domainsQuery.isLoading && (
+                            <SelectItem value="loading" disabled>
+                              Loading domains…
+                            </SelectItem>
+                          )}
+                          {domainsQuery.isError && (
+                            <SelectItem value="error" disabled>
+                              Failed to load domains
+                            </SelectItem>
+                          )}
+                          {!domainsQuery.isLoading &&
+                            !domainsQuery.isError &&
+                            (domainsQuery.data?.length ?? 0) === 0 && (
+                              <SelectItem value="none" disabled>
+                                No domains available
+                              </SelectItem>
+                            )}
+                          {domainsQuery.data?.map((domain: { id: number; name: string }) => (
+                            <SelectItem key={domain.id} value={domain.id.toString()}>
+                              {domain.name}
+                            </SelectItem>
+                          ))}
+                        </SelectContent>
                       </Select>
-                      <FormDescription>
-                        Choose which domain this API key can send emails from.
-                      </FormDescription>
+                      {apiKeyForm.formState.errors.domainId ? (
+                        <FormMessage />
+                      ) : (
+                        <FormDescription>
+                          Choose which domain this API key can send emails from.
+                        </FormDescription>
+                      )}
📝 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
<FormField
control={apiKeyForm.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>Domain access</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">All Domains</SelectItem>
{domainsQuery.data?.map((domain: { id: number; name: string }) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose which domain this API key can send emails from.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={apiKeyForm.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>Domain access</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? "all"}
disabled={domainsQuery.isLoading || domainsQuery.isError}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">All Domains</SelectItem>
{domainsQuery.isLoading && (
<SelectItem value="loading" disabled>
Loading domains…
</SelectItem>
)}
{domainsQuery.isError && (
<SelectItem value="error" disabled>
Failed to load domains
</SelectItem>
)}
{!domainsQuery.isLoading &&
!domainsQuery.isError &&
(domainsQuery.data?.length ?? 0) === 0 && (
<SelectItem value="none" disabled>
No domains available
</SelectItem>
)}
{domainsQuery.data?.map((domain: { id: number; name: string }) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
{apiKeyForm.formState.errors.domainId ? (
<FormMessage />
) : (
<FormDescription>
Choose which domain this API key can send emails from.
</FormDescription>
)}
</FormItem>
)}
/>

Comment on lines 20 to 26
return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Validate domain ownership (and optionally verification) at the API boundary

Before calling addApiKey, ensure the provided domainId exists and belongs to ctx.team.id. Without this, a client can associate a key with another team’s domain; the FK won’t stop that. Also return a clear 400/403 instead of a generic 500 from a DB FK violation.

+import { TRPCError } from "@trpc/server";
@@
-    .mutation(async ({ ctx, input }) => {
-      return addApiKey({
+    .mutation(async ({ ctx, input }) => {
+      // Validate domain ownership (and optionally verification)
+      if (input.domainId !== undefined) {
+        const domain = await ctx.db.domain.findUnique({
+          where: { id: input.domainId },
+          select: { id: true, teamId: true, status: true },
+        });
+        if (!domain || domain.teamId !== ctx.team.id) {
+          throw new TRPCError({
+            code: "BAD_REQUEST",
+            message: "Invalid domainId for this team",
+          });
+        }
+        // Optional: enforce only verified domains can be scoped
+        // if (domain.status !== "SUCCESS") {
+        //   throw new TRPCError({ code: "BAD_REQUEST", message: "Domain is not verified" });
+        // }
+      }
+      return addApiKey({
         name: input.name,
         permission: input.permission,
         teamId: ctx.team.id,
         domainId: input.domainId,
       });
     }),

If you’d like, I can add unit/integration tests that cover:

  • Creating a key with another team’s domainId → 400
  • Creating a key with non-existent domainId → 400
  • Creating a key with verified domainId → 200
  • (Optional) Unverified domainId → 400
📝 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
return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),
// At the top of the file, alongside your existing imports:
import { TRPCError } from "@trpc/server";
...
// In your router definition:
.mutation("createApiKey", async ({ ctx, input }) => {
// Validate domain ownership (and optionally verification)
if (input.domainId !== undefined) {
const domain = await ctx.db.domain.findUnique({
where: { id: input.domainId },
select: { id: true, teamId: true, status: true },
});
if (!domain || domain.teamId !== ctx.team.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid domainId for this team",
});
}
// Optional: enforce only verified domains can be scoped
// if (domain.status !== "SUCCESS") {
// throw new TRPCError({ code: "BAD_REQUEST", message: "Domain is not verified" });
// }
}
return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),

@@ -59,5 +59,5 @@ export const getTeamFromToken = async (c: Context) => {
logger.error({ err }, "Failed to update lastUsed on API key")
);

return { ...team, apiKeyId: apiKey.id };
return { ...team, apiKeyId: apiKey.id, apiKey };
Copy link

@coderabbitai coderabbitai bot Aug 22, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid exposing sensitive ApiKey fields in request context.

c.set("team", ...) now carries the full ApiKey, which includes tokenHash. If any handler logs c.var.team or accidentally serializes it, secrets may leak.

Recommend returning a safe, minimal shape (id, name, permission, domainId, domain?.name) and update hono.ts typing accordingly.

Apply this localized sanitization here:

-  return { ...team, apiKeyId: apiKey.id, apiKey };
+  const safeApiKey = {
+    id: apiKey.id,
+    name: apiKey.name,
+    permission: apiKey.permission,
+    domainId: apiKey.domainId,
+    domain: apiKey.domain ? { name: apiKey.domain.name } : null,
+  } as const;
+  return { ...team, apiKeyId: apiKey.id, apiKey: safeApiKey };

Optional: also validate the scheme prefix to harden auth parsing.

-  const token = authHeader.split(" ")[1];
+  const [scheme, token] = authHeader.split(" ");
+  if (scheme?.toLowerCase() !== "bearer" || !token) {
+    throw new UnsendApiError({ code: "UNAUTHORIZED", message: "Invalid Authorization header" });
+  }
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/auth.ts around line 62, the code currently
sets c.set("team", ...) with the full ApiKey (including tokenHash); replace this
with a sanitized team object containing only { id, name, permission, domainId,
domainName } (or domain?.name as domainName) so no secret fields are attached to
the request context, and update the hono.ts request/context typing to reflect
the new minimal shape; additionally, optionally validate the authorization
scheme prefix (e.g., "Bearer ") before parsing the token to harden auth parsing.

Copy link
Contributor

Choose a reason for hiding this comment

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

@enemyrr can you take a look at this, seems like valida comment, should only have need fields in context

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@@ -7,13 +7,13 @@ import { getRedis } from "~/server/redis";
import { getTeamFromToken } from "~/server/public-api/auth";
import { isSelfHosted } from "~/utils/common";
import { UnsendApiError } from "./api-error";
import { Team } from "@prisma/client";
import { Team, ApiKey } from "@prisma/client";
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Don’t expose full ApiKey type to route handlers; define a safe shape.

Using ApiKey in AppEnv makes tokenHash and other internals available to every handler. Adopt a sanitized type consistent with auth.ts.

-import { Team, ApiKey } from "@prisma/client";
+import { Team } from "@prisma/client";
+type PublicApiKey = {
+  id: number;
+  name: string;
+  permission: import("@prisma/client").ApiPermission;
+  domainId: number | null;
+  domain: { name: string } | null;
+};
@@
-    team: Team & { apiKeyId: number; apiKey: ApiKey & { domain?: { name: string } | null } };
+    team: Team & { apiKeyId: number; apiKey: PublicApiKey };

Follow-up: this pairs with the sanitization in apps/web/src/server/public-api/auth.ts.

Also applies to: 16-16

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/hono.ts around lines 10 and 16, the AppEnv
currently imports and exposes the full ApiKey Prisma type (which includes
tokenHash and other sensitive internals) to route handlers; replace that with a
sanitized ApiKey shape (only the public fields used by handlers) that matches
the sanitization defined in apps/web/src/server/public-api/auth.ts, update the
import/definition to use that safe type instead of ApiKey, and ensure AppEnv
references the sanitized type so tokenHash and other internals are not
accessible to handlers.

Comment on lines +58 to +80
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);

// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}

// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}

return domain;
}
Copy link

@coderabbitai coderabbitai bot Aug 22, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add team ownership assertion for the provided API key

validateApiKeyDomainAccess trusts the caller to pass an API key that belongs to teamId. Add a guard to prevent accidental cross-team use if this function ever gets reused elsewhere.

 export async function validateApiKeyDomainAccess(
   email: string,
   teamId: number,
   apiKey: ApiKey & { domain?: { name: string } | null }
 ) {
   // First validate the domain exists and is verified
   const domain = await validateDomainFromEmail(email, teamId);
 
+  // Ensure the API key belongs to the same team (defense-in-depth)
+  if (apiKey.teamId !== teamId) {
+    throw new UnsendApiError({
+      code: "FORBIDDEN",
+      message: "API key does not belong to this team",
+    });
+  }
+
   // If API key has no domain restriction (domainId is null), allow all domains
   if (!apiKey.domainId) {
     return domain;
   }
 
   // If API key is restricted to a specific domain, check if it matches
   if (apiKey.domainId !== domain.id) {
     throw new UnsendApiError({
       code: "FORBIDDEN",
       message: `API key does not have access to domain: ${domain.name}`,
     });
   }
 
   return domain;
 }
📝 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
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);
// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}
// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}
return domain;
}
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);
// Ensure the API key belongs to the same team (defense-in-depth)
if (apiKey.teamId !== teamId) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: "API key does not belong to this team",
});
}
// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}
// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}
return domain;
}
🤖 Prompt for AI Agents
In apps/web/src/server/service/domain-service.ts around lines 58 to 80, the
function validateApiKeyDomainAccess does not assert that the passed apiKey
belongs to the provided teamId, allowing potential cross-team use; add an early
guard after fetching/validating the domain (or immediately at function start)
that checks apiKey.teamId === teamId and if not, throw an UnsendApiError with a
FORBIDDEN code and a clear message about cross-team API key usage; keep the
existing domainId/null logic unchanged and place this ownership assertion before
any domain-specific access checks.

Copy link
Contributor

Choose a reason for hiding this comment

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

nah, it's already checked in validateDomainFromEmail

Copy link

Choose a reason for hiding this comment

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

@KMKoushik, understood! You're right that the domain ownership is validated in validateDomainFromEmail. I acknowledge your decision to keep the current implementation as is.

- Fix critical Prisma query issue: use findFirst instead of findUnique for Domain lookup
- Remove unused imports and improve type safety
- Update OpenAPI description for domains endpoint
- Improve error handling for deleted domains in UI
- Optimize API service to select only needed domain fields
- Add consistent time formatting and better validation schemas
Copy link

@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

♻️ Duplicate comments (2)
apps/web/src/server/service/domain-service.ts (1)

58-80: Add team-ownership assertion to prevent cross-team API key usage.

This function trusts that apiKey belongs to teamId. Add an early guard to block cross-team use before any domain checks. This mirrors feedback from an earlier review.

 export async function validateApiKeyDomainAccess(
   email: string,
   teamId: number,
   apiKey: ApiKey & { domain?: { name: string } | null }
 ) {
-  // First validate the domain exists and is verified
+  // Ensure the API key belongs to the same team (defense-in-depth)
+  if (apiKey.teamId !== teamId) {
+    throw new UnsendApiError({
+      code: "FORBIDDEN",
+      message: "API key does not belong to this team",
+    });
+  }
+
+  // First validate the domain exists and is verified
   const domain = await validateDomainFromEmail(email, teamId);

Follow-up tests to add (happy to draft):

  • apiKey.teamId ≠ teamId → throws FORBIDDEN
  • apiKey.domainId set and not equal to resolved domain → throws FORBIDDEN
  • apiKey.domainId unset → any verified team domain allowed
apps/web/src/server/api/routers/api.ts (1)

20-27: Add server-side validation for domainId (ownership and optionally verification).

Validate that input.domainId belongs to ctx.team.id before calling addApiKey. This prevents cross-team association and yields a clean 400/403 instead of a generic failure. This was raised previously as well.

+import { TRPCError } from "@trpc/server";
@@
   createToken: teamProcedure
     .input(
       z.object({
         name: z.string(),
         permission: z.nativeEnum(ApiPermission),
         domainId: z.number().int().positive().optional(),
       })
     )
-    .mutation(async ({ ctx, input }) => {
-      return addApiKey({
+    .mutation(async ({ ctx, input }) => {
+      // Validate domain ownership (and optional verification) at the boundary
+      if (input.domainId !== undefined) {
+        const domain = await ctx.db.domain.findUnique({
+          where: { id: input.domainId },
+          select: { id: true, teamId: true, status: true },
+        });
+        if (!domain || domain.teamId !== ctx.team.id) {
+          throw new TRPCError({
+            code: "BAD_REQUEST",
+            message: "Invalid domainId for this team",
+          });
+        }
+        // Optional: only allow verified domains to be scoped
+        // if (domain.status !== "SUCCESS") {
+        //   throw new TRPCError({ code: "BAD_REQUEST", message: "Domain is not verified" });
+        // }
+      }
+      return addApiKey({
         name: input.name,
         permission: input.permission,
         teamId: ctx.team.id,
         domainId: input.domainId,
       });
     }),

If you want stricter semantics, flip the optional check to require verified domains when domainId is provided.

🧹 Nitpick comments (2)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)

25-30: Unify query (minor) and consider exposing only verified domains.

  • Minor: build a single where and call findMany once; keeps things simpler.
  • Optional: if this endpoint is used by API clients to select “sendable” domains, consider filtering to verified domains only (status: "SUCCESS").
-    // If API key is restricted to a specific domain, only return that domain; else return all team domains
-    const domains = team.apiKey.domainId
-      ? await db.domain.findMany({
-          where: { teamId: team.id, id: team.apiKey.domainId },
-        })
-      : await db.domain.findMany({ where: { teamId: team.id } });
+    // If API key is restricted to a specific domain, only return that domain; else return all team domains
+    const where = {
+      teamId: team.id,
+      ...(team.apiKey.domainId ? { id: team.apiKey.domainId } : {}),
+      // Optional: uncomment to return only verified domains
+      // status: "SUCCESS",
+    };
+    const domains = await db.domain.findMany({ where });
apps/web/src/server/service/domain-service.ts (1)

37-39: Case-insensitive match and normalization for domain lookup.

Email domains are case-insensitive. Use a case-insensitive comparison (and normalize input) to avoid false negatives. If you routinely query by (teamId, name), consider a composite unique index for integrity and performance.

-  const domain = await db.domain.findFirst({
-    where: { name: fromDomain, teamId },
-  });
+  const domain = await db.domain.findFirst({
+    where: {
+      teamId,
+      name: { equals: fromDomain.toLowerCase(), mode: "insensitive" },
+    },
+  });

Architecture note:

  • Add a Prisma unique constraint (migration) to enforce uniqueness per team:
    @@unique([teamId, name], name: "domains_teamId_name_key")
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 80a2118 and f190453.

📒 Files selected for processing (6)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (4 hunks)
  • apps/web/src/server/api/routers/api.ts (3 hunks)
  • apps/web/src/server/public-api/api/domains/get-domains.ts (2 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
  • apps/web/src/server/service/domain-service.ts (3 hunks)
  • apps/web/src/server/service/email-service.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/service/email-service.ts
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports, and ensure proper naming of key components.

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
🧬 Code graph analysis (3)
apps/web/src/server/service/domain-service.ts (1)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-42)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)
apps/web/src/server/db.ts (1)
  • db (20-20)
🔇 Additional comments (5)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)

16-16: Good update to the route description.

Accurately reflects the new domain-scoped behavior.

apps/web/src/server/service/domain-service.ts (1)

9-9: Type import is appropriate.

Using ApiKey from Prisma here clarifies the contract of validateApiKeyDomainAccess.

apps/web/src/server/service/api-service.ts (1)

51-55: Including domain info with the API key is useful.

This enables downstream handlers (e.g., public API context) to enforce domain-scoped behavior without extra queries.

apps/web/src/server/api/routers/api.ts (2)

2-18: Good switch to z.nativeEnum(ApiPermission) and optional domainId input.

Type safety improves and matches the Prisma enum; the input contract is clear.


41-46: LGTM on enriching list output with domain info.

Selecting domainId and domain.name will make the UI “Domain Access” column straightforward to render.

- Implement detailed error handling for domain ownership and verification during API key creation.
- Introduce TRPCError responses for better client feedback on domain-related issues.
- Update addApiKey function to validate domain ownership and status before proceeding with key creation.
Copy link

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

♻️ Duplicate comments (1)
apps/web/src/server/service/api-service.ts (1)

20-35: Great defense-in-depth: ownership + verification checks added (resolves prior feedback).

Validating domainId and enforcing verified domains here is the right call and aligns with the PR objectives. This addresses the earlier recommendation to guard at the service layer as well.

🧹 Nitpick comments (7)
apps/web/src/server/service/api-service.ts (4)

22-29: Fold team ownership into the DB predicate (simpler and avoids a second check).

Query the domain with both id and teamId so the DB enforces ownership in one step. This reduces branching and keeps the “doesn’t exist vs not owned” ambiguity intact.

Apply this diff:

-      const domain = await db.domain.findUnique({
-        where: { id: domainId },
-        select: { id: true, teamId: true, status: true },
-      });
-      
-      if (!domain || domain.teamId !== teamId) {
-        throw new Error("DOMAIN_NOT_OWNED");
-      }
+      const domain = await db.domain.findFirst({
+        where: { id: domainId, teamId },
+        select: { id: true, status: true },
+      });
+      if (!domain) {
+        throw new Error("DOMAIN_NOT_OWNED");
+      }

31-34: Avoid magic strings for status; use the Prisma enum for type-safety.

Leverage DomainStatus.SUCCESS to prevent typos and benefit from compiler checks.

Apply this diff within the selected lines:

-      if (domain.status !== "SUCCESS") {
+      if (domain.status !== DomainStatus.SUCCESS) {
         throw new Error("DOMAIN_NOT_VERIFIED");
       }

Add this import (outside the selected range):

// at top of file
import { ApiPermission, DomainStatus } from "@prisma/client";

20-35: Prefer typed service errors over string-matching downstream.

Throw a typed error so the router can instanceof check instead of matching .message. This follows the guideline to use specific error types.

Apply this diff within the selected lines:

-        throw new Error("DOMAIN_NOT_OWNED");
+        throw new ApiServiceError("DOMAIN_NOT_OWNED");
@@
-        throw new Error("DOMAIN_NOT_VERIFIED");
+        throw new ApiServiceError("DOMAIN_NOT_VERIFIED");

And define the error class (outside the selected range):

// near the top of the file
export class ApiServiceError extends Error {
  constructor(public code: "DOMAIN_NOT_OWNED" | "DOMAIN_NOT_VERIFIED") {
    super(code);
    this.name = "ApiServiceError";
  }
}

61-63: Early-validate API key format before hitting the DB.

Guard against malformed keys to avoid unnecessary queries and potential undefined access.

Apply this diff:

 export async function getTeamAndApiKey(apiKey: string) {
-  const [, clientId, token] = apiKey.split("_") as [string, string, string];
+  const parts = apiKey.split("_");
+  if (parts.length !== 3 || parts[0] !== "us") return null;
+  const [, clientId, token] = parts as [string, string, string];
apps/web/src/server/api/routers/api.ts (3)

1-3: Order imports per guideline and prepare for typed error mapping.

Group externals and alphabetize; also import the typed service error for robust error handling.

Apply this diff:

-import { z } from "zod";
-import { ApiPermission } from "@prisma/client";
-import { TRPCError } from "@trpc/server";
+import { ApiPermission } from "@prisma/client";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { ApiServiceError } from "~/server/service/api-service";

15-19: Tighten Zod validation for name.

Protect against empty or overly long names at the boundary.

Apply this diff:

-      z.object({
-        name: z.string(),
+      z.object({
+        name: z.string().min(1).max(64),
         permission: z.nativeEnum(ApiPermission),
         domainId: z.number().int().positive().optional(),
       })

21-45: Use typed error mapping instead of string matching; default to sanitized 500.

Avoid brittle .message checks. Map ApiServiceError codes, and return a generic 500 for unknown errors.

Apply this diff:

-    .mutation(async ({ ctx, input }) => {
-      try {
-        return await addApiKey({
+    .mutation(async ({ ctx, input }) => {
+      try {
+        return await addApiKey({
           name: input.name,
           permission: input.permission,
           teamId: ctx.team.id,
           domainId: input.domainId,
         });
-      } catch (error) {
-        if (error instanceof Error) {
-          if (error.message === "DOMAIN_NOT_OWNED") {
-            throw new TRPCError({
-              code: "BAD_REQUEST",
-              message: "Domain does not belong to your team",
-            });
-          }
-          if (error.message === "DOMAIN_NOT_VERIFIED") {
-            throw new TRPCError({
-              code: "BAD_REQUEST", 
-              message: "Domain is not verified. Only verified domains can be used for API key restrictions",
-            });
-          }
-        }
-        throw error;
-      }
+      } catch (error) {
+        if (error instanceof ApiServiceError) {
+          if (error.code === "DOMAIN_NOT_OWNED") {
+            throw new TRPCError({
+              code: "BAD_REQUEST",
+              message: "Domain does not belong to your team",
+            });
+          }
+          if (error.code === "DOMAIN_NOT_VERIFIED") {
+            throw new TRPCError({
+              code: "BAD_REQUEST",
+              message:
+                "Domain is not verified. Only verified domains can be used for API key restrictions",
+            });
+          }
+        }
+        throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+      }
     }),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f190453 and 280f83d.

📒 Files selected for processing (2)
  • apps/web/src/server/api/routers/api.ts (3 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports, and ensure proper naming of key components.

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
🧬 Code graph analysis (1)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-59)
🔇 Additional comments (3)
apps/web/src/server/service/api-service.ts (2)

48-48: LGTM: domain association is persisted correctly.

Passing domainId through ensures the key is scoped as intended; omitting it keeps existing “all domains” behavior.


68-72: LGTM: eager-loading domain context is useful for callers.

Selecting only id and name is lean and sufficient for display/use.

apps/web/src/server/api/routers/api.ts (1)

60-65: LGTM: exposing domain context in listings.

Returning both domainId and domain.name is exactly what the UI needs to show scope clearly.

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
Copy link
Contributor

Choose a reason for hiding this comment

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

this might be a security problem, i'd rather delete the api key

@@ -59,5 +59,5 @@ export const getTeamFromToken = async (c: Context) => {
logger.error({ err }, "Failed to update lastUsed on API key")
);

return { ...team, apiKeyId: apiKey.id };
return { ...team, apiKeyId: apiKey.id, apiKey };
Copy link
Contributor

Choose a reason for hiding this comment

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

@enemyrr can you take a look at this, seems like valida comment, should only have need fields in context

// Validate domain ownership if domainId is provided
if (domainId !== undefined) {
const domain = await db.domain.findUnique({
where: { id: domainId },
Copy link
Contributor

Choose a reason for hiding this comment

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

@enemyrr instead, teamId should be added to the where condition and error should just domainId not found instead of DOMAIN_NOT_OWNED, this will give out that the domain id is valid. i don't want to leak that out

}

// Only allow verified domains to be scoped
if (domain.status !== "SUCCESS") {
Copy link
Contributor

Choose a reason for hiding this comment

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

not sure if we really need this,

});
} catch (error) {
if (error instanceof Error) {
if (error.message === "DOMAIN_NOT_OWNED") {
Copy link
Contributor

Choose a reason for hiding this comment

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

yep, i don't want this error to be show to user

@@ -33,7 +34,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
});
}

const domain = await db.domain.findUnique({
const domain = await db.domain.findFirst({
Copy link
Contributor

Choose a reason for hiding this comment

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

@enemyrr why is this changed

Copy link
Author

Choose a reason for hiding this comment

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

@KMKoushik The change from findUnique to findFirst was necessary because findUnique can only be used with fields that have a unique constraint.

In this case, we're querying with { name: fromDomain, teamId }, while name is unique, the combination of name + teamId is not defined as a unique constraint in the schema.

Using findUnique with non-unique field combinations causes Prisma runtime errors. findFirst is the correct method when filtering by multiple fields that don't form a unique constraint.

Comment on lines +58 to +80
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);

// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}

// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}

return domain;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nah, it's already checked in validateDomainFromEmail

@@ -2,7 +2,6 @@ import { createRoute, z } from "@hono/zod-openapi";
import { DomainSchema } from "~/lib/zod/domain-schema";
Copy link
Contributor

Choose a reason for hiding this comment

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

@enemyrr verify-domain api should also verify this!

@@ -2,7 +2,6 @@ import { createRoute, z } from "@hono/zod-openapi";
import { DomainSchema } from "~/lib/zod/domain-schema";
import { PublicAPIApp } from "~/server/public-api/hono";
Copy link
Contributor

Choose a reason for hiding this comment

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

along with email apis should also be restricted, we are now allowing to fetch emails by other domains as well.

get-email
list-emails
update-email
cancel-email

- Introduce checkIsValidEmailIdWithDomainRestriction function to validate email access based on API key domain restrictions.
- Update email-related API endpoints to utilize the new validation function, ensuring proper domain checks for email actions.
- Modify API key model to include domainId and adjust related logic for domain verification during email operations.
- Enhance error handling for domain access issues in email APIs, providing clearer feedback for unauthorized access.
Copy link

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

Caution

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

⚠️ Outside diff range comments (3)
apps/web/src/server/public-api/api/emails/list-emails.ts (1)

115-124: Bug: createdAt filter overwritten — combine gte/lte instead of replacing

Setting whereClause.createdAt twice drops the first bound. Merge both conditions.

Apply:

-    if (startDate) {
-      whereClause.createdAt = {
-        gte: new Date(startDate),
-      };
-    }
-    if (endDate) {
-      whereClause.createdAt = {
-        lte: new Date(endDate),
-      };
-    }
+    if (startDate || endDate) {
+      whereClause.createdAt = {
+        ...(startDate ? { gte: new Date(startDate) } : {}),
+        ...(endDate ? { lte: new Date(endDate) } : {}),
+      };
+    }
apps/web/src/server/public-api/api/emails/update-email.ts (1)

35-43: Fix OpenAPI description (copy-paste error)

The response description says “Retrieve the user” for an email scheduling endpoint. Update to reflect the actual operation for accurate public API docs.

-      description: "Retrieve the user",
+      description: "Update the email's scheduled time",
apps/web/src/server/public-api/api/emails/cancel-email.ts (1)

24-33: Fix OpenAPI description (copy-paste error)

This endpoint cancels a scheduled email, but the description says “Retrieve the user.” Update to reflect the correct operation.

-      description: "Retrieve the user",
+      description: "Cancel a scheduled email",
♻️ Duplicate comments (2)
apps/web/prisma/schema.prisma (1)

208-215: Optional: Enforce domain–team invariant at DB or service layer

Currently nothing prevents ApiKey.domainId from referencing a Domain of another team purely at the DB level. You already validate at service/router layers elsewhere; consider strengthening:

  • DB-level (requires composite relation): [domainId, teamId] -> Domain.[id, teamId] with a corresponding unique/index on Domain(teamId, id) or @@id([id, teamId]).
  • Or keep service-layer validation but add explicit tests.

If you want a DB-level approach, I can draft the exact Prisma schema changes in a follow-up.

apps/web/src/server/api/routers/api.ts (1)

22-27: Wrap service errors and validate domainId at the API boundary to return proper 400s (and use TRPCError import)

Currently this blindly forwards to addApiKey, and any validation error bubbles up as a generic 500. Per guidelines (“Use try/catch with specific error types”) and product comment not to surface raw errors, wrap and normalize them. Also, validate domainId belongs to ctx.team.id up front to fail fast.

Apply:

-    .mutation(async ({ ctx, input }) => {
-      return await addApiKey({
-        name: input.name,
-        permission: input.permission,
-        teamId: ctx.team.id,
-        domainId: input.domainId,
-      });
-    }),
+    .mutation(async ({ ctx, input }) => {
+      try {
+        if (input.domainId !== undefined) {
+          const domain = await ctx.db.domain.findFirst({
+            where: { id: input.domainId, teamId: ctx.team.id },
+            select: { id: true },
+          });
+          if (!domain) {
+            throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid domainId for this team" });
+          }
+        }
+        return await addApiKey({
+          name: input.name,
+          permission: input.permission,
+          teamId: ctx.team.id,
+          domainId: input.domainId,
+        });
+      } catch (err: any) {
+        // Map known service sentinel errors
+        if (err?.message === "DOMAIN_NOT_FOUND") {
+          throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid domainId for this team" });
+        }
+        throw err;
+      }
+    }),

Note: This also makes the TRPCError import used. If you choose not to handle here, remove the import. Also, return await is only needed because we catch; otherwise it’s redundant.

🧹 Nitpick comments (7)
apps/web/src/server/public-api/api/domains/verify-domain.ts (5)

28-29: Nit: clarify description to match response message.

The success message says “Domain verification started”. Consider making the description consistent.

-      description: "Verify domain",
+      description: "Start domain verification",

59-80: Early-return on domain restriction to avoid unnecessary DB work and simplify logic.

Short-circuit when the API key is scoped to a different domain. This also removes the need for the mutable let domain = null; (which widens to any unless annotated).

-    let domain = null;
-    
-    if (team.apiKey.domainId) {
-      // If API key is restricted to a specific domain, verify the requested domain matches
-      if (domainId === team.apiKey.domainId) {
-        domain = await db.domain.findFirst({
-          where: { 
-            teamId: team.id, 
-            id: domainId
-          },
-        });
-      }
-      // If domainId doesn't match the API key's restriction, domain remains null
-    } else {
-      // API key has access to all team domains
-      domain = await db.domain.findFirst({ 
-        where: { 
-          teamId: team.id, 
-          id: domainId 
-        } 
-      });
-    }
+    const restrictedDomainId = team.apiKey.domainId;
+    if (restrictedDomainId != null && domainId !== restrictedDomainId) {
+      return c.json({ error: "API key doesn't have access to this domain" }, 403);
+    }
+    const domain = await db.domain.findFirst({
+      where: { teamId: team.id, id: domainId },
+    });

90-93: Scope the update by teamId (defense-in-depth) and verify affected rows.

We validated team ownership earlier, but scoping the write and checking the result prevents accidental cross-tenant updates in case of future refactors and avoids TOCTOU edges.

-    await db.domain.update({
-      where: { id: domainId },
-      data: { isVerifying: true },
-    });
+    const { count } = await db.domain.updateMany({
+      where: { id: domainId, teamId: team.id },
+      data: { isVerifying: true },
+    });
+    if (count === 0) {
+      return c.json({ error: "Domain not found" }, 404);
+    }

54-98: Add structured error handling for Prisma errors.

Wrap DB calls in try/catch and map known Prisma errors to 4xx vs 5xx. This follows the repo guideline to use try/catch with specific error types and prevents leaking internals on exceptions.

// At top-level imports (illustrative, outside this hunk)
import { Prisma } from "@prisma/client";

// Inside handler
try {
  // existing handler logic...
} catch (err) {
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    // e.g., map P2025 (record not found) if it can surface here
    return c.json({ error: "Domain not found" }, 404);
  }
  // fallback
  return c.json({ error: "Internal server error" }, 500);
}

58-80: Consider centralizing domain-access checks in a shared service to keep behavior consistent across routes.

The PR adds a domain-access validation pathway in the domain service. Reusing it here would reduce duplication and ensure consistent 403/404 semantics across public API routes.

Happy to refactor this handler to use the shared validator if you point me to the exact helper signature you want standardized.

apps/web/src/server/public-api/api-utils.ts (1)

29-35: Optional: Unify email-id validation helpers

To reduce duplication and ensure consistent domain enforcement, have checkIsValidEmailId call the new function with apiKeyDomainId = null.

Proposed (outside current range):

export const checkIsValidEmailId = async (emailId: string, teamId: number) => {
  await checkIsValidEmailIdWithDomainRestriction(emailId, teamId, null);
};
apps/web/src/server/public-api/api/emails/update-email.ts (1)

27-29: Optional: validate schedule is in the future and pass a Date to the service

To prevent accidental back-dating, validate scheduledAt is in the future. If updateEmail expects a Date, convert here.

-            scheduledAt: z.string().datetime(),
+            scheduledAt: z
+              .string()
+              .datetime()
+              .refine((s) => !Number.isNaN(Date.parse(s)) && new Date(s) > new Date(), {
+                message: "scheduledAt must be a future datetime (ISO 8601)",
+              }),
-    await updateEmail(emailId, {
-      scheduledAt: c.req.valid("json").scheduledAt,
-    });
+    const { scheduledAt } = c.req.valid("json");
+    await updateEmail(emailId, {
+      // If updateEmail accepts string, keep it as-is; otherwise convert to Date:
+      scheduledAt: new Date(scheduledAt),
+    });

Confirm what type updateEmail(...).scheduledAt expects. If it’s a string today, I’ll adjust the diff to avoid the Date conversion and only keep the future-date validation.

Also applies to: 54-55

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 280f83d and 3288090.

📒 Files selected for processing (11)
  • apps/web/prisma/schema.prisma (2 hunks)
  • apps/web/src/server/api/routers/api.ts (3 hunks)
  • apps/web/src/server/public-api/api-utils.ts (1 hunks)
  • apps/web/src/server/public-api/api/domains/verify-domain.ts (1 hunks)
  • apps/web/src/server/public-api/api/emails/cancel-email.ts (2 hunks)
  • apps/web/src/server/public-api/api/emails/get-email.ts (1 hunks)
  • apps/web/src/server/public-api/api/emails/list-emails.ts (1 hunks)
  • apps/web/src/server/public-api/api/emails/update-email.ts (2 hunks)
  • apps/web/src/server/public-api/auth.ts (1 hunks)
  • apps/web/src/server/public-api/hono.ts (1 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/service/api-service.ts
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports, and ensure proper naming of key components.

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
🧠 Learnings (1)
📚 Learning: 2025-08-09T10:37:58.146Z
Learnt from: CR
PR: unsend-dev/unsend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-09T10:37:58.146Z
Learning: Applies to apps/smtp-server/**/*.{ts,tsx} : Use Hono for public API endpoints

Applied to files:

  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
🧬 Code graph analysis (6)
apps/web/src/server/public-api/api/emails/get-email.ts (1)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api/domains/verify-domain.ts (2)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api-utils.ts (2)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/public-api/api/emails/update-email.ts (1)
apps/web/src/server/public-api/api-utils.ts (1)
  • checkIsValidEmailIdWithDomainRestriction (37-58)
apps/web/src/server/public-api/api/emails/cancel-email.ts (1)
apps/web/src/server/public-api/api-utils.ts (1)
  • checkIsValidEmailIdWithDomainRestriction (37-58)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-57)
🔇 Additional comments (5)
apps/web/prisma/schema.prisma (1)

193-194: LGTM: Back-reference from Domain to ApiKeys is correct

The apiKeys: ApiKey[] relation is consistent and improves discoverability from Domain. No issues here.

apps/web/src/server/api/routers/api.ts (1)

42-47: LGTM: Exposing domain context in getApiKeys

Selecting domainId and domain.name is useful for the UI; preserves backward compatibility when domainId is null.

apps/web/src/server/public-api/api/emails/list-emails.ts (1)

126-129: LGTM: Domain scoping precedence

When the API key is domain-scoped, you correctly force whereClause.domainId = apiKey.domainId; otherwise, you honor the query filter. Matches the access-control intent.

apps/web/src/server/public-api/api/emails/update-email.ts (1)

5-5: LGTM: switched to domain-aware validator

Importing checkIsValidEmailIdWithDomainRestriction aligns this endpoint with the new domain-scoped access control.

apps/web/src/server/public-api/api/emails/cancel-email.ts (1)

5-5: LGTM: switched to domain-aware validator

The import aligns this endpoint with domain-scoped access control.

Comment on lines +208 to 215
domainId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Index missing for ApiKey.domainId; add for query performance and future constraints

Domain-scoped lookups (listing keys by domain, enforcing access, etc.) will benefit from an index. Add @@index([domainId]) to ApiKey.

Apply:

 model ApiKey {
   id           Int           @id @default(autoincrement())
   clientId     String        @unique
   tokenHash    String
   partialToken String
   name         String
   permission   ApiPermission @default(SENDING)
   domainId     Int?
+  @@index([domainId])
   createdAt    DateTime      @default(now())
   updatedAt    DateTime      @updatedAt
   lastUsed     DateTime?
   teamId       Int
   team         Team          @relation(fields: [teamId], references: [id], onDelete: Cascade)
   domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: Cascade)
 }

Additionally, given frequent filters by teamId and domainId (and sorts by createdAt), consider in a follow-up migration:

model Email {
  // ...
  @@index([teamId, domainId, createdAt(sort: Desc)])
}
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around lines 208 to 215, the ApiKey model is
missing an index on domainId which will hurt domain-scoped lookups and future
constraints; add an @@index([domainId]) declaration to the ApiKey model (and
then run the Prisma migration to apply it). Also consider as a follow-up adding
a composite index for Email on teamId + domainId + createdAt (sorted Desc) to
optimize common filtered-and-sorted queries.

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Schema/migration mismatch on ApiKey.domain referential action (Cascade vs SetNull) — pick one and align everywhere

The Prisma schema uses onDelete: Cascade for ApiKey.domain, but the migration (20250822125136...) sets the FK to ON DELETE SET NULL. This discrepancy can lead to drift warnings and unexpected runtime behavior. Given the prior product preference (“i'd rather delete the api key”), prefer Cascade and update the migration accordingly. Otherwise, switch the schema back to SetNull.

Action:

  • Update the existing migration or create a follow-up migration to align the FK action with the schema choice.
  • Add an entry in the changelog describing the semantics on domain deletion.

Apply this change in the Prisma schema only if you decide to keep SetNull instead:

-  domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: Cascade)
+  domain       Domain?       @relation(fields: [domainId], references: [id], onDelete: SetNull)

If keeping Cascade (recommended per product comment), alter the FK in the migration to ON DELETE CASCADE and re-generate the client.

📝 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
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around line 214 there is a referential-action
mismatch: the schema currently declares ApiKey.domain with onDelete: Cascade
while the existing migration SQL uses ON DELETE SET NULL; choose one and align
everywhere — recommended: keep Cascade, update the migration to use ON DELETE
CASCADE (or if you prefer SetNull, change the schema back to SetNull), then
either edit the existing migration SQL to replace SET NULL with CASCADE or
create a new follow-up migration that changes the FK to ON DELETE CASCADE, run
prisma migrate/resolve as appropriate, regenerate the Prisma client (prisma
generate), and add a changelog entry describing the domain-deletion semantics
for ApiKey.

Comment on lines +37 to +58
export const checkIsValidEmailIdWithDomainRestriction = async (
emailId: string,
teamId: number,
apiKeyDomainId?: number
) => {
const whereClause: { id: string; teamId: number; domainId?: number } = {
id: emailId,
teamId,
};

if (apiKeyDomainId !== undefined) {
whereClause.domainId = apiKeyDomainId;
}

const email = await db.email.findUnique({ where: whereClause });

if (!email) {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}

return email;
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix two issues: (1) null vs undefined check causes unintended filtering; (2) findUnique with non-unique where

  • If callers pass null (they do: team.apiKey.domainId is number | null), the current !== undefined check sets domainId: null, restricting to emails with NULL domainId — wrong.
  • findUnique({ where: { id, teamId, domainId? }}) is invalid unless there’s a composite unique; use findFirst.

Apply:

-export const checkIsValidEmailIdWithDomainRestriction = async (
-  emailId: string, 
-  teamId: number, 
-  apiKeyDomainId?: number
-) => {
-  const whereClause: { id: string; teamId: number; domainId?: number } = {
-    id: emailId,
-    teamId,
-  };
-
-  if (apiKeyDomainId !== undefined) {
-    whereClause.domainId = apiKeyDomainId;
-  }
-
-  const email = await db.email.findUnique({ where: whereClause });
+export const checkIsValidEmailIdWithDomainRestriction = async (
+  emailId: string,
+  teamId: number,
+  apiKeyDomainId: number | null
+) => {
+  const whereClause = {
+    id: emailId,
+    teamId,
+    ...(apiKeyDomainId !== null ? { domainId: apiKeyDomainId } : {}),
+  };
+
+  const email = await db.email.findFirst({ where: whereClause });
 
   if (!email) {
     throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
   }
 
   return email;
 };

Follow-up: consider refactoring existing checkIsValidEmailId to delegate to this function for consistency.

📝 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
export const checkIsValidEmailIdWithDomainRestriction = async (
emailId: string,
teamId: number,
apiKeyDomainId?: number
) => {
const whereClause: { id: string; teamId: number; domainId?: number } = {
id: emailId,
teamId,
};
if (apiKeyDomainId !== undefined) {
whereClause.domainId = apiKeyDomainId;
}
const email = await db.email.findUnique({ where: whereClause });
if (!email) {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}
return email;
};
export const checkIsValidEmailIdWithDomainRestriction = async (
emailId: string,
teamId: number,
apiKeyDomainId: number | null
) => {
const whereClause = {
id: emailId,
teamId,
...(apiKeyDomainId !== null ? { domainId: apiKeyDomainId } : {}),
};
const email = await db.email.findFirst({ where: whereClause });
if (!email) {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}
return email;
};
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api-utils.ts around lines 37 to 58, the
function incorrectly treats a null apiKeyDomainId as a valid filter and uses
findUnique with a non-unique where clause; update the guard to only add domainId
when apiKeyDomainId is not null or undefined (e.g., if (apiKeyDomainId != null)
whereClause.domainId = apiKeyDomainId), and replace db.email.findUnique(...)
with db.email.findFirst(...) so the query works without a composite unique;
afterwards consider refactoring checkIsValidEmailId to call this function for
consistency.

Comment on lines +28 to 49
description: "Verify domain",
},
403: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Forbidden - API key doesn't have access to this domain",
},
404: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Domain not found",
},
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

OpenAPI declares 403 but handler never returns it — align the contract and implementation.

You document a 403 (“Forbidden - API key doesn't have access to this domain”), but the handler currently always returns 404 in the failure branch. Either change the handler to return 403 for access-denied or remove the 403 from the spec. I’ve proposed a handler fix in another comment (Lines 82–88).


Align OpenAPI 403 response with handler implementation

The OpenAPI spec in apps/web/src/server/public-api/api/domains/verify-domain.ts (lines 28–49) declares a 403 (“Forbidden – API key doesn’t have access to this domain”), but the handler’s error branch (around lines 82–88) always returns a 404. To keep contract and code in sync, please choose one:

  • Update the handler so that when the API key lacks access it returns a 403 status with the { error: string } payload as documented.
  • Or, if access checks are consolidated elsewhere, remove the 403 entry from the OpenAPI definition.

Files to update:

  • apps/web/src/server/public-api/api/domains/verify-domain.ts
    • Remove or adjust the 403 response schema (lines 28–49)
    • Change the error branch to res.status(403).send({ error }) (around lines 82–88)
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/domains/verify-domain.ts around lines
28–49 and the handler around lines 82–88, the OpenAPI spec declares a 403
response but the handler currently always returns 404 for access-denied cases;
fix by either (A) updating the handler error branch to return
res.status(403).send({ error }) when the API key lacks access so it matches the
documented 403 schema, or (B) removing the 403 response block from the OpenAPI
definition if access checks are enforced elsewhere; apply the corresponding
change in this file so the contract and implementation are consistent.

Comment on lines +82 to +88
if (!domain) {
return c.json({
error: team.apiKey.domainId
? "API key doesn't have access to this domain"
: "Domain not found"
}, 404);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Return 403 for access-denied and 404 for missing domain.

This matches the OpenAPI and provides clearer semantics.

-    if (!domain) {
-      return c.json({
-        error: team.apiKey.domainId 
-          ? "API key doesn't have access to this domain" 
-          : "Domain not found"
-      }, 404);
-    }
+    if (!domain) {
+      const restricted = team.apiKey.domainId != null;
+      return c.json(
+        {
+          error: restricted
+            ? "API key doesn't have access to this domain"
+            : "Domain not found",
+        },
+        restricted ? 403 : 404,
+      );
+    }
📝 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
if (!domain) {
return c.json({
error: team.apiKey.domainId
? "API key doesn't have access to this domain"
: "Domain not found"
}, 404);
}
if (!domain) {
const restricted = team.apiKey.domainId != null;
return c.json(
{
error: restricted
? "API key doesn't have access to this domain"
: "Domain not found",
},
restricted ? 403 : 404,
);
}
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/domains/verify-domain.ts around lines 82
to 88, the handler currently returns a 404 for both missing domains and
access-denied cases; update the response so that if domain is falsy and
team.apiKey.domainId is set you return a 403 status with the access-denied
message, otherwise return a 404 with the "Domain not found" message, preserving
the same JSON shape.

@@ -37,7 +37,7 @@ function cancelScheduledEmail(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const emailId = c.req.param("emailId");
await checkIsValidEmailId(emailId, team.id);
await checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Bug: same null domainId handling and validator misuse apply here

This call will also pass null for legacy keys, which the current validator treats as an explicit domainId = null filter, denying access to emails for verified domains. Please apply the helper fix proposed in the other comment to restore backward compatibility and to emit a proper 403 when access is denied by domain.

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/emails/cancel-email.ts around line 40, the
call to checkIsValidEmailIdWithDomainRestriction(emailId, team.id,
team.apiKey.domainId) will pass null for legacy keys which the validator treats
as an explicit null filter and denies access; replace the direct pass with the
same helper used in the other comment that converts legacy null domainId to
undefined (restoring backward compatibility), and ensure the validator's
rejection is caught and converted to an HTTP 403 response when access is denied
by domain.

Comment on lines +63 to 74
const whereClause: { id: string; teamId: number; domainId?: number } = {
id: emailId,
teamId: team.id,
};

if (team.apiKey.domainId !== null) {
whereClause.domainId = team.apiKey.domainId;
}

const email = await db.email.findUnique({
where: {
id: emailId,
teamId: team.id,
},
where: whereClause,
select: {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: Using findUnique with non-unique composite where (id + teamId [+ domainId]) will not compile/run

findUnique only accepts a unique selector (e.g., { id }) or a declared composite unique. Adding teamId/domainId makes it an invalid UniqueWhere. Use findFirst with a normal where filter and preserve domain scoping.

Apply:

-    const whereClause: { id: string; teamId: number; domainId?: number } = {
-      id: emailId,
-      teamId: team.id,
-    };
-
-    if (team.apiKey.domainId !== null) {
-      whereClause.domainId = team.apiKey.domainId;
-    }
+    const whereClause = {
+      id: emailId,
+      teamId: team.id,
+      ...(team.apiKey.domainId !== null ? { domainId: team.apiKey.domainId } : {}),
+    };
@@
-    const email = await db.email.findUnique({
-      where: whereClause,
+    const email = await db.email.findFirst({
+      where: whereClause,
       select: {

Optional: import and use Prisma.EmailWhereInput for stricter typing of whereClause.

📝 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
const whereClause: { id: string; teamId: number; domainId?: number } = {
id: emailId,
teamId: team.id,
};
if (team.apiKey.domainId !== null) {
whereClause.domainId = team.apiKey.domainId;
}
const email = await db.email.findUnique({
where: {
id: emailId,
teamId: team.id,
},
where: whereClause,
select: {
const whereClause = {
id: emailId,
teamId: team.id,
...(team.apiKey.domainId !== null ? { domainId: team.apiKey.domainId } : {}),
};
const email = await db.email.findFirst({
where: whereClause,
select: {
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/emails/get-email.ts around lines 63–74,
the code builds a composite whereClause (id + teamId [+ domainId]) but calls
db.email.findUnique, which requires a unique selector; change to
db.email.findFirst and pass the built whereClause as the normal where filter so
non-unique composite filters compile and run correctly. Keep the domain scoping
logic (set domainId only when team.apiKey.domainId !== null), and optionally
import and type whereClause as Prisma.EmailWhereInput for stricter typing.

@@ -48,7 +48,7 @@ function updateEmailScheduledAt(app: PublicAPIApp) {
const team = c.var.team;
const emailId = c.req.param("emailId");

await checkIsValidEmailId(emailId, team.id);
await checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Bug: null domainId is treated as a filter (breaking “All Domains”) and validator likely misuses Prisma findUnique

Passing team.apiKey.domainId directly will be null for legacy “All Domains” keys. The current validator (see api-utils.ts snippet in the PR) checks apiKeyDomainId !== undefined, which is true for null and will set whereClause.domainId = null. That restricts queries to emails with domainId IS NULL, inadvertently denying access to emails belonging to any domain and breaking backward compatibility.

Additionally, the validator uses db.email.findUnique({ where: { id, teamId, domainId? } }). In Prisma, findUnique only accepts a unique selector (e.g., by id, or a defined composite unique). If id is already unique, adding teamId/domainId will not match the UniqueWhereInput type, causing a type error or being rejected by the client. The correct approach is either findFirst with a where filter, or fetch by { id, teamId } and then enforce domain.

Action:

  • Treat null the same as “no domain restriction” (don’t add domainId to the filter).
  • Replace findUnique with findFirst (or fetch by { id, teamId } then check domain).
  • Return a clear 403/“FORBIDDEN” error when the email exists but the key’s domain doesn’t match, to meet the PR objective “returns clear error messages when a key lacks domain access.”

Proposed fix for apps/web/src/server/public-api/api-utils.ts:

-export const checkIsValidEmailIdWithDomainRestriction = async (
-  emailId: string, 
-  teamId: number, 
-  apiKeyDomainId?: number
-) => {
-  const whereClause: { id: string; teamId: number; domainId?: number } = {
-    id: emailId,
-    teamId,
-  };
-
-  if (apiKeyDomainId !== undefined) {
-    whereClause.domainId = apiKeyDomainId;
-  }
-
-  const email = await db.email.findUnique({ where: whereClause });
-
-  if (!email) {
-    throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
-  }
-
-  return email;
-};
+export const checkIsValidEmailIdWithDomainRestriction = async (
+  emailId: string,
+  teamId: number,
+  apiKeyDomainId?: number | null
+) => {
+  // Fetch by id+teamId first (single query) then enforce domain restriction.
+  const email = await db.email.findFirst({
+    where: { id: emailId, teamId },
+    select: { id: true, teamId: true, domainId: true },
+  });
+
+  if (!email) {
+    throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
+  }
+
+  // Only enforce domain check when the API key is actually scoped.
+  if (apiKeyDomainId != null && email.domainId !== apiKeyDomainId) {
+    throw new UnsendApiError({
+      code: "FORBIDDEN",
+      message: "API key does not have access to this email's domain",
+    });
+  }
+
+  return email;
+};

No changes are needed at this call site after updating the helper.

I can push a follow-up commit with this fix and add tests covering: legacy key (null domain), scoped key allowed domain, scoped key disallowed domain (403), and non-existent email (404).

📝 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 checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);
// apps/web/src/server/public-api/api-utils.ts
export const checkIsValidEmailIdWithDomainRestriction = async (
emailId: string,
teamId: number,
apiKeyDomainId?: number | null
) => {
// Fetch by id+teamId first (single query) then enforce domain restriction.
const email = await db.email.findFirst({
where: { id: emailId, teamId },
select: { id: true, teamId: true, domainId: true },
});
if (!email) {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}
// Only enforce domain check when the API key is actually scoped.
if (apiKeyDomainId != null && email.domainId !== apiKeyDomainId) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: "API key does not have access to this email's domain",
});
}
return email;
};

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