fix(app): add dev server auth token to all API endpoints
Generate random VITE_DEV_API_TOKEN in dev.sh, validate Bearer token in shared server/dev-auth.ts middleware. Applied to all Vite plugins (fs, dev-chats, rss, web-search, tmdb, music-search) and claude-proxy. Client-side uses apiFetch() wrapper to attach the token automatically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b77c93607a
commit
cc7d9fc19e
@@ -1,5 +1,6 @@
|
||||
import type { AIAdapter, ChatMessage, ChatOptions } from './types'
|
||||
import { getApiKey } from '@/utils/key-vault'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
const BASE = import.meta.env.BASE_URL || '/'
|
||||
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
|
||||
@@ -31,7 +32,7 @@ export const claudeAdapter: AIAdapter = {
|
||||
.filter(m => m.role !== 'system')
|
||||
.map(m => ({ role: m.role, content: m.content }))
|
||||
|
||||
const res = await fetch(CLAUDE_PATH, {
|
||||
const res = await apiFetch(CLAUDE_PATH, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AIAdapter, ChatOptions } from './types'
|
||||
import { getApiKey } from '@/utils/key-vault'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
const OPENROUTER_PATH = '/api/openrouter'
|
||||
|
||||
@@ -41,7 +42,7 @@ export const openrouterAdapter: AIAdapter = {
|
||||
headers['Authorization'] = `Bearer ${vaultKey}`
|
||||
}
|
||||
|
||||
const res = await fetch(OPENROUTER_PATH, {
|
||||
const res = await apiFetch(OPENROUTER_PATH, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePersonaStore } from '@/stores/personas'
|
||||
import { useMemoryStore } from '@/stores/memory'
|
||||
import { useArchy } from '@/composables/useArchy'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||
|
||||
@@ -47,7 +48,7 @@ async function refreshWavlakeCatalog() {
|
||||
if (Date.now() - wavlakeFetchedAt < WAVLAKE_REFRESH_INTERVAL && wavlakeCatalog.value.length > 0) return
|
||||
try {
|
||||
const BASE = import.meta.env.BASE_URL || '/'
|
||||
const res = await fetch(`${BASE}api/music/rankings?days=30&limit=40`)
|
||||
const res = await apiFetch(`${BASE}api/music/rankings?days=30&limit=40`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data)) {
|
||||
@@ -236,7 +237,7 @@ async function streamClaude(
|
||||
if (params?.topP !== undefined) body.top_p = params.topP
|
||||
if (params?.stopSequences && params.stopSequences.length > 0) body.stop_sequences = params.stopSequences
|
||||
|
||||
const res = await fetch(CLAUDE_PATH, {
|
||||
const res = await apiFetch(CLAUDE_PATH, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
@@ -285,7 +286,7 @@ async function streamOpenRouter(
|
||||
headers['Authorization'] = `Bearer ${vaultKey}`
|
||||
}
|
||||
|
||||
const res = await fetch(OPENROUTER_PATH, {
|
||||
const res = await apiFetch(OPENROUTER_PATH, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
@@ -460,7 +461,7 @@ async function generateAutoTitle(conversationId: string) {
|
||||
const vaultKey = await getApiKey('claude')
|
||||
if (vaultKey) headers['x-api-key'] = vaultKey
|
||||
|
||||
const res = await fetch(CLAUDE_PATH, {
|
||||
const res = await apiFetch(CLAUDE_PATH, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref, computed, shallowRef, readonly } from 'vue'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
export interface ProjectInfo {
|
||||
name: string
|
||||
@@ -65,7 +66,7 @@ export function useCodeContext() {
|
||||
// In dev/demo mode, scan the Projects folder
|
||||
// This would be replaced by Archy integration later
|
||||
try {
|
||||
const response = await fetch(`/api/fs/list?path=${encodeURIComponent(PROJECTS_ROOT)}`)
|
||||
const response = await apiFetch(`/api/fs/list?path=${encodeURIComponent(PROJECTS_ROOT)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
projectList.value = data.projects ?? []
|
||||
@@ -153,7 +154,7 @@ export function useCodeContext() {
|
||||
|
||||
async function loadFileTree(projectPath: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/fs/tree?path=${encodeURIComponent(projectPath)}`)
|
||||
const response = await apiFetch(`/api/fs/tree?path=${encodeURIComponent(projectPath)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
fileTree.value = data.files ?? []
|
||||
@@ -188,7 +189,7 @@ export function useCodeContext() {
|
||||
const fullPath = activeProject.value
|
||||
? `${activeProject.value.path}/${filePath}`
|
||||
: filePath
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(fullPath)}`)
|
||||
const response = await apiFetch(`/api/fs/read?path=${encodeURIComponent(fullPath)}`)
|
||||
if (response.status === 413) {
|
||||
fileError.value = 'File too large to preview (max 1MB)'
|
||||
activeFileContent.value = ''
|
||||
@@ -233,7 +234,7 @@ export function useCodeContext() {
|
||||
const projectPath = `${PROJECTS_ROOT}/${safeName}`
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/fs/mkdir', {
|
||||
const res = await apiFetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: projectPath }),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
type TmdbResult = { posterUrl: string | null; backdropUrl: string | null }
|
||||
const memoryCache = new Map<string, TmdbResult>()
|
||||
const SESSION_KEY = 'aiui-poster-cache'
|
||||
@@ -187,7 +189,7 @@ async function fetchTmdbGeneric(
|
||||
try {
|
||||
const params = new URLSearchParams({ q: title.trim() })
|
||||
if (year && year > 0) params.set('y', String(year))
|
||||
const res = await fetch(`/api/tmdb/${endpoint}?${params}`)
|
||||
const res = await apiFetch(`/api/tmdb/${endpoint}?${params}`)
|
||||
if (!res.ok) return empty
|
||||
const data = (await res.json()) as { posterUrl?: string | null; backdropUrl?: string | null }
|
||||
const result: TmdbResult = {
|
||||
@@ -391,7 +393,7 @@ export async function fetchMusicCover(
|
||||
try {
|
||||
const base = import.meta.env.BASE_URL || '/'
|
||||
const params = new URLSearchParams({ q: title, title, artist })
|
||||
const wlRes = await fetch(`${base}api/music/search?${params}`)
|
||||
const wlRes = await apiFetch(`${base}api/music/search?${params}`)
|
||||
if (wlRes.ok) {
|
||||
const wlData = (await wlRes.json()) as { coverUrl?: string }
|
||||
if (wlData.coverUrl) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, shallowRef, computed, watch } from 'vue'
|
||||
import type { Song } from '@aiui/core/types/content'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
interface MusicSearchResult {
|
||||
source: 'wavlake'
|
||||
@@ -75,7 +76,7 @@ export function usePlayer() {
|
||||
if (title) params.set('title', title)
|
||||
if (artist) params.set('artist', artist)
|
||||
const base = import.meta.env.BASE_URL || '/'
|
||||
const res = await fetch(`${base}api/music/search?${params}`, {
|
||||
const res = await apiFetch(`${base}api/music/search?${params}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
export async function fetchRssFromUrls(urls: string[]): Promise<WebSearchResult[]> {
|
||||
const safe = urls.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u.trim())).slice(0, 8)
|
||||
@@ -7,7 +8,7 @@ export async function fetchRssFromUrls(urls: string[]): Promise<WebSearchResult[
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
safe.forEach((u) => params.append('url', u))
|
||||
const res = await fetch(`/api/rss-articles?${params}`, { signal: AbortSignal.timeout(15000) })
|
||||
const res = await apiFetch(`/api/rss-articles?${params}`, { signal: AbortSignal.timeout(15000) })
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { articles?: Array<{ title?: string; url?: string; content?: string; imgSrc?: string }> }
|
||||
const articles = data.articles ?? []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import type { FavoriteType } from '@/stores/favorites'
|
||||
import { getApiKey } from '@/utils/key-vault'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
const CACHE_KEY = 'aiui-similar-content'
|
||||
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
@@ -57,7 +58,7 @@ export function useSimilarContent() {
|
||||
const prompt = `List exactly 3 ${typeLabel}s similar to "${title}". For each, respond with ONLY a JSON array like: [{"title":"Name","reason":"one sentence why"}]. No other text.`
|
||||
|
||||
const base = import.meta.env.BASE_URL || '/'
|
||||
const res = await fetch(`${base}api/claude/v1/messages`, {
|
||||
const res = await apiFetch(`${base}api/claude/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
export interface WebSearchResult {
|
||||
title: string
|
||||
url: string
|
||||
@@ -9,7 +11,7 @@ export async function searchWeb(query: string): Promise<WebSearchResult[]> {
|
||||
if (!query.trim()) return []
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim() })
|
||||
const res = await fetch(`/api/web-search?${params}`, { signal: AbortSignal.timeout(10000) })
|
||||
const res = await apiFetch(`/api/web-search?${params}`, { signal: AbortSignal.timeout(10000) })
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
console.warn('[AIUI web-search]', res.status, body)
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import FileTree from '@/components/browse/FileTree.vue'
|
||||
import FilePreview from '@/components/browse/FilePreview.vue'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
interface FileEntry {
|
||||
name: string
|
||||
@@ -127,7 +128,7 @@ async function loadProjects() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/fs/list')
|
||||
const res = await apiFetch('/api/fs/list')
|
||||
if (!res.ok) throw new Error(`Failed to load: ${res.status}`)
|
||||
const data = await res.json()
|
||||
projects.value = data.projects ?? []
|
||||
@@ -144,7 +145,7 @@ async function openProject(project: Project) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch(`/api/fs/tree?path=${encodeURIComponent(project.path)}`)
|
||||
const res = await apiFetch(`/api/fs/tree?path=${encodeURIComponent(project.path)}`)
|
||||
if (!res.ok) throw new Error(`Failed to load: ${res.status}`)
|
||||
const data = await res.json()
|
||||
treeItems.value = data.files ?? []
|
||||
@@ -165,7 +166,7 @@ async function openFile(entry: FileEntry) {
|
||||
if (!currentProject.value) return
|
||||
const absolutePath = currentProject.value.path + '/' + entry.path
|
||||
try {
|
||||
const res = await fetch(`/api/fs/read?path=${encodeURIComponent(absolutePath)}`)
|
||||
const res = await apiFetch(`/api/fs/read?path=${encodeURIComponent(absolutePath)}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) {
|
||||
error.value = 'File too large to preview (max 1MB)'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Message, Conversation, WebSearchResult } from '@aiui/core/types/message'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
@@ -32,7 +33,7 @@ async function loadServerChats(): Promise<{ conversations: Map<string, Conversat
|
||||
const empty = { conversations: new Map<string, Conversation>(), activeId: null }
|
||||
if (!isDev) return empty
|
||||
try {
|
||||
const res = await fetch('/api/dev-chats')
|
||||
const res = await apiFetch('/api/dev-chats')
|
||||
if (!res.ok) return empty
|
||||
const data = await res.json() as {
|
||||
conversations?: Record<string, Conversation>
|
||||
@@ -54,7 +55,7 @@ function saveServerChats(conversations: Map<string, Conversation>, activeId: str
|
||||
if (devSaveTimer) clearTimeout(devSaveTimer)
|
||||
devSaveTimer = setTimeout(() => {
|
||||
const obj = Object.fromEntries(conversations)
|
||||
fetch('/api/dev-chats', {
|
||||
apiFetch('/api/dev-chats', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ conversations: obj, activeConversationId: activeId }),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Authenticated fetch wrapper for dev API endpoints.
|
||||
* Adds Authorization: Bearer <token> header when VITE_DEV_API_TOKEN is set.
|
||||
*/
|
||||
const DEV_TOKEN = import.meta.env.VITE_DEV_API_TOKEN as string | undefined
|
||||
|
||||
export function apiFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
if (DEV_TOKEN) {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Authorization', `Bearer ${DEV_TOKEN}`)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
return fetch(url, init)
|
||||
}
|
||||
Reference in New Issue
Block a user