diff --git a/.env.development b/.env.development index 39b25125c..e9029f539 100644 --- a/.env.development +++ b/.env.development @@ -71,3 +71,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection NODE_ENV=development DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true + +SOURCEBOT_LIGHTHOUSE_URL=http://localhost:3003 diff --git a/packages/backend/src/__mocks__/prisma.ts b/packages/backend/src/__mocks__/prisma.ts new file mode 100644 index 000000000..7a07a85b4 --- /dev/null +++ b/packages/backend/src/__mocks__/prisma.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +export const prisma = { + license: { + findUnique: vi.fn().mockResolvedValue(null), + }, +}; diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index 0df9ee20a..fda71ac44 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -1,5 +1,6 @@ import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db'; -import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { createLogger, env, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { hasEntitlement } from './entitlements.js'; import express, { Request, Response } from 'express'; import 'express-async-errors'; import * as http from "http"; @@ -100,7 +101,7 @@ export class Api { } private async triggerAccountPermissionSync(req: Request, res: Response) { - if (env.PERMISSION_SYNC_ENABLED !== 'true' || !hasEntitlement('permission-syncing')) { + if (env.PERMISSION_SYNC_ENABLED !== 'true' || !await hasEntitlement('permission-syncing')) { res.status(403).json({ error: 'Permission syncing is not enabled.' }); return; } diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index f1bfe9d05..9b88cd5ce 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/node"; import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; -import { env, hasEntitlement, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { env, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { ensureFreshAccountToken } from "./tokenRefresh.js"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -50,8 +51,8 @@ export class AccountPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index a6f42bf17..befb8593e 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -1,7 +1,8 @@ import * as Sentry from "@sentry/node"; import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; @@ -44,8 +45,8 @@ export class RepoPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/syncSearchContexts.test.ts b/packages/backend/src/ee/syncSearchContexts.test.ts index bfd2f8b1f..9aa1decfd 100644 --- a/packages/backend/src/ee/syncSearchContexts.test.ts +++ b/packages/backend/src/ee/syncSearchContexts.test.ts @@ -12,12 +12,15 @@ vi.mock('@sourcebot/shared', async (importOriginal) => { error: vi.fn(), debug: vi.fn(), })), - hasEntitlement: vi.fn(() => true), - getPlan: vi.fn(() => 'enterprise'), SOURCEBOT_SUPPORT_EMAIL: 'support@sourcebot.dev', }; }); +vi.mock('../entitlements.js', () => ({ + hasEntitlement: vi.fn(() => Promise.resolve(true)), + getPlan: vi.fn(() => Promise.resolve('enterprise')), +})); + import { syncSearchContexts } from './syncSearchContexts.js'; // Helper to build a repo record with GitLab topics stored in metadata. diff --git a/packages/backend/src/ee/syncSearchContexts.ts b/packages/backend/src/ee/syncSearchContexts.ts index cd745a356..3011def22 100644 --- a/packages/backend/src/ee/syncSearchContexts.ts +++ b/packages/backend/src/ee/syncSearchContexts.ts @@ -1,7 +1,8 @@ import micromatch from "micromatch"; import { createLogger } from "@sourcebot/shared"; import { PrismaClient } from "@sourcebot/db"; -import { getPlan, hasEntitlement, repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { getPlan, hasEntitlement } from "../entitlements.js"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; const logger = createLogger('sync-search-contexts'); @@ -15,9 +16,9 @@ interface SyncSearchContextsParams { export const syncSearchContexts = async (params: SyncSearchContextsParams) => { const { contexts, orgId, db } = params; - if (!hasEntitlement("search-contexts")) { + if (!await hasEntitlement("search-contexts")) { if (contexts) { - const plan = getPlan(); + const plan = await getPlan(); logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); } return false; diff --git a/packages/backend/src/entitlements.ts b/packages/backend/src/entitlements.ts new file mode 100644 index 000000000..f7030521e --- /dev/null +++ b/packages/backend/src/entitlements.ts @@ -0,0 +1,36 @@ +import { + Entitlement, + Plan, + getPlan as _getPlan, + getSeats as _getSeats, + hasEntitlement as _hasEntitlement, + getEntitlements as _getEntitlements, +} from "@sourcebot/shared"; +import { prisma } from "./prisma.js"; +import { SINGLE_TENANT_ORG_ID } from "./constants.js"; + +const getLicense = async () => { + return prisma.license.findUnique({ + where: { orgId: SINGLE_TENANT_ORG_ID }, + }); +} + +export const getPlan = async (): Promise => { + const license = await getLicense(); + return _getPlan(license); +} + +export const getSeats = async (): Promise => { + const license = await getLicense(); + return _getSeats(license); +} + +export const hasEntitlement = async (entitlement: Entitlement): Promise => { + const license = await getLicense(); + return _hasEntitlement(entitlement, license); +} + +export const getEntitlements = async (): Promise => { + const license = await getLicense(); + return _getEntitlements(license); +} diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index f82bb2282..998563408 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -4,7 +4,8 @@ import * as Sentry from "@sentry/node"; import { getTokenFromConfig } from "@sourcebot/shared"; import { createLogger } from "@sourcebot/shared"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import micromatch from "micromatch"; import pLimit from "p-limit"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; @@ -124,7 +125,7 @@ const getOctokitWithGithubApp = async ( url: string | undefined, context: string ): Promise => { - if (!hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { + if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { return octokit; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0999f7771..af3fd489c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,8 +1,9 @@ import "./instrument.js"; import * as Sentry from "@sentry/node"; -import { PrismaClient } from "@sourcebot/db"; -import { createLogger, env, getConfigSettings, getDBConnectionString, hasEntitlement } from "@sourcebot/shared"; +import { createLogger, env, getConfigSettings } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; +import { prisma } from "./prisma.js"; import 'express-async-errors'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -31,13 +32,6 @@ if (!existsSync(indexPath)) { await mkdir(indexPath, { recursive: true }); } -const prisma = new PrismaClient({ - datasources: { - db: { - url: getDBConnectionString(), - }, - }, -}); try { await redis.ping(); @@ -51,7 +45,7 @@ const promClient = new PromClient(); const settings = await getConfigSettings(env.CONFIG_PATH); -if (hasEntitlement('github-app')) { +if (await hasEntitlement('github-app')) { await GithubAppManager.getInstance().init(prisma); } @@ -66,11 +60,11 @@ connectionManager.startScheduler(); await repoIndexManager.startScheduler(); auditLogPruner.startScheduler(); -if (env.PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) { +if (env.PERMISSION_SYNC_ENABLED === 'true' && !await hasEntitlement('permission-syncing')) { logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.'); process.exit(1); } -else if (env.PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { +else if (env.PERMISSION_SYNC_ENABLED === 'true' && await hasEntitlement('permission-syncing')) { if (env.PERMISSION_SYNC_REPO_DRIVEN_ENABLED === 'true') { repoPermissionSyncer.startScheduler(); } diff --git a/packages/backend/src/prisma.ts b/packages/backend/src/prisma.ts new file mode 100644 index 000000000..325d50db7 --- /dev/null +++ b/packages/backend/src/prisma.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from "@sourcebot/db"; +import { getDBConnectionString } from "@sourcebot/shared"; + +export const prisma = new PrismaClient({ + datasources: { + db: { + url: getDBConnectionString(), + }, + }, +}); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index a69508515..d99727ea3 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -5,7 +5,7 @@ import { getTokenFromConfig } from "@sourcebot/shared"; import * as Sentry from "@sentry/node"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { GithubAppManager } from "./ee/githubAppManager.js"; -import { hasEntitlement } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import { StatusCodes } from "http-status-codes"; import { isOctokitRequestError } from "./github.js"; @@ -116,7 +116,7 @@ export const fetchWithRetry = async ( // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing. export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logger?: Logger): Promise => { // If we have github apps configured we assume that we must use them for github service auth - if (repo.external_codeHostType === 'github' && hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { + if (repo.external_codeHostType === 'github' && await hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { logger?.debug(`Using GitHub App for service auth for repo ${repo.displayName} hosted at ${repo.external_codeHostUrl}`); const owner = repo.displayName?.split('/')[0]; diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index 5b2ab0d5d..6bdbe819b 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { @@ -6,6 +7,9 @@ export default defineConfig({ watch: false, env: { DATA_CACHE_DIR: 'test-data' - } + }, + alias: { + './prisma.js': path.resolve(__dirname, 'src/__mocks__/prisma.ts'), + }, } }); \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260412062214_add_license_table/migration.sql b/packages/db/prisma/migrations/20260412062214_add_license_table/migration.sql new file mode 100644 index 000000000..1a245ff6d --- /dev/null +++ b/packages/db/prisma/migrations/20260412062214_add_license_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "License" ( + "id" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + "activationCode" TEXT NOT NULL, + "plan" TEXT, + "seats" INTEGER, + "status" TEXT, + "lastSyncAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "License_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "License_orgId_key" ON "License"("orgId"); + +-- AddForeignKey +ALTER TABLE "License" ADD CONSTRAINT "License_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3a96eea6e..76160c01e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,6 +291,21 @@ model Org { searchContexts SearchContext[] chats Chat[] + + license License? +} + +model License { + id String @id @default(cuid()) + orgId Int @unique + org Org @relation(fields: [orgId], references: [id]) + activationCode String + plan String? + seats Int? + status String? + lastSyncAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum OrgRole { diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index de841a0dd..460467f48 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -4,6 +4,7 @@ import { createLogger } from "./logger.js"; import { env } from "./env.server.js"; import { SOURCEBOT_SUPPORT_EMAIL, SOURCEBOT_UNLIMITED_SEATS } from "./constants.js"; import { verifySignature } from "./crypto.js"; +import { License } from "@sourcebot/db"; const logger = createLogger('entitlements'); @@ -31,7 +32,6 @@ export type Plan = keyof typeof planLabels; const entitlements = [ "search-contexts", "anonymous-access", - "multi-tenancy", "sso", "code-nav", "audit", @@ -75,25 +75,37 @@ const entitlementsByPlan: Record = { ], } as const; +const isValidPlan = (plan: string): plan is Plan => { + return plan in entitlementsByPlan; +} + +const ACTIVE_LICENSE_STATUSES = ['active', 'trialing', 'past_due'] as const; + +const isLicenseActive = (license: License | null): boolean => { + if (!license?.status) { + return false; + } + return ACTIVE_LICENSE_STATUSES.includes(license.status as typeof ACTIVE_LICENSE_STATUSES[number]); +} const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { try { const decodedPayload = base64Decode(payload); const payloadJson = JSON.parse(decodedPayload); const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson); - + const dataToVerify = JSON.stringify({ expiryDate: licenseData.expiryDate, id: licenseData.id, seats: licenseData.seats }); - + const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH); if (!isSignatureValid) { logger.error('License key signature verification failed'); process.exit(1); } - + return licenseData; } catch (error) { logger.error(`Failed to decode license key payload: ${error}`); @@ -101,7 +113,7 @@ const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { } } -export const getLicenseKey = (): LicenseKeyPayload | null => { +export const getOfflineLicenseKey = (): LicenseKeyPayload | null => { const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { const payload = licenseKey.substring(eeLicenseKeyPrefix.length); @@ -110,8 +122,8 @@ export const getLicenseKey = (): LicenseKeyPayload | null => { return null; } -export const getPlan = (): Plan => { - const licenseKey = getLicenseKey(); +export const getPlan = (license: License | null): Plan => { + const licenseKey = getOfflineLicenseKey(); if (licenseKey) { const expiryDate = new Date(licenseKey.expiryDate); if (expiryDate.getTime() < new Date().getTime()) { @@ -120,22 +132,34 @@ export const getPlan = (): Plan => { } return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; - } else { - return "oss"; + } + else if (license?.plan && isValidPlan(license.plan) && isLicenseActive(license)) { + return license.plan; + } + else { + return "oss"; } } -export const getSeats = (): number => { -const licenseKey = getLicenseKey(); - return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; +export const getSeats = (license: License | null): number => { + const licenseKey = getOfflineLicenseKey(); + if (licenseKey) { + return licenseKey.seats; + } + + if (license?.seats && isLicenseActive(license)) { + return license.seats; + } + + return SOURCEBOT_UNLIMITED_SEATS; } -export const hasEntitlement = (entitlement: Entitlement) => { - const entitlements = getEntitlements(); +export const hasEntitlement = (entitlement: Entitlement, license: License | null) => { + const entitlements = getEntitlements(license); return entitlements.includes(entitlement); } -export const getEntitlements = (): Entitlement[] => { - const plan = getPlan(); +export const getEntitlements = (license: License | null): Entitlement[] => { + const plan = getPlan(license); return entitlementsByPlan[plan]; } diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 46e74fcf0..c8c9e1dc1 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -266,6 +266,7 @@ const options = { SOURCEBOT_ENCRYPTION_KEY: z.string(), SOURCEBOT_INSTALL_ID: z.string().default("unknown"), + SOURCEBOT_LIGHTHOUSE_URL: z.string().url(), FALLBACK_GITHUB_CLOUD_TOKEN: z.string().optional(), FALLBACK_GITLAB_CLOUD_TOKEN: z.string().optional(), diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index a1eb34204..986eca78b 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -1,6 +1,6 @@ export { hasEntitlement, - getLicenseKey, + getOfflineLicenseKey, getPlan, getSeats, getEntitlements, diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 148b3a18b..74728183f 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ NODE_ENV: 'test', CONFIG_PATH: '/tmp/test-config.json', SOURCEBOT_ENCRYPTION_KEY: 'test-encryption-key-32-characters!', + SOURCEBOT_LIGHTHOUSE_URL: 'http://localhost:3003', } } }); diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 939ba442c..740b41b74 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,6 +1,6 @@ 'use server'; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; @@ -14,7 +14,7 @@ import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { getPlan, hasEntitlement } from "@sourcebot/shared"; +import { getPlan, hasEntitlement } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; import { createTransport } from "nodemailer"; @@ -30,7 +30,6 @@ import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; import { sew } from "@/middleware/sew"; const logger = createLogger('web-actions'); -const auditService = getAuditService(); ////// Actions /////// export const completeOnboarding = async (): Promise<{ success: boolean } | ServiceError> => sew(() => @@ -66,7 +65,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv }); if (existingApiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.creation_failed", actor: { id: user.id, @@ -99,7 +98,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv } }); - await auditService.createAudit({ + await createAudit({ action: "api_key.created", actor: { id: user.id, @@ -127,7 +126,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }); if (!apiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.deletion_failed", actor: { id: user.id, @@ -156,7 +155,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }, }); - await auditService.createAudit({ + await createAudit({ action: "api_key.deleted", actor: { id: user.id, @@ -528,7 +527,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea withAuth(async ({ org, user, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { const failAuditCallback = async (error: string) => { - await auditService.createAudit({ + await createAudit({ action: "user.invite_failed", actor: { id: user.id, @@ -548,7 +547,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea const hasAvailability = await orgHasAvailability(); if (!hasAvailability) { - await auditService.createAudit({ + await createAudit({ action: "user.invite_failed", actor: { id: user.id, @@ -679,7 +678,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); } - await auditService.createAudit({ + await createAudit({ action: "user.invites_created", actor: { id: user.id, @@ -968,7 +967,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = withAuth(async ({ org, user, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { const failAuditCallback = async (error: string) => { - await auditService.createAudit({ + await createAudit({ action: "user.join_request_approve_failed", actor: { id: user.id, @@ -1035,7 +1034,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); } - await auditService.createAudit({ + await createAudit({ action: "user.join_request_approved", actor: { id: user.id, @@ -1199,9 +1198,9 @@ export const getAnonymousAccessStatus = async (): Promise => sew(async () => { return await withAuth(async ({ org, role, prisma }) => { return await withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); + const hasAnonymousAccessEntitlement = await hasEntitlement("anonymous-access"); if (!hasAnonymousAccessEntitlement) { - const plan = getPlan(); + const plan = await getPlan(); console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); return { statusCode: StatusCodes.FORBIDDEN, diff --git a/packages/web/src/app/(app)/chat/[id]/page.tsx b/packages/web/src/app/(app)/chat/[id]/page.tsx index 208b93545..610d4eb47 100644 --- a/packages/web/src/app/(app)/chat/[id]/page.tsx +++ b/packages/web/src/app/(app)/chat/[id]/page.tsx @@ -14,7 +14,8 @@ import { __unsafePrisma } from '@/prisma'; import { ChatVisibility } from '@sourcebot/db'; import { Metadata } from 'next'; import { SBChatMessage } from '@/features/chat/types'; -import { env, hasEntitlement } from '@sourcebot/shared'; +import { env } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; import { captureEvent } from '@/lib/posthog'; interface PageProps { @@ -121,7 +122,7 @@ export default async function Page(props: PageProps) { const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); - const hasChatSharingEntitlement = hasEntitlement('chat-sharing'); + const hasChatSharingEntitlement = await hasEntitlement('chat-sharing'); return (
diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index a7110166a..27139b465 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -18,8 +18,8 @@ import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { notFound, redirect } from "next/navigation"; import { PendingApprovalCard } from "./components/pendingApproval"; import { SubmitJoinRequest } from "./components/submitJoinRequest"; -import { hasEntitlement } from "@sourcebot/shared"; import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "@/lib/entitlements"; import { GcpIapAuth } from "./components/gcpIapAuth"; import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; @@ -54,7 +54,7 @@ export default async function Layout(props: LayoutProps) { const session = await auth(); const anonymousAccessEnabled = await (async () => { - if (!hasEntitlement("anonymous-access")) { + if (!await hasEntitlement("anonymous-access")) { return false; } @@ -129,7 +129,7 @@ export default async function Layout(props: LayoutProps) { ) } - if (session && hasEntitlement("sso")) { + if (session && await hasEntitlement("sso")) { const linkedAccounts = await getLinkedAccounts(); if (isServiceError(linkedAccounts)) { throw new ServiceErrorException(linkedAccounts); @@ -163,7 +163,7 @@ export default async function Layout(props: LayoutProps) { ) } - const isPermissionSyncBannerVisible = session && hasEntitlement("permission-syncing"); + const isPermissionSyncBannerVisible = session && await hasEntitlement("permission-syncing"); const hasPendingFirstSync = isPermissionSyncBannerVisible ? (await getPermissionSyncStatus()) : null; return ( diff --git a/packages/web/src/app/(app)/settings/analytics/page.tsx b/packages/web/src/app/(app)/settings/analytics/page.tsx index 363e11285..eb0831447 100644 --- a/packages/web/src/app/(app)/settings/analytics/page.tsx +++ b/packages/web/src/app/(app)/settings/analytics/page.tsx @@ -2,10 +2,10 @@ import { AnalyticsContent } from "@/ee/features/analytics/analyticsContent"; import { AnalyticsEntitlementMessage } from "@/ee/features/analytics/analyticsEntitlementMessage"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; -import { hasEntitlement } from "@sourcebot/shared"; +import { hasEntitlement } from "@/lib/entitlements"; export default authenticatedPage(async () => { - const hasAnalyticsEntitlement = hasEntitlement("analytics"); + const hasAnalyticsEntitlement = await hasEntitlement("analytics"); if (!hasAnalyticsEntitlement) { return ; diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx index a6ede3cbf..ae4826d82 100644 --- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx +++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx @@ -32,7 +32,7 @@ interface BasicSettingsCardProps { export function BasicSettingsCard({ name, description, children }: BasicSettingsCardProps) { return ( -
+

{name}

{description}

diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 2c1aa8094..e2c92e6a8 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -6,7 +6,8 @@ import { isServiceError } from "@/lib/utils"; import { getConnectionStats, getOrgAccountRequests } from "@/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "@/lib/entitlements"; import { withAuth } from "@/middleware/withAuth"; import { NavGroup } from "../@sidebar/components/settingsSidebar/nav"; @@ -73,7 +74,7 @@ export const getSidebarNavGroups = async () => icon: "key-round" as const, } ] : []), - ...(hasEntitlement("sso") ? [ + ...(await hasEntitlement("sso") ? [ { title: "Linked Accounts", href: `/settings/linked-accounts`, diff --git a/packages/web/src/app/(app)/settings/license/activationCodeCard.tsx b/packages/web/src/app/(app)/settings/license/activationCodeCard.tsx new file mode 100644 index 000000000..d08322077 --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/activationCodeCard.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { SettingsCard } from "../components/settingsCard"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { activateLicense, deactivateLicense } from "@/ee/features/lighthouse/actions"; +import { isServiceError } from "@/lib/utils"; +import { useToast } from "@/components/hooks/use-toast"; +import { Separator } from "@/components/ui/separator"; + +interface ActivationCodeCardProps { + isActivated: boolean; +} + +export function ActivationCodeCard({ isActivated }: ActivationCodeCardProps) { + const [activationCode, setActivationCode] = useState(""); + const [isActivating, setIsActivating] = useState(false); + const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleActivate = useCallback(() => { + if (!activationCode.trim()) { + return; + } + + setIsActivating(true); + activateLicense(activationCode.trim()) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to activate license: ${response.message}`, + variant: "destructive", + }); + } else { + toast({ + description: "License activated successfully.", + }); + setActivationCode(""); + router.refresh(); + } + }) + .finally(() => { + setIsActivating(false); + }); + }, [activationCode, toast, router]); + + const handleDeactivate = useCallback(() => { + deactivateLicense() + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to remove license: ${response.message}`, + variant: "destructive", + }); + } else { + toast({ + description: "License removed successfully.", + }); + router.refresh(); + } + }); + }, [toast, router]); + + return ( + <> + +
+

Activation code

+

+ Enter your activation code to enable your enterprise license. +

+ +
+ {isActivated ? ( +
+ + sb_act_•••• + + +
+ ) : ( +
+ setActivationCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleActivate(); + } + }} + disabled={isActivating} + className="font-mono" + /> + + Activate + +
+ )} +
+
+
+ + + + + Remove activation code + + Are you sure you want to remove this activation code? Your deployment will lose access to enterprise features. + + + + Cancel + + Remove + + + + + + ); +} diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index fa4e6ba9f..3bfb1f141 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -1,118 +1,34 @@ -import { getLicenseKey, getEntitlements, getPlan, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; -import { Button } from "@/components/ui/button"; -import { Info, Mail } from "lucide-react"; -import { getOrgMembers } from "@/actions"; -import { isServiceError } from "@/lib/utils"; -import { ServiceErrorException } from "@/lib/serviceError"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; +import { ActivationCodeCard } from "./activationCodeCard"; +import { PurchaseButton } from "./purchaseButton"; +import { BasicSettingsCard } from "../components/settingsCard"; +import { getPlan } from "@/lib/entitlements"; -export default authenticatedPage(async () => { - const licenseKey = getLicenseKey(); - const entitlements = getEntitlements(); - const plan = getPlan(); +export default authenticatedPage(async ({ prisma, org }) => { + const license = await prisma.license.findUnique({ + where: { orgId: org.id }, + }); - if (!licenseKey) { - return ( -
-
-

License

-

View your license details.

-
- -
- -

No License Found

-

- Check out the docs for more information. -

-
-

- Want to try out Sourcebot's enterprise features? Reach out to us and we'll get back to you within - a couple hours with a trial license. -

-
- -
-
- ) - } - - const members = await getOrgMembers(); - if (isServiceError(members)) { - throw new ServiceErrorException(members); - } - - const numMembers = members.length; - const expiryDate = new Date(licenseKey.expiryDate); - const isExpired = expiryDate < new Date(); - const seats = licenseKey.seats; - const isUnlimited = seats === SOURCEBOT_UNLIMITED_SEATS; + const plan = await getPlan(); return (
-
-
-

License

-

View your license details.

-
- - -
- -
-
-

License Details

- -
-
-
License ID
-
{licenseKey.id}
-
- -
-
Plan
-
{plan}
-
- -
-
Entitlements
-
{entitlements?.join(", ") || "None"}
-
- -
-
Seats
-
- {isUnlimited ? 'Unlimited' : `${numMembers} / ${seats}`} -
-
- -
-
Expiry Date
-
- {expiryDate.toLocaleString("en-US", { - hour: "2-digit", - minute: "2-digit", - month: "long", - day: "numeric", - year: "numeric", - timeZoneName: "short" - })} {isExpired && '(Expired)'} -
-
-
-
+
+

License

+

Manage your license.

+ + {plan} + + +
- ) -}, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); + ); +}, { + minRole: OrgRole.OWNER, + redirectTo: '/settings' +}); diff --git a/packages/web/src/app/(app)/settings/license/purchaseButton.tsx b/packages/web/src/app/(app)/settings/license/purchaseButton.tsx new file mode 100644 index 000000000..75c1f23dd --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/purchaseButton.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { createCheckoutSession } from "@/ee/features/lighthouse/actions"; +import { isServiceError } from "@/lib/utils"; +import { useToast } from "@/components/hooks/use-toast"; + +export function PurchaseButton() { + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleClick = useCallback(() => { + setIsLoading(true); + + const successUrl = `${window.location.origin}/settings/license?checkout=success`; + const cancelUrl = `${window.location.origin}/settings/license`; + + createCheckoutSession(successUrl, cancelUrl) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to start checkout: ${response.message}`, + variant: "destructive", + }); + } else { + window.location.href = response.url; + } + }) + .finally(() => { + setIsLoading(false); + }); + }, [toast]); + + return ( + + Purchase a license + + ); +} diff --git a/packages/web/src/app/(app)/settings/linked-accounts/page.tsx b/packages/web/src/app/(app)/settings/linked-accounts/page.tsx index c44a75730..d0411174e 100644 --- a/packages/web/src/app/(app)/settings/linked-accounts/page.tsx +++ b/packages/web/src/app/(app)/settings/linked-accounts/page.tsx @@ -4,7 +4,6 @@ import { isServiceError } from "@/lib/utils"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { ShieldCheck } from "lucide-react"; import { LinkedAccountProviderCard } from "@/ee/features/sso/components/linkedAccountProviderCard"; -import { SettingsCardGroup } from "../components/settingsCard"; export default async function LinkedAccountsPage() { const linkedAccounts = await getLinkedAccounts(); diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx index b8e0a834e..59497a41a 100644 --- a/packages/web/src/app/(app)/settings/members/page.tsx +++ b/packages/web/src/app/(app)/settings/members/page.tsx @@ -7,7 +7,8 @@ import { TabSwitcher } from "@/components/ui/tab-switcher"; import { InvitesList } from "./components/invitesList"; import { getOrgInvites, getMe, getOrgAccountRequests } from "@/actions"; import { ServiceErrorException } from "@/lib/serviceError"; -import { getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; +import { SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; +import { getSeats, hasEntitlement } from "@/lib/entitlements"; import { RequestsList } from "./components/requestsList"; import { OrgRole } from "@sourcebot/db"; import { NotificationDot } from "../../components/notificationDot"; @@ -49,7 +50,7 @@ export default authenticatedPage(async ({ org, role }, const currentTab = tab || "members"; - const seats = getSeats(); + const seats = await getSeats(); const usedSeats = members.length const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats; @@ -134,7 +135,7 @@ export default authenticatedPage(async ({ org, role }, currentUserId={me.id} currentUserRole={role} orgName={org.name} - hasOrgManagement={hasEntitlement('org-management')} + hasOrgManagement={await hasEntitlement('org-management')} /> diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts index cdb71f642..ac72bd5d3 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts @@ -1,11 +1,12 @@ import { apiHandler } from '@/lib/apiHandler'; -import { env, hasEntitlement } from '@sourcebot/shared'; +import { env } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // RFC 8414: OAuth 2.0 Authorization Server Metadata // @see: https://datatracker.ietf.org/doc/html/rfc8414 export const GET = apiHandler(async () => { - if (!hasEntitlement('oauth')) { + if (!await hasEntitlement('oauth')) { return Response.json( { error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 404 } diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts index 2ec902530..1cb8cdd48 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts @@ -1,5 +1,6 @@ import { apiHandler } from '@/lib/apiHandler'; -import { env, hasEntitlement } from '@sourcebot/shared'; +import { env } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -11,7 +12,7 @@ const PROTECTED_RESOURCES = new Set([ ]); export const GET = apiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { - if (!hasEntitlement('oauth')) { + if (!await hasEntitlement('oauth')) { return Response.json( { error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 404 } diff --git a/packages/web/src/app/api/(server)/ee/audit/route.ts b/packages/web/src/app/api/(server)/ee/audit/route.ts index 57491bf79..ab4285377 100644 --- a/packages/web/src/app/api/(server)/ee/audit/route.ts +++ b/packages/web/src/app/api/(server)/ee/audit/route.ts @@ -6,7 +6,7 @@ import { ErrorCode } from "@/lib/errorCodes"; import { buildLinkHeader } from "@/lib/pagination"; import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; -import { getEntitlements } from "@sourcebot/shared"; +import { getEntitlements } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -24,7 +24,7 @@ const auditQueryParamsSchema = auditQueryParamsBaseSchema.refine( ); export const GET = apiHandler(async (request: NextRequest) => { - const entitlements = getEntitlements(); + const entitlements = await getEntitlements(); if (!entitlements.includes('audit')) { return serviceErrorResponse({ statusCode: StatusCodes.FORBIDDEN, diff --git a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts index c4cc7f245..46a1594df 100644 --- a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts @@ -4,7 +4,8 @@ import { ErrorCode } from "@/lib/errorCodes"; import { notFound, queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { withAuth } from "@/middleware/withAuth"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -41,7 +42,7 @@ export const GET = apiHandler(async ( }) } - if (!hasEntitlement('chat-sharing')) { + if (!await hasEntitlement('chat-sharing')) { return serviceErrorResponse({ statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.UNEXPECTED_ERROR, diff --git a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts index a8d223f9e..a4af50f01 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts @@ -1,7 +1,7 @@ import { apiHandler } from '@/lib/apiHandler'; import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; import { __unsafePrisma } from '@/prisma'; -import { hasEntitlement } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; import { NextRequest } from 'next/server'; import { z } from 'zod'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -15,7 +15,7 @@ const registerRequestSchema = z.object({ }); export const POST = apiHandler(async (request: NextRequest) => { - if (!hasEntitlement('oauth')) { + if (!await hasEntitlement('oauth')) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 403 } diff --git a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts index 69d98db95..dff1f956e 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts @@ -1,6 +1,6 @@ import { revokeToken } from '@/ee/features/oauth/server'; import { apiHandler } from '@/lib/apiHandler'; -import { hasEntitlement } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -8,7 +8,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // Always returns 200 regardless of whether the token existed. // @see: https://datatracker.ietf.org/doc/html/rfc7009 export const POST = apiHandler(async (request: NextRequest) => { - if (!hasEntitlement('oauth')) { + if (!await hasEntitlement('oauth')) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 403 } diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts index 5638b10fd..de92ed8bf 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts @@ -1,6 +1,6 @@ import { verifyAndExchangeCode, verifyAndRotateRefreshToken, ACCESS_TOKEN_TTL_SECONDS } from '@/ee/features/oauth/server'; import { apiHandler } from '@/lib/apiHandler'; -import { hasEntitlement } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -8,7 +8,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // Supports grant_type=authorization_code with PKCE (RFC 7636). // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2 export const POST = apiHandler(async (request: NextRequest) => { - if (!hasEntitlement('oauth')) { + if (!await hasEntitlement('oauth')) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 403 } diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts index be23b2e92..f9b32cdee 100644 --- a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts +++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts @@ -2,7 +2,8 @@ import { ServiceError } from "@/lib/serviceError"; import { withAuth } from "@/middleware/withAuth"; -import { env, getEntitlements } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { getEntitlements } from "@/lib/entitlements"; import { AccountPermissionSyncJobStatus } from "@sourcebot/db"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; @@ -18,7 +19,7 @@ export interface PermissionSyncStatusResponse { */ export const getPermissionSyncStatus = async (): Promise => sew(async () => withAuth(async ({ prisma, user }) => { - const entitlements = getEntitlements(); + const entitlements = await getEntitlements(); if (!entitlements.includes('permission-syncing')) { return { statusCode: StatusCodes.FORBIDDEN, diff --git a/packages/web/src/app/api/(server)/ee/user/route.ts b/packages/web/src/app/api/(server)/ee/user/route.ts index ea7789663..a91eb17e9 100644 --- a/packages/web/src/app/api/(server)/ee/user/route.ts +++ b/packages/web/src/app/api/(server)/ee/user/route.ts @@ -1,6 +1,6 @@ 'use server'; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { apiHandler } from "@/lib/apiHandler"; import { ErrorCode } from "@/lib/errorCodes"; import { serviceErrorResponse, missingQueryParam, notFound } from "@/lib/serviceError"; @@ -8,15 +8,15 @@ import { isServiceError } from "@/lib/utils"; import { withAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; -import { createLogger, hasEntitlement } from "@sourcebot/shared"; +import { createLogger } from "@sourcebot/shared"; +import { hasEntitlement } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { NextRequest } from "next/server"; const logger = createLogger('ee-user-api'); -const auditService = getAuditService(); export const GET = apiHandler(async (request: NextRequest) => { - if (!hasEntitlement('org-management')) { + if (!await hasEntitlement('org-management')) { return serviceErrorResponse({ statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, @@ -50,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return notFound('User not found'); } - await auditService.createAudit({ + await createAudit({ action: "user.read", actor: { id: user.id, @@ -112,7 +112,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { return notFound('User not found'); } - await auditService.createAudit({ + await createAudit({ action: "user.delete", actor: { id: currentUser.id, diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts index eb52232f3..58ed5738f 100644 --- a/packages/web/src/app/api/(server)/ee/users/route.ts +++ b/packages/web/src/app/api/(server)/ee/users/route.ts @@ -1,21 +1,21 @@ 'use server'; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { apiHandler } from "@/lib/apiHandler"; import { serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { withAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; -import { createLogger, hasEntitlement } from "@sourcebot/shared"; +import { createLogger } from "@sourcebot/shared"; +import { hasEntitlement } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; const logger = createLogger('ee-users-api'); -const auditService = getAuditService(); export const GET = apiHandler(async () => { - if (!hasEntitlement('org-management')) { + if (!await hasEntitlement('org-management')) { return serviceErrorResponse({ statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, @@ -62,7 +62,7 @@ export const GET = apiHandler(async () => { }) ); - await auditService.createAudit({ + await createAudit({ action: "user.list", actor: { id: user.id, diff --git a/packages/web/src/app/api/(server)/mcp/route.ts b/packages/web/src/app/api/(server)/mcp/route.ts index a4225e20e..cdba8367e 100644 --- a/packages/web/src/app/api/(server)/mcp/route.ts +++ b/packages/web/src/app/api/(server)/mcp/route.ts @@ -9,16 +9,17 @@ import { StatusCodes } from 'http-status-codes'; import { NextRequest } from 'next/server'; import { sew } from "@/middleware/sew"; import { apiHandler } from '@/lib/apiHandler'; -import { env, hasEntitlement } from '@sourcebot/shared'; +import { env } from '@sourcebot/shared'; +import { hasEntitlement } from '@/lib/entitlements'; // On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728) // so they can discover the authorization server and initiate the authorization code flow. // Only advertised when the oauth entitlement is active. // @see: https://modelcontextprotocol.io/specification/2025-03-26/basic/authentication // @see: https://datatracker.ietf.org/doc/html/rfc9728 -function mcpErrorResponse(error: ServiceError): Response { +async function mcpErrorResponse(error: ServiceError): Promise { const response = serviceErrorResponse(error); - if (error.statusCode === StatusCodes.UNAUTHORIZED && hasEntitlement('oauth')) { + if (error.statusCode === StatusCodes.UNAUTHORIZED && await hasEntitlement('oauth')) { const issuer = env.AUTH_URL.replace(/\/$/, ''); response.headers.set( 'WWW-Authenticate', @@ -87,7 +88,7 @@ export const POST = apiHandler(async (request: NextRequest) => { ); if (isServiceError(response)) { - return mcpErrorResponse(response); + return await mcpErrorResponse(response); } return response; @@ -123,7 +124,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { ); if (isServiceError(result)) { - return mcpErrorResponse(result); + return await mcpErrorResponse(result); } return result; diff --git a/packages/web/src/app/api/(server)/repos/listReposApi.ts b/packages/web/src/app/api/(server)/repos/listReposApi.ts index 6844feb7e..dbfaf2955 100644 --- a/packages/web/src/app/api/(server)/repos/listReposApi.ts +++ b/packages/web/src/app/api/(server)/repos/listReposApi.ts @@ -1,5 +1,5 @@ import { sew } from "@/middleware/sew"; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { ListReposQueryParams, RepositoryQuery } from "@/lib/types"; import { withOptionalAuth } from "@/middleware/withAuth"; import { getBrowsePath } from "@/app/(app)/browse/hooks/utils"; @@ -10,7 +10,7 @@ export const listRepos = async ({ query, page, perPage, sort, direction, source withOptionalAuth(async ({ org, prisma, user }) => { if (user) { const resolvedSource = source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined; - getAuditService().createAudit({ + await createAudit({ action: 'user.listed_repos', actor: { id: user.id, type: 'user' }, target: { id: org.id.toString(), type: 'org' }, diff --git a/packages/web/src/app/components/organizationAccessSettings.tsx b/packages/web/src/app/components/organizationAccessSettings.tsx index d6846e933..29c8ddc46 100644 --- a/packages/web/src/app/components/organizationAccessSettings.tsx +++ b/packages/web/src/app/components/organizationAccessSettings.tsx @@ -4,7 +4,8 @@ import { OrganizationAccessSettingsWrapper } from "./organizationAccessSettingsW import { getOrgMetadata } from "@/lib/utils" import { SINGLE_TENANT_ORG_ID } from "@/lib/constants" import { __unsafePrisma } from "@/prisma" -import { hasEntitlement, env } from "@sourcebot/shared" +import { env } from "@sourcebot/shared" +import { hasEntitlement } from "@/lib/entitlements" export async function OrganizationAccessSettings() { const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); @@ -18,7 +19,7 @@ export async function OrganizationAccessSettings() { const baseUrl = env.AUTH_URL; const inviteLink = createInviteLink(baseUrl, org.inviteLinkId) - const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); + const hasAnonymousAccessEntitlement = await hasEntitlement("anonymous-access"); const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; const memberApprovalEnvVarSet = env.REQUIRE_APPROVAL_NEW_MEMBERS !== undefined; diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index 466705044..c3bdef3ec 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -9,9 +9,7 @@ import { ErrorCode } from "@/lib/errorCodes"; import { getAuthenticatedUser } from "@/middleware/withAuth"; import { __unsafePrisma } from "@/prisma"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; -import { getAuditService } from "@/ee/features/audit/factory"; - -const auditService = getAuditService(); +import { createAudit } from "@/ee/features/audit/audit"; export const joinOrganization = async (inviteLinkId?: string) => sew(async () => { const authResult = await getAuthenticatedUser(); @@ -83,7 +81,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } const failAuditCallback = async (error: string) => { - await auditService.createAudit({ + await createAudit({ action: "user.invite_accept_failed", actor: { id: user.id, @@ -122,7 +120,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return addUserToOrgRes; } - await auditService.createAudit({ + await createAudit({ action: "user.invite_accepted", actor: { id: user.id, diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index 4081ccc88..7bd615a61 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -29,7 +29,7 @@ export default async function InvitePage(props: InvitePageProps) { const session = await auth(); if (!session) { - const providers = getIdentityProviderMetadata(); + const providers = await getIdentityProviderMetadata(); return ; } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 60d521ad2..b6c509269 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -9,59 +9,61 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { SessionProvider } from "next-auth/react"; import { env, SOURCEBOT_VERSION } from "@sourcebot/shared"; import { PlanProvider } from "@/features/entitlements/planProvider"; -import { getEntitlements } from "@sourcebot/shared"; +import { getEntitlements } from "@/lib/entitlements"; export const metadata: Metadata = { - metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined, - // Using the title.template will allow child pages to set the title - // while keeping a consistent suffix. - title: { - default: "Sourcebot", - template: "%s | Sourcebot", - }, - description: - "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", - manifest: "/manifest.json", + metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined, + // Using the title.template will allow child pages to set the title + // while keeping a consistent suffix. + title: { + default: "Sourcebot", + template: "%s | Sourcebot", + }, + description: + "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", + manifest: "/manifest.json", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const entitlements = await getEntitlements(); + return ( - - {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_SCAN === 'true' && ( -