From 697a8197d5a9308141ab1b6607d7a4e194f0f529 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:59:34 +0530 Subject: [PATCH 1/2] feat(web): add POST /api/connections/{id}/sync to trigger a connection sync over the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the existing connection-sync worker call over the web app's public API. OWNER-gated, org-scoped (cross-org access returns 404, not 403). The new action lives in workerApi/actions.ts alongside the existing UI server action; the new route is a thin wrapper that validates the path param and unwraps the result. Without this endpoint, on-call scripts that need to force a re-sync must either drive the React UI (not scriptable) or hit the worker's internal port (not exposed publicly). With it, the public API covers read, list, and trigger — enough for a Prometheus → Slack → curl recovery flow. Refs #1520. --- CHANGELOG.md | 3 + .../sourcebot-public.openapi.json | 96 +++++++++ docs/docs.json | 3 +- .../connections/[id]/sync/route.test.ts | 193 ++++++++++++++++++ .../(server)/connections/[id]/sync/route.ts | 32 +++ .../web/src/features/workerApi/actions.ts | 34 ++- packages/web/src/openapi/publicApiDocument.ts | 26 +++ packages/web/src/openapi/publicApiSchemas.ts | 4 + 8 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts create mode 100644 packages/web/src/app/api/(server)/connections/[id]/sync/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc9693cc..3f6873cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `POST /api/connections/{id}/sync` enqueues a new `ConnectionSyncJob` for a connection in the caller's org; the caller must be an OWNER, cross-org access returns 404, and the response is `{ jobId }` so on-call scripts can force a re-sync without going through the web UI. [#1520](https://github.com/sourcebot-dev/sourcebot/pull/1520) + ### Fixed - Upgraded `seroval` to `^1.5.6`. [#1508](https://github.com/sourcebot-dev/sourcebot/pull/1508) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 7419445de..524d0b8af 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -537,6 +537,18 @@ "status" ] }, + "PublicSyncConnectionResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "description": "The id of the new ConnectionSyncJob row. Poll the connection detail endpoint to see when the job transitions from PENDING/IN_PROGRESS to COMPLETED/FAILED." + } + }, + "required": [ + "jobId" + ] + }, "PublicFileSourceResponse": { "type": "object", "properties": { @@ -1479,6 +1491,90 @@ } } }, + "/api/connections/{id}/sync": { + "post": { + "operationId": "triggerConnectionSync", + "tags": [ + "System" + ], + "summary": "Trigger a connection sync", + "description": "Enqueues a new ConnectionSyncJob row for the connection in the caller's org. The job runs asynchronously; the response returns the new job id. The caller must be an OWNER in the org. Cross-org access returns 404 (not 403) to avoid leaking the existence of connections in other orgs.", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "202": { + "description": "Sync job enqueued. Poll `GET /api/connections/{id}` to see the job progress.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSyncConnectionResponse" + } + } + } + }, + "400": { + "description": "Invalid `id` path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "401": { + "description": "Not authenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "403": { + "description": "Caller is not an OWNER in the org.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Connection not found in the org.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Worker is unreachable or returned an unexpected response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, "/api/source": { "get": { "operationId": "getFileSource", diff --git a/docs/docs.json b/docs/docs.json index ad45e7fc8..b3b2fd958 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -217,7 +217,8 @@ "icon": "server", "pages": [ "GET /api/version", - "GET /api/health" + "GET /api/health", + "POST /api/connections/{id}/sync" ] } ] diff --git a/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts b/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts new file mode 100644 index 000000000..019727166 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { OrgRole } from '@sourcebot/db'; + +type AuthContext = { + user: { id: string }; + org: { id: number }; + role: OrgRole; + prisma: unknown; +}; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as AuthContext | undefined, + findFirst: vi.fn(), + workerResponse: undefined as { ok: boolean; status: number; json: () => Promise } | undefined, +})); + +vi.mock('server-only', () => ({})); + +vi.mock('@sourcebot/db', () => ({ + OrgRole: { MEMBER: 'MEMBER', OWNER: 'OWNER' }, +})); + +vi.mock('@sourcebot/shared', () => ({ + env: { WORKER_API_URL: 'http://worker.example.com' }, + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn(async (callback: (ctx: AuthContext) => unknown) => { + if (!mocks.authContext) { + return { statusCode: 401, errorCode: 'NOT_AUTHENTICATED', message: 'Not authenticated' }; + } + return callback(mocks.authContext); + }), +})); + +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn(), + captureRequestError: vi.fn(), +})); + +vi.mock('@opentelemetry/sdk-trace-base', () => ({ + getEnv: () => ({}), + TraceIdRatioBasedSampler: vi.fn(), + ParentBasedSampler: vi.fn(), + AlwaysOnSampler: vi.fn(), + AlwaysOffSampler: vi.fn(), +})); + +const setAuth = (opts: { + role?: OrgRole; + connectionExists?: boolean; +}) => { + mocks.findFirst.mockImplementation(async () => opts.connectionExists ? { id: 1 } : null); + mocks.authContext = { + user: { id: 'user_1' }, + org: { id: 1 }, + role: opts.role ?? OrgRole.OWNER, + prisma: { connection: { findFirst: mocks.findFirst } }, + }; +}; + +const makeRequest = (id: string): NextRequest => + new NextRequest(`http://localhost/api/connections/${id}/sync`, { method: 'POST' }); + +const { POST } = await import('./route'); + +// Mock the global fetch that the action uses to call the worker. +const originalFetch = globalThis.fetch; +beforeEach(() => { + vi.restoreAllMocks(); + globalThis.fetch = vi.fn(async () => { + if (!mocks.workerResponse) { + throw new Error('workerResponse not set'); + } + return new Response(JSON.stringify(await mocks.workerResponse.json()), { + status: mocks.workerResponse.status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + mocks.authContext = undefined; + mocks.workerResponse = undefined; +}); + +// Restore the original fetch when the suite finishes so other test +// files are not affected. +import { afterAll } from 'vitest'; +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +describe('POST /api/connections/:id/sync', () => { + test('returns 401 when no authenticated user', async () => { + mocks.authContext = undefined; + const response = await POST(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + expect(response.status).toBe(401); + }); + + test('returns 403 when the caller is a MEMBER (not OWNER)', async () => { + setAuth({ role: OrgRole.MEMBER, connectionExists: true }); + const response = await POST(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + expect(response.status).toBe(403); + // The worker must not be called when the caller is a MEMBER. + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('returns 400 when id is not a positive integer', async () => { + setAuth({ role: OrgRole.OWNER, connectionExists: false }); + const response = await POST(makeRequest('abc'), { params: Promise.resolve({ id: 'abc' }) }); + expect(response.status).toBe(400); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('returns 400 when id is zero or negative', async () => { + setAuth({ role: OrgRole.OWNER, connectionExists: false }); + const response = await POST(makeRequest('0'), { params: Promise.resolve({ id: '0' }) }); + expect(response.status).toBe(400); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('returns 404 when the connection does not exist in the org', async () => { + setAuth({ role: OrgRole.OWNER, connectionExists: false }); + const response = await POST(makeRequest('999'), { params: Promise.resolve({ id: '999' }) }); + expect(response.status).toBe(404); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('returns 404 (not 403) for cross-org access to avoid leaking existence', async () => { + // The mock returns null for `connectionExists: false` regardless + // of which id is requested, simulating the case where the + // connection lives in a different org and is therefore invisible + // to the current org. The action scopes by `where: { id, orgId }` + // so cross-org ids collapse to "not found", same as the detail + // endpoint. + setAuth({ role: OrgRole.OWNER, connectionExists: false }); + const response = await POST(makeRequest('42'), { params: Promise.resolve({ id: '42' }) }); + expect(response.status).toBe(404); + // Assert that the action scoped the query by orgId. A future + // change that drops the orgId filter would still pass the 404 + // assertion (mock returns null regardless of args) but would + // expose cross-org data in production. + expect(mocks.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 42, orgId: 1 } }), + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('returns 202 with the new jobId on the happy path', async () => { + setAuth({ role: OrgRole.OWNER, connectionExists: true }); + mocks.workerResponse = { + ok: true, + status: 200, + json: async () => ({ jobId: 'job_abc' }), + }; + + const response = await POST(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + const body = await response.json(); + + expect(response.status).toBe(202); + expect(body).toEqual({ jobId: 'job_abc' }); + + // The action called the worker with the connectionId in the body. + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://worker.example.com/api/sync-connection', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ connectionId: 1 }), + }), + ); + }); + + test('returns 500 when the worker returns a non-ok response', async () => { + setAuth({ role: OrgRole.OWNER, connectionExists: true }); + mocks.workerResponse = { + ok: false, + status: 500, + json: async () => ({ error: 'worker exploded' }), + } as unknown as { ok: boolean; status: number; json: () => Promise }; + + const response = await POST(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + expect(response.status).toBe(500); + }); +}); diff --git a/packages/web/src/app/api/(server)/connections/[id]/sync/route.ts b/packages/web/src/app/api/(server)/connections/[id]/sync/route.ts new file mode 100644 index 000000000..97c9c2250 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/[id]/sync/route.ts @@ -0,0 +1,32 @@ +'use server'; + +import { NextRequest } from 'next/server'; +import { z } from 'zod'; + +import { triggerConnectionSync } from '@/features/workerApi/actions'; +import { apiHandler } from '@/lib/apiHandler'; +import { queryParamsSchemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; + +const connectionIdParamSchema = z.coerce.number().int().positive(); + +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to triggerConnectionSync() which calls withAuth + withMinimumOrgRole(OWNER) +export const POST = apiHandler(async ( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + const { id: rawId } = await params; + const idParsed = connectionIdParamSchema.safeParse(rawId); + if (!idParsed.success) { + return serviceErrorResponse(queryParamsSchemaValidationError(idParsed.error)); + } + const id = idParsed.data; + + const result = await triggerConnectionSync(id); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json({ jobId: result.jobId }, { status: 202 }); +}); diff --git a/packages/web/src/features/workerApi/actions.ts b/packages/web/src/features/workerApi/actions.ts index 8492a7b7e..890b4d22d 100644 --- a/packages/web/src/features/workerApi/actions.ts +++ b/packages/web/src/features/workerApi/actions.ts @@ -1,7 +1,7 @@ 'use server'; import { sew } from "@/middleware/sew"; -import { repositoryNotFound, unexpectedError } from "@/lib/serviceError"; +import { notFound, repositoryNotFound, unexpectedError } from "@/lib/serviceError"; import { withAuth, withOptionalAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; @@ -37,6 +37,38 @@ export const syncConnection = async (connectionId: number) => sew(() => ) ); +// Public-API variant: scopes the lookup to the caller's org before +// delegating to the worker, so an OWNER cannot trigger a sync on a +// connection that lives in a different org just by guessing the id. +// Used by POST /api/connections/{id}/sync. +export const triggerConnectionSync = async (connectionId: number) => sew(() => + withAuth(({ prisma, org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const connection = await prisma.connection.findFirst({ + where: { id: connectionId, orgId: org.id }, + select: { id: true }, + }); + if (!connection) { + return notFound(`Connection ${connectionId} not found in org.`); + } + + const response = await fetch(`${WORKER_API_URL}/api/sync-connection`, { + method: 'POST', + body: JSON.stringify({ connectionId }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + return unexpectedError('Failed to sync connection'); + } + + const data = await response.json(); + const schema = z.object({ jobId: z.string() }); + return schema.parse(data); + }) + ) +); + export const indexRepo = async (repoId: number) => sew(() => withAuth(({ role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index d0f11ee84..5ea4a15c1 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -18,6 +18,7 @@ import { publicGetDiffResponseSchema, publicGetTreeRequestSchema, publicHealthResponseSchema, + publicSyncConnectionResponseSchema, publicCommitDetailSchema, publicGetCommitQuerySchema, publicListCommitAuthorsQuerySchema, @@ -201,6 +202,31 @@ export function createPublicOpenApiDocument(version: string) { }, }); + registry.registerPath({ + method: 'post', + path: '/api/connections/{id}/sync', + operationId: 'triggerConnectionSync', + tags: [systemTag.name], + summary: 'Trigger a connection sync', + description: "Enqueues a new ConnectionSyncJob row for the connection in the caller's org. The job runs asynchronously; the response returns the new job id. The caller must be an OWNER in the org. Cross-org access returns 404 (not 403) to avoid leaking the existence of connections in other orgs.", + request: { + params: z.object({ + id: z.coerce.number().int().positive(), + }), + }, + responses: { + 202: { + description: 'Sync job enqueued. Poll `GET /api/connections/{id}` to see the job progress.', + content: jsonContent(publicSyncConnectionResponseSchema), + }, + 400: errorJson('Invalid `id` path parameter.'), + 401: errorJson('Not authenticated.'), + 403: errorJson('Caller is not an OWNER in the org.'), + 404: errorJson('Connection not found in the org.'), + 500: errorJson('Worker is unreachable or returned an unexpected response.'), + }, + }); + registry.registerPath({ method: 'get', path: '/api/source', diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index de1c4011f..00a028d45 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -64,6 +64,10 @@ export const publicHealthResponseSchema = z.object({ status: z.enum(['ok']), }).openapi('PublicHealthResponse'); +export const publicSyncConnectionResponseSchema = z.object({ + jobId: z.string().describe('The id of the new ConnectionSyncJob row. Poll the connection detail endpoint to see when the job transitions from PENDING/IN_PROGRESS to COMPLETED/FAILED.'), +}).openapi('PublicSyncConnectionResponse'); + // EE: User Management export const publicEeUserSchema = z.object({ id: z.string(), From b02648bda7608b20e817556715012edc6388d761 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:09:59 +0530 Subject: [PATCH 2/2] refactor(web): dedupe the sync-connection worker call CodeRabbit finding: triggerConnectionSync repeated the fetch + ok check + jobId Zod parse already in syncConnection. Extracted both into callWorkerSyncConnection, a private helper that both actions now delegate to. Future changes to the worker call (timeouts, retries, response shape) live in one place. Also moves the afterAll import in route.test.ts to the top of the file so the test file's imports are grouped consistently with the rest of the suite. Skipped: the suggestion to add a request timeout to the worker fetch. The existing UI flow (syncConnection) has the same un-timed fetch, so adding a timeout only to the public-API action would create inconsistency. A future PR that adds a project-wide fetch helper should fix both at once. Refs #1521. --- .../connections/[id]/sync/route.test.ts | 3 +- .../web/src/features/workerApi/actions.ts | 55 ++++++++----------- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts b/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts index 019727166..5b600888e 100644 --- a/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts +++ b/packages/web/src/app/api/(server)/connections/[id]/sync/route.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { NextRequest } from 'next/server'; import { OrgRole } from '@sourcebot/db'; @@ -94,7 +94,6 @@ beforeEach(() => { // Restore the original fetch when the suite finishes so other test // files are not affected. -import { afterAll } from 'vitest'; afterAll(() => { globalThis.fetch = originalFetch; }); diff --git a/packages/web/src/features/workerApi/actions.ts b/packages/web/src/features/workerApi/actions.ts index 890b4d22d..2fcc31c68 100644 --- a/packages/web/src/features/workerApi/actions.ts +++ b/packages/web/src/features/workerApi/actions.ts @@ -11,28 +11,31 @@ import { requestAccountPermissionSync } from "./client.server"; const WORKER_API_URL = env.WORKER_API_URL; +// Single point of contact for the worker: POST /api/sync-connection +// and parse the { jobId } response. Returns either the parsed +// { jobId } or a ServiceError if the worker is unreachable or +// returns a non-ok response. Both syncConnection (UI) and +// triggerConnectionSync (public API) go through this helper. +const callWorkerSyncConnection = async (connectionId: number) => { + const response = await fetch(`${WORKER_API_URL}/api/sync-connection`, { + method: 'POST', + body: JSON.stringify({ connectionId }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + return unexpectedError('Failed to sync connection'); + } + + const data = await response.json(); + const schema = z.object({ jobId: z.string() }); + return schema.parse(data); +}; + export const syncConnection = async (connectionId: number) => sew(() => withAuth(({ role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const response = await fetch(`${WORKER_API_URL}/api/sync-connection`, { - method: 'POST', - body: JSON.stringify({ - connectionId - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - return unexpectedError('Failed to sync connection'); - } - - const data = await response.json(); - const schema = z.object({ - jobId: z.string(), - }); - return schema.parse(data); + return callWorkerSyncConnection(connectionId); }) ) ); @@ -52,19 +55,7 @@ export const triggerConnectionSync = async (connectionId: number) => sew(() => return notFound(`Connection ${connectionId} not found in org.`); } - const response = await fetch(`${WORKER_API_URL}/api/sync-connection`, { - method: 'POST', - body: JSON.stringify({ connectionId }), - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - return unexpectedError('Failed to sync connection'); - } - - const data = await response.json(); - const schema = z.object({ jobId: z.string() }); - return schema.parse(data); + return callWorkerSyncConnection(connectionId); }) ) );