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
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,11 @@ Server actions should be used for mutations (POST/PUT/DELETE operations), not fo

## Authentication

Use `withAuthV2` or `withOptionalAuthV2` from `@/withAuthV2` to protect server actions and API routes.
Use `withAuth` or `withOptionalAuth` from `@/middleware/withAuth` to protect server actions and API routes.

- **`withAuthV2`** - Requires authentication. Returns `notAuthenticated()` if user is not logged in.
- **`withOptionalAuthV2`** - Allows anonymous access if the org has anonymous access enabled. `user` may be `undefined`.
- **`withMinimumOrgRole`** - Wrap inside auth context to require a minimum role (e.g., `OrgRole.OWNER`).
- **`withAuth`** - Requires authentication. Returns `notAuthenticated()` if user is not logged in.
- **`withOptionalAuth`** - Allows anonymous access if the org has anonymous access enabled. `user` may be `undefined`.
- **`withMinimumOrgRole`** - Wrap inside auth context to require a minimum role (e.g., `OrgRole.OWNER`). Import from `@/middleware/withMinimumOrgRole`.

**Important:** Always use the `prisma` instance provided by the auth context. This instance has `userScopedPrismaClientExtension` applied, which enforces repository visibility rules (e.g., filtering repos based on user permissions). Do NOT import `prisma` directly from `@/prisma` in actions or routes that return data to the client.

Expand All @@ -168,19 +168,19 @@ Use `withAuthV2` or `withOptionalAuthV2` from `@/withAuthV2` to protect server a
```ts
'use server';

import { sew } from "@/actions";
import { withAuthV2 } from "@/withAuthV2";
import { sew } from "@/middleware/sew";
import { withAuth } from "@/middleware/withAuth";

export const myProtectedAction = async ({ id }: { id: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
withAuth(async ({ org, user, prisma }) => {
// user is guaranteed to be defined
// prisma is scoped to the user
return { success: true };
})
);

export const myPublicAction = async ({ id }: { id: string }) => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
withOptionalAuth(async ({ org, user, prisma }) => {
// user may be undefined for anonymous access
return { success: true };
})
Expand All @@ -192,10 +192,10 @@ export const myPublicAction = async ({ id }: { id: string }) => sew(() =>
```ts
import { serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { withAuthV2 } from "@/withAuthV2";
import { withAuth } from "@/middleware/withAuth";

export const GET = apiHandler(async (request: NextRequest) => {
const result = await withAuthV2(async ({ org, user, prisma }) => {
const result = await withAuth(async ({ org, user, prisma }) => {
// ... your logic
return data;
});
Expand Down
78 changes: 29 additions & 49 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import { getAuditService } from "@/ee/features/audit/factory";
import { env, getSMTPConnectionURL } from "@sourcebot/shared";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, orgNotFound, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
import { notFound, orgNotFound, ServiceError } from "@/lib/serviceError";
import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma";
import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs';
import { generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/shared";
import { ApiKey, ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
Expand All @@ -26,36 +25,17 @@ import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
import { ApiKeyPayload, RepositoryQuery } from "./lib/types";
import { withAuthV2, withOptionalAuthV2, withMinimumOrgRole, withAuthV2_skipOrgMembershipCheck } from "./withAuthV2";
import { withAuth, withOptionalAuth, withAuth_skipOrgMembershipCheck } from "./middleware/withAuth";
import { withMinimumOrgRole } from "./middleware/withMinimumOrgRole";
import { getBrowsePath } from "./app/[domain]/browse/hooks/utils";
import { sew } from "@/middleware/sew";

const logger = createLogger('web-actions');
const auditService = getAuditService();

/**
* "Service Error Wrapper".
*
* Captures any thrown exceptions, logs them to the console and Sentry,
* and returns a generic unexpected service error.
*/
export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> => {
try {
return await fn();
} catch (e) {
Sentry.captureException(e);
logger.error(e);

if (e instanceof ServiceErrorException) {
return e.serviceError;
}

return unexpectedError(`An unexpected error occurred. Please try again later.`);
}
}

////// Actions ///////
export const completeOnboarding = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuthV2(async ({ org, prisma }) => {
withAuth(async ({ org, prisma }) => {
await prisma.org.update({
where: { id: org.id },
data: {
Expand Down Expand Up @@ -122,7 +102,7 @@ export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiK


export const createApiKey = async (name: string): Promise<{ key: string } | ServiceError> => sew(() =>
withAuthV2(async ({ org, user, role, prisma }) => {
withAuth(async ({ org, user, role, prisma }) => {
if ((env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true' || env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') && role !== OrgRole.OWNER) {
logger.error(`API key creation is disabled for non-admin users. User ${user.id} is not an owner.`);
return {
Expand Down Expand Up @@ -192,7 +172,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv
}));

export const deleteApiKey = async (name: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
withAuth(async ({ org, user, prisma }) => {
const apiKey = await prisma.apiKey.findFirst({
where: {
name,
Expand Down Expand Up @@ -252,7 +232,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } |
}));

export const getUserApiKeys = async (): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
withAuth(async ({ org, user, prisma }) => {
const apiKeys = await prisma.apiKey.findMany({
where: {
orgId: org.id,
Expand All @@ -277,7 +257,7 @@ export const getRepos = async ({
where?: Prisma.RepoWhereInput,
take?: number
} = {}) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
withOptionalAuth(async ({ org, prisma }) => {
const repos = await prisma.repo.findMany({
where: {
orgId: org.id,
Expand Down Expand Up @@ -313,7 +293,7 @@ export const getRepos = async ({
* Returns a set of aggregated stats about the repos in the org
*/
export const getReposStats = async () => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
withOptionalAuth(async ({ org, prisma }) => {
const [
// Total number of repos.
numberOfRepos,
Expand Down Expand Up @@ -364,7 +344,7 @@ export const getReposStats = async () => sew(() =>
)

export const getConnectionStats = async () => sew(() =>
withAuthV2(async ({ org, prisma }) => {
withAuth(async ({ org, prisma }) => {
const [
numberOfConnections,
numberOfConnectionsWithFirstTimeSyncJobsInProgress,
Expand Down Expand Up @@ -400,7 +380,7 @@ export const getConnectionStats = async () => sew(() =>
);

export const getRepoInfoByName = async (repoName: string) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
withOptionalAuth(async ({ org, prisma }) => {
// @note: repo names are represented by their remote url
// on the code host. E.g.,:
// - github.com/sourcebot-dev/sourcebot
Expand Down Expand Up @@ -459,7 +439,7 @@ export const getRepoInfoByName = async (repoName: string) => sew(() =>
}));

export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
withOptionalAuth(async ({ org, prisma }) => {
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
return {
statusCode: StatusCodes.BAD_REQUEST,
Expand Down Expand Up @@ -595,12 +575,12 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
}));

export const getCurrentUserRole = async (): Promise<OrgRole | ServiceError> => sew(() =>
withOptionalAuthV2(async ({ role }) => {
withOptionalAuth(async ({ role }) => {
return role;
}));

export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuthV2(async ({ org, user, role, prisma }) =>
withAuth(async ({ org, user, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const failAuditCallback = async (error: string) => {
await auditService.createAudit({
Expand Down Expand Up @@ -776,7 +756,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
));

export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuthV2(async ({ org, role, prisma }) =>
withAuth(async ({ org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const invite = await prisma.invite.findUnique({
where: {
Expand All @@ -802,7 +782,7 @@ export const cancelInvite = async (inviteId: string): Promise<{ success: boolean
));

export const getMe = async () => sew(() =>
withAuthV2(async ({ user, prisma }) => {
withAuth(async ({ user, prisma }) => {
const userWithOrgs = await prisma.user.findUnique({
where: {
id: user.id,
Expand Down Expand Up @@ -835,7 +815,7 @@ export const getMe = async () => sew(() =>
}));

export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuthV2_skipOrgMembershipCheck(async ({ user, prisma }) => {
withAuth_skipOrgMembershipCheck(async ({ user, prisma }) => {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
Expand Down Expand Up @@ -909,7 +889,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
}));

export const getInviteInfo = async (inviteId: string) => sew(() =>
withAuthV2_skipOrgMembershipCheck(async ({ user, prisma }) => {
withAuth_skipOrgMembershipCheck(async ({ user, prisma }) => {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
Expand Down Expand Up @@ -946,7 +926,7 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}));

export const getOrgMembers = async () => sew(() =>
withAuthV2(async ({ org, prisma }) => {
withAuth(async ({ org, prisma }) => {
const members = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
Expand All @@ -970,7 +950,7 @@ export const getOrgMembers = async () => sew(() =>
}));

export const getOrgInvites = async () => sew(() =>
withAuthV2(async ({ org, prisma }) => {
withAuth(async ({ org, prisma }) => {
const invites = await prisma.invite.findMany({
where: {
orgId: org.id,
Expand All @@ -985,7 +965,7 @@ export const getOrgInvites = async () => sew(() =>
}));

export const getOrgAccountRequests = async () => sew(() =>
withAuthV2(async ({ org, prisma }) => {
withAuth(async ({ org, prisma }) => {
const requests = await prisma.accountRequest.findMany({
where: {
orgId: org.id,
Expand Down Expand Up @@ -1122,7 +1102,7 @@ export const getMemberApprovalRequired = async (domain: string): Promise<boolean
});

export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
withAuthV2(async ({ org, role, prisma }) =>
withAuth(async ({ org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
await prisma.org.update({
where: { id: org.id },
Expand All @@ -1137,7 +1117,7 @@ export const setMemberApprovalRequired = async (required: boolean): Promise<{ su
);

export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
withAuthV2(async ({ org, role, prisma }) =>
withAuth(async ({ org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
await prisma.org.update({
where: { id: org.id },
Expand All @@ -1152,7 +1132,7 @@ export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success:
);

export const approveAccountRequest = async (requestId: string) => sew(async () =>
withAuthV2(async ({ org, user, role, prisma }) =>
withAuth(async ({ org, user, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const failAuditCallback = async (error: string) => {
await auditService.createAudit({
Expand Down Expand Up @@ -1242,7 +1222,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () =
));

export const rejectAccountRequest = async (requestId: string) => sew(() =>
withAuthV2(async ({ org, role, prisma }) =>
withAuth(async ({ org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const request = await prisma.accountRequest.findUnique({
where: {
Expand All @@ -1268,7 +1248,7 @@ export const rejectAccountRequest = async (requestId: string) => sew(() =>


export const getSearchContexts = async () => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
withOptionalAuth(async ({ org, prisma }) => {
const searchContexts = await prisma.searchContext.findMany({
where: {
orgId: org.id,
Expand All @@ -1287,7 +1267,7 @@ export const getSearchContexts = async () => sew(() =>
}));

export const getRepoImage = async (repoId: number): Promise<ArrayBuffer | ServiceError> => sew(async () => {
return await withOptionalAuthV2(async ({ org, prisma }) => {
return await withOptionalAuth(async ({ org, prisma }) => {
const repo = await prisma.repo.findUnique({
where: {
id: repoId,
Expand Down Expand Up @@ -1383,7 +1363,7 @@ export const getAnonymousAccessStatus = async (domain: string): Promise<boolean
});

export const setAnonymousAccessStatus = async (enabled: boolean): Promise<ServiceError | boolean> => sew(async () => {
return await withAuthV2(async ({ org, role, prisma }) => {
return await withAuth(async ({ org, role, prisma }) => {
return await withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
if (!hasAnonymousAccessEntitlement) {
Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/app/[domain]/askgh/[owner]/[repo]/api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import 'server-only';

import { sew } from '@/actions';
import { sew } from "@/middleware/sew";
import { notFound, ServiceError } from '@/lib/serviceError';
import { withOptionalAuthV2 } from '@/withAuthV2';
import { withOptionalAuth } from '@/middleware/withAuth';
import { RepoInfo } from './types';

export const getRepoInfo = async (repoId: number): Promise<RepoInfo | ServiceError> => sew(() =>
withOptionalAuthV2(async ({ prisma }) => {
withOptionalAuth(async ({ prisma }) => {
const repo = await prisma.repo.findUnique({
where: { id: repoId },
include: {
Expand Down
7 changes: 4 additions & 3 deletions packages/web/src/app/[domain]/repos/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCurrentUserRole, sew } from "@/actions"
import { getCurrentUserRole } from "@/actions"
import { sew } from "@/middleware/sew"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
Expand All @@ -8,7 +9,7 @@ import { env } from "@sourcebot/shared"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { ServiceErrorException } from "@/lib/serviceError"
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"
import { withOptionalAuthV2 } from "@/withAuthV2"
import { withOptionalAuth } from "@/middleware/withAuth"
import { getConfigSettings, repoMetadataSchema } from "@sourcebot/shared"
import { ExternalLink, Info } from "lucide-react"
import Image from "next/image"
Expand Down Expand Up @@ -190,7 +191,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
}

const getRepoWithJobs = async (repoId: number) => sew(() =>
withOptionalAuthV2(async ({ prisma, org }) => {
withOptionalAuth(async ({ prisma, org }) => {

const repo = await prisma.repo.findUnique({
where: {
Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/app/[domain]/repos/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { sew } from "@/actions";
import { sew } from "@/middleware/sew";
import { ServiceErrorException } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { withOptionalAuthV2 } from "@/withAuthV2";
import { withOptionalAuth } from "@/middleware/withAuth";
import { ReposTable } from "./components/reposTable";
import { RepoIndexingJobStatus, Prisma } from "@sourcebot/db";
import z from "zod";
Expand Down Expand Up @@ -96,7 +96,7 @@ interface GetReposParams {
}

const getRepos = async ({ skip, take, search, status, sortBy, sortOrder }: GetReposParams) => sew(() =>
withOptionalAuthV2(async ({ prisma }) => {
withOptionalAuth(async ({ prisma }) => {
const whereClause: Prisma.RepoWhereInput = {
...(search ? {
displayName: { contains: search, mode: 'insensitive' },
Expand Down
Loading
Loading