feat(web,ui): per-provider git forge API base URL and detection URLs

Configure a default API base URL per git provider (github/gitlab/gitea)
in settings.json gitProviders, with GitHub Enterprise support (Octokit
baseUrl + device-flow web origin derived from the API base), and replace
the client-side custom-domains list with server-persisted detection URL
chips (SSH/HTTPS forms normalized to hosts). The configured API base host
auto-counts as a detection host. Settings round-trip through the existing
/api/config/settings sanitizer; the UI store hydrates from server settings
with a one-time localStorage migration.
This commit is contained in:
2026-08-17 09:55:11 +00:00
parent a87b3fd228
commit 0fc857959e
55 changed files with 1685 additions and 272 deletions
@@ -29,7 +29,8 @@
- `clearGiteaAuth()`: remove the current account.
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
- `GITEA_AUTH_FILE`: auth file path.
- There is **no default base URL**: Gitea/Forgejo is self-hosted, so the instance URL is always user-provided.
- `getGiteaDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitea.apiBaseUrl` from `settings.json`, else `null`. Used to prefill the connect form and as the connect/status default; stored accounts still require an explicit base URL (there is no invented host).
- There is **no built-in default base URL**: Gitea/Forgejo is self-hosted, so the instance URL is always user-provided.
### Client (`client.js`)
@@ -46,7 +47,7 @@
- Auth storage: `~/.config/openchamber/gitea-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
- Base URL resolution: the caller-supplied `baseUrl` (normalized) is the only source — there is no default instance. Stored entries without a usable base URL are dropped.
- Base URL resolution: the caller-supplied `baseUrl` (normalized) is the primary source — there is no built-in default instance. A configured `settings.json` `gitProviders.gitea.apiBaseUrl` acts as the connect-form default/fallback. Stored entries without a usable base URL are dropped.
- Account id: `` `${host}:${username}` `` (e.g. `gitea.example.com:alice`), falling back to `token:<first8>` when the username is missing.
- Auth header on every request: `Authorization: token <pat>`.
- Gitea's `GET /user` uses `login`/`full_name`/`html_url`; `setGiteaAuth` accepts both that and the `username`/`web_url` variants.
@@ -91,8 +92,8 @@
| Method | Path | Shape |
|---|---|---|
| GET | `/api/gitea/auth/status` | `{ connected, user?, accounts[] }` |
| POST | `/api/gitea/auth/connect` | body `{ accessToken, baseUrl }` -> `{ connected, user, accounts }`; `400` for missing/invalid token or base URL |
| GET | `/api/gitea/auth/status` | `{ connected, user?, accounts[], defaultBaseUrl? }` (`defaultBaseUrl` present when connected; the configured `gitProviders.gitea.apiBaseUrl`, else `null`) |
| POST | `/api/gitea/auth/connect` | body `{ accessToken, baseUrl? }` -> `{ connected, user, accounts }`; `400` for missing/invalid token; `400` when neither a valid `baseUrl` nor a configured default exists |
| POST | `/api/gitea/auth/activate` | body `{ accountId }` -> `{ connected, user, accounts }`; `404` unknown account |
| DELETE | `/api/gitea/auth` | `{ removed }` |
| GET | `/api/gitea/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected |
+11 -3
View File
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getProviderApiBaseUrl } from '../git-providers/config.js';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
@@ -9,9 +10,16 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitea-auth.json');
// Gitea/Forgejo are self-hosted — there is deliberately NO default base URL.
// The instance URL is always user-provided (see `normalizeBaseUrl`); auth.js
// never invents a host for a stored account.
// Gitea/Forgejo are self-hosted — there is deliberately NO built-in default
// base URL. The instance URL is always user-provided (see `normalizeBaseUrl`);
// auth.js never invents a host for a stored account. A configured
// settings.json gitProviders.gitea.apiBaseUrl can act as the default for the
// connect form, but stored accounts still require an explicit baseUrl.
/** Effective default Gitea base URL: configured settings.json value, else null (no built-in default). */
export function getGiteaDefaultBaseUrl() {
return getProviderApiBaseUrl('gitea');
}
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
+1
View File
@@ -6,6 +6,7 @@ export {
clearGiteaAuth,
normalizeBaseUrl,
GITEA_AUTH_FILE,
getGiteaDefaultBaseUrl,
} from './auth.js';
export {
+4 -3
View File
@@ -237,7 +237,7 @@ export function registerGiteaRoutes(app, options = {}) {
app.get('/api/gitea/auth/status', async (_req, res) => {
try {
const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth } = await getGiteaLibraries();
const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth, getGiteaDefaultBaseUrl } = await getGiteaLibraries();
const auth = getGiteaAuth();
const accounts = getGiteaAuthAccounts();
if (!auth?.accessToken) {
@@ -261,6 +261,7 @@ export function registerGiteaRoutes(app, options = {}) {
connected: true,
...(user ? { user } : {}),
accounts,
defaultBaseUrl: getGiteaDefaultBaseUrl(),
});
} catch (error) {
console.error('Failed to get Gitea auth status:', error);
@@ -275,8 +276,8 @@ export function registerGiteaRoutes(app, options = {}) {
return res.status(400).json({ error: 'accessToken is required' });
}
const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts } = await getGiteaLibraries();
const baseUrl = normalizeBaseUrl(req.body?.baseUrl);
const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts, getGiteaDefaultBaseUrl } = await getGiteaLibraries();
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGiteaDefaultBaseUrl();
if (!baseUrl) {
return res.status(400).json({ error: 'baseUrl is required and must be a valid URL' });
}
+42 -2
View File
@@ -1,7 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
@@ -201,7 +201,6 @@ describe('Gitea auth routes', () => {
test('me returns the connected user', async () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/me');
expect(response.status).toBe(200);
@@ -216,6 +215,47 @@ describe('Gitea auth routes', () => {
});
});
describe('Gitea configured default base URL', () => {
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
afterEach(() => {
if (fs.existsSync(SETTINGS_FILE)) {
fs.unlinkSync(SETTINGS_FILE);
}
});
test('auth/status reports the configured defaultBaseUrl', async () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } },
}));
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'https://gitea.example.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/auth/status');
expect(response.status).toBe(200);
expect(response.body.connected).toBe(true);
expect(response.body.defaultBaseUrl).toBe('https://gitea.example.com');
});
test('auth/connect falls back to the configured default base URL when baseUrl is blank', async () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } },
}));
const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/auth/connect')
.send({ accessToken: 'gitea-valid' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true });
expect(fetchMock.mock.calls[0][0]).toBe('https://gitea.example.com/api/v1/user');
expect(getGiteaAuth()?.baseUrl).toBe('https://gitea.example.com');
});
});
describe('Gitea data routes', () => {
beforeEach(() => {
resetAuthFile();