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:
@@ -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 |
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
clearGiteaAuth,
|
||||
normalizeBaseUrl,
|
||||
GITEA_AUTH_FILE,
|
||||
getGiteaDefaultBaseUrl,
|
||||
} from './auth.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user