Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
3efe8e1
feat(web): surface subscription cancellation on license card
brendan-kellam Apr 21, 2026
3af6374
feat(web): add offline license card to settings
brendan-kellam Apr 21, 2026
9c2b28b
feat(web): banner system for license + permission sync
brendan-kellam Apr 21, 2026
a7ec2ef
handle failure case where lighthouse cannot be reached
brendan-kellam Apr 22, 2026
9760419
chore(web): mirror lighthouse trial + checkout schema changes
brendan-kellam Apr 22, 2026
de8d57d
feat(web): trial banner + plumbing
brendan-kellam Apr 22, 2026
dad1dac
add badges to invoice cards
brendan-kellam Apr 23, 2026
d5365ae
add basic billing docs
brendan-kellam Apr 24, 2026
c725e3a
refactor(lighthouse): throw ServiceErrorException on ping failure
brendan-kellam Apr 24, 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
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.');
}
Comment on lines +48 to 51
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all call sites of startScheduler() for repoPermissionSyncer / accountPermissionSyncer
rg -nP -C2 '\b(repoPermissionSyncer|accountPermissionSyncer)\s*\.\s*startScheduler\s*\(' --type=ts

Repository: sourcebot-dev/sourcebot

Length of output: 559


🏁 Script executed:

cat -n packages/backend/src/index.ts | sed -n '60,80p'

Repository: sourcebot-dev/sourcebot

Length of output: 916


🏁 Script executed:

cat -n packages/backend/src/index.ts | sed -n '1,75p'

Repository: sourcebot-dev/sourcebot

Length of output: 3390


🏁 Script executed:

# Check if these methods are async in their respective classes
for class in "RepoPermissionSyncer" "AccountPermissionSyncer" "AuditLogPruner" "ConnectionManager"; do
    echo "=== $class ==="
    rg -A3 "startScheduler\s*\(" packages/backend/src/ee --type=ts -g "*${class}*"
    rg -A3 "startScheduler\s*\(" packages/backend/src --type=ts -g "*${class}*" | head -20
done

Repository: sourcebot-dev/sourcebot

Length of output: 177


🏁 Script executed:

# Find the class files first
echo "=== Finding class files ==="
fd -e ts -e js "RepoPermissionSyncer|AccountPermissionSyncer|AuditLogPruner|ConnectionManager" packages/backend/src

echo -e "\n=== Checking RepoPermissionSyncer ==="
cat packages/backend/src/ee/repoPermissionSyncer.ts | head -60

echo -e "\n=== Checking AccountPermissionSyncer ==="
cat packages/backend/src/ee/accountPermissionSyncer.ts | head -60

echo -e "\n=== Checking AuditLogPruner ==="
cat packages/backend/src/ee/auditLogPruner.ts | head -60

echo -e "\n=== Checking ConnectionManager ==="
cat packages/backend/src/connectionManager.ts | head -60

Repository: sourcebot-dev/sourcebot

Length of output: 9457


🏁 Script executed:

# Check ConnectionManager.startScheduler() signature
rg -A10 "public.*startScheduler" packages/backend/src/connectionManager.ts

Repository: sourcebot-dev/sourcebot

Length of output: 559


Async startScheduler() calls may produce unhandled rejections at call sites.

The startScheduler() methods on repoPermissionSyncer (line 69) and accountPermissionSyncer (line 71) in packages/backend/src/index.ts are async and throw errors internally, but are invoked without await. If either method's Promise rejects, the error will be swallowed as an unhandled rejection instead of crashing the application during startup. Both calls must be awaited to properly handle errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/src/ee/repoPermissionSyncer.ts` around lines 48 - 51,
startScheduler() on repoPermissionSyncer and accountPermissionSyncer can reject
but are currently invoked without awaiting them, causing unhandled promise
rejections; update the startup code that calls
repoPermissionSyncer.startScheduler() and
accountPermissionSyncer.startScheduler() (in packages/backend/src/index.ts) to
await both calls (or handle their returned Promises with .catch and rethrow) so
any rejection is observed and the process fails fast during startup.


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';
Comment on lines +8 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify guest-user conventions and FK relationships that reference User.
set -euo pipefail

echo "=== Where is the guest User with id='1' created/seeded? ==="
rg -nP -C3 $'\bid:\s*["\']1["\']' --type=ts --type=sql --type=prisma || true
rg -nPU -C3 "(?s)guest[^\n]*user[^\n]*id" --type=ts || true

echo
echo "=== Prisma schema: User model and anything referencing it ==="
fd -t f schema.prisma --exec rg -nP -C3 'model\s+User\b|userId|createdById|actorId' {} \;

echo
echo "=== All tables with onDelete semantics on userId/createdById ==="
fd -t f schema.prisma --exec rg -nP -C1 'onDelete' {} \;

echo
echo "=== Other migrations touching 'GUEST' or User id='1' ==="
fd -t f -e sql --exec rg -nP -C2 "GUEST|WHERE id = '1'|WHERE \"id\" = '1'" {} \;

Repository: sourcebot-dev/sourcebot

Length of output: 8832


🏁 Script executed:

#!/bin/bash
# Find seed files, database initialization, and when GUEST role was introduced

echo "=== Search for seed/seeding files ==="
fd -t f \( -name "*seed*" -o -name "*init*" -o -name "*fixture*" \) | head -20

echo
echo "=== List all migrations in order ==="
fd -t f -e sql -path "*/migrations/*" | sort

echo
echo "=== Look for GUEST role introduction in migrations ==="
fd -t f -e sql -path "*/migrations/*" | xargs rg -l "GUEST" | sort

echo
echo "=== Check early migrations for GUEST role ==="
fd -t f -e sql -path "*/migrations/*" | sort | head -5 | xargs -I {} bash -c "echo '=== {} ===' && head -20 {}"

echo
echo "=== Search for 'guest' user references in TypeScript code ==="
rg -i "guest.*user|user.*guest" --type=ts -C2 | head -40

Repository: sourcebot-dev/sourcebot

Length of output: 3966


🏁 Script executed:

#!/bin/bash
# Read the migration that introduced GUEST role
echo "=== GUEST role introduction migration ==="
cat "packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql"

echo
echo "=== Current migration being reviewed ==="
cat "packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql"

echo
echo "=== Check for any user creation or seeding in migrations ==="
fd -type f -path "*/migrations/*" -name "*.sql" | xargs rg -l "INSERT INTO.*User|SELECT.*FROM.*User" | head -5

echo
echo "=== Look for initialization or setup code ==="
rg -i "seed|init" packages/db --type=ts --type=sql | head -20

Repository: sourcebot-dev/sourcebot

Length of output: 2274


🏁 Script executed:

#!/bin/bash
# Search more carefully for how id='1' was used or created

echo "=== All migrations sorted chronologically ==="
ls -la packages/db/prisma/migrations/ | tail -15

echo
echo "=== Migrations between GUEST introduction and removal ==="
ls packages/db/prisma/migrations/ | sort | awk '/20250520182630/,/20260417224042/' 

echo
echo "=== Search for explicit '1' in migration context (not regex) ==="
rg "WHERE id = '1'|WHERE \"id\" = '1'|userId.*=.*'1'|user.*id.*'1'" packages/db/prisma/migrations/ 

echo
echo "=== Search for all migrations that touch UserToOrg ==="
fd -type f -path "*/migrations/*" -name "*.sql" | xargs rg -l "UserToOrg" | sort

echo
echo "=== Look for git log or comments about guest user ==="
rg -i "guest" packages/db/prisma --type=sql -B2 -A2

Repository: sourcebot-dev/sourcebot

Length of output: 5985


Hard-coded guest user id='1' is brittle and relies on an established but fragile convention.

While the codebase does establish id='1' as the guest user (evidenced by earlier migrations like 20251129063148_change_chat_created_by_to_optional), hardcoding this dependency makes the migration fragile. If the guest user is ever re-seeded with a different id or the convention changes, this migration will either fail silently (leaving the guest user in place but with no memberships) or delete the wrong user.

Additionally, the Prisma schema shows all User foreign key references use onDelete: Cascade, meaning this delete will silently cascade-delete all related data: Chat records, ChatAccess entries, Account rows, ApiKey entries, OAuth tokens (authorization codes, refresh tokens, access tokens), Invite records, RequestInvite entries, and Audit log references. Verify that this data loss is intentional.

Prefer deriving the guest user from the GUEST role membership rather than a hard-coded id, or add an explicit marker/flag to identify the guest user unambiguously.

Safer approach
-- Remove the guest user and its membership (only holder of GUEST role)
-DELETE FROM "UserToOrg" WHERE "role" = 'GUEST';
-DELETE FROM "User" WHERE id = '1';
+-- Identify and delete the guest user through GUEST role membership
+DELETE FROM "User"
+WHERE id IN (
+  SELECT DISTINCT "userId" FROM "UserToOrg" WHERE "role" = 'GUEST'
+);
+DELETE FROM "UserToOrg" WHERE "role" = 'GUEST';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql`
around lines 8 - 10, The migration currently hard-codes id='1' when deleting the
guest User; instead derive the guest user from the GUEST membership: delete
UserToOrg rows WHERE "role" = 'GUEST' as you already do, then delete from "User"
only those users who were the sole holder of a GUEST membership (i.e., users
whose only UserToOrg rows are role='GUEST') by selecting user ids from
"UserToOrg" (use a subquery that finds users with no non-'GUEST' memberships)
rather than deleting id='1'; also keep a clear TODO/confirm in the migration
near the DELETE of "User" to ensure the cascading deletion of related data
(Chat, ApiKey, Account, OAuth tokens, etc.) is intentional.


-- 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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "License" ADD COLUMN "cancelAt" TIMESTAMP(3);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "License" ADD COLUMN "trialEnd" TIMESTAMP(3);

-- AlterTable
ALTER TABLE "Org" ADD COLUMN "trialUsedAt" TIMESTAMP(3);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "License" ADD COLUMN "hasPaymentMethod" BOOLEAN;
Loading
Loading