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:
Dorian
2026-03-06 01:23:17 +00:00
co-authored by Claude Opus 4.6
parent b77c93607a
commit cc7d9fc19e
22 changed files with 214 additions and 30 deletions
+2 -1
View File
@@ -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({
+5 -4
View File
@@ -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 -1
View File
@@ -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) {
+2 -1
View File
@@ -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({
+3 -1
View File
@@ -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)
+4 -3
View File
@@ -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)'
+3 -2
View File
@@ -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 }),
+14
View File
@@ -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)
}