Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ApiKey" ADD COLUMN "domainId" INTEGER;

-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE;
3 changes: 3 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ model Domain {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
apiKeys ApiKey[]
}

enum ApiPermission {
Expand All @@ -209,11 +210,13 @@ model ApiKey {
partialToken String
name String
permission ApiPermission @default(SENDING)
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, onUpdate: Cascade)
}

enum EmailStatus {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ import {
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";

const apiKeySchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
}),
domainId: z.string().optional(),
});

export default function AddApiKey() {
Expand All @@ -41,12 +49,15 @@ export default function AddApiKey() {
const [isCopied, setIsCopied] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);

const domainsQuery = api.domain.domains.useQuery();

const utils = api.useUtils();

const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
resolver: zodResolver(apiKeySchema),
defaultValues: {
name: "",
domainId: "all",
},
});

Expand All @@ -55,14 +66,16 @@ export default function AddApiKey() {
{
name: values.name,
permission: "FULL",
domainId:
values.domainId === "all" ? undefined : Number(values.domainId),
},
{
onSuccess: (data) => {
utils.apiKey.invalidate();
setApiKey(data);
apiKeyForm.reset();
},
},
}
);
}

Expand Down Expand Up @@ -180,6 +193,41 @@ export default function AddApiKey() {
</FormItem>
)}
/>
<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>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] hover:bg-gray-100 focus:bg-gray-100"
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default function ApiList() {
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Token</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Domain Access</TableHead>
<TableHead>Last used</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="rounded-tr-xl">Action</TableHead>
Expand All @@ -33,7 +34,7 @@ export default function ApiList() {
<TableBody>
{apiKeysQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<TableCell colSpan={7} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
Expand All @@ -42,7 +43,7 @@ export default function ApiList() {
</TableRow>
) : apiKeysQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<TableCell colSpan={7} className="text-center py-4">
<p>No API keys added</p>
</TableCell>
</TableRow>
Expand All @@ -52,9 +53,14 @@ export default function ApiList() {
<TableCell>{apiKey.name}</TableCell>
<TableCell>{apiKey.partialToken}</TableCell>
<TableCell>{apiKey.permission}</TableCell>
<TableCell>
{apiKey.domainId
? apiKey.domain?.name ?? "Domain removed"
: "All domains"}
</TableCell>
<TableCell>
{apiKey.lastUsed
? formatDistanceToNow(apiKey.lastUsed)
? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
: "Never"}
</TableCell>
<TableCell>
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/server/api/routers/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { z } from "zod";
import { ApiPermission } from "@prisma/client";
import { TRPCError } from "@trpc/server";

import {
apiKeyProcedure,
Expand All @@ -10,13 +12,18 @@ import { addApiKey, deleteApiKey } from "~/server/service/api-service";
export const apiRouter = createTRPCRouter({
createToken: teamProcedure
.input(
z.object({ name: z.string(), permission: z.enum(["FULL", "SENDING"]) })
z.object({
name: z.string(),
permission: z.nativeEnum(ApiPermission),
domainId: z.number().int().positive().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return addApiKey({
return await addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
Comment on lines +22 to 27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Map service error to typed TRPC error

addApiKey throws "DOMAIN_NOT_FOUND". Convert to TRPC NOT_FOUND so clients get a proper 404-ish signal instead of 500.

-    .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 {
+        return await addApiKey({
+          name: input.name,
+          permission: input.permission,
+          teamId: ctx.team.id,
+          domainId: input.domainId,
+        });
+      } catch (err) {
+        if (err instanceof Error && err.message === "DOMAIN_NOT_FOUND") {
+          throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" });
+        }
+        throw err;
+      }
+    }),
📝 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 await addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
.mutation(async ({ ctx, input }) => {
try {
return await addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
} catch (err) {
if (err instanceof Error && err.message === "DOMAIN_NOT_FOUND") {
throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" });
}
throw err;
}
}),
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/api.ts around lines 22 to 27, add error
mapping when calling addApiKey: wrap the await addApiKey(...) call in a
try/catch, import TRPCError from '@trpc/server', and if the caught error has
code === 'DOMAIN_NOT_FOUND' throw new TRPCError({ code: 'NOT_FOUND', message:
error.message || 'Domain not found' }); otherwise rethrow the original error so
other failures surface as before.

}),

Expand All @@ -32,6 +39,12 @@ export const apiRouter = createTRPCRouter({
partialToken: true,
lastUsed: true,
createdAt: true,
domainId: true,
domain: {
select: {
name: true,
},
},
},
});

Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/server/public-api/api-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,26 @@ export const checkIsValidEmailId = async (emailId: string, teamId: number) => {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}
};

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" });
}
Comment on lines +47 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Critical: null domainId narrows the query unexpectedly (unrestricted keys break); also use findFirst for composite filters

When team.apiKey.domainId is null (unrestricted key), this sets whereClause.domainId = null, filtering to emails with domainId = null and causing false NOT_FOUND. Additionally, findUnique with non-unique combinations is fragile; prefer findFirst with a compound where.

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 email = await db.email.findFirst({
+    where: {
+      id: emailId,
+      teamId,
+      ...(apiKeyDomainId != null ? { domainId: apiKeyDomainId } : {}),
+    },
+  });
 
   if (!email) {
     throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
   }
 
   return email;
 };
📝 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 (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" });
}
export const checkIsValidEmailIdWithDomainRestriction = async (
emailId: string,
teamId: number,
apiKeyDomainId?: number | null
) => {
const email = await db.email.findFirst({
where: {
id: emailId,
teamId,
...(apiKeyDomainId != null ? { domainId: apiKeyDomainId } : {}),
},
});
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 47 to 55, the code
currently sets whereClause.domainId to apiKeyDomainId which, when apiKeyDomainId
is null (an unrestricted key), narrows the query to domainId = null and causes
false NOT_FOUNDs, and it uses findUnique which is fragile for composite filters;
fix by only adding domainId to the whereClause when apiKeyDomainId is neither
undefined nor null (i.e., check apiKeyDomainId !== undefined && apiKeyDomainId
!== null), and replace db.email.findUnique(...) with db.email.findFirst({ where:
whereClause }) so the query is not incorrectly restricted and supports
non-unique/composite filters.


return email;
};
10 changes: 7 additions & 3 deletions apps/web/src/server/public-api/api/domains/get-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
import { db } from "~/server/db";
import { getTeamFromToken } from "~/server/public-api/auth";

const route = createRoute({
method: "get",
Expand All @@ -14,7 +13,7 @@ const route = createRoute({
schema: z.array(DomainSchema),
},
},
description: "Retrieve the user",
description: "Retrieve domains accessible by the API key",
},
},
});
Expand All @@ -23,7 +22,12 @@ function getDomains(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;

const 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 } });

return c.json(domains);
});
Expand Down
60 changes: 57 additions & 3 deletions apps/web/src/server/public-api/api/domains/verify-domain.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { db } from "~/server/db";

const route = createRoute({
Expand All @@ -26,15 +25,70 @@ const route = createRoute({
}),
},
},
description: "Create a new domain",
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",
},
Comment on lines +28 to 49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Docs/code mismatch for 403 vs 404

OpenAPI declares a 403, but the handler always returns 404 on denied access. Return 403 when the API key is restricted to another domain.

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/domains/verify-domain.ts around lines
28-49 the OpenAPI spec declares a 403 for "API key doesn't have access to this
domain" but the handler currently always returns 404 on denied access; update
the request handling so that when an API key is valid but restricted to a
different domain the handler responds with HTTP 403 (with a JSON body matching
the existing { error: string } schema), and only return 404 when the domain
genuinely does not exist; ensure the condition checks the API key's domain
ownership/permissions before the not-found branch and return the appropriate 403
response there.

},
});

function verifyDomain(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const domainId = c.req.valid("param").id;

// Check if API key has access to this domain
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
}
});
}

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

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Return correct status for access denial

Send 403 when the API key is restricted to a different domain; 404 when the domain doesn't exist for the team.

-    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 isForbidden =
+        Boolean(team.apiKey.domainId) && domainId !== team.apiKey.domainId;
+      return c.json(
+        {
+          error: isForbidden
+            ? "API key doesn't have access to this domain"
+            : "Domain not found",
+        },
+        isForbidden ? 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 isForbidden =
Boolean(team.apiKey.domainId) && domainId !== team.apiKey.domainId;
return c.json(
{
error: isForbidden
? "API key doesn't have access to this domain"
: "Domain not found",
},
isForbidden ? 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 404 for any missing domain; change this so
that if the requesting API key is restricted (team.apiKey.domainId is truthy)
you return a 403 JSON response indicating access is denied, otherwise return a
404 JSON response indicating the domain was not found for the team. Implement
the conditional check and set the appropriate HTTP status code and message
accordingly.


await db.domain.update({
where: { id: c.req.valid("param").id },
where: { id: domainId },
data: { isVerifying: true },
});

Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/server/public-api/api/emails/cancel-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { cancelEmail } from "~/server/service/email-service";
import { checkIsValidEmailId } from "../../api-utils";
import { checkIsValidEmailIdWithDomainRestriction } from "../../api-utils";

const route = createRoute({
method: "post",
Expand Down Expand Up @@ -37,7 +37,11 @@ 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 ?? undefined
);

await cancelEmail(emailId);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/server/public-api/api/emails/get-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ const route = createRoute({
function send(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;

const emailId = c.req.param("emailId");

const email = await db.email.findUnique({
where: {
id: emailId,
teamId: team.id,
domainId: team.apiKey.domainId ?? undefined,
},
select: {
id: true,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/server/public-api/api/emails/list-emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ function listEmails(app: PublicAPIApp) {
};
}

if (domainId && domainId.length > 0) {
if (team.apiKey.domainId !== null) {
whereClause.domainId = team.apiKey.domainId;
} else if (domainId && domainId.length > 0) {
whereClause.domainId = { in: domainId };
}

Expand Down
Loading