Skip to content
Open
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
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ To build a specific package:
yarn workspace @sourcebot/<package-name> build
```

## Backend Workloads

Use the workload system in `packages/backend` for background work. Define the queue payload and default job behavior in the shared queue registry, implement a `Workload`, and register it with the `JobManager`.

### Execution locks

- Key an execution lock by the logical resource being mutated, not by the job ID. Workloads that mutate the same resource must use the exact same lock key. For example, repo indexing, repo cleanup, and repo permission syncing share the per-repo lock.
- An execution lock serializes work but does not deduplicate it. Multiple jobs for one resource may still be queued and will execute one at a time.
- The lock lease is extended automatically while work is running. The workload's `AbortSignal` is aborted if extension fails or the worker shuts down.
- Abortion is cooperative. Call `signal.throwIfAborted()` before side effects and after long-running or external operations so work stops promptly after losing the lock. The signal cannot cancel an operation that has already been submitted.
- `onStarted` runs after the execution lock is acquired and immediately before `process`. `onCompleted` and `onTerminalFailure` are BullMQ event hooks and run after the processor has returned and released the lock.

### Lifecycle state

- In `onStarted`, upsert the workload-specific job row as `IN_PROGRESS`. If the parent resource tracks a `latest...JobId`, update that pointer in the same database transaction.
- Completion and terminal-failure hooks must always update their own historical job row by job ID. Do not condition that update on the job still being latest. Every job row should record its actual outcome.
- `onTerminalFailure` only runs after the job exhausts all retry attempts. Intermediate failures are retried without marking the lifecycle row as terminally failed.
- If a completion or failure hook publishes state onto the parent resource, use a conditional `updateMany` keyed by both the resource ID and its `latest...JobId`. This prevents an older hook from overwriting state belonging to a newer job after the execution lock has been released.
- Parent-resource state written inside `process` is already serialized by the execution lock. It does not need a latest-job conditional merely because the resource tracks the latest job ID.

## File Naming

Files should use camelCase starting with a lowercase letter:
Expand Down
12 changes: 8 additions & 4 deletions docs/snippets/schemas/v3/index.schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"resyncConnectionPollingIntervalMs": {
"type": "number",
"description": "The polling rate (in milliseconds) at which the db should be checked for connections that need to be re-synced. Defaults to 1 second.",
"minimum": 1
"minimum": 1,
"deprecated": true
},
"reindexRepoPollingIntervalMs": {
"type": "number",
Expand All @@ -52,7 +53,8 @@
"maxRepoGarbageCollectionJobConcurrency": {
"type": "number",
"description": "The number of repo GC jobs to run concurrently. Defaults to 8.",
"minimum": 1
"minimum": 1,
"deprecated": true
},
"repoGarbageCollectionGracePeriodMs": {
"type": "number",
Expand Down Expand Up @@ -216,7 +218,8 @@
"resyncConnectionPollingIntervalMs": {
"type": "number",
"description": "The polling rate (in milliseconds) at which the db should be checked for connections that need to be re-synced. Defaults to 1 second.",
"minimum": 1
"minimum": 1,
"deprecated": true
},
"reindexRepoPollingIntervalMs": {
"type": "number",
Expand All @@ -236,7 +239,8 @@
"maxRepoGarbageCollectionJobConcurrency": {
"type": "number",
"description": "The number of repo GC jobs to run concurrently. Defaults to 8.",
"minimum": 1
"minimum": 1,
"deprecated": true
},
"repoGarbageCollectionGracePeriodMs": {
"type": "number",
Expand Down
7 changes: 5 additions & 2 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"vitest": "^4.1.4"
},
"dependencies": {
"@bull-board/api": "6.11.2",
"@bull-board/express": "6.11.2",
"@bull-board/ui": "6.11.2",
"@coderabbitai/bitbucket": "^1.1.3",
"@gitbeaker/rest": "^40.5.1",
"@octokit/app": "^16.1.1",
Expand All @@ -35,7 +38,7 @@
"@types/express": "^5.0.0",
"argparse": "^2.0.1",
"azure-devops-node-api": "^15.1.1",
"bullmq": "^5.34.10",
"bullmq": "^5.81.3",
"chokidar": "^4.0.3",
"cross-fetch": "^4.0.0",
"dotenv": "^16.4.5",
Expand All @@ -46,7 +49,7 @@
"gitea-js": "^1.22.0",
"glob": "^11.1.0",
"http-status-codes": "^2.3.0",
"ioredis": "^5.4.2",
"ioredis": "^5.11.1",
"lowdb": "^7.0.1",
"micromatch": "^4.0.8",
"p-limit": "^7.2.0",
Expand Down
133 changes: 28 additions & 105 deletions packages/backend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
import { ExpressAdapter } from '@bull-board/express';
import { Octokit } from '@octokit/rest';
import * as Sentry from "@sentry/node";
import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db';
import * as Sentry from '@sentry/node';
import { hasEntitlement } from './entitlements.js';
import { createLogger, doesIdpSupportPermissionSyncing, env } from '@sourcebot/shared';
import { createLogger, env, JOB_PRIORITIES } from '@sourcebot/shared';
import express, { NextFunction, Request, Response } from 'express';
import 'express-async-errors';
import * as http from "http";
import { ConnectionManager } from './connectionManager.js';
import { AccountPermissionSyncer } from './ee/accountPermissionSyncer.js';
import z from 'zod';
import { SINGLE_TENANT_ORG_ID } from './constants.js';
import { isGitHubRateLimitError, isNotFound } from './errors.js';
import { PromClient } from './promClient.js';
import { RepoIndexManager } from './repoIndexManager.js';
import { createGitHubRepoRecord } from './repoCompileUtils.js';
import { isGitHubRateLimitError, isNotFound } from './errors.js';
import { Octokit } from '@octokit/rest';
import { SINGLE_TENANT_ORG_ID } from './constants.js';
import z from 'zod';
import type { JobManager } from './types.js';

const logger = createLogger('api');

Expand All @@ -26,24 +26,27 @@ export class Api {
constructor(
promClient: PromClient,
private prisma: PrismaClient,
private connectionManager: ConnectionManager,
private repoIndexManager: RepoIndexManager,
private accountPermissionSyncer: AccountPermissionSyncer,
private jobManager: JobManager,
) {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const bullBoardAdapter = new ExpressAdapter();
bullBoardAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: jobManager.getQueues().map(queue => new BullMQAdapter(queue, { readOnlyMode: true })),
serverAdapter: bullBoardAdapter,
});
app.use('/admin/queues', bullBoardAdapter.getRouter());
Comment on lines +35 to +41

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the worker API port is exposed in deployment configs.
set -euo pipefail

# Find the worker API URL/port default and any published port mappings.
rg -n 'WORKER_API_URL' --glob '!**/node_modules/**' -C 2

fd -H -i -e yml -e yaml -e conf 'docker-compose|Caddyfile|nginx' --exec rg -n -C 3 'ports|proxy|upstream' {} \;

fd -H 'Dockerfile*' --exec rg -n 'EXPOSE' {} \;

Repository: sourcebot-dev/sourcebot

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(api\.ts|package\.json|Dockerfile[^/]*|docker-compose[^/]*\.(yml|yaml)|.*\.(yml|yaml)|Caddyfile|nginx[^/]*)$' | head -200

printf '%s\n' '--- api.ts outline and relevant source ---'
wc -l packages/backend/src/api.ts 2>/dev/null || true
ast-grep outline packages/backend/src/api.ts 2>/dev/null || true
sed -n '1,140p' packages/backend/src/api.ts 2>/dev/null || true

printf '%s\n' '--- backend references ---'
rg -n -C 3 'WORKER_API_URL|worker.*(api|port)|API_PORT|PORT|admin/queues|metrics|helmet|auth|listen\(' packages README.md .github 2>/dev/null | head -400

printf '%s\n' '--- deployment exposure declarations ---'
rg -n -C 3 'ports:|EXPOSE|host:|listen:|targetPort:|containerPort:|proxy_pass|upstream|WORKER_API_URL|admin/queues' --glob '*.yml' --glob '*.yaml' --glob 'Dockerfile*' --glob 'Caddyfile' --glob 'nginx*' --glob '*.json' . 2>/dev/null | head -500

Repository: sourcebot-dev/sourcebot

Length of output: 39719


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- WORKER_API_URL declarations and consumers ---'
rg -n -C 5 'WORKER_API_URL' . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' 2>/dev/null | head -300

printf '%s\n' '--- startup and container routing ---'
for f in entrypoint.sh Dockerfile docker-compose.yml docker-compose-dev.yml; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    sed -n '1,280p' "$f"
  fi
done
rg -n -C 4 'backend|worker|api|3000|3001|next start|node .*backend|tsx|WORKER' Dockerfile docker-compose.yml docker-compose-dev.yml packages/*/package.json package.json 2>/dev/null | head -500

printf '%s\n' '--- queue payload definitions and sensitive identifiers ---'
rg -n -C 4 'Queue|queue|Job|payload|connectionId|repoId|accountId|connection_id|repository_id' packages/backend/src packages/shared/src packages/db/src 2>/dev/null | head -600

printf '%s\n' '--- helmet dependency and middleware ---'
rg -n -C 3 '"helmet"|from ['"'"'"]helmet|app\.use\(' package.json packages/*/package.json yarn.lock packages 2>/dev/null | head -300

printf '%s\n' '--- read-only deployment verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

compose = Path("docker-compose.yml").read_text()
dockerfile = Path("Dockerfile").read_text()

published = re.findall(r'^\s*-\s*["\']?([^"\']*3000:3000[^"\']*)["\']?\s*$', compose, re.M)
print("compose_3000_mappings:", published)
print("dockerfile_exposes_3000:", bool(re.search(r'^\s*EXPOSE\s+3000\s*$', dockerfile, re.M)))
print("dockerfile_binds_all_interfaces_hint:", 'HOSTNAME="0.0.0.0"' in dockerfile)
print("loopback_only_3000_mapping:", any(x.strip().startswith(("127.0.0.1:", "localhost:")) for x in published))
PY

Repository: sourcebot-dev/sourcebot

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- supervisor process configuration ---'
if [ -f supervisord.conf ]; then
  cat -n supervisord.conf
fi
fd -H -t f 'supervisord.conf|supervisor.*\.conf' . --exec sh -c 'echo "--- $1 ---"; cat -n "$1"' sh {} \;

printf '%s\n' '--- backend startup commands ---'
rg -n -C 5 'new Api|Api\(|WORKER_API_URL|3060|supervisord|next start|packages/backend/dist|backend.*start' packages Dockerfile supervisord.conf entrypoint.sh package.json 2>/dev/null | head -250

printf '%s\n' '--- queue registry and payload declarations ---'
rg -n -C 5 'QUEUE|QueueName|DataOf|repo-index|connection-sync|account.*permission|permission-sync|interface .*Job|type .*Job' packages/shared/src packages/backend/src/types.ts packages/backend/src/jobManager.ts packages/backend/src 2>/dev/null | head -350

printf '%s\n' '--- exact identifier-bearing job triggers ---'
rg -n -C 3 'jobManager\.(trigger|upsertJobScheduler)|trigger\(' packages/backend/src --glob '*.ts' 2>/dev/null | rg -n -C 2 'connectionId|repoId|accountId|permission|index|sync' | head -250

printf '%s\n' '--- helmet availability ---'
for f in packages/backend/package.json package.json packages/web/package.json; do
  [ -f "$f" ] && { echo "--- $f ---"; rg -n '"helmet"|express' "$f"; }
done
rg -n '(^|/)(helmet|`@types/helmet`)@|helmet@' yarn.lock 2>/dev/null | head -30

printf '%s\n' '--- read-only port verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

env = Path("packages/shared/src/env.server.ts").read_text()
compose = Path("docker-compose.yml").read_text()
api = Path("packages/backend/src/api.ts").read_text()

default = re.search(r'WORKER_API_URL:\s*z\.string\(\)\.url\(\)\.default\("([^"]+)"\)', env)
api_port = re.search(r'Number\(workerApiUrl\.port\).*?protocol === "https:" \? 443 : 80', api, re.S)
published = re.findall(r'^\s*-\s*["\']?([^"\']+)["\']?\s*$', compose, re.M)
print("worker_api_default:", default.group(1) if default else None)
print("compose_published_ports:", [x for x in published if "3000:3000" in x or "3060:3060" in x])
print("worker_api_port_is_3060_by_default:", default is not None and ":3060" in default.group(1))
print("worker_api_route_mounted_without_auth_in_source:",
      "app.use('/admin/queues', bullBoardAdapter.getRouter());" in api and
      "app.use('/metrics'," in api)
PY

Repository: sourcebot-dev/sourcebot

Length of output: 42800


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- shared queue registry ---'
if [ -f packages/shared/src/queue.ts ]; then
  cat -n packages/shared/src/queue.ts
else
  fd -H -t f 'queue.ts' packages/shared --exec sh -c 'echo "--- $1 ---"; cat -n "$1"' sh {} \;
fi

printf '%s\n' '--- account permission workload payload use ---'
fd -H -t f '*account*permission*' packages/backend/src --exec sh -c '
  echo "--- $1 ---"
  rg -n -C 4 "accountId|queueSpec|data:|trigger|upsertJobScheduler" "$1"
' sh {} \;

printf '%s\n' '--- Bull Board adapter configuration ---'
rg -n -C 5 'BullMQAdapter|readOnlyMode|createBullBoard|job\.data|stacktrace|failedReason' packages/backend/src packages/backend/package.json yarn.lock 2>/dev/null | head -250

Repository: sourcebot-dev/sourcebot

Length of output: 8528


Protect Bull Board when the worker API is externally reachable.

The default deployment publishes only port 3000; WORKER_API_URL defaults the backend to localhost:3060. If a deployment publishes or proxies port 3060, add basic authentication or shared-secret middleware before mounting /admin/queues. The read-only dashboard still exposes job payload IDs and failure details. Add helmet() as defense-in-depth for the HTML dashboard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/api.ts` around lines 35 - 41, Protect the Bull Board
route in the API setup around createBullBoard and app.use('/admin/queues', ...):
add shared-secret or basic-auth middleware before mounting the router, and apply
helmet() for the dashboard response. Ensure both protections remain in effect
whenever the backend is externally reachable, while preserving the existing
read-only BullMQAdapter configuration.

Source: Linters/SAST tools


// Prometheus metrics endpoint
app.use('/metrics', async (_req: Request, res: Response) => {
res.set('Content-Type', promClient.registry.contentType);
const metrics = await promClient.registry.metrics();
res.end(metrics);
});

app.post('/api/sync-connection', this.syncConnection.bind(this));
app.post('/api/index-repo', this.indexRepo.bind(this));
app.post('/api/trigger-account-permission-sync', this.triggerAccountPermissionSync.bind(this));
app.post(`/api/experimental/add-github-repo`, this.experimental_addGithubRepo.bind(this));

app.use((error: unknown, _req: Request, _res: Response, next: NextFunction) => {
Expand All @@ -53,97 +56,10 @@ export class Api {

this.server = app.listen(PORT, () => {
logger.debug(`API server is running on port ${PORT}`);
logger.debug(`Bull Board is available at ${workerApiUrl.origin}/admin/queues`);
});
}

private async syncConnection(req: Request, res: Response) {
const schema = z.object({
connectionId: z.number(),
}).strict();

const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}

const { connectionId } = parsed.data;
const connection = await this.prisma.connection.findUnique({
where: {
id: connectionId,
}
});

if (!connection) {
res.status(404).json({ error: 'Connection not found' });
return;
}

const [jobId] = await this.connectionManager.createJobs([connection]);

res.status(200).json({ jobId });
}

private async indexRepo(req: Request, res: Response) {
const schema = z.object({
repoId: z.number(),
}).strict();

const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}

const { repoId } = parsed.data;
const repo = await this.prisma.repo.findUnique({
where: { id: repoId },
});

if (!repo) {
res.status(404).json({ error: 'Repo not found' });
return;
}

const [jobId] = await this.repoIndexManager.createJobs([repo], RepoIndexingJobType.INDEX);
res.status(200).json({ jobId });
}

private async triggerAccountPermissionSync(req: Request, res: Response) {
if (env.PERMISSION_SYNC_ENABLED !== 'true' || !await hasEntitlement('permission-syncing')) {
res.status(403).json({ error: 'Permission syncing is not enabled.' });
return;
}

const schema = z.object({
accountId: z.string(),
}).strict();

const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}

const { accountId } = parsed.data;
const account = await this.prisma.account.findUnique({
where: { id: accountId },
});

if (!account) {
res.status(404).json({ error: 'Account not found' });
return;
}

if (!doesIdpSupportPermissionSyncing(account.providerType)) {
res.status(400).json({ error: `Provider '${account.providerType}' does not support permission syncing.` });
return;
}

const jobId = await this.accountPermissionSyncer.schedulePermissionSyncForAccount(account);
res.status(200).json({ jobId });
}

private async experimental_addGithubRepo(req: Request, res: Response) {
const schema = z.object({
owner: z.string(),
Expand Down Expand Up @@ -196,7 +112,14 @@ export class Api {
create: record,
});

const [jobId ] = await this.repoIndexManager.createJobs([repo], RepoIndexingJobType.INDEX);
const jobId = await this.jobManager.trigger(
'repo-index',
{
repoId: repo.id,
type: RepoIndexingJobType.INDEX,
},
{ priority: JOB_PRIORITIES.INTERACTIVE },
);

res.status(200).json({ jobId, repoId: repo.id });
}
Expand Down
Loading
Loading