Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
33291b8
wip on service ping
brendan-kellam Apr 10, 2026
dea5249
add concept of License to the database and wire it into the enitlemen…
brendan-kellam Apr 12, 2026
50914b3
wip on e2e checkout flow
brendan-kellam Apr 12, 2026
478c4c9
basic card to display current plan
brendan-kellam Apr 12, 2026
52dab4f
store activation code at rest
brendan-kellam Apr 14, 2026
c99b3d0
add manage subscription button
brendan-kellam Apr 14, 2026
abe2158
wip on lighthouse client
brendan-kellam Apr 17, 2026
949634f
wip on lighthouse client
brendan-kellam Apr 17, 2026
26dce02
remove the concept of a plan and just rely on entitlements at the app…
brendan-kellam Apr 17, 2026
420d441
fix sidebar
brendan-kellam Apr 17, 2026
12f7507
add temporary refresh license button
brendan-kellam Apr 17, 2026
409b881
clarified org availability design
brendan-kellam Apr 17, 2026
50f7fbd
entitlements naming nit
brendan-kellam Apr 17, 2026
37317e0
remove concept of a GUEST user
brendan-kellam Apr 18, 2026
fdf0256
remove anonymous-access entitlement
brendan-kellam Apr 18, 2026
cf6aeaf
change offline license to support optional seats
brendan-kellam Apr 18, 2026
41967e1
handle license validation for all paths
brendan-kellam Apr 18, 2026
f80a11d
refactor some actions
brendan-kellam Apr 18, 2026
cae073d
add explicit service ping calls to user <> organization update paths
brendan-kellam Apr 18, 2026
201f0b2
fix refresh bug with sidebar
brendan-kellam Apr 18, 2026
74dd139
add billing details card
brendan-kellam Apr 18, 2026
6758ced
fix invite redemption when user already a member
brendan-kellam Apr 18, 2026
1dbb9a7
wip on license card
brendan-kellam Apr 18, 2026
cf88304
further wip on license card / activaction code card
brendan-kellam Apr 18, 2026
f738aff
add recent invoices UI
brendan-kellam Apr 18, 2026
e8cc9be
nit on seats terminology
brendan-kellam Apr 19, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 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

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
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
18 changes: 6 additions & 12 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,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();
}
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,20 @@
-- CreateTable
CREATE TABLE "License" (
"id" TEXT NOT NULL,
"orgId" INTEGER NOT NULL,
"activationCode" TEXT NOT NULL,
"entitlements" 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;
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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "License" ADD COLUMN "currency" TEXT,
ADD COLUMN "interval" TEXT,
ADD COLUMN "intervalCount" INTEGER,
ADD COLUMN "nextRenewalAmount" INTEGER,
ADD COLUMN "nextRenewalAt" TIMESTAMP(3),
ADD COLUMN "planName" TEXT,
ADD COLUMN "unitAmount" INTEGER;
23 changes: 22 additions & 1 deletion packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -291,12 +291,33 @@ 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
entitlements String[]
seats Int?
status String? /// See LicenseStatus in packages/shared/src/types.ts
planName String?
unitAmount Int?
currency String?
interval String?
intervalCount Int?
nextRenewalAt DateTime?
nextRenewalAmount Int?
lastSyncAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

enum OrgRole {
OWNER
MEMBER
GUEST
}

model UserToOrg {
Expand Down
2 changes: 0 additions & 2 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export const API_KEY_PREFIX = 'sbk_';
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';

export const SOURCEBOT_UNLIMITED_SEATS = -1;

/**
* Default settings.
*/
Expand Down
Loading