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:
@@ -0,0 +1,48 @@
|
||||
# Git Providers Configuration Module
|
||||
|
||||
## Purpose
|
||||
|
||||
- This module owns the per-provider git hosting configuration (`gitProviders` in the user settings file): API base URLs and provider-detection hostnames for GitHub, GitLab, and Gitea.
|
||||
- It is the single source of truth for the effective provider defaults consumed by `packages/web/server/lib/{github,gitlab,gitea}` and is validated end-to-end through the settings GET/PUT routes (the `gitProviders` key round-trips via `sanitizeSettingsUpdate` in `packages/web/server/lib/opencode/settings-helpers.js`).
|
||||
|
||||
## Entrypoints
|
||||
|
||||
- `packages/web/server/lib/git-providers/config.js`: the single module file, exporting the helpers directly.
|
||||
|
||||
## Public exports
|
||||
|
||||
- `GIT_PROVIDER_DEFAULTS`: `{ github: 'https://api.github.com', gitlab: 'https://gitlab.com', gitea: null }`. Built-in defaults are **not persisted**; they are applied at read time by getters.
|
||||
- `normalizeBaseUrl(raw)`: normalize an API base URL (add `https://` when the scheme is missing, strip trailing slashes, preserve subpaths like `/gitlab`), `null` for empty/unparseable input.
|
||||
- `normalizeDetectionHost(raw)`: extract the bare lowercase hostname from any git remote/URL form (`https://`, `ssh://`, scp-like `git@host:path`, IPv6); mirrors `packages/ui/src/lib/gitHost.ts` `parseGitHost`.
|
||||
- `sanitizeGitProviders(payload)`: validate/normalize the `gitProviders` shape — only `github|gitlab|gitea` keys survive; `apiBaseUrl` via `normalizeBaseUrl`, `detectUrls` deduped bare hostnames; empty/absent values dropped; returns `undefined` when nothing valid remains.
|
||||
- `readGitProvidersConfig()`: read the `gitProviders` section from `settings.json` (`OPENCHAMBER_DATA_DIR` env override, else `~/.config/openchamber`); never throws, returns `{}` on missing/invalid data.
|
||||
- `getProviderApiBaseUrl(provider)`: configured value -> `GIT_PROVIDER_DEFAULTS[provider]` -> `null` (gitea).
|
||||
- `githubWebOriginFromApiBase(apiBase)`: GitHub web origin from an API base — `https://api.github.com` -> `https://github.com`; Enterprise `https://host/api[/v3]` -> `https://host` (trailing `/api`/`/api/v3` stripped, subpath prefixes kept); otherwise the URL origin; never throws, falls back to `https://github.com`.
|
||||
|
||||
## Settings shape
|
||||
|
||||
`~/.config/openchamber/settings.json`:
|
||||
|
||||
```json
|
||||
"gitProviders": {
|
||||
"github": { "apiBaseUrl": "https://github.example.com/api/v3", "detectUrls": ["github.example.com"] },
|
||||
"gitlab": { "apiBaseUrl": "https://gitlab.example.com", "detectUrls": [] },
|
||||
"gitea": { "apiBaseUrl": "", "detectUrls": ["gitea.example.com"] }
|
||||
}
|
||||
```
|
||||
|
||||
- `apiBaseUrl`: API base URL; the per-account baseUrl (gitlab/gitea accounts) still wins when set; this is the default/fallback plus connect-form prefill.
|
||||
- `detectUrls`: SSH/HTTPS URLs normalized to bare hostnames for provider autodetection (client-side; the server only persists/validates them).
|
||||
- The whole `gitProviders` key is omitted when empty.
|
||||
|
||||
## Consumers
|
||||
|
||||
- `packages/web/server/lib/github/octokit.js`, `device-flow.js`, `routes.js`, `repo/index.js`, `pr-status.js`, `repo/fork-detection.js`: GitHub Enterprise support (Octokit `baseUrl`, device-flow web origin, remote parsing, fallback URLs).
|
||||
- `packages/web/server/lib/gitlab/auth.js`, `client.js`, `routes.js`: effective default base URL.
|
||||
- `packages/web/server/lib/gitea/auth.js`, `routes.js`: connect-form default / status `defaultBaseUrl`.
|
||||
- `packages/web/server/lib/opencode/settings-helpers.js`: `gitProviders` persistence whitelist.
|
||||
|
||||
## Notes for contributors
|
||||
|
||||
- Readers must never throw: `readGitProvidersConfig` and `githubWebOriginFromApiBase` fail closed.
|
||||
- No new dependencies.
|
||||
@@ -0,0 +1,203 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
|
||||
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
|
||||
// Built-in defaults are applied at read time by getters; they are never
|
||||
// persisted (sanitizeGitProviders only stores user-provided overrides).
|
||||
const GIT_PROVIDER_KEYS = ['github', 'gitlab', 'gitea'];
|
||||
export const GIT_PROVIDER_DEFAULTS = {
|
||||
github: 'https://api.github.com',
|
||||
gitlab: 'https://gitlab.com',
|
||||
gitea: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a user-provided API base URL. Adds `https://` when no scheme is
|
||||
* present, strips a trailing slash, preserves any subpath (e.g. `/gitlab`),
|
||||
* and returns null for anything unparseable or empty.
|
||||
*/
|
||||
export function normalizeBaseUrl(raw) {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
let value = raw.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) {
|
||||
value = `https://${value}`;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
return null;
|
||||
}
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
return parsed.href.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const normalizeHost = (host) =>
|
||||
String(host || '').replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
||||
|
||||
/**
|
||||
* Extract the bare lowercase hostname from any git remote / URL form:
|
||||
* `https://host/...`, `ssh://git@host/...`, scp-like `git@host:path`,
|
||||
* `host:path`, and bracketed or unbracketed IPv6. Returns null for empty or
|
||||
* unparseable input.
|
||||
*/
|
||||
export function normalizeDetectionHost(raw) {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// scp-like form: [user@]host:path — never applies once a scheme is present.
|
||||
if (!value.includes('://')) {
|
||||
const authority = value.slice(value.lastIndexOf('@') + 1);
|
||||
// Bracketed IPv6, e.g. `[2001:db8::1]` or `[2001:db8::1]:owner/repo.git`.
|
||||
if (authority.startsWith('[')) {
|
||||
const close = authority.indexOf(']');
|
||||
if (close > 0 && authority.slice(1, close).includes(':')) {
|
||||
return normalizeHost(authority.slice(1, close));
|
||||
}
|
||||
// Malformed brackets fall through to URL parsing, which rejects them.
|
||||
} else {
|
||||
const colon = authority.indexOf(':');
|
||||
if (colon > 0) {
|
||||
const candidate = authority.slice(0, colon);
|
||||
// A single-segment pre-colon value without a dot is not a host — the
|
||||
// guard rejects Windows paths like `C:\foo`. Hosts with a numeric
|
||||
// port (`localhost:3000`) still resolve via the URL branch.
|
||||
if (!candidate.includes('/') && candidate.includes('.')) {
|
||||
return normalizeHost(candidate);
|
||||
}
|
||||
}
|
||||
// Unbracketed IPv6 (e.g. `2001:db8::1`): parse as a bracketed host.
|
||||
if (authority.includes(':') && !authority.includes('/') && authority.length > 2) {
|
||||
try {
|
||||
return normalizeHost(new URL(`ssh://[${authority}]`).hostname);
|
||||
} catch {
|
||||
// Not IPv6; fall through to generic URL parsing.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value.includes('://') ? value : `ssh://${value}`);
|
||||
return normalizeHost(parsed.hostname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeDetectionHosts = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const hosts = [];
|
||||
for (const raw of value) {
|
||||
const host = normalizeDetectionHost(raw);
|
||||
if (!host || seen.has(host)) continue;
|
||||
seen.add(host);
|
||||
hosts.push(host);
|
||||
}
|
||||
return hosts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate and normalize a `gitProviders` settings value. Only the known
|
||||
* provider keys (github|gitlab|gitea) survive; per provider, `apiBaseUrl` is
|
||||
* normalized via normalizeBaseUrl and `detectUrls` becomes a deduped array of
|
||||
* bare hostnames. Empty/absent values are dropped. Returns undefined when
|
||||
* nothing valid remains, otherwise the normalized partial object.
|
||||
*/
|
||||
export function sanitizeGitProviders(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
const result = {};
|
||||
for (const provider of GIT_PROVIDER_KEYS) {
|
||||
const entry = payload[provider];
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const normalized = {};
|
||||
if (entry.apiBaseUrl !== undefined && entry.apiBaseUrl !== null) {
|
||||
const baseUrl = normalizeBaseUrl(entry.apiBaseUrl);
|
||||
if (baseUrl) normalized.apiBaseUrl = baseUrl;
|
||||
}
|
||||
if (entry.detectUrls !== undefined && entry.detectUrls !== null) {
|
||||
const hosts = sanitizeDetectionHosts(entry.detectUrls);
|
||||
if (hosts.length > 0) normalized.detectUrls = hosts;
|
||||
}
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
result[provider] = normalized;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `gitProviders` section of the user settings file
|
||||
* (`~/.config/openchamber/settings.json`, overridable via
|
||||
* OPENCHAMBER_DATA_DIR). Never throws; returns {} on missing/invalid data.
|
||||
*/
|
||||
export function readGitProvidersConfig() {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) || {};
|
||||
return sanitizeGitProviders(parsed.gitProviders) ?? {};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective API base URL for a provider: the configured settings.json value if
|
||||
* present, else the built-in default (null for gitea, which has none).
|
||||
*/
|
||||
export function getProviderApiBaseUrl(provider) {
|
||||
return readGitProvidersConfig()[provider]?.apiBaseUrl || GIT_PROVIDER_DEFAULTS[provider] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the GitHub web origin from an API base URL. The public API host
|
||||
* (`https://api.github.com`) maps to `https://github.com`; an Enterprise API
|
||||
* base (`https://host/api/v3` or `https://host/api`) maps to `https://host`
|
||||
* (trailing `/api[/v3]` path segments are stripped, so subpath installs like
|
||||
* `https://host/ghe/api/v3` keep their prefix). Anything else yields the
|
||||
* origin of the URL. Never throws; falls back to `https://github.com`.
|
||||
*/
|
||||
export function githubWebOriginFromApiBase(apiBase) {
|
||||
try {
|
||||
const url = new URL(apiBase);
|
||||
if (!url.hostname) {
|
||||
return 'https://github.com';
|
||||
}
|
||||
if (url.hostname === 'api.github.com') {
|
||||
return 'https://github.com';
|
||||
}
|
||||
const pathname = url.pathname.replace(/\/+$/, '');
|
||||
const stripped = pathname.replace(/\/api\/v3$/, '').replace(/\/api$/, '');
|
||||
return `${url.protocol}//${url.host}${stripped}`;
|
||||
} catch {
|
||||
return 'https://github.com';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-providers-'));
|
||||
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
|
||||
|
||||
const {
|
||||
GIT_PROVIDER_DEFAULTS,
|
||||
normalizeBaseUrl,
|
||||
normalizeDetectionHost,
|
||||
sanitizeGitProviders,
|
||||
readGitProvidersConfig,
|
||||
getProviderApiBaseUrl,
|
||||
githubWebOriginFromApiBase,
|
||||
} = await import('./config.js');
|
||||
|
||||
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
describe('normalizeBaseUrl', () => {
|
||||
test('adds https scheme when missing', () => {
|
||||
expect(normalizeBaseUrl('github.example.com')).toBe('https://github.example.com');
|
||||
expect(normalizeBaseUrl('gitlab.example.com/gitlab')).toBe('https://gitlab.example.com/gitlab');
|
||||
});
|
||||
|
||||
test('strips trailing slashes but preserves subpaths', () => {
|
||||
expect(normalizeBaseUrl('https://github.example.com/api/v3/')).toBe('https://github.example.com/api/v3');
|
||||
expect(normalizeBaseUrl('https://gitlab.example.com/')).toBe('https://gitlab.example.com');
|
||||
expect(normalizeBaseUrl('https://gitlab.example.com/gitlab/')).toBe('https://gitlab.example.com/gitlab');
|
||||
});
|
||||
|
||||
test('keeps an explicit non-https scheme', () => {
|
||||
expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080');
|
||||
});
|
||||
|
||||
test('returns null for empty or unparseable input', () => {
|
||||
expect(normalizeBaseUrl('')).toBeNull();
|
||||
expect(normalizeBaseUrl(' ')).toBeNull();
|
||||
expect(normalizeBaseUrl('not a url')).toBeNull();
|
||||
expect(normalizeBaseUrl(null)).toBeNull();
|
||||
expect(normalizeBaseUrl(undefined)).toBeNull();
|
||||
expect(normalizeBaseUrl(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDetectionHost', () => {
|
||||
test('extracts the host from https remotes', () => {
|
||||
expect(normalizeDetectionHost('https://Github.Example.com/owner/repo.git')).toBe('github.example.com');
|
||||
expect(normalizeDetectionHost('https://github.com/owner/repo')).toBe('github.com');
|
||||
});
|
||||
|
||||
test('extracts the host from scp-like and ssh remotes', () => {
|
||||
expect(normalizeDetectionHost('git@github.example.com:owner/repo.git')).toBe('github.example.com');
|
||||
expect(normalizeDetectionHost('ssh://git@github.example.com/owner/repo.git')).toBe('github.example.com');
|
||||
expect(normalizeDetectionHost('github.example.com:owner/repo.git')).toBe('github.example.com');
|
||||
});
|
||||
|
||||
test('handles ports, user info, and IPv6', () => {
|
||||
expect(normalizeDetectionHost('https://github.example.com:8443/owner/repo')).toBe('github.example.com');
|
||||
expect(normalizeDetectionHost('ssh://user@host.example.com/owner/repo')).toBe('host.example.com');
|
||||
expect(normalizeDetectionHost('[2001:db8::1]:owner/repo.git')).toBe('2001:db8::1');
|
||||
expect(normalizeDetectionHost('2001:db8::1')).toBe('2001:db8::1');
|
||||
});
|
||||
|
||||
test('returns null for empty or unparseable input', () => {
|
||||
expect(normalizeDetectionHost('')).toBeNull();
|
||||
expect(normalizeDetectionHost(null)).toBeNull();
|
||||
expect(normalizeDetectionHost(42)).toBeNull();
|
||||
expect(normalizeDetectionHost('C:\\foo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeGitProviders', () => {
|
||||
test('normalizes a valid payload', () => {
|
||||
expect(sanitizeGitProviders({
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['https://github.example.com/owner/repo.git'] },
|
||||
gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] },
|
||||
gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] },
|
||||
})).toEqual({
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
gitea: { detectUrls: ['gitea.example.com'] },
|
||||
});
|
||||
});
|
||||
|
||||
test('dedupes and lowercases detectUrls', () => {
|
||||
expect(sanitizeGitProviders({
|
||||
github: { detectUrls: ['GitHub.Example.com', 'https://github.example.com/x', 'other.example.com', 'other.example.com'] },
|
||||
})).toEqual({
|
||||
github: { detectUrls: ['github.example.com', 'other.example.com'] },
|
||||
});
|
||||
});
|
||||
|
||||
test('drops malformed or empty entries', () => {
|
||||
expect(sanitizeGitProviders({ github: { apiBaseUrl: ' ' } })).toBeUndefined();
|
||||
expect(sanitizeGitProviders({ github: { detectUrls: 'not-an-array' } })).toBeUndefined();
|
||||
expect(sanitizeGitProviders({ unknown: { apiBaseUrl: 'https://x.example.com' } })).toBeUndefined();
|
||||
expect(sanitizeGitProviders('not-an-object')).toBeUndefined();
|
||||
expect(sanitizeGitProviders(null)).toBeUndefined();
|
||||
expect(sanitizeGitProviders([])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ignores unknown provider keys', () => {
|
||||
expect(sanitizeGitProviders({
|
||||
github: { apiBaseUrl: 'https://github.example.com' },
|
||||
bitbucket: { apiBaseUrl: 'https://bitbucket.example.com' },
|
||||
})).toEqual({
|
||||
github: { apiBaseUrl: 'https://github.example.com' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readGitProvidersConfig / getProviderApiBaseUrl', () => {
|
||||
test('returns {} / defaults when no settings file exists', () => {
|
||||
expect(readGitProvidersConfig()).toEqual({});
|
||||
expect(getProviderApiBaseUrl('github')).toBe('https://api.github.com');
|
||||
expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.com');
|
||||
expect(getProviderApiBaseUrl('gitea')).toBeNull();
|
||||
});
|
||||
|
||||
test('reads the configured values from settings.json', () => {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
|
||||
gitProviders: {
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
},
|
||||
}));
|
||||
expect(readGitProvidersConfig()).toEqual({
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
expect(getProviderApiBaseUrl('github')).toBe('https://github.example.com/api/v3');
|
||||
expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.example.com');
|
||||
expect(getProviderApiBaseUrl('gitea')).toBeNull();
|
||||
});
|
||||
|
||||
test('never throws on a malformed settings file', () => {
|
||||
fs.writeFileSync(SETTINGS_FILE, '{not-json');
|
||||
expect(readGitProvidersConfig()).toEqual({});
|
||||
expect(getProviderApiBaseUrl('github')).toBe(GIT_PROVIDER_DEFAULTS.github);
|
||||
});
|
||||
});
|
||||
|
||||
describe('githubWebOriginFromApiBase', () => {
|
||||
test('maps the public api host to github.com', () => {
|
||||
expect(githubWebOriginFromApiBase('https://api.github.com')).toBe('https://github.com');
|
||||
});
|
||||
|
||||
test('maps enterprise api bases to the host', () => {
|
||||
expect(githubWebOriginFromApiBase('https://github.example.com/api/v3')).toBe('https://github.example.com');
|
||||
expect(githubWebOriginFromApiBase('https://github.example.com/api')).toBe('https://github.example.com');
|
||||
});
|
||||
|
||||
test('keeps subpath prefixes and plain origins', () => {
|
||||
expect(githubWebOriginFromApiBase('https://github.example.com/ghe/api/v3')).toBe('https://github.example.com/ghe');
|
||||
expect(githubWebOriginFromApiBase('https://github.example.com')).toBe('https://github.example.com');
|
||||
expect(githubWebOriginFromApiBase('https://github.example.com:8443/api/v3')).toBe('https://github.example.com:8443');
|
||||
});
|
||||
|
||||
test('falls back for invalid input and never throws', () => {
|
||||
expect(githubWebOriginFromApiBase('')).toBe('https://github.com');
|
||||
expect(githubWebOriginFromApiBase(null)).toBe('https://github.com');
|
||||
expect(githubWebOriginFromApiBase('not a url')).toBe('https://github.com');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -32,18 +32,26 @@
|
||||
|
||||
### Device flow
|
||||
|
||||
- `startDeviceFlow({ clientId, scope })`: request device code.
|
||||
- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
|
||||
- `startDeviceFlow({ clientId, scope, webOrigin? })`: request device code.
|
||||
- `exchangeDeviceCode({ clientId, deviceCode, webOrigin? })`: poll for access token.
|
||||
|
||||
### Octokit
|
||||
|
||||
- `getOctokitOrNull()`: current Octokit or `null`.
|
||||
- `createOctokit(token, baseUrl?)`: Octokit factory; the optional `baseUrl` (GitHub Enterprise API base) is passed to the Octokit constructor.
|
||||
|
||||
### Repo
|
||||
|
||||
- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`.
|
||||
- `parseGitHubRemoteUrl(raw, options?)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`; `options.host` / `options.webOrigin` default to `github.com` / `https://github.com` and are used for self-hosted (Enterprise) remotes.
|
||||
- `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote.
|
||||
|
||||
## Git provider configuration
|
||||
|
||||
Per-provider settings come from `~/.config/openchamber/settings.json` under `gitProviders` (validated in `packages/web/server/lib/git-providers/config.js`, persisted via the settings GET/PUT routes). GitHub resolution:
|
||||
|
||||
- API base URL: configured `gitProviders.github.apiBaseUrl` -> default `https://api.github.com`. The configured value drives the Octokit `baseUrl` (`getOctokitOrNull`, device-flow account activation).
|
||||
- Device flow web origin: derived from the API base via `githubWebOriginFromApiBase` — the public host maps to `https://github.com`; an Enterprise base (`https://host/api/v3` or `https://host/api`) maps to `https://host`.
|
||||
|
||||
## Auth storage and config
|
||||
|
||||
- Auth storage: `~/.config/openchamber/github-auth.json`
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
const DEFAULT_WEB_ORIGIN = 'https://github.com';
|
||||
|
||||
const encodeForm = (params) => {
|
||||
const body = new URLSearchParams();
|
||||
@@ -32,16 +31,19 @@ async function postForm(url, params) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startDeviceFlow({ clientId, scope }) {
|
||||
return postForm(DEVICE_CODE_URL, {
|
||||
const deviceCodeUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/device/code`;
|
||||
const accessTokenUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/oauth/access_token`;
|
||||
|
||||
export async function startDeviceFlow({ clientId, scope, webOrigin }) {
|
||||
return postForm(deviceCodeUrl(webOrigin), {
|
||||
client_id: clientId,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
export async function exchangeDeviceCode({ clientId, deviceCode }) {
|
||||
export async function exchangeDeviceCode({ clientId, deviceCode, webOrigin }) {
|
||||
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
|
||||
const payload = await postForm(ACCESS_TOKEN_URL, {
|
||||
const payload = await postForm(accessTokenUrl(webOrigin), {
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: DEVICE_GRANT_TYPE,
|
||||
|
||||
@@ -28,3 +28,8 @@ export {
|
||||
parseGitHubRemoteUrl,
|
||||
resolveGitHubRepoFromDirectory,
|
||||
} from './repo/index.js';
|
||||
|
||||
export {
|
||||
getProviderApiBaseUrl,
|
||||
githubWebOriginFromApiBase,
|
||||
} from '../git-providers/config.js';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
|
||||
import { getGhCliToken } from './gh-cli-credential.js';
|
||||
import { getProviderApiBaseUrl } from '../git-providers/config.js';
|
||||
|
||||
// Per-request timeout for every GitHub call. Octokit v22 uses native fetch,
|
||||
// which has no built-in timeout — without this, a stuck connection hangs until
|
||||
@@ -69,8 +70,12 @@ const createConditionalFetch = (token) => async (url, options = {}) => {
|
||||
};
|
||||
|
||||
/** Create an Octokit instance with per-request timeout + ETag revalidation. */
|
||||
export function createOctokit(token) {
|
||||
return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } });
|
||||
export function createOctokit(token, baseUrl) {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
request: { fetch: createConditionalFetch(token) },
|
||||
});
|
||||
}
|
||||
|
||||
export function getOctokitOrNull() {
|
||||
@@ -80,5 +85,5 @@ export function getOctokitOrNull() {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return createOctokit(token);
|
||||
return createOctokit(token, getProviderApiBaseUrl('github'));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { stat } from 'node:fs/promises';
|
||||
import { getRemotes, getStatus } from '../git/index.js';
|
||||
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
|
||||
import { noteIfGitHubRateLimit } from './rate-limit.js';
|
||||
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../git-providers/config.js';
|
||||
|
||||
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const directoryExists = async (dir) => {
|
||||
if (!dir) return false;
|
||||
@@ -295,7 +298,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
||||
pushCandidate({
|
||||
owner: parent.owner.login,
|
||||
repo: parent.name,
|
||||
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
|
||||
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
|
||||
}, candidate.remoteName, candidate.priority + 0.1);
|
||||
}
|
||||
|
||||
@@ -304,7 +307,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
||||
pushCandidate({
|
||||
owner: source.owner.login,
|
||||
repo: source.name,
|
||||
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
|
||||
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
|
||||
}, candidate.remoteName, candidate.priority + 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolveGitHubRepoFromDirectory } from './index.js';
|
||||
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
|
||||
|
||||
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const REPO_METADATA_TTL_MS = 5 * 60_000;
|
||||
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
|
||||
@@ -75,7 +78,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
|
||||
result.push({
|
||||
owner: parent.owner.login,
|
||||
repo: parent.name,
|
||||
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
|
||||
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
|
||||
source: 'upstream',
|
||||
});
|
||||
}
|
||||
@@ -89,7 +92,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
|
||||
result.push({
|
||||
owner: source.owner.login,
|
||||
repo: source.name,
|
||||
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
|
||||
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
|
||||
source: 'upstream',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { getRemoteUrl } from '../../git/index.js';
|
||||
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
|
||||
|
||||
export const parseGitHubRemoteUrl = (raw) => {
|
||||
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const webHostFromOrigin = (webOrigin) => {
|
||||
try {
|
||||
return new URL(webOrigin).hostname || 'github.com';
|
||||
} catch {
|
||||
return 'github.com';
|
||||
}
|
||||
};
|
||||
|
||||
export const parseGitHubRemoteUrl = (raw, { host = 'github.com', webOrigin = 'https://github.com' } = {}) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -10,34 +21,36 @@ export const parseGitHubRemoteUrl = (raw) => {
|
||||
}
|
||||
|
||||
// git@github.com:OWNER/REPO.git
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
const rest = value.slice('git@github.com:'.length);
|
||||
const scpPrefix = `git@${host}:`;
|
||||
if (value.startsWith(scpPrefix)) {
|
||||
const rest = value.slice(scpPrefix.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// ssh://git@github.com/OWNER/REPO.git
|
||||
if (value.startsWith('ssh://git@github.com/')) {
|
||||
const rest = value.slice('ssh://git@github.com/'.length);
|
||||
const sshPrefix = `ssh://git@${host}/`;
|
||||
if (value.startsWith(sshPrefix)) {
|
||||
const rest = value.slice(sshPrefix.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// https://github.com/OWNER/REPO(.git)
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname !== 'github.com') {
|
||||
if (url.hostname !== host) {
|
||||
return null;
|
||||
}
|
||||
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -48,8 +61,10 @@ export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'or
|
||||
if (!remoteUrl) {
|
||||
return { repo: null, remoteUrl: null };
|
||||
}
|
||||
const webOrigin = getGitHubWebOrigin();
|
||||
const host = webHostFromOrigin(webOrigin);
|
||||
return {
|
||||
repo: parseGitHubRemoteUrl(remoteUrl),
|
||||
repo: parseGitHubRemoteUrl(remoteUrl, { host, webOrigin }),
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ export function registerGitHubRoutes(app) {
|
||||
|
||||
app.post('/api/github/auth/start', async (_req, res) => {
|
||||
try {
|
||||
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
||||
const { getGitHubClientId, getGitHubScopes, startDeviceFlow, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
|
||||
const clientId = getGitHubClientId();
|
||||
if (!clientId) {
|
||||
return res.status(400).json({
|
||||
@@ -330,10 +330,12 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const scope = getGitHubScopes();
|
||||
const webOrigin = githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const payload = await startDeviceFlow({
|
||||
clientId,
|
||||
scope,
|
||||
webOrigin,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
@@ -353,7 +355,7 @@ export function registerGitHubRoutes(app) {
|
||||
|
||||
app.post('/api/github/auth/complete', async (req, res) => {
|
||||
try {
|
||||
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
||||
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
|
||||
const clientId = getGitHubClientId();
|
||||
if (!clientId) {
|
||||
return res.status(400).json({
|
||||
@@ -369,7 +371,10 @@ export function registerGitHubRoutes(app) {
|
||||
return res.status(400).json({ error: 'deviceCode is required' });
|
||||
}
|
||||
|
||||
const payload = await exchangeDeviceCode({ clientId, deviceCode });
|
||||
const apiBase = getProviderApiBaseUrl('github');
|
||||
const webOrigin = githubWebOriginFromApiBase(apiBase);
|
||||
|
||||
const payload = await exchangeDeviceCode({ clientId, deviceCode, webOrigin });
|
||||
|
||||
if (payload?.error) {
|
||||
return res.json({
|
||||
@@ -385,7 +390,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { createOctokit } = await import('./octokit.js');
|
||||
const octokit = createOctokit(accessToken);
|
||||
const octokit = createOctokit(accessToken, apiBase);
|
||||
const user = await getGitHubUserSummary(octokit);
|
||||
|
||||
setGitHubAuth({
|
||||
@@ -1773,6 +1778,9 @@ export function registerGitHubRoutes(app) {
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
||||
|
||||
const { getProviderApiBaseUrl } = await getGitHubLibraries();
|
||||
const apiBase = getProviderApiBaseUrl('github');
|
||||
|
||||
const repoNetwork = await resolveRepoNetwork(octokit, directory);
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
@@ -1817,7 +1825,7 @@ export function registerGitHubRoutes(app) {
|
||||
const issues = items
|
||||
.filter((item) => !item?.pull_request)
|
||||
.map((item) => {
|
||||
const repoFullName = (item.repository_url || '').replace('https://api.github.com/repos/', '');
|
||||
const repoFullName = (item.repository_url || '').replace(`${apiBase}/repos/`, '');
|
||||
const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName);
|
||||
return mapIssueSummary(item, matched || reposToQuery[0]);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
- `clearGitLabAuth()`: remove the current account.
|
||||
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
|
||||
- `GITLAB_AUTH_FILE`: auth file path.
|
||||
- `DEFAULT_GITLAB_BASE_URL`: `https://gitlab.com`.
|
||||
- `DEFAULT_GITLAB_BASE_URL`: `https://gitlab.com` (compatibility constant).
|
||||
- `getGitLabDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitlab.apiBaseUrl` from `settings.json` if present, else `https://gitlab.com`. Used for stored-account fallback and the auth status/connect `defaultBaseUrl` fields.
|
||||
|
||||
### Client (`client.js`)
|
||||
|
||||
@@ -45,7 +46,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) -> `DEFAULT_GITLAB_BASE_URL`.
|
||||
- Base URL resolution: caller-supplied `baseUrl` (normalized) -> effective default via `getGitLabDefaultBaseUrl()` (configured `settings.json` `gitProviders.gitlab.apiBaseUrl`, else `https://gitlab.com`).
|
||||
- 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>`.
|
||||
|
||||
|
||||
@@ -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,8 +10,15 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
|
||||
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitlab-auth.json');
|
||||
|
||||
// Kept for compatibility with existing consumers/tests; the effective fallback
|
||||
// lives in the git-providers defaults (GIT_PROVIDER_DEFAULTS.gitlab).
|
||||
export const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com';
|
||||
|
||||
/** Effective default GitLab base URL: configured settings.json value, else the built-in default. */
|
||||
export function getGitLabDefaultBaseUrl() {
|
||||
return getProviderApiBaseUrl('gitlab');
|
||||
}
|
||||
|
||||
function ensureStorageDir() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
@@ -119,7 +127,7 @@ function normalizeAuthEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
|
||||
if (!accessToken) return null;
|
||||
const baseUrl = normalizeBaseUrl(entry.baseUrl) || DEFAULT_GITLAB_BASE_URL;
|
||||
const baseUrl = normalizeBaseUrl(entry.baseUrl) || getGitLabDefaultBaseUrl();
|
||||
const username = typeof entry.username === 'string' ? entry.username : '';
|
||||
|
||||
const accountId = resolveAccountId({
|
||||
@@ -218,7 +226,7 @@ export function getGitLabAuthAccounts() {
|
||||
avatarUrl: entry.avatarUrl || null,
|
||||
webUrl: entry.webUrl || null,
|
||||
},
|
||||
baseUrl: entry.baseUrl || DEFAULT_GITLAB_BASE_URL,
|
||||
baseUrl: entry.baseUrl || getGitLabDefaultBaseUrl(),
|
||||
current: Boolean(entry.current),
|
||||
}));
|
||||
}
|
||||
@@ -227,7 +235,7 @@ export function setGitLabAuth({ accessToken, baseUrl, user }) {
|
||||
if (!accessToken || typeof accessToken !== 'string') {
|
||||
throw new Error('accessToken is required');
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || DEFAULT_GITLAB_BASE_URL;
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || getGitLabDefaultBaseUrl();
|
||||
const normalizedUser = user && typeof user === 'object'
|
||||
? {
|
||||
username: typeof user.username === 'string' ? user.username : undefined,
|
||||
|
||||
@@ -15,8 +15,11 @@ const {
|
||||
normalizeBaseUrl,
|
||||
GITLAB_AUTH_FILE,
|
||||
DEFAULT_GITLAB_BASE_URL,
|
||||
getGitLabDefaultBaseUrl,
|
||||
} = await import('./auth.js');
|
||||
|
||||
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
@@ -25,6 +28,9 @@ afterEach(() => {
|
||||
if (fs.existsSync(GITLAB_AUTH_FILE)) {
|
||||
fs.unlinkSync(GITLAB_AUTH_FILE);
|
||||
}
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
const aliceUser = {
|
||||
@@ -135,6 +141,19 @@ describe('multi-account switching', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitLabDefaultBaseUrl', () => {
|
||||
test('falls back to the built-in default when nothing is configured', () => {
|
||||
expect(getGitLabDefaultBaseUrl()).toBe(DEFAULT_GITLAB_BASE_URL);
|
||||
});
|
||||
|
||||
test('returns the configured settings.json default when present', () => {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
|
||||
gitProviders: { gitlab: { apiBaseUrl: 'https://gitlab.example.com' } },
|
||||
}));
|
||||
expect(getGitLabDefaultBaseUrl()).toBe('https://gitlab.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearGitLabAuth', () => {
|
||||
test('removes the current account and deletes the file when empty', () => {
|
||||
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getGitLabAuth, DEFAULT_GITLAB_BASE_URL } from './auth.js';
|
||||
import { getGitLabAuth, getGitLabDefaultBaseUrl } from './auth.js';
|
||||
|
||||
// Per-request timeout for every GitLab call. GitLab REST can hang under load
|
||||
// (especially self-hosted instances); bounding each request lets the caller
|
||||
@@ -113,7 +113,7 @@ export function isGitLabRateLimited() {
|
||||
// ---- Response helpers ----
|
||||
|
||||
const joinApiUrl = (baseUrl, path) => {
|
||||
const base = String(baseUrl || DEFAULT_GITLAB_BASE_URL).replace(/\/+$/, '');
|
||||
const base = String(baseUrl || getGitLabDefaultBaseUrl()).replace(/\/+$/, '');
|
||||
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
|
||||
return `${base}/api/v4${p}`;
|
||||
};
|
||||
@@ -305,7 +305,7 @@ export function createGitLabClient({ token, baseUrl }) {
|
||||
|
||||
function normalizeBaseForClient(baseUrl) {
|
||||
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
||||
return DEFAULT_GITLAB_BASE_URL;
|
||||
return getGitLabDefaultBaseUrl();
|
||||
}
|
||||
return baseUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
normalizeBaseUrl,
|
||||
GITLAB_AUTH_FILE,
|
||||
DEFAULT_GITLAB_BASE_URL,
|
||||
getGitLabDefaultBaseUrl,
|
||||
} from './auth.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -314,11 +314,12 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
|
||||
app.get('/api/gitlab/auth/status', async (_req, res) => {
|
||||
try {
|
||||
const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries();
|
||||
const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
|
||||
const auth = getGitLabAuth();
|
||||
const accounts = getGitLabAuthAccounts();
|
||||
const defaultBaseUrl = getGitLabDefaultBaseUrl();
|
||||
if (!auth?.accessToken) {
|
||||
return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
|
||||
return res.json({ connected: false, accounts, defaultBaseUrl });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
@@ -327,7 +328,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
const resp = await client.user();
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
clearGitLabAuth();
|
||||
return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
|
||||
return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl });
|
||||
}
|
||||
if (resp.status === 200 && resp.data) {
|
||||
user = mapGitLabUser(resp.data);
|
||||
@@ -338,7 +339,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
connected: true,
|
||||
...(user ? { user } : {}),
|
||||
accounts,
|
||||
defaultBaseUrl: DEFAULT_GITLAB_BASE_URL,
|
||||
defaultBaseUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get GitLab auth status:', error);
|
||||
@@ -353,8 +354,8 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'accessToken is required' });
|
||||
}
|
||||
|
||||
const { normalizeBaseUrl, DEFAULT_GITLAB_BASE_URL, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries();
|
||||
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || DEFAULT_GITLAB_BASE_URL;
|
||||
const { normalizeBaseUrl, getGitLabDefaultBaseUrl, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries();
|
||||
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGitLabDefaultBaseUrl();
|
||||
|
||||
const { createGitLabClient } = await getGitLabLibraries();
|
||||
const client = createGitLabClient({ token: accessToken, baseUrl });
|
||||
@@ -371,7 +372,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
connected: true,
|
||||
user: mapGitLabUser(resp.data),
|
||||
accounts: getGitLabAuthAccounts(),
|
||||
defaultBaseUrl: DEFAULT_GITLAB_BASE_URL,
|
||||
defaultBaseUrl: getGitLabDefaultBaseUrl(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect GitLab:', error);
|
||||
@@ -386,7 +387,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'accountId is required' });
|
||||
}
|
||||
|
||||
const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries();
|
||||
const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
|
||||
const activated = activateGitLabAuth(accountId);
|
||||
if (!activated) {
|
||||
return res.status(404).json({ error: 'GitLab account not found' });
|
||||
@@ -394,8 +395,9 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
|
||||
const auth = getGitLabAuth();
|
||||
const accounts = getGitLabAuthAccounts();
|
||||
const defaultBaseUrl = getGitLabDefaultBaseUrl();
|
||||
if (!auth?.accessToken) {
|
||||
return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
|
||||
return res.json({ connected: false, accounts, defaultBaseUrl });
|
||||
}
|
||||
|
||||
let user = auth.username
|
||||
@@ -416,7 +418,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ connected: true, user, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
|
||||
return res.json({ connected: true, user, accounts, defaultBaseUrl });
|
||||
} catch (error) {
|
||||
console.error('Failed to activate GitLab account:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' });
|
||||
|
||||
@@ -208,7 +208,7 @@ Transport-triggered health checks share the periodic monitor's failure accountin
|
||||
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
||||
- Returned API:
|
||||
- `normalizePwaAppName(value, fallback?)`
|
||||
- `sanitizeSettingsUpdate(payload)`
|
||||
- `sanitizeSettingsUpdate(payload)` — whitelist of persisted keys; includes `gitProviders` (validated via `packages/web/server/lib/git-providers/config.js` `sanitizeGitProviders`), which therefore round-trips through GET/PUT `/api/config/settings`.
|
||||
- `mergePersistedSettings(current, changes)`
|
||||
- `formatSettingsResponse(settings)`
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sanitizeGitProviders } from '../git-providers/config.js';
|
||||
|
||||
export const createSettingsHelpers = (dependencies) => {
|
||||
const {
|
||||
normalizePathForPersistence,
|
||||
@@ -481,6 +483,10 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
const trimmed = candidate.gitModelId.trim();
|
||||
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
const gitProviders = sanitizeGitProviders(candidate.gitProviders);
|
||||
if (gitProviders) {
|
||||
result.gitProviders = gitProviders;
|
||||
}
|
||||
if (typeof candidate.pwaAppName === 'string') {
|
||||
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
||||
}
|
||||
|
||||
@@ -465,6 +465,46 @@ describe('settings helpers', () => {
|
||||
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
|
||||
expect(sanitized.recentModels).toEqual(payload.recentModels);
|
||||
});
|
||||
|
||||
it('round-trips a valid gitProviders payload through sanitizeSettingsUpdate', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
const payload = {
|
||||
gitProviders: {
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
||||
gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] },
|
||||
gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate(payload)).toEqual({
|
||||
gitProviders: {
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
gitea: { detectUrls: ['gitea.example.com'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('drops malformed gitProviders payloads entirely', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ gitProviders: 'not-an-object' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ gitProviders: [] })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ gitProviders: null })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { unknown: { apiBaseUrl: 'https://x.example.com' } } })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { apiBaseUrl: ' ' } } })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { detectUrls: 'github.example.com' } } })).toEqual({});
|
||||
});
|
||||
|
||||
it('normalizes gitProviders apiBaseUrl scheme and strips trailing slashes', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({
|
||||
gitProviders: { github: { apiBaseUrl: 'github.example.com/api/v3/' } },
|
||||
})).toEqual({
|
||||
gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('session retention settings persistence', () => {
|
||||
|
||||
Reference in New Issue
Block a user