Changeset 0.12.0 (#63)

This commit is contained in:
2026-02-25 21:38:49 +00:00
parent c9d8e9457e
commit 88216ec4cb
59 changed files with 13115 additions and 139 deletions

636
api.js Normal file
View File

@@ -0,0 +1,636 @@
// ==========================================
// Chat Switchboard API Client
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
//
// BASE_PATH: injected via index.html <script>
// at container startup. All API paths are
// prefixed automatically.
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
const API = {
accessToken: null,
refreshToken: null,
user: null,
_refreshing: null,
// ── Bootstrap ────────────────────────────
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
// Unknown age — refresh soon to get a fresh token
if (this.refreshToken) this._scheduleRefresh(60);
}
} catch (e) { /* corrupt storage */ }
},
saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
}));
},
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
get isAuthed() { return !!this.accessToken; },
get isAdmin() { return this.user?.role === 'admin'; },
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return this._parseJSON(resp, '/api/v1/health');
},
// Detect proxy interception: HTTP 200 but HTML instead of JSON
async _parseJSON(resp, context) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
// Proxy returned an HTML page (block page, auth wall, etc.)
let title = 'unknown';
try {
const html = await resp.clone().text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* best effort */ }
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
return resp.json();
},
async login(login, password) {
const data = await this._post('/api/v1/auth/login', { login, password }, true);
this._setAuth(data);
return data;
},
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
if (!data.pending) this._setAuth(data);
return data;
},
async logout() {
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
catch (e) { /* best effort */ }
this.clearTokens();
},
async refresh() {
if (this._refreshing) return this._refreshing;
this._refreshing = (async () => {
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
return true;
} catch (e) {
this.clearTokens();
return false;
} finally {
this._refreshing = null;
}
})();
return this._refreshing;
},
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
this._scheduleRefresh(data.expires_in || 900);
},
_refreshTimer: null,
_scheduleRefresh(expiresIn) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
this._refreshTimer = setTimeout(async () => {
if (!this.refreshToken) return;
console.debug('🔄 Proactive token refresh');
await this.refresh();
}, ms);
},
// ── Channels ──────────────────────────────
listChannels(page = 1, perPage = 100, type = '') {
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
},
createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title, type };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/channels', body);
},
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
// ── Messages ─────────────────────────────
listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Message Tree (forking) ───────────────
getActivePath(channelId) {
return this._get(`/api/v1/channels/${channelId}/path`);
},
editMessage(channelId, messageId, content) {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
const body = {};
if (model) body.model = model;
if (presetId) body.preset_id = presetId;
if (apiConfigId) body.provider_config_id = apiConfigId;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the regeneration request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the regeneration stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
updateCursor(channelId, activeLeafId) {
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
},
listSiblings(channelId, messageId) {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Summarize & Continue ────────────────
summarizeChannel(channelId) {
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
const body = { channel_id: channelId, content, stream: true };
if (presetId) {
body.preset_id = presetId;
// Preset resolves the model on backend; still pass model for channel metadata
if (model) body.model = model;
} else {
if (model) body.model = model;
}
if (apiConfigId) body.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the completion request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
// Also check 200 OK but HTML (proxy returning block page with 200)
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the completion stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
// ── Models ───────────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
// User presets
listUserPresets() { return this._get('/api/v1/presets'); },
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
listAllModels() { return this._get('/api/v1/models'); },
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
// ── Profile & Settings ───────────────────
async getProfile() {
const data = await this._get('/api/v1/profile');
// Sync user object with profile data (including avatar)
if (data && this.user) {
this.user.avatar = data.avatar || null;
this.user.display_name = data.display_name;
this.saveTokens();
}
return data;
},
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
getSettings() { return this._get('/api/v1/settings'); },
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
// ── Admin ────────────────────────────────
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
if (teamIds?.length) body.team_ids = teamIds;
if (teamRole) body.team_role = teamRole;
return this._put(`/api/v1/admin/users/${id}/active`, body);
},
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
getPublicSettings() { return this._get('/api/v1/settings/public'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(visibility) {
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
return this._put('/api/v1/admin/models/bulk', {
visibility,
is_enabled: visibility === 'enabled'
});
},
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── Admin Presets ────────────────────────
adminListPresets() { return this._get('/api/v1/admin/presets'); },
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
// ── Admin Audit ─────────────────────────
adminListAudit(params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/admin/audit?${q}`);
},
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
// ── Team Admin Self-Service ─────────────
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
teamListAudit(teamId, params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
},
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
// ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); },
createPreset(preset) { return this._post('/api/v1/presets', preset); },
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── Attachments ─────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created attachment object with id, metadata, etc.
*/
async uploadAttachment(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
},
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
async downloadAttachmentBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
headers: { 'Authorization': `Bearer ${this.accessToken}` },
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
}
return resp.blob();
},
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
// Vault
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── HTTP Internals ───────────────────────
_esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
};
},
async _get(path) { return this._authed(path); },
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _authed(path, method = 'GET', body) {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401 && this.refreshToken) {
if (await this.refresh()) {
return this._raw(path, method, body, true);
}
}
throw e;
}
},
async _raw(path, method = 'GET', body, auth = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
// Check if the error response is also a proxy page
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
let title = 'unknown';
try {
const html = await resp.text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* */ }
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
err.status = resp.status;
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, path);
}
};