From a37925906ff37c3b960842d0d2ccd59b0f2239c3 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 19 Mar 2025 12:00:43 -0700 Subject: [PATCH 1/6] wip on single tenancy mode --- .env.development | 3 +- packages/web/src/actions.ts | 70 ++++++++++++++----- .../[domain]/components/orgSelector/index.tsx | 17 +++-- .../app/[domain]/connections/[id]/page.tsx | 7 -- packages/web/src/app/[domain]/layout.tsx | 35 +++++----- .../app/[domain]/settings/(general)/page.tsx | 6 -- .../app/[domain]/settings/members/page.tsx | 17 ++--- packages/web/src/data/user.ts | 39 ----------- packages/web/src/env.mjs | 2 + packages/web/src/initialize.ts | 46 ++++++++++++ packages/web/src/instrumentation.ts | 16 +++-- packages/web/src/lib/constants.ts | 8 ++- 12 files changed, 152 insertions(+), 114 deletions(-) delete mode 100644 packages/web/src/data/user.ts create mode 100644 packages/web/src/initialize.ts diff --git a/.env.development b/.env.development index aae06e86..d01b5c09 100644 --- a/.env.development +++ b/.env.development @@ -80,4 +80,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection # CONFIG_MAX_REPOS_NO_TOKEN= # SOURCEBOT_ROOT_DOMAIN= -# NODE_ENV= \ No newline at end of file +# NODE_ENV= +# SOURCEBOT_TENANCY_MODE=mutli \ No newline at end of file diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 122675c7..8ee0024f 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -16,7 +16,6 @@ import { decrypt, encrypt } from "@sourcebot/crypto" import { getConnection } from "./data/connection"; import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { cookies, headers } from "next/headers" -import { getUser } from "@/data/user"; import { Session } from "next-auth"; import { env } from "@/env.mjs"; import Stripe from "stripe"; @@ -25,7 +24,7 @@ import InviteUserEmail from "./emails/inviteUserEmail"; import { createTransport } from "nodemailer"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { RepositoryQuery } from "./lib/types"; -import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; +import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; import { stripeClient } from "./lib/stripe"; import { IS_BILLING_ENABLED } from "./lib/stripe"; @@ -34,6 +33,16 @@ const ajv = new Ajv({ }); export const withAuth = async (fn: (session: Session) => Promise) => { + if (env.SOURCEBOT_TENANCY_MODE === 'single') { + return fn({ + user: { + id: SINGLE_TENANT_USER_ID, + email: SINGLE_TENANT_USER_EMAIL, + }, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), + }); + } + const session = await auth(); if (!session) { return notAuthenticated(); @@ -89,11 +98,6 @@ export const withOrgMembership = async (session: Session, domain: string, fn: }); } -export const isAuthed = async () => { - const session = await auth(); - return session != null; -} - export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => withAuth(async (session) => { const org = await prisma.org.create({ @@ -695,6 +699,38 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ }, /* minRequiredRole = */ OrgRole.OWNER) ); +export const getMe = async () => + withAuth(async (session) => { + const user = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + include: { + orgs: { + include: { + org: true, + } + }, + } + }); + + if (!user) { + return notFound(); + } + + return { + id: user.id, + email: user.email, + name: user.name, + memberships: user.orgs.map((org) => ({ + id: org.orgId, + role: org.role, + domain: org.org.domain, + name: org.org.name, + })) + } + }); + export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => withAuth(async (session) => { const invite = await prisma.invite.findUnique({ @@ -710,9 +746,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return notFound(); } - const user = await getUser(session.user.id); - if (!user) { - return notFound(); + const user = await getMe(); + if (isServiceError(user)) { + return user; } // Check if the user is the recipient of the invite @@ -765,10 +801,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean }); export const getInviteInfo = async (inviteId: string) => - withAuth(async (session) => { - const user = await getUser(session.user.id); - if (!user) { - return notFound(); + withAuth(async () => { + const user = await getMe(); + if (isServiceError(user)) { + return user; } const invite = await prisma.invite.findUnique({ @@ -880,9 +916,9 @@ export const createOnboardingSubscription = async (domain: string) => return notFound(); } - const user = await getUser(session.user.id); - if (!user) { - return notFound(); + const user = await getMe(); + if (isServiceError(user)) { + return user; } if (!stripeClient) { diff --git a/packages/web/src/app/[domain]/components/orgSelector/index.tsx b/packages/web/src/app/[domain]/components/orgSelector/index.tsx index e4c89908..769a072e 100644 --- a/packages/web/src/app/[domain]/components/orgSelector/index.tsx +++ b/packages/web/src/app/[domain]/components/orgSelector/index.tsx @@ -1,7 +1,7 @@ -import { auth } from "@/auth"; -import { getUserOrgs } from "../../../../data/user"; import { OrgSelectorDropdown } from "./orgSelectorDropdown"; import { prisma } from "@/prisma"; +import { getMe } from "@/actions"; +import { isServiceError } from "@/lib/utils"; interface OrgSelectorProps { domain: string; @@ -10,12 +10,11 @@ interface OrgSelectorProps { export const OrgSelector = async ({ domain, }: OrgSelectorProps) => { - const session = await auth(); - if (!session) { + const user = await getMe(); + if (isServiceError(user)) { return null; } - const orgs = await getUserOrgs(session.user.id); const activeOrg = await prisma.org.findUnique({ where: { domain, @@ -28,10 +27,10 @@ export const OrgSelector = async ({ return ( ({ - name: org.name, - id: org.id, - domain: org.domain, + orgs={user.memberships.map(({ name, domain, id }) => ({ + name, + domain, + id, }))} activeOrgId={activeOrg.id} /> diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index 5e5c8be3..7aa97735 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -15,7 +15,6 @@ import { ConfigSetting } from "./components/configSetting" import { DeleteConnectionSetting } from "./components/deleteConnectionSetting" import { DisplayNameSetting } from "./components/displayNameSetting" import { RepoList } from "./components/repoList" -import { auth } from "@/auth" import { getConnectionByDomain } from "@/data/connection" import { Overview } from "./components/overview" @@ -30,11 +29,6 @@ interface ConnectionManagementPageProps { } export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) { - const session = await auth(); - if (!session) { - return null; - } - const connection = await getConnectionByDomain(Number(params.id), params.domain); if (!connection) { return @@ -42,7 +36,6 @@ export default async function ConnectionManagementPage({ params, searchParams }: const currentTab = searchParams.tab || "overview"; - return (
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index b1b66794..5c65a289 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -13,6 +13,7 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/co import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { env } from "@/env.mjs"; interface LayoutProps { children: React.ReactNode, @@ -29,24 +30,26 @@ export default async function Layout({ return } + if (env.SOURCEBOT_TENANCY_MODE === 'multi') { + const session = await auth(); + if (!session) { + return + } - const session = await auth(); - if (!session) { - return - } - - - const membership = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - orgId: org.id, - userId: session.user.id + const membership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: org.id, + userId: session.user.id + } } - } - }); + }); - if (!membership) { - return + if (!membership) { + return + } + } else { + // no-op } if (!org.isOnboarded) { @@ -57,7 +60,7 @@ export default async function Layout({ ) } - if (IS_BILLING_ENABLED) { + if (IS_BILLING_ENABLED && env.SOURCEBOT_TENANCY_MODE === 'multi') { const subscription = await fetchSubscription(domain); if ( subscription && diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index 45ddfb73..489634dc 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -1,4 +1,3 @@ -import { auth } from "@/auth"; import { ChangeOrgNameCard } from "./components/changeOrgNameCard"; import { isServiceError } from "@/lib/utils"; import { getCurrentUserRole } from "@/actions"; @@ -13,11 +12,6 @@ interface GeneralSettingsPageProps { } export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) { - const session = await auth(); - if (!session) { - return null; - } - const currentUserRole = await getCurrentUserRole(domain) if (isServiceError(currentUserRole)) { return
Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index a1227420..e4fdc9de 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -1,14 +1,12 @@ import { MembersList } from "./components/membersList"; import { getOrgMembers } from "@/actions"; import { isServiceError } from "@/lib/utils"; -import { auth } from "@/auth"; -import { getUser, getUserRoleInOrg } from "@/data/user"; import { getOrgFromDomain } from "@/data/org"; import { InviteMemberCard } from "./components/inviteMemberCard"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { TabSwitcher } from "@/components/ui/tab-switcher"; import { InvitesList } from "./components/invitesList"; -import { getOrgInvites } from "@/actions"; +import { getOrgInvites, getMe } from "@/actions"; import { IS_BILLING_ENABLED } from "@/lib/stripe"; interface MembersSettingsPageProps { params: { @@ -20,23 +18,18 @@ interface MembersSettingsPageProps { } export default async function MembersSettingsPage({ params: { domain }, searchParams: { tab } }: MembersSettingsPageProps) { - const session = await auth(); - if (!session) { - return null; - } - const members = await getOrgMembers(domain); const org = await getOrgFromDomain(domain); if (!org) { return null; } - const user = await getUser(session.user.id); - if (!user) { + const me = await getMe(); + if (isServiceError(me)) { return null; } - const userRoleInOrg = await getUserRoleInOrg(user.id, org.id); + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; if (!userRoleInOrg) { return null; } @@ -78,7 +71,7 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa diff --git a/packages/web/src/data/user.ts b/packages/web/src/data/user.ts deleted file mode 100644 index 624f5b9f..00000000 --- a/packages/web/src/data/user.ts +++ /dev/null @@ -1,39 +0,0 @@ -import 'server-only'; -import { prisma } from "@/prisma"; - -export const getUser = async (userId: string) => { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - - return user; -} - -export const getUserOrgs = async (userId: string) => { - const orgs = await prisma.org.findMany({ - where: { - members: { - some: { - userId: userId, - }, - }, - }, - }); - - return orgs; -} - -export const getUserRoleInOrg = async (userId: string, orgId: number) => { - const userToOrg = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - userId, - orgId, - } - }, - }); - - return userToOrg?.role; -} \ No newline at end of file diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 4c27285c..bd85d35e 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -41,6 +41,8 @@ export const env = createEnv({ NODE_ENV: z.enum(["development", "test", "production"]), SOURCEBOT_TELEMETRY_DISABLED: booleanSchema.default('false'), DATABASE_URL: z.string().url(), + + SOURCEBOT_TENANCY_MODE: z.enum(["multi", "single"]).default("multi"), }, // @NOTE: Make sure you destructure all client variables in the // `experimental__runtimeEnv` block below. diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts new file mode 100644 index 00000000..510ed8a7 --- /dev/null +++ b/packages/web/src/initialize.ts @@ -0,0 +1,46 @@ +import { OrgRole } from '@sourcebot/db'; +import { env } from './env.mjs'; +import { prisma } from "@/prisma"; +import { SINGLE_TENANT_USER_ID, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_NAME, SINGLE_TENANT_USER_EMAIL } from './lib/constants'; + +const initSingleTenancy = async () => { + const user = await prisma.user.upsert({ + where: { + id: SINGLE_TENANT_USER_ID, + }, + update: {}, + create: { + id: SINGLE_TENANT_USER_ID, + email: SINGLE_TENANT_USER_EMAIL, + }, + }); + + await prisma.org.upsert({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + update: {}, + create: { + name: SINGLE_TENANT_ORG_NAME, + domain: SINGLE_TENANT_ORG_DOMAIN, + id: SINGLE_TENANT_ORG_ID, + isOnboarded: true, + members: { + create: { + role: OrgRole.OWNER, + user: { + connect: { + id: user.id, + } + } + } + } + } + }); + + console.log('init!'); +} + +if (env.SOURCEBOT_TENANCY_MODE === 'single') { + await initSingleTenancy(); +} \ No newline at end of file diff --git a/packages/web/src/instrumentation.ts b/packages/web/src/instrumentation.ts index 8aff09f0..0c87feb9 100644 --- a/packages/web/src/instrumentation.ts +++ b/packages/web/src/instrumentation.ts @@ -1,13 +1,17 @@ import * as Sentry from '@sentry/nextjs'; export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - await import('../sentry.server.config'); - } + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('../sentry.server.config'); + } - if (process.env.NEXT_RUNTIME === 'edge') { - await import('../sentry.edge.config'); - } + if (process.env.NEXT_RUNTIME === 'edge') { + await import('../sentry.edge.config'); + } + + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import ('./initialize'); + } } export const onRequestError = Sentry.captureRequestError; diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index 1b8ff041..472f1704 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -22,4 +22,10 @@ export const TEAM_FEATURES = [ "Built on-top of zoekt, Google's code search engine. Blazingly fast and powerful (regex, symbol) code search.", ] -export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed'; \ No newline at end of file +export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed'; + +export const SINGLE_TENANT_USER_ID = '1'; +export const SINGLE_TENANT_USER_EMAIL = 'default@example.com'; +export const SINGLE_TENANT_ORG_ID = 1; +export const SINGLE_TENANT_ORG_DOMAIN = 'default'; +export const SINGLE_TENANT_ORG_NAME = 'default'; \ No newline at end of file From 7b1fc0d8e5e6f3c0aa5e733d68bedf37fc77ef43 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 19 Mar 2025 12:48:38 -0700 Subject: [PATCH 2/6] Add middleware to redirect all requests to the single tenant --- packages/web/src/lib/constants.ts | 2 +- packages/web/src/middleware.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/middleware.ts diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index 472f1704..0a0fec79 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -27,5 +27,5 @@ export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile export const SINGLE_TENANT_USER_ID = '1'; export const SINGLE_TENANT_USER_EMAIL = 'default@example.com'; export const SINGLE_TENANT_ORG_ID = 1; -export const SINGLE_TENANT_ORG_DOMAIN = 'default'; +export const SINGLE_TENANT_ORG_DOMAIN = '~'; export const SINGLE_TENANT_ORG_NAME = 'default'; \ No newline at end of file diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts new file mode 100644 index 00000000..825be57b --- /dev/null +++ b/packages/web/src/middleware.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { env } from './env.mjs' +import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants' + +// The following middleware is used to redirect all requests to +// the single tenant domain (when in single tenant mode). +export async function middleware(request: NextRequest) { + if (env.SOURCEBOT_TENANCY_MODE !== 'single') { + return NextResponse.next(); + } + + const url = request.nextUrl.clone(); + const pathSegments = url.pathname.split('/').filter(Boolean); + const currentDomain = pathSegments[0]; + + // If we're already on the correct domain path, allow + if (currentDomain === SINGLE_TENANT_ORG_DOMAIN) { + return NextResponse.next(); + } + + url.pathname = `/${SINGLE_TENANT_ORG_DOMAIN}${pathSegments.length > 1 ? '/' + pathSegments.slice(1).join('/') : ''}`; + return NextResponse.redirect(url); +} + +export const config = { + // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher + matcher: [ + '/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)' + ], +} \ No newline at end of file From d9dad845e4a741e2635858680120336059d20831 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 19 Mar 2025 15:12:25 -0700 Subject: [PATCH 3/6] improved error handling for unexpected errors. Added withTenancyModeEnforcement that gates certain actions to a particular tenancy mode --- packages/web/src/actions.ts | 199 +++++++++--------- .../app/[domain]/browse/[...path]/page.tsx | 22 +- .../web/src/app/[domain]/connections/page.tsx | 4 +- packages/web/src/app/[domain]/layout.tsx | 2 +- .../app/[domain]/settings/(general)/page.tsx | 11 +- .../app/[domain]/settings/billing/page.tsx | 10 +- .../app/[domain]/settings/members/page.tsx | 13 +- .../app/[domain]/settings/secrets/page.tsx | 4 +- packages/web/src/app/error.tsx | 148 +++++++++++++ packages/web/src/app/global-error.tsx | 22 +- packages/web/src/env.mjs | 3 +- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/serviceError.ts | 29 ++- packages/web/src/lib/types.ts | 5 +- 14 files changed, 317 insertions(+), 156 deletions(-) create mode 100644 packages/web/src/app/error.tsx diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 8ee0024f..e8bec44f 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2,7 +2,7 @@ import Ajv from "ajv"; import { auth } from "./auth"; -import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists } from "@/lib/serviceError"; +import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists, stripeClientNotInitialized } from "@/lib/serviceError"; import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; @@ -23,7 +23,7 @@ import { render } from "@react-email/components"; import InviteUserEmail from "./emails/inviteUserEmail"; import { createTransport } from "nodemailer"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; -import { RepositoryQuery } from "./lib/types"; +import { RepositoryQuery, TenancyMode } from "./lib/types"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; import { stripeClient } from "./lib/stripe"; import { IS_BILLING_ENABLED } from "./lib/stripe"; @@ -98,75 +98,89 @@ export const withOrgMembership = async (session: Session, domain: string, fn: }); } +export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () => Promise) => { + 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(); +} + export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => - withAuth(async (session) => { - const org = await prisma.org.create({ - data: { - name, - domain, - members: { - create: { - role: "OWNER", - user: { - connect: { - id: session.user.id, + withTenancyModeEnforcement('multi', () => + withAuth(async (session) => { + const org = await prisma.org.create({ + data: { + name, + domain, + members: { + create: { + role: "OWNER", + user: { + connect: { + id: session.user.id, + } } } } } - } - }); + }); - return { - id: org.id, - } - }); + return { + id: org.id, + } + })); export const updateOrgName = async (name: string, domain: string) => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const { success } = orgNameSchema.safeParse(name); - if (!success) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "Invalid organization url", - } satisfies ServiceError; - } + withTenancyModeEnforcement('multi', () => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const { success } = orgNameSchema.safeParse(name); + if (!success) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Invalid organization url", + } satisfies ServiceError; + } - await prisma.org.update({ - where: { id: orgId }, - data: { name }, - }); + await prisma.org.update({ + where: { id: orgId }, + data: { name }, + }); - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER) - ) + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); export const updateOrgDomain = async (newDomain: string, existingDomain: string) => - withAuth((session) => - withOrgMembership(session, existingDomain, async ({ orgId }) => { - const { success } = await orgDomainSchema.safeParseAsync(newDomain); - if (!success) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "Invalid organization url", - } satisfies ServiceError; - } + withTenancyModeEnforcement('multi', () => + withAuth((session) => + withOrgMembership(session, existingDomain, async ({ orgId }) => { + const { success } = await orgDomainSchema.safeParseAsync(newDomain); + if (!success) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Invalid organization url", + } satisfies ServiceError; + } - await prisma.org.update({ - where: { id: orgId }, - data: { domain: newDomain }, - }); + await prisma.org.update({ + where: { id: orgId }, + data: { domain: newDomain }, + }); - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER), - ) + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => @@ -212,24 +226,25 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo ); export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const secrets = await prisma.secret.findMany({ - where: { - orgId, - }, - select: { - key: true, - createdAt: true - } - }); + withTenancyModeEnforcement('multi', () => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const secrets = await prisma.secret.findMany({ + where: { + orgId, + }, + select: { + key: true, + createdAt: true + } + }); - return secrets.map((secret) => ({ - key: secret.key, - createdAt: secret.createdAt, - })); + return secrets.map((secret) => ({ + key: secret.key, + createdAt: secret.createdAt, + })); - })); + }))); export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => @@ -732,7 +747,7 @@ export const getMe = async () => }); export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => - withAuth(async (session) => { + withAuth(async () => { const invite = await prisma.invite.findUnique({ where: { id: inviteId, @@ -922,11 +937,7 @@ export const createOnboardingSubscription = async (domain: string) => } if (!stripeClient) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED, - message: "Stripe client is not initialized.", - } satisfies ServiceError; + return stripeClientNotInitialized(); } const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({ @@ -1028,11 +1039,7 @@ export const createStripeCheckoutSession = async (domain: string) => } if (!stripeClient) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED, - message: "Stripe client is not initialized.", - } satisfies ServiceError; + return stripeClientNotInitialized(); } const orgMembers = await prisma.userToOrg.findMany({ @@ -1094,11 +1101,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise; + notFound(); } const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); @@ -48,19 +48,14 @@ export default async function BrowsePage({ const org = await getOrgFromDomain(params.domain); if (!org) { - return + notFound(); } // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata // given it's name or id. const reposResponse = await listRepositories(org.id); if (isServiceError(reposResponse)) { - // @todo : proper error handling - return ( - <> - Error: {reposResponse.message} - - ) + throw new ServiceErrorException(reposResponse); } const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); @@ -145,12 +140,7 @@ const CodePreviewWrapper = async ({ ) } - // @todo : proper error handling - return ( - <> - Error: {fileSourceResponse.message} - - ) + throw new ServiceErrorException(fileSourceResponse); } return ( diff --git a/packages/web/src/app/[domain]/connections/page.tsx b/packages/web/src/app/[domain]/connections/page.tsx index 2f37ccbf..772863ce 100644 --- a/packages/web/src/app/[domain]/connections/page.tsx +++ b/packages/web/src/app/[domain]/connections/page.tsx @@ -1,14 +1,14 @@ import { ConnectionList } from "./components/connectionList"; import { Header } from "../components/header"; import { NewConnectionCard } from "./components/newConnectionCard"; -import NotFoundPage from "@/app/not-found"; import { getConnections } from "@/actions"; import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) { const connections = await getConnections(domain); if (isServiceError(connections)) { - return ; + throw new ServiceErrorException(connections); } return ( diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 5c65a289..0122fae2 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -60,7 +60,7 @@ export default async function Layout({ ) } - if (IS_BILLING_ENABLED && env.SOURCEBOT_TENANCY_MODE === 'multi') { + if (IS_BILLING_ENABLED) { const subscription = await fetchSubscription(domain); if ( subscription && diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index 489634dc..90ad8884 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -4,7 +4,8 @@ import { getCurrentUserRole } from "@/actions"; import { getOrgFromDomain } from "@/data/org"; import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard"; import { env } from "@/env.mjs"; - +import { ServiceErrorException } from "@/lib/serviceError"; +import { ErrorCode } from "@/lib/errorCodes"; interface GeneralSettingsPageProps { params: { domain: string; @@ -14,12 +15,16 @@ interface GeneralSettingsPageProps { export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) { const currentUserRole = await getCurrentUserRole(domain) if (isServiceError(currentUserRole)) { - return
Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(currentUserRole); } const org = await getOrgFromDomain(domain) if (!org) { - return
Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException({ + message: "Failed to fetch organization.", + statusCode: 500, + errorCode: ErrorCode.NOT_FOUND, + }); } return ( diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index e7214018..5f66e6cc 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -7,7 +7,7 @@ import { isServiceError } from "@/lib/utils" import { ChangeBillingEmailCard } from "./changeBillingEmailCard" import { notFound } from "next/navigation" import { IS_BILLING_ENABLED } from "@/lib/stripe" - +import { ServiceErrorException } from "@/lib/serviceError" export const metadata: Metadata = { title: "Billing | Settings", description: "Manage your subscription and billing information", @@ -29,21 +29,21 @@ export default async function BillingPage({ const subscription = await getSubscriptionData(domain) if (isServiceError(subscription)) { - return
Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(subscription); } if (!subscription) { - return
todo
+ throw new Error("Subscription not found"); } const currentUserRole = await getCurrentUserRole(domain) if (isServiceError(currentUserRole)) { - return
Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(currentUserRole); } const billingEmail = await getSubscriptionBillingEmail(domain); if (isServiceError(billingEmail)) { - return
Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(billingEmail); } return ( diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index e4fdc9de..cab223e6 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -8,6 +8,7 @@ import { TabSwitcher } from "@/components/ui/tab-switcher"; import { InvitesList } from "./components/invitesList"; import { getOrgInvites, getMe } from "@/actions"; import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { ServiceErrorException } from "@/lib/serviceError"; interface MembersSettingsPageProps { params: { domain: string @@ -18,29 +19,29 @@ interface MembersSettingsPageProps { } export default async function MembersSettingsPage({ params: { domain }, searchParams: { tab } }: MembersSettingsPageProps) { - const members = await getOrgMembers(domain); const org = await getOrgFromDomain(domain); if (!org) { - return null; + throw new Error("Organization not found"); } const me = await getMe(); if (isServiceError(me)) { - return null; + throw new ServiceErrorException(me); } const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; if (!userRoleInOrg) { - return null; + throw new Error("User role not found"); } + const members = await getOrgMembers(domain); if (isServiceError(members)) { - return null; + throw new ServiceErrorException(members); } const invites = await getOrgInvites(domain); if (isServiceError(invites)) { - return null; + throw new ServiceErrorException(invites); } const currentTab = tab || "members"; diff --git a/packages/web/src/app/[domain]/settings/secrets/page.tsx b/packages/web/src/app/[domain]/settings/secrets/page.tsx index 86525df8..c9aeab77 100644 --- a/packages/web/src/app/[domain]/settings/secrets/page.tsx +++ b/packages/web/src/app/[domain]/settings/secrets/page.tsx @@ -2,6 +2,8 @@ import { getSecrets } from "@/actions"; import { SecretsList } from "./components/secretsList"; import { isServiceError } from "@/lib/utils"; import { ImportSecretCard } from "./components/importSecretCard"; +import { ServiceErrorException } from "@/lib/serviceError"; + interface SecretsPageProps { params: { domain: string; @@ -11,7 +13,7 @@ interface SecretsPageProps { export default async function SecretsPage({ params: { domain } }: SecretsPageProps) { const secrets = await getSecrets(domain); if (isServiceError(secrets)) { - return null; + throw new ServiceErrorException(secrets); } return ( diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx new file mode 100644 index 00000000..4e084150 --- /dev/null +++ b/packages/web/src/app/error.tsx @@ -0,0 +1,148 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect, useMemo } from 'react' +import { useState } from "react" +import { Copy, CheckCircle2, TriangleAlert } from "lucide-react" +import Link from 'next/link'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { serviceErrorSchema } from '@/lib/serviceError'; +import { SourcebotLogo } from './components/sourcebotLogo'; + +export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + const { message, errorCode, statusCode } = useMemo(() => { + + try { + const body = JSON.parse(error.message); + const { success, data: serviceError } = serviceErrorSchema.safeParse(body); + if (success) { + return { + message: serviceError.message, + errorCode: serviceError.errorCode, + statusCode: serviceError.statusCode, + } + } + } catch { } + + return { + message: error.message, + } + }, [error]); + + return ( +
+ + +
+ ) +} + +interface ErrorCardProps { + message: string + errorCode?: string | number + statusCode?: string | number + onReloadButtonClicked: () => void +} + +function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: ErrorCardProps) { + const [copied, setCopied] = useState(null) + + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text) + setCopied(field) + setTimeout(() => setCopied(null), 2000) + } + + return ( + + + + + Unexpected Error + + + An unexpected error occurred. Please reload the page and try again. If the issue persists, please contact us. + + + +
+ copyToClipboard(message, "message")} + copied={copied === "message"} + /> + + {errorCode && ( + copyToClipboard(errorCode.toString(), "errorCode")} + copied={copied === "errorCode"} + /> + )} + + {statusCode && ( + copyToClipboard(statusCode.toString(), "statusCode")} + copied={copied === "statusCode"} + /> + )} +
+ +
+
+ ) +} + +interface ErrorFieldProps { + label: string + value: string | number + onCopy: () => void + copied: boolean +} + +function ErrorField({ label, value, onCopy, copied }: ErrorFieldProps) { + return ( +
+
{label}
+
+
{value}
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/global-error.tsx b/packages/web/src/app/global-error.tsx index 9bda5fee..bfea313f 100644 --- a/packages/web/src/app/global-error.tsx +++ b/packages/web/src/app/global-error.tsx @@ -5,19 +5,19 @@ import NextError from "next/error"; import { useEffect } from "react"; export default function GlobalError({ error }: { error: Error & { digest?: string } }) { - useEffect(() => { - Sentry.captureException(error); - }, [error]); + useEffect(() => { + Sentry.captureException(error); + }, [error]); - return ( - - - {/* `NextError` is the default Next.js error page component. Its type + return ( + + + {/* `NextError` is the default Next.js error page component. Its type definition requires a `statusCode` prop. However, since the App Router does not expose status codes for errors, we simply pass 0 to render a generic error message. */} - - - - ); + + + + ); } \ No newline at end of file diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index bd85d35e..b93e7a2e 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -3,6 +3,7 @@ import { z } from "zod"; // Booleans are specified as 'true' or 'false' strings. const booleanSchema = z.enum(["true", "false"]); +export const tenancyModeSchema = z.enum(["multi", "single"]); // Numbers are treated as strings in .env files. // coerce helps us convert them to numbers. @@ -42,7 +43,7 @@ export const env = createEnv({ SOURCEBOT_TELEMETRY_DISABLED: booleanSchema.default('false'), DATABASE_URL: z.string().url(), - SOURCEBOT_TENANCY_MODE: z.enum(["multi", "single"]).default("multi"), + SOURCEBOT_TENANCY_MODE: tenancyModeSchema.default("multi"), }, // @NOTE: Make sure you destructure all client variables in the // `experimental__runtimeEnv` block below. diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index e978a175..65449088 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -20,4 +20,5 @@ export enum ErrorCode { SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED', + ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE', } diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index 325d8da3..52a2b506 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -1,11 +1,22 @@ import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "./errorCodes"; -import { ZodError } from "zod"; +import { z, ZodError } from "zod"; -export interface ServiceError { - statusCode: StatusCodes; - errorCode: ErrorCode; - message: string; +export const serviceErrorSchema = z.object({ + statusCode: z.number(), + errorCode: z.string(), + message: z.string(), +}); + +export type ServiceError = z.infer; + +/** + * Useful for throwing errors and handling them in error boundaries. + */ +export class ServiceErrorException extends Error { + constructor(public readonly serviceError: ServiceError) { + super(JSON.stringify(serviceError)); + } } export const serviceErrorResponse = ({ statusCode, errorCode, message }: ServiceError) => { @@ -107,4 +118,12 @@ export const secretAlreadyExists = (): ServiceError => { errorCode: ErrorCode.SECRET_ALREADY_EXISTS, message: "Secret already exists", } +} + +export const stripeClientNotInitialized = (): ServiceError => { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED, + message: "Stripe client is not initialized.", + } } \ No newline at end of file diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 5614a24d..b4720577 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, repositoryQuerySchema, searchRequestSchema, searchResponseSchema, symbolSchema, getVersionResponseSchema } from "./schemas"; +import { tenancyModeSchema } from "@/env.mjs"; export type KeymapType = "default" | "vim"; @@ -25,4 +26,6 @@ export type GetVersionResponse = z.infer; export enum SearchQueryParams { query = "query", maxMatchDisplayCount = "maxMatchDisplayCount", -} \ No newline at end of file +} + +export type TenancyMode = z.infer; \ No newline at end of file From d974ab174d963a757ece7c125ebf1cf96bf8db34 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 19 Mar 2025 21:30:20 -0700 Subject: [PATCH 4/6] Add SOURCEBOT_AUTH_ENABLED env-var --- packages/web/src/actions.ts | 121 +++++++++--------- .../components/importSecretDialog.tsx | 2 +- .../[domain]/components/navigationMenu.tsx | 45 ++++--- packages/web/src/app/[domain]/layout.tsx | 12 +- packages/web/src/app/[domain]/page.tsx | 28 ++-- .../web/src/app/[domain]/repos/columns.tsx | 4 +- packages/web/src/app/[domain]/repos/page.tsx | 6 +- .../app/[domain]/repos/repositoryTable.tsx | 42 +++--- .../web/src/app/[domain]/upgrade/page.tsx | 13 +- .../(server)/auth/verifyCredentials/route.ts | 74 ----------- .../web/src/app/api/(server)/repos/route.ts | 4 +- .../web/src/app/api/(server)/search/route.ts | 3 +- .../web/src/app/api/(server)/source/route.ts | 3 +- packages/web/src/auth.ts | 107 +++++++++++++--- packages/web/src/env.mjs | 1 + packages/web/src/initialize.ts | 55 ++++---- packages/web/src/lib/constants.ts | 2 +- packages/web/src/lib/schemas.ts | 8 -- packages/web/src/middleware.ts | 15 ++- 19 files changed, 290 insertions(+), 255 deletions(-) delete mode 100644 packages/web/src/app/api/(server)/auth/verifyCredentials/route.ts diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index e8bec44f..1c6bf587 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -23,7 +23,7 @@ import { render } from "@react-email/components"; import InviteUserEmail from "./emails/inviteUserEmail"; import { createTransport } from "nodemailer"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; -import { RepositoryQuery, TenancyMode } from "./lib/types"; +import { TenancyMode } from "./lib/types"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; import { stripeClient } from "./lib/stripe"; import { IS_BILLING_ENABLED } from "./lib/stripe"; @@ -32,19 +32,27 @@ const ajv = new Ajv({ validateFormats: false, }); -export const withAuth = async (fn: (session: Session) => Promise) => { - if (env.SOURCEBOT_TENANCY_MODE === 'single') { - return fn({ - user: { - id: SINGLE_TENANT_USER_ID, - email: SINGLE_TENANT_USER_EMAIL, - }, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), - }); - } - +export const withAuth = async (fn: (session: Session) => Promise, allowSingleTenantUnauthedAccess: boolean = false) => { const session = await auth(); if (!session) { + if ( + env.SOURCEBOT_TENANCY_MODE === 'single' && + env.SOURCEBOT_AUTH_ENABLED === 'false' && + allowSingleTenantUnauthedAccess === true + ) { + // To allow for unauthed acccess in single-tenant mode, we can + // create a fake session with the default user. This user has membership + // in the default org. + // @see: initialize.ts + return fn({ + user: { + id: SINGLE_TENANT_USER_ID, + email: SINGLE_TENANT_USER_EMAIL, + }, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), + }); + } + return notAuthenticated(); } return fn(session); @@ -135,28 +143,27 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } })); export const updateOrgName = async (name: string, domain: string) => - withTenancyModeEnforcement('multi', () => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const { success } = orgNameSchema.safeParse(name); - if (!success) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "Invalid organization url", - } satisfies ServiceError; - } + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const { success } = orgNameSchema.safeParse(name); + if (!success) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Invalid organization url", + } satisfies ServiceError; + } - await prisma.org.update({ - where: { id: orgId }, - data: { name }, - }); + await prisma.org.update({ + where: { id: orgId }, + data: { name }, + }); - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + ); export const updateOrgDomain = async (newDomain: string, existingDomain: string) => withTenancyModeEnforcement('multi', () => @@ -226,25 +233,23 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo ); export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => - withTenancyModeEnforcement('multi', () => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const secrets = await prisma.secret.findMany({ - where: { - orgId, - }, - select: { - key: true, - createdAt: true - } - }); - - return secrets.map((secret) => ({ - key: secret.key, - createdAt: secret.createdAt, - })); + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const secrets = await prisma.secret.findMany({ + where: { + orgId, + }, + select: { + key: true, + createdAt: true + } + }); - }))); + return secrets.map((secret) => ({ + key: secret.key, + createdAt: secret.createdAt, + })); + })); export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => @@ -294,8 +299,7 @@ export const checkIfSecretExists = async (key: string, domain: string): Promise< }); return !!secret; - }) - ); + })); export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => @@ -379,9 +383,9 @@ export const getConnectionInfo = async (connectionId: number, domain: string) => numLinkedRepos: connection.repos.length, } }) - ) + ); -export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}): Promise => +export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { const repos = await prisma.repo.findMany({ @@ -420,8 +424,8 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, })); - }) - ); + } + ), /* allowSingleTenantUnauthedAccess = */ true); export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => withAuth((session) => @@ -443,7 +447,8 @@ export const createConnection = async (name: string, type: string, connectionCon return { id: connection.id, } - })); + }) + ); export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => @@ -1085,7 +1090,7 @@ export const createStripeCheckoutSession = async (domain: string) => url: stripeSession.url, } }) - ) + ); export const getCustomerPortalSessionLink = async (domain: string): Promise => withAuth((session) => diff --git a/packages/web/src/app/[domain]/components/importSecretDialog.tsx b/packages/web/src/app/[domain]/components/importSecretDialog.tsx index 264d9d28..853b298e 100644 --- a/packages/web/src/app/[domain]/components/importSecretDialog.tsx +++ b/packages/web/src/app/[domain]/components/importSecretDialog.tsx @@ -63,7 +63,7 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo const response = await createSecret(data.key, data.value, domain); if (isServiceError(response)) { toast({ - description: `❌ Failed to create secret` + description: `❌ Failed to create secret. Reason: ${response.message}` }); captureEvent('wa_secret_combobox_import_secret_fail', { type: codeHostType, diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index be3954dd..3fdd7fe1 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -13,6 +13,7 @@ import { ProgressNavIndicator } from "./progressNavIndicator"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { TrialNavIndicator } from "./trialNavIndicator"; import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { env } from "@/env.mjs"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; @@ -39,10 +40,14 @@ export const NavigationMenu = async ({ /> - - + {env.SOURCEBOT_TENANCY_MODE === 'multi' && ( + <> + + + + )} @@ -60,20 +65,24 @@ export const NavigationMenu = async ({ - - - - Connections - - - - - - - Settings - - - + {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( + + + + Connections + + + + )} + {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( + + + + Settings + + + + )} diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 0122fae2..db4aff6f 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -14,7 +14,7 @@ import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { IS_BILLING_ENABLED } from "@/lib/stripe"; import { env } from "@/env.mjs"; - +import { notFound, redirect } from "next/navigation"; interface LayoutProps { children: React.ReactNode, params: { domain: string } @@ -27,13 +27,13 @@ export default async function Layout({ const org = await getOrgFromDomain(domain); if (!org) { - return + return notFound(); } - if (env.SOURCEBOT_TENANCY_MODE === 'multi') { + if (env.SOURCEBOT_AUTH_ENABLED === 'true') { const session = await auth(); if (!session) { - return + redirect('/login'); } const membership = await prisma.userToOrg.findUnique({ @@ -46,10 +46,8 @@ export default async function Layout({ }); if (!membership) { - return + return notFound(); } - } else { - // no-op } if (!org.isOnboarded) { diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index 3d380d4b..abcf0c91 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -43,48 +43,48 @@ export default async function Home({ params: { domain } }: { params: { domain: s title="Search in files or paths" > - test todo (both test and todo) + test todo (both test and todo) - test or todo (either test or todo) + test or todo (either test or todo) - {`"exit boot"`} (exact match) + {`"exit boot"`} (exact match) - TODO case:yes (case sensitive) + TODO case:yes (case sensitive) - file:README setup (by filename) + file:README setup (by filename) - repo:torvalds/linux test (by repo) + repo:torvalds/linux test (by repo) - lang:typescript (by language) + lang:typescript (by language) - rev:HEAD (by branch or tag) + rev:HEAD (by branch or tag) - file:{`\\.py$`} {`(files that end in ".py")`} + file:{`\\.py$`} {`(files that end in ".py")`} - sym:main {`(symbols named "main")`} + sym:main {`(symbols named "main")`} - todo -lang:c (negate filter) + todo -lang:c (negate filter) - content:README (search content only) + content:README (search content only) @@ -130,10 +130,10 @@ const QueryExplanation = ({ children }: { children: React.ReactNode }) => { ) } -const Query = ({ query, children }: { query: string, children: React.ReactNode }) => { +const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => { return ( {children} diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx index 3ac63f8b..2c8a3776 100644 --- a/packages/web/src/app/[domain]/repos/columns.tsx +++ b/packages/web/src/app/[domain]/repos/columns.tsx @@ -93,13 +93,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => { ) } -export const columns = (domain: string): ColumnDef[] => [ +export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef[] => [ { accessorKey: "name", header: () => (
Repository - + {isAddNewRepoButtonVisible && }
), cell: ({ row }) => { diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 20da1248..9ba31340 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -2,6 +2,8 @@ import { RepositoryTable } from "./repositoryTable"; import { getOrgFromDomain } from "@/data/org"; import { PageNotFound } from "../components/pageNotFound"; import { Header } from "../components/header"; +import { env } from "@/env.mjs"; + export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) { const org = await getOrgFromDomain(domain); if (!org) { @@ -15,7 +17,9 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
- +
diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx index 59ee672b..14cc6a33 100644 --- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx +++ b/packages/web/src/app/[domain]/repos/repositoryTable.tsx @@ -11,7 +11,11 @@ import { useMemo } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { env } from "@/env.mjs"; -export const RepositoryTable = () => { +interface RepositoryTableProps { + isAddNewRepoButtonVisible: boolean; +} + +export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTableProps) => { const domain = useDomain(); const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({ @@ -48,31 +52,31 @@ export const RepositoryTable = () => { const tableColumns = useMemo(() => { if (reposLoading) { - return columns(domain).map((column) => { + return columns(domain, isAddNewRepoButtonVisible).map((column) => { if ('accessorKey' in column && column.accessorKey === "name") { - return { + return { + ...column, + cell: () => ( +
+ {/* Avatar skeleton */} + {/* Repository name skeleton */} +
+ ), + } + } + + return { ...column, cell: () => ( -
- {/* Avatar skeleton */} - {/* Repository name skeleton */} -
+
+ +
), - } - } - - return { - ...column, - cell: () => ( -
- -
- ), } - }) + }) } - return columns(domain); + return columns(domain, isAddNewRepoButtonVisible); }, [reposLoading, domain]); diff --git a/packages/web/src/app/[domain]/upgrade/page.tsx b/packages/web/src/app/[domain]/upgrade/page.tsx index cd46aaf9..cd8f238b 100644 --- a/packages/web/src/app/[domain]/upgrade/page.tsx +++ b/packages/web/src/app/[domain]/upgrade/page.tsx @@ -9,8 +9,13 @@ import { isServiceError } from "@/lib/utils"; import Link from "next/link"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; +import { env } from "@/env.mjs"; +import { IS_BILLING_ENABLED } from "@/lib/stripe"; export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) { + if (!IS_BILLING_ENABLED) { + redirect(`/${domain}`); + } const subscription = await fetchSubscription(domain); if (!subscription) { @@ -52,9 +57,11 @@ export default async function Upgrade({ params: { domain } }: { params: { domain

- + {env.SOURCEBOT_TENANCY_MODE === 'multi' && ( + + )}
{ - const user = await prisma.user.findUnique({ - where: { email } - }); - - // The user doesn't exist, so create a new one. - if (!user) { - const hashedPassword = bcrypt.hashSync(password, 10); - const newUser = await prisma.user.create({ - data: { - email, - hashedPassword, - } - }); - - return { - id: newUser.id, - email: newUser.email, - } - - // Otherwise, the user exists, so verify the password. - } else { - if (!user.hashedPassword) { - return null; - } - - if (!bcrypt.compareSync(password, user.hashedPassword)) { - return null; - } - - return { - id: user.id, - email: user.email, - name: user.name ?? undefined, - image: user.image ?? undefined, - }; - } - -} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index ba13f3e6..bab9bc9d 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -22,5 +22,5 @@ const getRepos = (domain: string) => withOrgMembership(session, domain, async ({ orgId }) => { const response = await listRepositories(orgId); return response; - }) - ); \ No newline at end of file + } + ), /* allowSingleTenantUnauthedAccess */ true); \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index 4bb119a9..813640e7 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -30,4 +30,5 @@ const postSearch = (request: SearchRequest, domain: string) => withOrgMembership(session, domain, async ({ orgId }) => { const response = await search(request, orgId); return response; - })) \ No newline at end of file + } + ), /* allowSingleTenantUnauthedAccess */ true); \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 858857f6..1c9318b4 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -32,4 +32,5 @@ const postSource = (request: FileSourceRequest, domain: string) => withOrgMembership(session, domain, async ({ orgId }) => { const response = await getFileSource(request, orgId); return response; - })); + } + ), /* allowSingleTenantUnauthedAccess */ true); diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index bcac55b3..39b76523 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -1,5 +1,5 @@ import 'next-auth/jwt'; -import NextAuth, { DefaultSession } from "next-auth" +import NextAuth, { DefaultSession, User as AuthJsUser } from "next-auth" import GitHub from "next-auth/providers/github" import Google from "next-auth/providers/google" import Credentials from "next-auth/providers/credentials" @@ -7,13 +7,15 @@ import EmailProvider from "next-auth/providers/nodemailer"; import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/prisma"; import { env } from "@/env.mjs"; -import { User } from '@sourcebot/db'; +import { OrgRole, User } from '@sourcebot/db'; import 'next-auth/jwt'; import type { Provider } from "next-auth/providers"; -import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas'; +import { verifyCredentialsRequestSchema } from './lib/schemas'; import { createTransport } from 'nodemailer'; import { render } from '@react-email/render'; import MagicLinkEmail from './emails/magicLinkEmail'; +import { SINGLE_TENANT_ORG_ID } from './lib/constants'; +import bcrypt from 'bcrypt'; export const runtime = 'nodejs'; @@ -89,24 +91,45 @@ export const getProviders = () => { return null; } const { email, password } = body.data; - - // authorize runs in the edge runtime (where we cannot make DB calls / access environment variables), - // so we need to make a request to the server to verify the credentials. - const response = await fetch(new URL('/api/auth/verifyCredentials', env.AUTH_URL), { - method: 'POST', - body: JSON.stringify({ email, password }), + + const user = await prisma.user.findUnique({ + where: { email } }); - - if (!response.ok) { - return null; - } - - const user = verifyCredentialsResponseSchema.parse(await response.json()); - return { - id: user.id, - email: user.email, - name: user.name, - image: user.image, + + // The user doesn't exist, so create a new one. + if (!user) { + const hashedPassword = bcrypt.hashSync(password, 10); + const newUser = await prisma.user.create({ + data: { + email, + hashedPassword, + } + }); + + const authJsUser: AuthJsUser = { + id: newUser.id, + email: newUser.email, + } + + onCreateUser({ user: authJsUser }); + return authJsUser; + + // Otherwise, the user exists, so verify the password. + } else { + if (!user.hashedPassword) { + return null; + } + + if (!bcrypt.compareSync(password, user.hashedPassword)) { + return null; + } + + return { + id: user.id, + email: user.email, + name: user.name ?? undefined, + image: user.image ?? undefined, + }; } } })); @@ -115,6 +138,47 @@ export const getProviders = () => { return providers; } +const onCreateUser = async ({ user }: { user: AuthJsUser }) => { + // In single-tenant mode w/ auth, we assign the first user to sign + // up as the owner of the default org. + if ( + env.SOURCEBOT_TENANCY_MODE === 'single' && + env.SOURCEBOT_AUTH_ENABLED === 'true' + ) { + await prisma.$transaction(async (tx) => { + const defaultOrg = await tx.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + include: { + members: true, + } + }); + + // Only the first user to sign up will be an owner of the default org. + if (defaultOrg?.members.length === 0) { + await tx.org.update({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + data: { + members: { + create: { + role: OrgRole.OWNER, + user: { + connect: { + id: user.id, + } + } + } + } + } + }); + } + }); + } +} + const useSecureCookies = env.AUTH_URL?.startsWith("https://") ?? false; const hostName = env.AUTH_URL ? new URL(env.AUTH_URL).hostname : "localhost"; @@ -125,6 +189,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ strategy: "jwt", }, trustHost: true, + events: { + createUser: onCreateUser, + }, callbacks: { async jwt({ token, user: _user }) { const user = _user as User | undefined; diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index b93e7a2e..f77fd7ed 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -44,6 +44,7 @@ export const env = createEnv({ DATABASE_URL: z.string().url(), SOURCEBOT_TENANCY_MODE: tenancyModeSchema.default("multi"), + SOURCEBOT_AUTH_ENABLED: booleanSchema.default('true'), }, // @NOTE: Make sure you destructure all client variables in the // `experimental__runtimeEnv` block below. diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 510ed8a7..6ef83e49 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -3,18 +3,11 @@ import { env } from './env.mjs'; import { prisma } from "@/prisma"; import { SINGLE_TENANT_USER_ID, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_NAME, SINGLE_TENANT_USER_EMAIL } from './lib/constants'; -const initSingleTenancy = async () => { - const user = await prisma.user.upsert({ - where: { - id: SINGLE_TENANT_USER_ID, - }, - update: {}, - create: { - id: SINGLE_TENANT_USER_ID, - email: SINGLE_TENANT_USER_EMAIL, - }, - }); +if (env.SOURCEBOT_AUTH_ENABLED === 'false' && env.SOURCEBOT_TENANCY_MODE === 'multi') { + throw new Error('SOURCEBOT_AUTH_ENABLED must be true when SOURCEBOT_TENANCY_MODE is multi'); +} +const initSingleTenancy = async () => { await prisma.org.upsert({ where: { id: SINGLE_TENANT_ORG_ID, @@ -24,21 +17,39 @@ const initSingleTenancy = async () => { name: SINGLE_TENANT_ORG_NAME, domain: SINGLE_TENANT_ORG_DOMAIN, id: SINGLE_TENANT_ORG_ID, - isOnboarded: true, - members: { - create: { - role: OrgRole.OWNER, - user: { - connect: { - id: user.id, + isOnboarded: env.SOURCEBOT_AUTH_ENABLED === 'false', + } + }); + + if (env.SOURCEBOT_AUTH_ENABLED === 'false') { + // Default user for single tenancy unauthed access + await prisma.user.upsert({ + where: { + id: SINGLE_TENANT_USER_ID, + }, + update: {}, + create: { + id: SINGLE_TENANT_USER_ID, + email: SINGLE_TENANT_USER_EMAIL, + }, + }); + + await prisma.org.update({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + data: { + members: { + create: { + role: OrgRole.MEMBER, + user: { + connect: { id: SINGLE_TENANT_USER_ID } } } } } - } - }); - - console.log('init!'); + }); + } } if (env.SOURCEBOT_TENANCY_MODE === 'single') { diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index 0a0fec79..faee5be9 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -25,7 +25,7 @@ export const TEAM_FEATURES = [ export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed'; export const SINGLE_TENANT_USER_ID = '1'; -export const SINGLE_TENANT_USER_EMAIL = 'default@example.com'; +export const SINGLE_TENANT_USER_EMAIL = 'default@sourcebot.dev'; export const SINGLE_TENANT_ORG_ID = 1; export const SINGLE_TENANT_ORG_DOMAIN = '~'; export const SINGLE_TENANT_ORG_NAME = 'default'; \ No newline at end of file diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 0345aac7..93c52345 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -183,14 +183,6 @@ export const verifyCredentialsRequestSchema = z.object({ password: z.string().min(8), }); - -export const verifyCredentialsResponseSchema = z.object({ - id: z.string().optional(), - name: z.string().optional(), - email: z.string().optional(), - image: z.string().optional(), -}); - export const orgNameSchema = z.string().min(2, { message: "Organization name must be at least 3 characters long." }); export const orgDomainSchema = z.string() diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 825be57b..b8c1580c 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -3,14 +3,23 @@ import type { NextRequest } from 'next/server' import { env } from './env.mjs' import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants' -// The following middleware is used to redirect all requests to -// the single tenant domain (when in single tenant mode). export async function middleware(request: NextRequest) { + const url = request.nextUrl.clone(); + if (env.SOURCEBOT_TENANCY_MODE !== 'single') { return NextResponse.next(); } - const url = request.nextUrl.clone(); + // Enable these domains when auth is enabled. + if (env.SOURCEBOT_AUTH_ENABLED === 'true' && + ( + url.pathname.startsWith('/login') || + url.pathname.startsWith('/redeem') + ) + ) { + return NextResponse.next(); + } + const pathSegments = url.pathname.split('/').filter(Boolean); const currentDomain = pathSegments[0]; From 85166af175077a01cd4d1acfcecce70c116da9ad Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 20 Mar 2025 10:03:14 -0700 Subject: [PATCH 5/6] random: set CONFIG_MAX_REPOS_NO_TOKEN default to max int, effectively disabling this feature by default (since this is only useful in certain scenarios like cloud) --- packages/web/src/actions.ts | 18 +++++++++--------- packages/web/src/env.mjs | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 1c6bf587..b82ef598 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1433,6 +1433,15 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } satisfies ServiceError; } + const isValidConfig = ajv.validate(schema, parsedConfig); + if (!isValidConfig) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`, + } satisfies ServiceError; + } + const { numRepos, hasToken } = (() => { switch (connectionType) { case "github": { @@ -1479,15 +1488,6 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } satisfies ServiceError; } - const isValidConfig = ajv.validate(schema, parsedConfig); - if (!isValidConfig) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`, - } satisfies ServiceError; - } - return parsedConfig; } diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index f77fd7ed..2b5f1df0 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -37,7 +37,7 @@ export const env = createEnv({ STRIPE_ENABLE_TEST_CLOCKS: booleanSchema.default('false'), // Misc - CONFIG_MAX_REPOS_NO_TOKEN: numberSchema.default(500), + CONFIG_MAX_REPOS_NO_TOKEN: numberSchema.default(Number.MAX_SAFE_INTEGER), SOURCEBOT_ROOT_DOMAIN: z.string().default("localhost:3000"), NODE_ENV: z.enum(["development", "test", "production"]), SOURCEBOT_TELEMETRY_DISABLED: booleanSchema.default('false'), From ed945d2b0463e31b84c5c49875b67decf7f70c17 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 20 Mar 2025 10:04:12 -0700 Subject: [PATCH 6/6] Change the tenancy mode to default to single --- packages/web/src/env.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 2b5f1df0..612b77f5 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -43,7 +43,7 @@ export const env = createEnv({ SOURCEBOT_TELEMETRY_DISABLED: booleanSchema.default('false'), DATABASE_URL: z.string().url(), - SOURCEBOT_TENANCY_MODE: tenancyModeSchema.default("multi"), + SOURCEBOT_TENANCY_MODE: tenancyModeSchema.default("single"), SOURCEBOT_AUTH_ENABLED: booleanSchema.default('true'), }, // @NOTE: Make sure you destructure all client variables in the