feat(web,ui): per-project git provider API base URL overrides

Per provider (github|gitlab|gitea) a project override stored in
~/.config/openchamber/projects/<projectId>.json under gitProviders wins
over the global settings.json value (precedence: project override > global
> built-in default). Server forge routes resolve the override per request
directory (worktree-aware via git-common-dir + containment + path fallback,
60s TTL cache); the override host is also accepted for remote parsing and
client detection. New GET/PUT /api/projects/:projectId/git-providers route;
client openchamberConfig preserves the server-owned gitProviders key;
Projects page gains a Git provider API base URLs section; detection store
hydrates per-project overrides (memory-only, server-authoritative).
This commit is contained in:
2026-08-17 09:57:54 +00:00
parent 697925ee0d
commit 66edc74fac
42 changed files with 1765 additions and 87 deletions
@@ -47,6 +47,7 @@
- Auth storage: `~/.config/openchamber/gitlab-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
- Base URL resolution: caller-supplied `baseUrl` (normalized) -> effective default via `getGitLabDefaultBaseUrl()` (configured `settings.json` `gitProviders.gitlab.apiBaseUrl`, else `https://gitlab.com`).
- Per-project overrides: data routes resolve a directory-scoped API base via `getEffectiveProviderApiBaseUrl('gitlab', directory)` (in `packages/web/server/lib/git-providers/project-config.js`). A per-project `gitProviders.gitlab.apiBaseUrl` override (stored under `projects/<projectId>.json`) replaces the global default for that project's routes, and its host is accepted for directory-to-repo resolution (`resolveGitLabRepoFromDirectory`); a connected account whose host matches the remote keeps its own base URL. Global routes (`auth/connect`, `auth/status`, `auth/activate`, `me`, `repo/branches`) stay global.
- Account id: `` `${host}:${username}` `` (e.g. `gitlab.com:alice`), falling back to `token:<first8>` when the username is missing.
- Auth header on every request: `PRIVATE-TOKEN: <pat>`.
+17 -1
View File
@@ -1,5 +1,6 @@
import { getRemoteUrl } from '../git/index.js';
import { getGitLabAuthAccounts, normalizeBaseUrl } from './auth.js';
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
// When no explicit host allowlist is provided, accept gitlab.com or any host
// that matches the base URL of a stored GitLab account. Never github.com.
@@ -115,8 +116,23 @@ export async function resolveGitLabRepoFromDirectory(directory, remoteName = 'or
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
// A per-project API base override makes its host acceptable for directory
// resolution even when no connected account covers it.
const overrideBaseUrl = getEffectiveProviderApiBaseUrl('gitlab', directory);
let knownHosts;
if (overrideBaseUrl) {
knownHosts = acceptedHosts();
try {
const host = new URL(overrideBaseUrl).hostname.toLowerCase();
if (host) {
knownHosts.add(host);
}
} catch {
// ignore a malformed override base URL
}
}
return {
repo: parseGitLabRemoteUrl(remoteUrl),
repo: parseGitLabRemoteUrl(remoteUrl, knownHosts),
remoteUrl,
};
}
@@ -10,6 +10,21 @@ vi.mock('../git/index.js', () => ({
getRemoteUrl: vi.fn(async () => null),
}));
// Per-project overrides only apply for the directory configured with one; all
// other directories fall through to the real (global-only) resolution.
vi.mock('../git-providers/project-config.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getEffectiveProviderApiBaseUrl: vi.fn((provider, directory) => {
if (directory === '/override/project') {
return provider === 'gitlab' ? 'https://gitlab.override.example' : actual.getEffectiveProviderApiBaseUrl(provider, directory);
}
return actual.getEffectiveProviderApiBaseUrl(provider, directory);
}),
};
});
const { parseGitLabRemoteUrl, resolveGitLabRepoFromDirectory } = await import('./repo.js');
const { getRemoteUrl } = await import('../git/index.js');
const { setGitLabAuth, clearGitLabAuth } = await import('./auth.js');
@@ -119,4 +134,17 @@ describe('resolveGitLabRepoFromDirectory', () => {
expect(repo).toBeNull();
expect(remoteUrl).toBeNull();
});
test('accepts the per-project override host for a directory with an override', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.override.example:team/app.git');
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/override/project');
expect(remoteUrl).toBe('git@gitlab.override.example:team/app.git');
expect(repo).toMatchObject({ namespace: 'team', project: 'app', host: 'gitlab.override.example' });
});
test('rejects the override host for a directory without an override', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.override.example:team/app.git');
const { repo } = await resolveGitLabRepoFromDirectory('/some/project');
expect(repo).toBeNull();
});
});
+48 -19
View File
@@ -1,3 +1,5 @@
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
// Route-level budget for composite GitLab calls (lists, comments, MR context).
// The client bounds each individual request at 8s; this caps the whole route
// so a slow self-hosted instance cannot hold a response (and a client socket)
@@ -287,9 +289,36 @@ export function registerGitLabRoutes(app, options = {}) {
return gitlabLibraries;
};
const getClient = async () => {
const { getGitLabClientOrNull } = await getGitLabLibraries();
return getGitLabClientOrNull();
const hostFromBaseUrl = (baseUrl) => {
try {
return new URL(baseUrl).hostname || null;
} catch {
return null;
}
};
const getClient = async (directory) => {
const { getGitLabClientOrNull, createGitLabClient, getGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
const auth = getGitLabAuth();
if (!auth?.accessToken) {
return null;
}
const effectiveBaseUrl = directory ? getEffectiveProviderApiBaseUrl('gitlab', directory) : null;
// No project override: the account's own base URL keeps driving requests
// exactly as before.
if (!effectiveBaseUrl || effectiveBaseUrl === getGitLabDefaultBaseUrl()) {
return getGitLabClientOrNull();
}
// A per-project override is in play. A connected account whose host
// matches the remote still wins; otherwise the override serves as the API
// base (it makes its host acceptable even with no account covering it).
const accountHost = hostFromBaseUrl(auth.baseUrl);
const { resolveGitLabRepoFromDirectory } = await getGitLabLibraries();
const { repo } = await resolveGitLabRepoFromDirectory(directory).catch(() => ({ repo: null }));
if (repo?.host && accountHost && accountHost === repo.host) {
return getGitLabClientOrNull();
}
return createGitLabClient({ token: auth.accessToken, baseUrl: effectiveBaseUrl });
};
// Resolve which GitLab project a request targets. A directory-local git
@@ -474,7 +503,7 @@ export function registerGitLabRoutes(app, options = {}) {
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const searchQuery = asString(req.query?.query);
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, issues: [], page: effectivePage, hasMore: false });
}
@@ -522,7 +551,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, issue: null });
}
@@ -564,7 +593,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, comments: [] });
}
@@ -619,7 +648,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'directory, number, body are required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -682,7 +711,7 @@ export function registerGitLabRoutes(app, options = {}) {
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
: undefined;
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -732,7 +761,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'directory and number are required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -828,7 +857,7 @@ export function registerGitLabRoutes(app, options = {}) {
const searchQuery = asString(req.query?.query);
const sourceBranch = asString(req.query?.sourceBranch);
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, mrs: [], page: effectivePage, hasMore: false });
}
@@ -880,7 +909,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, mr: null, comments: [], files: [] });
}
@@ -997,7 +1026,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, commits: [] });
}
@@ -1051,7 +1080,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false, events: [] });
}
@@ -1113,7 +1142,7 @@ export function registerGitLabRoutes(app, options = {}) {
? req.body.removeSourceBranch
: false;
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -1170,7 +1199,7 @@ export function registerGitLabRoutes(app, options = {}) {
const title = asString(req.body?.title);
const description = typeof req.body?.description === 'string' ? req.body.description : undefined;
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -1261,7 +1290,7 @@ export function registerGitLabRoutes(app, options = {}) {
}
const squash = typeof req.body?.squash === 'boolean' ? req.body.squash : undefined;
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -1318,7 +1347,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'directory, number, body are required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -1377,7 +1406,7 @@ export function registerGitLabRoutes(app, options = {}) {
return res.status(400).json({ error: 'directory and number are required' });
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return res.json({ connected: false });
}
@@ -1428,7 +1457,7 @@ export function registerGitLabRoutes(app, options = {}) {
if (!directory && !requestedProject) {
return { error: 'directory or namespace/project is required' };
}
const client = await getClient();
const client = await getClient(directory);
if (!client) {
return { client: null };
}