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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097)
- Expired offline license keys no longer crash the process. An expired key now degrades to the unlicensed state. [#1109](https://github.com/sourcebot-dev/sourcebot/pull/1109)

## [4.16.9] - 2026-04-15

### Added
Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ if (condition) doSomething();
- Events fired from multiple sources (web app, MCP server, API) must NOT use the `wa_` prefix (e.g., `ask_message_sent`, `tool_used`).
- Multi-source events should include a `source` property to identify the origin (e.g., `'sourcebot-web-client'`, `'sourcebot-mcp-server'`, `'sourcebot-ask-agent'`).

## Conditional ClassNames

Use `cn()` from `@/lib/utils` for conditional classNames instead of template literal interpolation:

```tsx
// Correct
className={cn("border-b transition-colors", isActive ? "border-foreground" : "border-transparent")}

// Incorrect
className={`border-b transition-colors ${isActive ? "border-foreground" : "border-transparent"}`}
```

## Tailwind CSS

Use Tailwind color classes directly instead of CSS variable syntax:
Expand Down
3 changes: 1 addition & 2 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1025,8 +1025,7 @@
"type": "string",
"enum": [
"OWNER",
"MEMBER",
"GUEST"
"MEMBER"
]
},
"createdAt": {
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
]
},
"docs/license-key",
"docs/billing",
"docs/configuration/transactional-emails",
"docs/configuration/structured-logging",
"docs/configuration/audit-logs"
Expand Down
38 changes: 38 additions & 0 deletions docs/docs/billing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: Billing
sidebarTitle: Billing
---

Sourcebot Enterprise is available on monthly and yearly plans. Both are seat-based. This page explains how seats are billed, how changes mid-term are handled, and what happens at renewal.

## Seat count
Your seat count is the number of active users in your Sourcebot instance. Seat usage is reported to Sourcebot on a daily interval, and your subscription quantity is kept in sync with that number.

## Monthly plans
Monthly subscriptions are billed at the start of each billing cycle. Users added mid-cycle are prorated across the remaining days and appear on your next invoice. Users removed mid-cycle take effect at the next cycle. There is no refund for the remainder of the current one.

In short: you can scale up at any time and pay the prorated difference. Scaling down is effectively free until the cycle rolls over.

## Yearly plans

Yearly subscriptions are billed upfront for a committed seat count. As users are added during the term, the seat count rises but you aren't charged immediately. Every three months we reconcile. Any seats added that quarter are billed, prorated across the quarters remaining in the term.

Seats only move upward during the term. Shrinking the user count does not refund, and does not reduce the seat count until renewal. At renewal, you're invoiced at your current seat count, and that number becomes the committed baseline for the next year.

### Example

Suppose you start a yearly plan in January with 100 seats.

- In Q1, your user count grows to 110. At the end of Q1, you're invoiced for 10 seats prorated across the 3 remaining quarters.
- In Q2, your user count stays at 110. No reconciliation invoice is generated.
- In Q3, your user count grows to 120. At the end of Q3, you're invoiced for 10 seats prorated across the 1 remaining quarter.
- In Q4, reconciliation does not generate a charge (there are no remaining quarters to prorate across).
- At renewal in January, you're invoiced at 120 seats for the next year. 120 becomes the new committed baseline.

## Cancellation

Cancelling a subscription takes effect at the end of the current billing cycle (monthly) or term (yearly). You retain access to Sourcebot Enterprise features until that point.

## Questions?

For billing questions, [contact us](mailto:support@sourcebot.dev).
4 changes: 3 additions & 1 deletion docs/docs/license-key.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ docker run \

## Questions?

If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact).
For how seats are priced and reconciled across billing cycles, see [Billing](/docs/billing).

For any other licensing questions, please [contact us](https://www.sourcebot.dev/contact).
7 changes: 7 additions & 0 deletions packages/backend/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { vi } from 'vitest';

export const prisma = {
license: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
5 changes: 3 additions & 2 deletions packages/backend/src/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 4 additions & 3 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.');
}

Expand Down
7 changes: 4 additions & 3 deletions packages/backend/src/ee/repoPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.');
}

Expand Down
7 changes: 5 additions & 2 deletions packages/backend/src/ee/syncSearchContexts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions packages/backend/src/ee/syncSearchContexts.ts
Original file line number Diff line number Diff line change
@@ -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 { hasEntitlement } from "../entitlements.js";
import { SearchContext } from "@sourcebot/schemas/v3/index.type";

const logger = createLogger('sync-search-contexts');
Expand All @@ -15,10 +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();
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}."`);
logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`);
}
return false;
}
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/src/entitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
Entitlement,
_hasEntitlement,
_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 hasEntitlement = async (entitlement: Entitlement): Promise<boolean> => {
const license = await getLicense();
return _hasEntitlement(entitlement, license);
}

export const getEntitlements = async (): Promise<Entitlement[]> => {
const license = await getLicense();
return _getEntitlements(license);
}
5 changes: 3 additions & 2 deletions packages/backend/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -124,7 +125,7 @@ const getOctokitWithGithubApp = async (
url: string | undefined,
context: string
): Promise<Octokit> => {
if (!hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) {
if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) {
return octokit;
}

Expand Down
22 changes: 8 additions & 14 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,13 +32,6 @@ if (!existsSync(indexPath)) {
await mkdir(indexPath, { recursive: true });
}

const prisma = new PrismaClient({
datasources: {
db: {
url: getDBConnectionString(),
},
},
});

try {
await redis.ping();
Expand All @@ -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);
}

Expand All @@ -66,15 +60,15 @@ 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();
await repoPermissionSyncer.startScheduler();
}
accountPermissionSyncer.startScheduler();
await accountPermissionSyncer.startScheduler();
}

const api = new Api(
Expand Down
10 changes: 10 additions & 0 deletions packages/backend/src/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { PrismaClient } from "@sourcebot/db";
import { getDBConnectionString } from "@sourcebot/shared";

export const prisma = new PrismaClient({
datasources: {
db: {
url: getDBConnectionString(),
},
},
});
4 changes: 2 additions & 2 deletions packages/backend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -116,7 +116,7 @@ export const fetchWithRetry = async <T>(
// 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<RepoAuthCredentials | undefined> => {
// 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];
Expand Down
6 changes: 5 additions & 1 deletion packages/backend/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
test: {
environment: 'node',
watch: false,
env: {
DATA_CACHE_DIR: 'test-data'
}
},
alias: {
'./prisma.js': path.resolve(__dirname, 'src/__mocks__/prisma.ts'),
},
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
Warnings:

- The values [GUEST] on the enum `OrgRole` will be removed. If these variants are still used in the database, this will fail.

*/

-- Remove the guest user and its membership (only holder of GUEST role)
DELETE FROM "UserToOrg" WHERE "role" = 'GUEST';
DELETE FROM "User" WHERE id = '1';

-- AlterEnum
BEGIN;
CREATE TYPE "OrgRole_new" AS ENUM ('OWNER', 'MEMBER');
ALTER TABLE "UserToOrg" ALTER COLUMN "role" DROP DEFAULT;
ALTER TABLE "UserToOrg" ALTER COLUMN "role" TYPE "OrgRole_new" USING ("role"::text::"OrgRole_new");
ALTER TYPE "OrgRole" RENAME TO "OrgRole_old";
ALTER TYPE "OrgRole_new" RENAME TO "OrgRole";
DROP TYPE "OrgRole_old";
ALTER TABLE "UserToOrg" ALTER COLUMN "role" SET DEFAULT 'MEMBER';
COMMIT;
Loading
Loading