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
+124 -9
View File
@@ -1,9 +1,124 @@
// Self-destructing service worker: unregisters itself and clears caches
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', async () => {
const keys = await caches.keys()
await Promise.all(keys.map(k => caches.delete(k)))
const clients = await self.clients.matchAll({ type: 'window' })
for (const client of clients) client.navigate(client.url)
await self.registration.unregister()
})
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-f97094b3'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.rat89nkoims"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/api\.anthropic\.com\/.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/^https:\/\/openrouter\.ai\/.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/web-search\?.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/rss-articles\?.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/tmdb\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "tmdb-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 86400
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/image\.tmdb\.org\/.*/i, new workbox.CacheFirst({
"cacheName": "tmdb-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 604800
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/upload\.wikimedia\.org\/.*/i, new workbox.CacheFirst({
"cacheName": "wiki-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 604800
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/d12wklypp119aj\.cloudfront\.net\/image\/.*/i, new workbox.CacheFirst({
"cacheName": "wavlake-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 300,
maxAgeSeconds: 604800
})]
}), 'GET');
}));
+5
View File
@@ -9,6 +9,11 @@ APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$APP_DIR"
# Generate a dev API token if not already set
if [ -z "$VITE_DEV_API_TOKEN" ]; then
export VITE_DEV_API_TOKEN=$(openssl rand -hex 16)
fi
cleanup() {
kill 0 2>/dev/null || true
wait 2>/dev/null || true
+3
View File
@@ -3,6 +3,7 @@ import { createServer } from 'http'
import { readFileSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { validateDevAuth } from './dev-auth.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -294,6 +295,8 @@ const server = createServer((req, res) => {
return
}
if (!validateDevAuth(req, res)) return
let body = ''
req.on('data', (chunk) => { body += chunk })
req.on('end', () => {
+18
View File
@@ -0,0 +1,18 @@
/**
* Shared dev server authentication middleware.
* Validates Bearer token on all /api/* requests.
* Token is auto-generated in scripts/dev.sh and injected via VITE_DEV_API_TOKEN.
*/
import type { IncomingMessage, ServerResponse } from 'http'
const DEV_TOKEN = process.env.VITE_DEV_API_TOKEN ?? ''
/** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */
export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean {
if (!DEV_TOKEN) return true // No token configured, skip auth
const auth = req.headers.authorization
if (auth === `Bearer ${DEV_TOKEN}`) return true
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Unauthorized' }))
return false
}
+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)
}
+2
View File
@@ -1,6 +1,7 @@
import type { Plugin, Connect } from 'vite'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { validateDevAuth } from './server/dev-auth'
const CHATS_DIR = '.dev'
const CHATS_FILE = 'chats.json'
@@ -37,6 +38,7 @@ export function devChatsPlugin(): Plugin {
},
configureServer(server) {
server.middlewares.use('/api/dev-chats', (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (!validateDevAuth(req, res)) return
if (req.method === 'GET') {
const data = readChats(chatsPath)
res.setHeader('Content-Type', 'application/json')
+5
View File
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { readdirSync, statSync, readFileSync, existsSync, mkdirSync } from 'fs'
import { join, resolve, relative } from 'path'
import { validateDevAuth } from './server/dev-auth'
const PROJECTS_ROOT = '/Users/dorian/Projects'
@@ -187,18 +188,22 @@ export function fsPlugin(): Plugin {
configureServer(server) {
server.middlewares.use('/api/fs/list', (req, res, next) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
handleList(req, res)
})
server.middlewares.use('/api/fs/tree', (req, res, next) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
handleTree(req, res)
})
server.middlewares.use('/api/fs/read', (req, res, next) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
handleRead(req, res)
})
server.middlewares.use('/api/fs/mkdir', (req, res, next) => {
if (req.method !== 'POST') return next()
if (!validateDevAuth(req, res)) return
handleMkdir(req, res)
})
},
+3
View File
@@ -1,5 +1,6 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { validateDevAuth } from './server/dev-auth'
export interface MusicSearchResult {
source: 'wavlake'
@@ -220,6 +221,7 @@ async function getWavlakeRankings(
function createSearchMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
const q = url.searchParams.get('q')?.trim()
const title = url.searchParams.get('title')?.trim()
@@ -267,6 +269,7 @@ function createRankingsMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
const days = parseInt(url.searchParams.get('days') ?? '7', 10)
const genre = url.searchParams.get('genre')?.trim() || undefined
+2
View File
@@ -1,6 +1,7 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import Parser from 'rss-parser'
import { validateDevAuth } from './server/dev-auth'
export interface RssArticle {
title: string
@@ -117,6 +118,7 @@ async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
function createRssMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next()
+2
View File
@@ -1,6 +1,7 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { loadEnv } from 'vite'
import { validateDevAuth } from './server/dev-auth'
const TMDB_POSTER = 'https://image.tmdb.org/t/p/w342'
const TMDB_BACKDROP = 'https://image.tmdb.org/t/p/w780'
@@ -8,6 +9,7 @@ const TMDB_BACKDROP = 'https://image.tmdb.org/t/p/w780'
function createTmdbSearchMiddleware(tmdbKey: string | undefined, type: 'movie' | 'tv') {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
const q = url.searchParams.get('q')?.trim()
const y = url.searchParams.get('y')
+2
View File
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { loadEnv } from 'vite'
import { search as searchDuckDuckGo } from 'duck-duck-scrape'
import { validateDevAuth } from './server/dev-auth'
export interface WebSearchResult {
title: string
@@ -83,6 +84,7 @@ async function fetchFromBrave(
function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: string | undefined) {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
const q = url.searchParams.get('q')?.trim()
if (!q) {