This commit is contained in:
bkellam 2025-09-23 21:30:00 -07:00
parent 6abe7a40a5
commit e990edbb10
15 changed files with 630 additions and 841 deletions

View file

@ -4,13 +4,12 @@ import { getAuditService } from "@/ee/features/audit/factory";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError"; import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError } from "@/lib/serviceError";
import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs'; import { decrypt, encrypt, generateApiKey, getTokenFromConfig } from "@sourcebot/crypto";
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema"; import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
@ -29,7 +28,6 @@ import { StatusCodes } from "http-status-codes";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { Octokit } from "octokit"; import { Octokit } from "octokit";
import { auth } from "./auth";
import { getConnection } from "./data/connection"; import { getConnection } from "./data/connection";
import { getOrgFromDomain } from "./data/org"; import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils"; import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
@ -37,9 +35,9 @@ import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail"; import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
import { orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { ApiKeyPayload, TenancyMode } from "./lib/types"; import { sew } from "./sew";
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
import { withMinimumOrgRole } from "./withMinimumOrgRole"; import { withMinimumOrgRole } from "./withMinimumOrgRole";
@ -50,142 +48,6 @@ const ajv = new Ajv({
const logger = createLogger('web-actions'); const logger = createLogger('web-actions');
const auditService = getAuditService(); const auditService = getAuditService();
/**
* "Service Error Wrapper".
*
* Captures any thrown exceptions and converts them to a unexpected
* service error. Also logs them with Sentry.
*/
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 Error) {
return unexpectedError(e.message);
}
return unexpectedError(`An unexpected error occurred. Please try again later.`);
}
}
export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
const session = await auth();
if (!session) {
// First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not,
// then this is an invalid unauthed request and we return a 401.
const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN);
if (apiKey) {
const apiKeyOrError = await verifyApiKey(apiKey);
if (isServiceError(apiKeyOrError)) {
logger.error(`Invalid API key: ${JSON.stringify(apiKey)}. Error: ${JSON.stringify(apiKeyOrError)}`);
return notAuthenticated();
}
const user = await prisma.user.findUnique({
where: {
id: apiKeyOrError.apiKey.createdById,
},
});
if (!user) {
logger.error(`No user found for API key: ${apiKey}`);
return notAuthenticated();
}
await prisma.apiKey.update({
where: {
hash: apiKeyOrError.apiKey.hash,
},
data: {
lastUsedAt: new Date(),
},
});
return fn(user.id, apiKeyOrError.apiKey.hash);
} else if (
allowAnonymousAccess &&
!isServiceError(anonymousAccessEnabled) &&
anonymousAccessEnabled
) {
if (!hasEntitlement("anonymous-access")) {
const plan = getPlan();
logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return notAuthenticated();
}
// To support anonymous access a guest user is created in initialize.ts, which we return here
return fn(SOURCEBOT_GUEST_USER_ID, undefined);
}
return notAuthenticated();
}
return fn(session.user.id, undefined);
}
export const withOrgMembership = async <T>(userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise<T>, minRequiredRole: OrgRole = OrgRole.MEMBER) => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return notFound("Organization not found");
}
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId,
orgId: org.id,
}
},
});
if (!membership) {
return notFound("User not a member of this organization");
}
const getAuthorizationPrecedence = (role: OrgRole): number => {
switch (role) {
case OrgRole.GUEST:
return 0;
case OrgRole.MEMBER:
return 1;
case OrgRole.OWNER:
return 2;
}
}
if (getAuthorizationPrecedence(membership.role) < getAuthorizationPrecedence(minRequiredRole)) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "You do not have sufficient permissions to perform this action.",
} satisfies ServiceError;
}
return fn({
org: org,
userRole: membership.role,
});
}
export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () => Promise<T>) => {
if (env.SOURCEBOT_TENANCY_MODE !== mode) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.ACTION_DISALLOWED_IN_TENANCY_MODE,
message: "This action is not allowed in the current tenancy mode.",
} satisfies ServiceError;
}
return fn();
}
////// Actions /////// ////// Actions ///////
export const updateOrgName = async (name: string) => sew(() => export const updateOrgName = async (name: string) => sew(() =>
@ -322,59 +184,6 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe
} }
})); }));
export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiKey: ApiKey } | ServiceError> => sew(async () => {
const parts = apiKeyPayload.apiKey.split("-");
if (parts.length !== 2 || parts[0] !== "sourcebot") {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_API_KEY,
message: "Invalid API key",
} satisfies ServiceError;
}
const hash = hashSecret(parts[1])
const apiKey = await prisma.apiKey.findUnique({
where: {
hash,
},
});
if (!apiKey) {
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorCode: ErrorCode.INVALID_API_KEY,
message: "Invalid API key",
} satisfies ServiceError;
}
const apiKeyTargetOrg = await prisma.org.findUnique({
where: {
domain: apiKeyPayload.domain,
},
});
if (!apiKeyTargetOrg) {
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorCode: ErrorCode.INVALID_API_KEY,
message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not exist.`,
} satisfies ServiceError;
}
if (apiKey.orgId !== apiKeyTargetOrg.id) {
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorCode: ErrorCode.INVALID_API_KEY,
message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not match the API key's org.`,
} satisfies ServiceError;
}
return {
apiKey,
}
});
export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() => export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() =>
withAuthV2(async ({ user, org, prisma }) => { withAuthV2(async ({ user, org, prisma }) => {
const userId = user.id; const userId = user.id;
@ -1236,10 +1045,9 @@ export const getMe = async () => sew(() =>
})); }));
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async () => { withOptionalAuthV2(async ({ user, prisma }) => {
const user = await getMe(); if (!user) {
if (isServiceError(user)) { return notAuthenticated();
return user;
} }
const invite = await prisma.invite.findUnique({ const invite = await prisma.invite.findUnique({
@ -1315,10 +1123,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
})); }));
export const getInviteInfo = async (inviteId: string) => sew(() => export const getInviteInfo = async (inviteId: string) => sew(() =>
withAuth(async () => { withOptionalAuthV2(async ({ user }) => {
const user = await getMe(); if (!user) {
if (isServiceError(user)) { return notAuthenticated();
return user;
} }
const invite = await prisma.invite.findUnique({ const invite = await prisma.invite.findUnique({
@ -1613,38 +1420,23 @@ export const getOrgAccountRequests = async (domain: string) => sew(() =>
})); }));
})); }));
export const createAccountRequest = async (userId: string, domain: string) => sew(async () => { export const createAccountRequest = async () => sew(() =>
const user = await prisma.user.findUnique({ withOptionalAuthV2(async ({ user, org, prisma }) => {
where: {
id: userId,
},
});
if (!user) { if (!user) {
return notFound("User not found"); return notAuthenticated();
}
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return notFound("Organization not found");
} }
const existingRequest = await prisma.accountRequest.findUnique({ const existingRequest = await prisma.accountRequest.findUnique({
where: { where: {
requestedById_orgId: { requestedById_orgId: {
requestedById: userId, requestedById: user.id,
orgId: org.id, orgId: org.id,
}, },
}, },
}); });
if (existingRequest) { if (existingRequest) {
logger.warn(`User ${userId} already has an account request for org ${org.id}. Skipping account request creation.`); logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`);
return { return {
success: true, success: true,
existingRequest: true, existingRequest: true,
@ -1654,7 +1446,7 @@ export const createAccountRequest = async (userId: string, domain: string) => se
if (!existingRequest) { if (!existingRequest) {
await prisma.accountRequest.create({ await prisma.accountRequest.create({
data: { data: {
requestedById: userId, requestedById: user.id,
orgId: org.id, orgId: org.id,
}, },
}); });
@ -1676,7 +1468,7 @@ export const createAccountRequest = async (userId: string, domain: string) => se
}); });
if (!owner) { if (!owner) {
logger.error(`Failed to find owner for org ${org.id} when drafting email for account request from ${userId}`); logger.error(`Failed to find owner for org ${org.id} when drafting email for account request from ${user.id}`);
} else { } else {
const html = await render(JoinRequestSubmittedEmail({ const html = await render(JoinRequestSubmittedEmail({
baseUrl: deploymentUrl, baseUrl: deploymentUrl,
@ -1713,7 +1505,7 @@ export const createAccountRequest = async (userId: string, domain: string) => se
success: true, success: true,
existingRequest: false, existingRequest: false,
} }
}); }));
export const getMemberApprovalRequired = async (domain: string): Promise<boolean | ServiceError> => sew(async () => { export const getMemberApprovalRequired = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({

View file

@ -10,17 +10,16 @@ import { useRouter } from "next/navigation"
interface SubmitButtonProps { interface SubmitButtonProps {
domain: string domain: string
userId: string
} }
export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) { export function SubmitAccountRequestButton({ domain }: SubmitButtonProps) {
const { toast } = useToast() const { toast } = useToast()
const router = useRouter() const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true) setIsSubmitting(true)
const result = await createAccountRequest(userId, domain) const result = await createAccountRequest();
if (!isServiceError(result)) { if (!isServiceError(result)) {
if (result.existingRequest) { if (result.existingRequest) {
toast({ toast({

View file

@ -1,6 +1,5 @@
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { auth } from "@/auth"
import { SubmitAccountRequestButton } from "./submitAccountRequestButton" import { SubmitAccountRequestButton } from "./submitAccountRequestButton"
interface SubmitJoinRequestProps { interface SubmitJoinRequestProps {
@ -8,13 +7,6 @@ interface SubmitJoinRequestProps {
} }
export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => { export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
return ( return (
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-6"> <div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" /> <LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
@ -45,7 +37,7 @@ export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-center"> <div className="flex justify-center">
<SubmitAccountRequestButton domain={domain} userId={userId} /> <SubmitAccountRequestButton domain={domain} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew } from "@/sew";
import { withOptionalAuthV2 } from "@/withAuthV2";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions"; import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent"; import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types"; import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
@ -9,7 +10,6 @@ import { isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider"; import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { OrgRole } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { import {
createUIMessageStream, createUIMessageStream,
@ -52,8 +52,7 @@ export async function POST(req: Request) {
const { messages, id, selectedSearchScopes, languageModelId } = parsed.data; const { messages, id, selectedSearchScopes, languageModelId } = parsed.data;
const response = await sew(() => const response = await sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => {
// Validate that the chat exists and is not readonly. // Validate that the chat exists and is not readonly.
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { where: {
@ -100,8 +99,7 @@ export async function POST(req: Request) {
domain, domain,
orgId: org.id, orgId: org.id,
}); });
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true })
)
) )
if (isServiceError(response)) { if (isServiceError(response)) {

View file

@ -1,16 +1,20 @@
"use server"; "use server";
import { withAuth } from "@/actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { orgNotFound, ServiceError } from "@/lib/serviceError"; import { notAuthenticated, orgNotFound, ServiceError } from "@/lib/serviceError";
import { sew } from "@/actions"; import { sew } from "@/sew";
import { addUserToOrganization } from "@/lib/authUtils"; import { addUserToOrganization } from "@/lib/authUtils";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { withOptionalAuthV2 } from "@/withAuthV2";
export const joinOrganization = async (orgId: number, inviteLinkId?: string) => sew(async () => export const joinOrganization = async (orgId: number, inviteLinkId?: string) => sew(async () =>
withAuth(async (userId) => { withOptionalAuthV2(async ({ user }) => {
if (!user) {
return notAuthenticated();
}
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { where: {
id: orgId, id: orgId,
@ -40,7 +44,7 @@ export const joinOrganization = async (orgId: number, inviteLinkId?: string) =>
} }
} }
const addUserToOrgRes = await addUserToOrganization(userId, org.id); const addUserToOrgRes = await addUserToOrganization(user.id, org.id);
if (isServiceError(addUserToOrgRes)) { if (isServiceError(addUserToOrgRes)) {
return addUserToOrgRes; return addUserToOrgRes;
} }

View file

@ -1,17 +1,15 @@
'use server'; 'use server';
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew } from "@/sew";
import { OrgRole } from "@sourcebot/db"; import { withAuthV2 } from "@/withAuthV2";
import { prisma } from "@/prisma";
import { ServiceError } from "@/lib/serviceError"; import { ServiceError } from "@/lib/serviceError";
import { AnalyticsResponse } from "./types"; import { AnalyticsResponse } from "./types";
import { hasEntitlement } from "@sourcebot/shared"; import { hasEntitlement } from "@sourcebot/shared";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
export const getAnalytics = async (domain: string, apiKey: string | undefined = undefined): Promise<AnalyticsResponse | ServiceError> => sew(() => export const getAnalytics = async (domain: string, _apiKey: string | undefined = undefined): Promise<AnalyticsResponse | ServiceError> => sew(() =>
withAuth((userId, _apiKeyHash) => withAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => {
if (!hasEntitlement("analytics")) { if (!hasEntitlement("analytics")) {
return { return {
statusCode: StatusCodes.FORBIDDEN, statusCode: StatusCodes.FORBIDDEN,
@ -99,5 +97,5 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
return rows; return rows;
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) })
); );

View file

@ -1,9 +1,10 @@
"use server"; "use server";
import { prisma } from "@/prisma";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew } from "@/sew";
import { withAuthV2 } from "@/withAuthV2";
import { withMinimumOrgRole } from "@/withMinimumOrgRole";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { ServiceError } from "@/lib/serviceError"; import { ServiceError } from "@/lib/serviceError";
@ -13,16 +14,15 @@ import { AuditEvent } from "./types";
const auditService = getAuditService(); const auditService = getAuditService();
const logger = createLogger('audit-utils'); const logger = createLogger('audit-utils');
export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersion' | 'orgId' | 'actor' | 'target'>, domain: string) => sew(async () => export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersion' | 'orgId' | 'actor' | 'target'>, _domain: string) => sew(async () =>
withAuth((userId) => withAuthV2(async ({ user, org }) => {
withOrgMembership(userId, domain, async ({ org }) => { await auditService.createAudit({ ...event, orgId: org.id, actor: { id: user.id, type: "user" }, target: { id: org.id.toString(), type: "org" } })
await auditService.createAudit({ ...event, orgId: org.id, actor: { id: userId, type: "user" }, target: { id: org.id.toString(), type: "org" } }) })
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true)
); );
export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() => export const fetchAuditRecords = async (domain: string, _apiKey: string | undefined = undefined) => sew(() =>
withAuth((userId) => withAuthV2(async ({ user, org, prisma, role }) =>
withOrgMembership(userId, domain, async ({ org }) => { withMinimumOrgRole(role, OrgRole.OWNER, async () => {
try { try {
const auditRecords = await prisma.audit.findMany({ const auditRecords = await prisma.audit.findMany({
where: { where: {
@ -36,7 +36,7 @@ export const fetchAuditRecords = async (domain: string, apiKey: string | undefin
await auditService.createAudit({ await auditService.createAudit({
action: "audit.fetch", action: "audit.fetch",
actor: { actor: {
id: userId, id: user.id,
type: "user" type: "user"
}, },
target: { target: {
@ -55,5 +55,6 @@ export const fetchAuditRecords = async (domain: string, apiKey: string | undefin
message: "Failed to fetch audit logs", message: "Failed to fetch audit logs",
} satisfies ServiceError; } satisfies ServiceError;
} }
}, /* minRequiredRole = */ OrgRole.OWNER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) })
)
); );

View file

@ -1,9 +1,9 @@
'use server'; 'use server';
import { getMe, sew, withAuth } from "@/actions"; import { sew } from "@/sew";
import { ServiceError, stripeClientNotInitialized, notFound } from "@/lib/serviceError"; import { ServiceError, stripeClientNotInitialized, notFound } from "@/lib/serviceError";
import { withOrgMembership } from "@/actions"; import { withAuthV2 } from "@/withAuthV2";
import { prisma } from "@/prisma"; import { withMinimumOrgRole } from "@/withMinimumOrgRole";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import { stripeClient } from "./stripe"; import { stripeClient } from "./stripe";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
@ -17,13 +17,8 @@ import { createLogger } from "@sourcebot/logger";
const logger = createLogger('billing-actions'); const logger = createLogger('billing-actions');
export const createOnboardingSubscription = async (domain: string) => sew(() => export const createOnboardingSubscription = async (domain: string) => sew(() =>
withAuth(async (userId) => withAuthV2(async ({ user, org, prisma, role }) =>
withOrgMembership(userId, domain, async ({ org }) => { withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const user = await getMe();
if (isServiceError(user)) {
return user;
}
if (!stripeClient) { if (!stripeClient) {
return stripeClientNotInitialized(); return stripeClientNotInitialized();
} }
@ -108,12 +103,10 @@ export const createOnboardingSubscription = async (domain: string) => sew(() =>
message: "Failed to create subscription", message: "Failed to create subscription",
} satisfies ServiceError; } satisfies ServiceError;
} }
}, /* minRequiredRole = */ OrgRole.OWNER) })));
));
export const createStripeCheckoutSession = async (domain: string) => sew(() => export const createStripeCheckoutSession = async (domain: string) => sew(() =>
withAuth((userId) => withAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => {
if (!org.stripeCustomerId) { if (!org.stripeCustomerId) {
return notFound(); return notFound();
} }
@ -164,12 +157,11 @@ export const createStripeCheckoutSession = async (domain: string) => sew(() =>
return { return {
url: stripeSession.url, url: stripeSession.url,
} }
}) }));
));
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> => sew(() => export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> => sew(() =>
withAuth((userId) => withAuthV2(async ({ org, role }) =>
withOrgMembership(userId, domain, async ({ org }) => { withMinimumOrgRole(role, OrgRole.OWNER, async () => {
if (!org.stripeCustomerId) { if (!org.stripeCustomerId) {
return notFound(); return notFound();
} }
@ -185,12 +177,10 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
}); });
return portalSession.url; return portalSession.url;
}, /* minRequiredRole = */ OrgRole.OWNER) })));
));
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> => sew(() => export const getSubscriptionBillingEmail = async (_domain: string): Promise<string | ServiceError> => sew(() =>
withAuth(async (userId) => withAuthV2(async ({ org }) => {
withOrgMembership(userId, domain, async ({ org }) => {
if (!org.stripeCustomerId) { if (!org.stripeCustomerId) {
return notFound(); return notFound();
} }
@ -204,12 +194,11 @@ export const getSubscriptionBillingEmail = async (domain: string): Promise<strin
return notFound(); return notFound();
} }
return customer.email!; return customer.email!;
}) }));
));
export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => sew(() => export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) => withAuthV2(async ({ org, role }) =>
withOrgMembership(userId, domain, async ({ org }) => { withMinimumOrgRole(role, OrgRole.OWNER, async () => {
if (!org.stripeCustomerId) { if (!org.stripeCustomerId) {
return notFound(); return notFound();
} }
@ -225,12 +214,10 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
return { return {
success: true, success: true,
} }
}, /* minRequiredRole = */ OrgRole.OWNER) })));
));
export const getSubscriptionInfo = async (domain: string) => sew(() => export const getSubscriptionInfo = async (_domain: string) => sew(() =>
withAuth(async (userId) => withAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => {
const subscription = await getSubscriptionForOrg(org.id, prisma); const subscription = await getSubscriptionForOrg(org.id, prisma);
if (isServiceError(subscription)) { if (isServiceError(subscription)) {
@ -244,5 +231,4 @@ export const getSubscriptionInfo = async (domain: string) => sew(() =>
perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, perSeatPrice: subscription.items.data[0].price.unit_amount! / 100,
nextBillingDate: subscription.current_period_end!, nextBillingDate: subscription.current_period_end!,
} }
}) }));
));

View file

@ -1,6 +1,7 @@
'use server'; 'use server';
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew } from "@/sew";
import { withAuthV2, withOptionalAuthV2 } from "@/withAuthV2";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { SOURCEBOT_GUEST_USER_ID } from "@/lib/constants"; import { SOURCEBOT_GUEST_USER_ID } from "@/lib/constants";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
@ -32,9 +33,8 @@ import path from 'path';
import { LanguageModelInfo, SBChatMessage } from "./types"; import { LanguageModelInfo, SBChatMessage } from "./types";
export const createChat = async (domain: string) => sew(() => export const createChat = async (domain: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID;
const isGuestUser = userId === SOURCEBOT_GUEST_USER_ID; const isGuestUser = userId === SOURCEBOT_GUEST_USER_ID;
const chat = await prisma.chat.create({ const chat = await prisma.chat.create({
@ -49,12 +49,12 @@ export const createChat = async (domain: string) => sew(() =>
return { return {
id: chat.id, id: chat.id,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) })
); );
export const getChatInfo = async ({ chatId }: { chatId: string }, domain: string) => sew(() => export const getChatInfo = async ({ chatId }: { chatId: string }, domain: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID;
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { where: {
id: chatId, id: chatId,
@ -76,12 +76,12 @@ export const getChatInfo = async ({ chatId }: { chatId: string }, domain: string
name: chat.name, name: chat.name,
isReadonly: chat.isReadonly, isReadonly: chat.isReadonly,
}; };
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) })
); );
export const updateChatMessages = async ({ chatId, messages }: { chatId: string, messages: SBChatMessage[] }, domain: string) => sew(() => export const updateChatMessages = async ({ chatId, messages }: { chatId: string, messages: SBChatMessage[] }, domain: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID;
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { where: {
id: chatId, id: chatId,
@ -123,16 +123,15 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return { return {
success: true, success: true,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) })
); );
export const getUserChatHistory = async (domain: string) => sew(() => export const getUserChatHistory = async (domain: string) => sew(() =>
withAuth((userId) => withAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => {
const chats = await prisma.chat.findMany({ const chats = await prisma.chat.findMany({
where: { where: {
orgId: org.id, orgId: org.id,
createdById: userId, createdById: user.id,
}, },
orderBy: { orderBy: {
updatedAt: 'desc', updatedAt: 'desc',
@ -146,12 +145,11 @@ export const getUserChatHistory = async (domain: string) => sew(() =>
visibility: chat.visibility, visibility: chat.visibility,
})) }))
}) })
)
); );
export const updateChatName = async ({ chatId, name }: { chatId: string, name: string }, domain: string) => sew(() => export const updateChatName = async ({ chatId, name }: { chatId: string, name: string }, domain: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID;
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { where: {
id: chatId, id: chatId,
@ -184,12 +182,11 @@ export const updateChatName = async ({ chatId, name }: { chatId: string, name: s
return { return {
success: true, success: true,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) })
); );
export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() => export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ org }) => {
withOrgMembership(userId, domain, async ({ org }) => {
// From the language model ID, attempt to find the // From the language model ID, attempt to find the
// corresponding config in `config.json`. // corresponding config in `config.json`.
const languageModelConfig = const languageModelConfig =
@ -235,13 +232,12 @@ User question: ${message}`;
return { return {
success: true, success: true,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true })
)
); );
export const deleteChat = async ({ chatId }: { chatId: string }, domain: string) => sew(() => export const deleteChat = async ({ chatId }: { chatId: string }, domain: string) => sew(() =>
withAuth((userId) => withAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const userId = user.id;
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { where: {
id: chatId, id: chatId,
@ -278,7 +274,6 @@ export const deleteChat = async ({ chatId }: { chatId: string }, domain: string)
success: true, success: true,
} }
}) })
)
); );
export const submitFeedback = async ({ export const submitFeedback = async ({
@ -290,8 +285,8 @@ export const submitFeedback = async ({
messageId: string, messageId: string,
feedbackType: 'like' | 'dislike' feedbackType: 'like' | 'dislike'
}, domain: string) => sew(() => }, domain: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ user, org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID;
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { where: {
id: chatId, id: chatId,
@ -337,7 +332,7 @@ export const submitFeedback = async ({
}); });
return { success: true }; return { success: true };
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) })
); );
/** /**

View file

@ -1,13 +1,13 @@
'use server'; 'use server';
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew } from "@/sew";
import { withOptionalAuthV2 } from "@/withAuthV2";
import { searchResponseSchema } from "@/features/search/schemas"; import { searchResponseSchema } from "@/features/search/schemas";
import { search } from "@/features/search/searchApi"; import { search } from "@/features/search/searchApi";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { FindRelatedSymbolsResponse } from "./types"; import { FindRelatedSymbolsResponse } from "./types";
import { ServiceError } from "@/lib/serviceError"; import { ServiceError } from "@/lib/serviceError";
import { SearchResponse } from "../search/types"; import { SearchResponse } from "../search/types";
import { OrgRole } from "@sourcebot/db";
// The maximum number of matches to return from the search API. // The maximum number of matches to return from the search API.
const MAX_REFERENCE_COUNT = 1000; const MAX_REFERENCE_COUNT = 1000;
@ -20,8 +20,7 @@ export const findSearchBasedSymbolReferences = async (
}, },
domain: string, domain: string,
): Promise<FindRelatedSymbolsResponse | ServiceError> => sew(() => ): Promise<FindRelatedSymbolsResponse | ServiceError> => sew(() =>
withAuth((session) => withOptionalAuthV2(async () => {
withOrgMembership(session, domain, async () => {
const { const {
symbolName, symbolName,
language, language,
@ -41,7 +40,7 @@ export const findSearchBasedSymbolReferences = async (
} }
return parseRelatedSymbolsSearchResponse(searchResult); return parseRelatedSymbolsSearchResponse(searchResult);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) })
); );
@ -53,8 +52,7 @@ export const findSearchBasedSymbolDefinitions = async (
}, },
domain: string, domain: string,
): Promise<FindRelatedSymbolsResponse | ServiceError> => sew(() => ): Promise<FindRelatedSymbolsResponse | ServiceError> => sew(() =>
withAuth((session) => withOptionalAuthV2(async () => {
withOrgMembership(session, domain, async () => {
const { const {
symbolName, symbolName,
language, language,
@ -74,7 +72,7 @@ export const findSearchBasedSymbolDefinitions = async (
} }
return parseRelatedSymbolsSearchResponse(searchResult); return parseRelatedSymbolsSearchResponse(searchResult);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) })
); );
const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => {

View file

@ -1,6 +1,6 @@
'use server'; 'use server';
import { sew } from '@/actions'; import { sew } from "@/sew";
import { env } from '@/env.mjs'; import { env } from '@/env.mjs';
import { notFound, unexpectedError } from '@/lib/serviceError'; import { notFound, unexpectedError } from '@/lib/serviceError';
import { withOptionalAuthV2 } from '@/withAuthV2'; import { withOptionalAuthV2 } from '@/withAuthV2';

View file

@ -5,7 +5,7 @@ import { fileNotFound, ServiceError, unexpectedError } from "../../lib/serviceEr
import { FileSourceRequest, FileSourceResponse } from "./types"; import { FileSourceRequest, FileSourceResponse } from "./types";
import { isServiceError } from "../../lib/utils"; import { isServiceError } from "../../lib/utils";
import { search } from "./searchApi"; import { search } from "./searchApi";
import { sew } from "@/actions"; import { sew } from "@/sew";
import { withOptionalAuthV2 } from "@/withAuthV2"; import { withOptionalAuthV2 } from "@/withAuthV2";
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here. // @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
// This will allow us to support permalinks to files at a specific revision that may not be indexed // This will allow us to support permalinks to files at a specific revision that may not be indexed

View file

@ -9,7 +9,7 @@ import { StatusCodes } from "http-status-codes";
import { zoektSearchResponseSchema } from "./zoektSchema"; import { zoektSearchResponseSchema } from "./zoektSchema";
import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { SearchRequest, SearchResponse, SourceRange } from "./types";
import { PrismaClient, Repo } from "@sourcebot/db"; import { PrismaClient, Repo } from "@sourcebot/db";
import { sew } from "@/actions"; import { sew } from "@/sew";
import { base64Decode } from "@sourcebot/shared"; import { base64Decode } from "@sourcebot/shared";
import { withOptionalAuthV2 } from "@/withAuthV2"; import { withOptionalAuthV2 } from "@/withAuthV2";

28
packages/web/src/sew.ts Normal file
View file

@ -0,0 +1,28 @@
'use server';
import * as Sentry from "@sentry/nextjs";
import { ServiceError, unexpectedError } from "./lib/serviceError";
import { createLogger } from "@sourcebot/logger";
const logger = createLogger('service-error-wrapper');
/**
* "Service Error Wrapper".
*
* Captures any thrown exceptions and converts them to a unexpected
* service error. Also logs them with Sentry.
*/
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 Error) {
return unexpectedError(e.message);
}
return unexpectedError(`An unexpected error occurred. Please try again later.`);
}
};

View file

@ -5,8 +5,6 @@ import { headers } from "next/headers";
import { auth } from "./auth"; import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError"; import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
import { SINGLE_TENANT_ORG_ID } from "./lib/constants"; import { SINGLE_TENANT_ORG_ID } from "./lib/constants";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./lib/errorCodes";
import { getOrgMetadata, isServiceError } from "./lib/utils"; import { getOrgMetadata, isServiceError } from "./lib/utils";
import { hasEntitlement } from "@sourcebot/shared"; import { hasEntitlement } from "@sourcebot/shared";