Compare commits

..

21 Commits

Author SHA1 Message Date
diegosouzapw 6b34a39f6e chore(release): bump version to v1.0.10 and update changelog 2026-02-21 12:35:37 -03:00
diegosouzapw f0513683e5 fix(qwen): extract email from id_token to allow multiple accounts 2026-02-21 12:22:56 -03:00
diegosouzapw b3e53ca250 chore(release): bump version to v1.0.9 and update changelog 2026-02-21 10:00:53 -03:00
diegosouzapw 7a3b21eff8 fix(settings): add blockedProviders and requireAuthForModels to Zod schema 2026-02-21 09:49:15 -03:00
diegosouzapw f8c8501036 chore(release): bump version to v1.0.8 2026-02-21 09:31:10 -03:00
Diego Rodrigues de Sa e Souza 4e1b5a5253 Merge pull request #102 from diegosouzapw/feat/issue-fixes-and-security
Release v1.0.8
2026-02-21 09:30:54 -03:00
diegosouzapw 1aa27ceefe chore(workflows): move agent workflows to global dir 2026-02-21 09:11:14 -03:00
diegosouzapw 340dcf9515 feat(core): api key protection, provider blocking, windows fix, ui and docs 2026-02-21 09:05:59 -03:00
Adarsh 1bd5d552c1 fix: extract email from id_token for Codex OAuth to allow multiple accounts (#97)
- Parse JWT id_token to extract email in mapTokens function
- Allows database to distinguish between different Codex accounts
- Fixes issue where adding 3rd+ Codex account would update existing connection instead of creating new one
2026-02-21 07:00:37 -03:00
Adarsh 862d94b859 Fix/auto generate jwt secret (#94)
* fix: auto-generate JWT_SECRET at startup if not provided
- Add ensureJwtSecret() in instrumentation.ts to generate random 64-char secret
- Mark JWT_SECRET as non-required in secretsValidator
- Remove fatal error log for missing JWT_SECRET in proxy.ts
Fixes login failure when running via CLI without JWT_SECRET env var.

* feat: improve auth flow and login page UX
- Add setupComplete to settings validation schema for proper persistence
- Support ?tab= query param in settings page for direct tab navigation
- Redesign login page with professional two-column layout
- Add context-aware states for onboarding/password setup flows
- Smooth animations and refined visual hierarchy

* fix: 404 page to use primary color theme (coral red) instead of purple
2026-02-21 06:55:40 -03:00
diegosouzapw be36d9c452 Merge fix/issues-89-90-91-v1.0.7: resolve #89 #90 #91, bump v1.0.7 2026-02-20 11:51:19 -03:00
diegosouzapw 5ce95dd829 fix: resolve issues #89 #90 #91 — stream default, custom models, OAuth redirect
- fix(chatCore): stream defaults to false per OpenAI spec (body.stream === true)
  closes #89
- fix(models): custom OpenAI-compatible providers now appear in /v1/models
  Added raw providerId check against activeAliases
  closes #90
- fix(OAuthModal): Google OAuth providers force localhost redirect URI
  Targeted amber warning + redirect_uri_mismatch error guidance for remote deployments
  closes #91
- docs: Add 'OAuth em Servidor Remoto' tutorial in README with 7-step Google Cloud guide
- docs: .env.example prominent warning block for Google OAuth remote setup
- chore: bump version 1.0.6 → 1.0.7
- docs: CHANGELOG entry for v1.0.7
2026-02-20 11:50:50 -03:00
diegosouzapw d0fa01cc0a fix: increase JWT session to 30 days + auto-refresh in middleware
JWT session tokens now last 30 days instead of 24 hours. The middleware
silently refreshes the token when it has less than 7 days remaining,
so active users never get locked out of the dashboard.

Auto-refresh fires on any /dashboard request and sets a fresh 30-day
cookie via Set-Cookie header — completely transparent to the user.
2026-02-20 07:02:18 -03:00
diegosouzapw c69ad118d1 release: v1.0.6 — provider/combo toggles, strict model filtering 2026-02-20 02:59:35 -03:00
diegosouzapw 0deeaf5ffb feat: combo enable/disable toggle + provider toggle always visible
1. Combos page: added Toggle to each ComboCard — disable/enable combos
   with optimistic UI update. Disabled combos show opacity-50.
2. /v1/models: disabled combos (isActive=false) are excluded from the
   model list.
3. Providers page: toggle is now always visible instead of hover-only.
2026-02-20 02:53:14 -03:00
diegosouzapw 87c7c92ddd fix: /v1/models only shows models from configured providers
Previously, if no connections existed in the database, ALL provider
models (378+) were shown as a fallback. Now only combos and custom
models appear when no provider connections are configured.

Models are strictly filtered by activeAliases — only providers with
at least one active (enabled) connection show their models. This
ensures /v1/models reflects exactly what the user has set up.
2026-02-20 02:44:38 -03:00
diegosouzapw f12b90f373 fix: /v1/models respects disabled providers — track total connections before filter
Root cause: after filtering disabled connections (isActive=false), the
connections array could be empty. The fallback 'connections.length > 0'
then treated it as 'no connections configured' and showed ALL models.

Fix: Track totalConnectionCount before filtering. Use it in all filter
conditions so disabled providers are properly excluded even when every
connection is toggled off.
2026-02-20 02:35:10 -03:00
diegosouzapw 4e37027e1f feat: add enable/disable toggle to provider cards on main page
Toggle appears on hover next to the chevron. Clicking it batch-toggles
all connections for that provider (enable/disable). Uses optimistic UI
update with parallel API calls. stopPropagation prevents navigation.
2026-02-20 02:22:47 -03:00
diegosouzapw 245345f37d fix: treat private/LAN IPs as localhost for OAuth redirect URI
Google OAuth rejects private IPs (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
with 'device_id and device_name are required' error. Now these are treated
the same as localhost, using http://localhost:{port}/callback as redirect URI.
2026-02-20 02:13:07 -03:00
diegosouzapw 99be29ce8e docs: update READMEs + CHANGELOG to v1.0.5 2026-02-20 01:50:06 -03:00
diegosouzapw 6aaaaccb12 fix: set DATA_DIR=/app/data in Dockerfile and docker-compose.yml
Ensures the Docker volume mount at /app/data matches the app data directory.
Previously DATA_DIR was not set, causing the app to default to ~/.omniroute/
which is not backed by the volume — leading to empty database on redeploys.
2026-02-20 01:47:22 -03:00
34 changed files with 966 additions and 251 deletions
-54
View File
@@ -1,54 +0,0 @@
---
description: Git workflow — NEVER commit directly to main. Always use feature branches.
---
# Git Workflow
## ⚠️ CRITICAL RULE: NEVER commit directly to `main`
## Steps
1. **Before starting any work**, create a feature branch from `main`:
```bash
git checkout main && git pull origin main
git checkout -b feature/<feature-name>
```
2. **During development**, commit to the feature branch:
```bash
git add -A && git commit -m "<type>(<scope>): <description>"
```
3. **Before pushing**, verify the build passes:
```bash
npm run build
```
4. **When the feature is complete and verified**, push the branch and STOP:
```bash
git push origin feature/<feature-name>
```
5. **DO NOT** create a PR, merge, or push to `main`. Let the user handle that.
## Branch naming convention
- `feature/<name>` — new features
- `fix/<name>` — bugfixes
- `refactor/<name>` — refactoring
- `docker/<name>` — Docker / infrastructure changes
- `style/<name>` — UI / CSS changes
## Commit types
- `feat` — new feature
- `fix` — bugfix
- `refactor` — code refactoring
- `style` — UI / CSS changes
- `docker` — Docker / infrastructure
- `docs` — documentation
- `chore` — maintenance
+32 -4
View File
@@ -82,17 +82,45 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Provider OAuth Credentials (optional — override hardcoded defaults)
# These can also be set via data/provider-credentials.json
# CLAUDE_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_ID=
GEMINI_OAUTH_CLIENT_SECRET=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
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) — IMPORTANT FOR REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
# The built-in Google OAuth credentials ONLY work when OmniRoute runs on
# localhost (127.0.0.1 / local network). They are registered with
# redirect_uri = http://localhost:PORT/callback and Google will reject any
# other redirect URI with: redirect_uri_mismatch.
#
# If you are hosting OmniRoute on a remote server (VPS, Docker, cloud), you
# MUST register your own Google Cloud OAuth 2.0 credentials:
#
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: "Web application")
# 3. Add your server URL as Authorized redirect URI:
# https://your-server.com/callback
# 4. Copy the Client ID and Client Secret below.
#
# See the full tutorial in README.md → "OAuth em Servidor Remoto" section.
#
# Antigravity (Google Gemini Code Assist):
# ANTIGRAVITY_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
ANTIGRAVITY_OAUTH_CLIENT_SECRET=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
# Gemini CLI (Google AI):
# GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
# GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
# GEMINI_CLI_OAUTH_CLIENT_ID=
GEMINI_OAUTH_CLIENT_SECRET=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
GEMINI_CLI_OAUTH_CLIENT_SECRET=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
# ─────────────────────────────────────────────────────────────────────────────
# CLAUDE_OAUTH_CLIENT_ID=
# CODEX_OAUTH_CLIENT_ID=
# CODEX_OAUTH_CLIENT_SECRET=
# QWEN_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_ID=
IFLOW_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
# ANTIGRAVITY_OAUTH_CLIENT_ID=
ANTIGRAVITY_OAUTH_CLIENT_SECRET=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
# API Key Providers (Phase 1 + Phase 4)
# Add via Dashboard → Providers → Add API Key, or set here
+106
View File
@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.0.10] — 2026-02-21
> ### 🐛 Bugfix — Multi-Account Support for Qwen
>
> Solves the issue where adding a second Qwen account would overwrite the first one.
### 🐛 Bug Fixes
- **OAuth Accounts** — Extracted user email from the `id_token` using JWT decoding for Qwen and similar providers, allowing multiple accounts of the same provider to be authenticated simultaneously instead of triggering the fallback overwrite logic ([#99](https://github.com/diegosouzapw/OmniRoute/issues/99))
---
## [1.0.9] — 2026-02-21
> ### 🐛 Hotfix — Settings Persistence
>
> Fixes blocked providers and API auth toggle not being saved after page reload.
### 🐛 Bug Fixes
- **Settings Persistence** — Added `requireAuthForModels` (boolean) and `blockedProviders` (string array) to the Zod validation schema, which was silently stripping these fields during PATCH requests, preventing them from being saved to the database
---
## [1.0.8] — 2026-02-21
> ### 🔒 API Security & Windows Support
>
> Adds API Endpoint Protection for `/models`, Windows server startup fixes, and UI improvements.
### ✨ New Features
- **API Endpoint Protection (`/models`)** — New Security Tab settings to optionally require an API key for the `/v1/models` endpoint (returns 404 when unauthorized) and to selectively block specific providers from appearing in the models list ([#100](https://github.com/diegosouzapw/OmniRoute/issues/100), [#96](https://github.com/diegosouzapw/OmniRoute/issues/96))
- **Interactive Provider UI** — Blocked Providers setting features an interactive chip selector with visual badges for all available AI providers
### 🐛 Bug Fixes
- **Windows Server Startup** — Fixed `ERR_INVALID_FILE_URL_PATH` crash on Windows by safely wrapping `import.meta.url` resolution with a fallback to `process.cwd()` for globally installed npm packages ([#98](https://github.com/diegosouzapw/OmniRoute/issues/98))
- **Combo buttons visibility** — Fixed layout overlap and tight spacing for the Quick Action buttons (Clone / Delete / Test) on the Combos page on narrower screens ([#95](https://github.com/diegosouzapw/OmniRoute/issues/95))
---
## [1.0.7] — 2026-02-20
> ### 🐛 Bugfix Release — OpenAI Compatibility, Custom Models & OAuth UX
>
> Fixes three community-reported issues: stream default now follows OpenAI spec, custom OpenAI-compatible providers appear in `/v1/models`, and Google OAuth shows a clear error + tutorial for remote deployments.
### 🐛 Bug Fixes
- **`stream` defaults to `false`** — Aligns with the OpenAI specification which explicitly states `stream` defaults to `false`. Previously OmniRoute defaulted to `true`, causing SSE data to be returned instead of a JSON object, breaking clients like Spacebot, OpenCode, and standard Python/Rust/Go OpenAI SDKs that don't explicitly set `stream: true` ([#89](https://github.com/diegosouzapw/OmniRoute/issues/89))
- **Custom AI providers now appear in `/v1/models`** — OpenAI-compatible custom providers (e.g. FriendLI) whose provider ID wasn't in the built-in alias map were silently excluded from the models list even when active. Fixed by also checking the raw provider ID from the database against active connections ([#90](https://github.com/diegosouzapw/OmniRoute/issues/90))
- **OAuth `redirect_uri_mismatch` — improved UX for remote deployments** — Google OAuth providers (Antigravity, Gemini CLI) now always use `localhost` as redirect URI matching the registered credentials. Remote-access users see a targeted amber warning with a link to the new setup guide. The token exchange error message explains the root cause and guides users to configure their own credentials ([#91](https://github.com/diegosouzapw/OmniRoute/issues/91))
### 📖 Documentation
- **OAuth em Servidor Remoto tutorial** — New README section with step-by-step guide to configure custom Google Cloud OAuth 2.0 credentials for remote/VPS/Docker deployments
- **`.env.example` Google OAuth block** — Added prominent warning block explaining remote credential requirements with direct links to Google Cloud Console
### 📁 Files Modified
| File | Change |
| -------------------------------------- | ------------------------------------------------------------------------------------------- |
| `open-sse/handlers/chatCore.ts` | `stream` defaults to `false` (was `true`) per OpenAI spec |
| `src/app/api/v1/models/route.ts` | Added raw `providerId` check for custom models active-provider filter |
| `src/shared/components/OAuthModal.tsx` | Force `localhost` redirect for Google OAuth; improved `redirect_uri_mismatch` error message |
| `.env.example` | Added ⚠️ Google OAuth remote credentials block with step-by-step instructions |
| `README.md` | New "🔐 OAuth em Servidor Remoto" tutorial section |
---
## [1.0.6] — 2026-02-20
> ### ✨ Provider & Combo Toggles — Strict Model Filtering
>
> `/v1/models` now shows only models from providers with active connections. Combos and providers can be toggled on/off directly from the dashboard.
### ✨ New Features
- **Provider toggle on Providers page** — Enable/disable all connections for a provider directly from the main Providers list. Toggle is always visible, no hover needed
- **Combo enable/disable toggle** — Each combo on the Combos page now has a toggle. Disabled combos are excluded from `/v1/models`
- **OAuth private IP support** — Expanded localhost detection to include private/LAN IPs (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`) for correct OAuth redirect URIs
### 🐛 Bug Fixes
- **`/v1/models` strict filtering** — Models are now shown only from providers with active, enabled connections. Previously, if no connections existed or all were disabled, all 378+ models were shown as a fallback
- **Disabled provider models hidden** — Toggling off a provider immediately removes its models from `/v1/models`
---
## [1.0.5] — 2026-02-20
> ### 🐛 Hotfix — Model Filtering & Docker DATA_DIR
>
> Filters all model types in `/v1/models` by active providers and fixes Docker data directory mismatch.
### 🐛 Bug Fixes
- **`/v1/models` full filtering** — Embedding, image, rerank, audio, and moderation models are now filtered by active provider connections, matching chat model behavior. Providers like Together AI no longer appear without a configured API key (#88)
- **Docker `DATA_DIR`** — Added `ENV DATA_DIR=/app/data` to Dockerfile and `docker-compose.yml` ensuring the volume mount always matches the app data directory — prevents empty database on container recreation
---
## [1.0.4] — 2026-02-19
> ### 🔧 Provider Filtering, OAuth Proxy Fix & Documentation
@@ -321,6 +424,9 @@ New environment variables:
---
[1.0.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.7
[1.0.6]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.6
[1.0.5]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.5
[1.0.4]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.4
[1.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.0
[1.0.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.3
+2 -1
View File
@@ -20,7 +20,8 @@ ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
# Runtime writable location for localDb when DATA_DIR is configured to /app/data
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
+4 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| Image | Tag | Größe | Beschreibung |
| ------------------------ | -------- | ------ | ------------------------ |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Letztes stabiles Release |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Aktuelle Version |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Aktuelle Version |
---
@@ -893,7 +893,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
**Verbindungstest zeigt „Invalid" für OpenAI-kompatible Anbieter**
- Viele Anbieter stellen den `/models` Endpoint nicht bereit
- OmniRoute v1.0.4+ enthält Fallback-Validierung via Chat Completions
- OmniRoute v1.0.6+ enthält Fallback-Validierung via Chat Completions
- Stelle sicher, dass die Base URL den `/v1` Suffix enthält
</details>
@@ -903,7 +903,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
## 🛠️ Technologie-Stack
- **Runtime**: Node.js 20+
- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.4)
- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Datenbank**: LowDB (JSON) + SQLite (Domain-Status + Proxy-Logs)
- **Streaming**: Server-Sent Events (SSE)
@@ -962,7 +962,7 @@ Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für detaillierte Richtlinien.
```bash
# Release erstellen — npm-Veröffentlichung erfolgt automatisch
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+4 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| Imagen | Tag | Tamaño | Descripción |
| ------------------------ | -------- | ------ | ---------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versión estable |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Versión actual |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Versión actual |
---
@@ -893,7 +893,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
**Prueba de conexión muestra "Invalid" para proveedores compatibles con OpenAI**
- Muchos proveedores no exponen el endpoint `/models`
- OmniRoute v1.0.4+ incluye validación vía chat completions como fallback
- OmniRoute v1.0.6+ incluye validación vía chat completions como fallback
- Asegúrate de que la URL base incluya el sufijo `/v1`
</details>
@@ -903,7 +903,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
## 🛠️ Stack Tecnológico
- **Runtime**: Node.js 20+
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.4)
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Base de Datos**: LowDB (JSON) + SQLite (estado del dominio + logs de proxy)
- **Streaming**: Server-Sent Events (SSE)
@@ -961,7 +961,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para directrices detalladas.
```bash
# Crea un release — la publicación en npm ocurre automáticamente
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+4 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| Image | Tag | Taille | Description |
| ------------------------ | -------- | ------ | ----------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Dernière version stable |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Version actuelle |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Version actuelle |
---
@@ -893,7 +893,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
**Le test de connexion affiche « Invalid » pour les fournisseurs compatibles OpenAI**
- Beaucoup de fournisseurs n'exposent pas le point de terminaison `/models`
- OmniRoute v1.0.4+ inclut une validation de secours via chat completions
- OmniRoute v1.0.6+ inclut une validation de secours via chat completions
- Assurez-vous que l'URL de base inclut le suffixe `/v1`
</details>
@@ -903,7 +903,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
## 🛠️ Stack technologique
- **Runtime** : Node.js 20+
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.4)
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.6)
- **Framework** : Next.js 16 + React 19 + Tailwind CSS 4
- **Base de données** : LowDB (JSON) + SQLite (état du domaine + logs proxy)
- **Streaming** : Server-Sent Events (SSE)
@@ -961,7 +961,7 @@ Consultez [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives détaillées.
```bash
# Créer un release — la publication npm est automatique
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+4 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| Immagine | Tag | Dimensione | Descrizione |
| ------------------------ | -------- | ---------- | ----------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Ultima versione stabile |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Versione attuale |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Versione attuale |
---
@@ -893,7 +893,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
**Il test di connessione mostra "Invalid" per provider compatibili OpenAI**
- Molti provider non espongono l'endpoint `/models`
- OmniRoute v1.0.4+ include validazione fallback tramite chat completions
- OmniRoute v1.0.6+ include validazione fallback tramite chat completions
- Assicurati che la URL base includa il suffisso `/v1`
</details>
@@ -903,7 +903,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
## 🛠️ Stack Tecnologico
- **Runtime**: Node.js 20+
- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.4)
- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Database**: LowDB (JSON) + SQLite (stato dominio + log proxy)
- **Streaming**: Server-Sent Events (SSE)
@@ -962,7 +962,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida dettagliate.
```bash
# Crea un rilascio — la pubblicazione npm avviene automaticamente
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+97 -3
View File
@@ -373,6 +373,7 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📊 **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level |
| 🛡 **API Endpoint Protection** | Auth gating + provider blocking for the `/models` endpoint |
### 📊 Observability & Analytics
@@ -858,6 +859,99 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
---
## 🔐 OAuth em Servidor Remoto (Remote OAuth Setup)
<a name="oauth-em-servidor-remoto"></a>
> **⚠️ IMPORTANTE para usuários com OmniRoute em VPS/Docker/servidor remoto**
### Por que o OAuth do Antigravity / Gemini CLI falha em servidores remotos?
Os provedores **Antigravity** e **Gemini CLI** usam **Google OAuth 2.0** para autenticação. O Google exige que a `redirect_uri` usada no fluxo OAuth seja **exatamente** uma das URIs pré-cadastradas no Google Cloud Console do aplicativo.
As credenciais OAuth embutidas no OmniRoute estão cadastradas **apenas para `localhost`**. Quando você acessa o OmniRoute em um servidor remoto (ex: `https://omniroute.meuservidor.com`), o Google rejeita a autenticação com:
```
Error 400: redirect_uri_mismatch
```
### Solução: Configure suas próprias credenciais OAuth
Você precisa criar um **OAuth 2.0 Client ID** no Google Cloud Console com a URI do seu servidor.
#### Passo a passo
**1. Acesse o Google Cloud Console**
Abra: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
**2. Crie um novo OAuth 2.0 Client ID**
- Clique em **"+ Create Credentials"** → **"OAuth client ID"**
- Tipo de aplicativo: **"Web application"**
- Nome: escolha qualquer nome (ex: `OmniRoute Remote`)
**3. Adicione as Authorized Redirect URIs**
No campo **"Authorized redirect URIs"**, adicione:
```
https://seu-servidor.com/callback
```
> Substitua `seu-servidor.com` pelo domínio ou IP do seu servidor (inclua a porta se necessário, ex: `http://45.33.32.156:20128/callback`).
**4. Salve e copie as credenciais**
Após criar, o Google mostrará o **Client ID** e o **Client Secret**.
**5. Configure as variáveis de ambiente**
No seu `.env` (ou nas variáveis de ambiente do Docker):
```bash
# Para Antigravity:
ANTIGRAVITY_OAUTH_CLIENT_ID=seu-client-id.apps.googleusercontent.com
ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
# Para Gemini CLI:
GEMINI_OAUTH_CLIENT_ID=seu-client-id.apps.googleusercontent.com
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
```
**6. Reinicie o OmniRoute**
```bash
# Se usando npm:
npm run dev
# Se usando Docker:
docker restart omniroute
```
**7. Tente conectar novamente**
Dashboard → Providers → Antigravity (ou Gemini CLI) → OAuth
Agora o Google redirecionará corretamente para `https://seu-servidor.com/callback` e a autenticação funcionará.
---
### Workaround temporário (sem configurar credenciais próprias)
Se não quiser criar credenciais próprias agora, ainda é possível usar o fluxo **manual de URL**:
1. O OmniRoute abrirá a URL de autorização do Google
2. Após você autorizar, o Google tentará redirecionar para `localhost` (que falha no servidor remoto)
3. **Copie a URL completa** da barra de endereço do seu browser (mesmo que a página não carregue)
4. Cole essa URL no campo que aparece no modal de conexão do OmniRoute
5. Clique em **"Connect"**
> Este workaround funciona porque o código de autorização na URL é válido independente do redirect ter carregado ou não.
---
## 🐛 Troubleshooting
<details>
@@ -906,7 +1000,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
**Connection test shows "Invalid" for OpenAI-compatible providers**
- Many providers don't expose a `/models` endpoint
- OmniRoute v1.0.4+ includes fallback validation via chat completions
- OmniRoute v1.0.6+ includes fallback validation via chat completions
- Ensure base URL includes `/v1` suffix
</details>
@@ -916,7 +1010,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
## 🛠️ Tech Stack
- **Runtime**: Node.js 20+
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.4)
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs)
- **Streaming**: Server-Sent Events (SSE)
@@ -1020,7 +1114,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
```bash
# Create a release — npm publish happens automatically
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+5 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| Imagem | Tag | Tamanho | Descrição |
| ------------------------ | -------- | ------- | --------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versão estável |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Versão atual |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Versão atual |
---
@@ -373,6 +373,7 @@ Acesso via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🔒 **Spoofing de Fingerprint TLS** | Bypass de detecção de bot via TLS com wreq-js |
| 🌐 **Filtragem de IP** | Allowlist/blocklist para controle de acesso à API |
| 📊 **Rate Limits Editáveis** | RPM, gap mínimo e concorrência máxima configuráveis |
| 🛡 **Proteção de Endpoint API** | Gateway de Auth + bloqueio de provedores para o endpoint `/models` |
### 📊 Observabilidade e Analytics
@@ -900,7 +901,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo:
**Teste de conexão mostra "Invalid" para provedores compatíveis com OpenAI**
- Muitos provedores não expõem endpoint `/models`
- OmniRoute v1.0.4+ inclui validação via chat completions como fallback
- OmniRoute v1.0.6+ inclui validação via chat completions como fallback
- Certifique-se de que a base URL inclui sufixo `/v1`
</details>
@@ -910,7 +911,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo:
## 🛠️ Stack Tecnológico
- **Runtime**: Node.js 20+
- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.4)
- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Banco de Dados**: LowDB (JSON) + SQLite (estado do domínio + logs de proxy)
- **Streaming**: Server-Sent Events (SSE)
@@ -1014,7 +1015,7 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes detalhadas.
```bash
# Crie um release — publicação no npm acontece automaticamente
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+4 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| Образ | Тег | Размер | Описание |
| ------------------------ | -------- | ------ | -------------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Последний стабильный релиз |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Текущая версия |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Текущая версия |
---
@@ -893,7 +893,7 @@ OmniRoute включает встроенный фреймворк оценки
**Тест подключения показывает «Invalid» для OpenAI-совместимых провайдеров**
- Многие провайдеры не предоставляют endpoint `/models`
- OmniRoute v1.0.4+ включает fallback-валидацию через chat completions
- OmniRoute v1.0.6+ включает fallback-валидацию через chat completions
- Убедитесь что base URL содержит суффикс `/v1`
</details>
@@ -903,7 +903,7 @@ OmniRoute включает встроенный фреймворк оценки
## 🛠️ Технологический стек
- **Runtime**: Node.js 20+
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.4)
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **База данных**: LowDB (JSON) + SQLite (состояние домена + proxy-логи)
- **Стриминг**: Server-Sent Events (SSE)
@@ -961,7 +961,7 @@ OmniRoute включает встроенный фреймворк оценки
```bash
# Создайте релиз — публикация в npm происходит автоматически
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+4 -4
View File
@@ -243,7 +243,7 @@ docker compose --profile cli up -d
| 镜像 | 标签 | 大小 | 描述 |
| ------------------------ | -------- | ------ | ---------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | 最新稳定版 |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | 当前版本 |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | 当前版本 |
---
@@ -893,7 +893,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
**兼容 OpenAI 的提供商连接测试显示 "Invalid"**
- 许多提供商不暴露 `/models` 端点
- OmniRoute v1.0.4+ 包含通过 chat completions 的回退验证
- OmniRoute v1.0.6+ 包含通过 chat completions 的回退验证
- 确保 base URL 包含 `/v1` 后缀
</details>
@@ -903,7 +903,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
## 🛠️ 技术栈
- **运行时**: Node.js 20+
- **语言**: TypeScript 5.9 — `src/``open-sse/`**100% TypeScript**v1.0.4
- **语言**: TypeScript 5.9 — `src/``open-sse/`**100% TypeScript**v1.0.6
- **框架**: Next.js 16 + React 19 + Tailwind CSS 4
- **数据库**: LowDB (JSON) + SQLite(领域状态 + 代理日志)
- **流式传输**: Server-Sent Events (SSE)
@@ -961,7 +961,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
```bash
# 创建发布 — npm 发布自动完成
gh release create v1.0.4 --title "v1.0.4" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+2
View File
@@ -18,6 +18,8 @@
x-common: &common
restart: unless-stopped
env_file: .env
environment:
- DATA_DIR=/app/data # Must match the volume mount below
volumes:
- omniroute-data:/app/data
healthcheck:
+1 -1
View File
@@ -46,7 +46,7 @@ Four modes for debugging API translations: **Playground** (format converter), **
## ⚙️ Settings
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security, routing, resilience, and advanced configuration.
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security (includes API endpoint protection and custom provider blocking), routing, resilience, and advanced configuration.
![Settings Dashboard](screenshots/06-settings.png)
+1 -1
View File
@@ -610,7 +610,7 @@ The settings page is organized into 5 tabs for easy navigation:
| Tab | Contents |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **Security** | Login/Password settings and IP Access Control (allowlist/blocklist) |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
+2 -2
View File
@@ -110,8 +110,8 @@ export async function handleChatCore({
const modelTargetFormat = getModelTargetFormat(alias, model);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Default to streaming unless client explicitly sets stream: false
const stream = body.stream !== false;
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
const stream = body.stream === true;
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
if (isCacheable(body, clientRawRequest?.headers)) {
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "1.0.5",
"version": "1.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "1.0.5",
"version": "1.0.6",
"license": "MIT",
"workspaces": [
"open-sse"
@@ -3840,7 +3840,7 @@
}
},
"node_modules/content-type": {
"version": "1.0.5",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "1.0.5",
"version": "1.0.10",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
+72 -41
View File
@@ -6,6 +6,7 @@ import {
Button,
Modal,
Input,
Toggle,
CardSkeleton,
ModelSelectModal,
ProxyConfigModal,
@@ -170,6 +171,25 @@ export default function CombosPage() {
}
};
const handleToggleCombo = async (combo) => {
const newActive = combo.isActive === false ? true : false;
// Optimistic update
setCombos((prev) => prev.map((c) => (c.id === combo.id ? { ...c, isActive: newActive } : c)));
try {
await fetch(`/api/combos/${combo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
});
} catch (error) {
// Revert on error
setCombos((prev) =>
prev.map((c) => (c.id === combo.id ? { ...c, isActive: !newActive } : c))
);
notify.error("Failed to toggle combo");
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
@@ -219,6 +239,7 @@ export default function CombosPage() {
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
/>
))}
</div>
@@ -287,12 +308,14 @@ function ComboCard({
testing,
onProxy,
hasProxy,
onToggle,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
const isDisabled = combo.isActive === false;
return (
<Card padding="sm" className="group">
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Icon */}
@@ -386,47 +409,55 @@ function ComboCard({
</div>
{/* Actions */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Toggle
size="sm"
checked={!isDisabled}
onChange={onToggle}
title={isDisabled ? "Enable combo" : "Disable combo"}
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
>
{testing ? "progress_activity" : "play_arrow"}
</span>
</button>
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
>
{testing ? "progress_activity" : "play_arrow"}
</span>
</button>
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>
</div>
</div>
</Card>
@@ -3,7 +3,16 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import PropTypes from "prop-types";
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
import {
Card,
CardSkeleton,
Badge,
Button,
Input,
Modal,
Select,
Toggle,
} from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import {
FREE_PROVIDERS,
@@ -148,6 +157,29 @@ export default function ProvidersPage() {
return { connected, error, total, errorCode, errorTime, allDisabled };
};
// Toggle all connections for a provider on/off
const handleToggleProvider = async (providerId: string, authType: string, newActive: boolean) => {
const providerConns = connections.filter(
(c) => c.provider === providerId && c.authType === authType
);
// Optimistically update UI
setConnections((prev) =>
prev.map((c) =>
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
)
);
// Fire API calls in parallel
await Promise.allSettled(
providerConns.map((c) =>
fetch(`/api/providers/${c.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
})
)
);
};
const handleBatchTest = async (mode, providerId = null) => {
if (testingMode) return;
setTestingMode(mode === "provider" ? providerId : mode);
@@ -237,6 +269,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, "oauth")}
authType="oauth"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
/>
))}
</div>
@@ -273,6 +306,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, "oauth")}
authType="free"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
/>
))}
</div>
@@ -310,6 +344,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, "apikey")}
authType="apikey"
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
/>
))}
</div>
@@ -373,6 +408,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
onToggle={(active) => handleToggleProvider(info.id, "apikey", active)}
/>
))}
</div>
@@ -425,7 +461,7 @@ export default function ProvidersPage() {
);
}
function ProviderCard({ providerId, provider, stats, authType }) {
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
@@ -490,9 +526,28 @@ function ProviderCard({ providerId, provider, stats, authType }) {
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
<div className="flex items-center gap-2">
{stats.total > 0 && (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(!allDisabled ? false : true);
}}
className=""
>
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
)}
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
</div>
</div>
</Card>
</Link>
@@ -517,7 +572,7 @@ ProviderCard.propTypes = {
};
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
@@ -605,9 +660,28 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
<div className="flex items-center gap-2">
{stats.total > 0 && (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(!allDisabled ? false : true);
}}
className=""
>
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
)}
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
</div>
</div>
</Card>
</Link>
@@ -2,11 +2,12 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";
export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false });
const [loading, setLoading] = useState(true);
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
@@ -37,6 +38,29 @@ export default function SecurityTab() {
}
};
const updateSetting = async (key: string, value: any) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};
const toggleBlockedProvider = (providerId: string) => {
const current: string[] = settings.blockedProviders || [];
const updated = current.includes(providerId)
? current.filter((p) => p !== providerId)
: [...current, providerId];
updateSetting("blockedProviders", updated);
};
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
@@ -70,6 +94,8 @@ export default function SecurityTab() {
}
};
const blockedProviders: string[] = settings.blockedProviders || [];
return (
<div className="flex flex-col gap-6">
<Card>
@@ -146,6 +172,92 @@ export default function SecurityTab() {
)}
</div>
</Card>
{/* API Endpoint Protection */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
api
</span>
</div>
<h3 className="text-lg font-semibold">API Endpoint Protection</h3>
</div>
<div className="flex flex-col gap-4">
{/* Require auth for /models */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require API key for /models</p>
<p className="text-sm text-text-muted">
When ON, the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
endpoint returns 404 for unauthenticated requests. Prevents model discovery by
unauthorized users.
</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
onChange={() => updateSetting("requireAuthForModels", !settings.requireAuthForModels)}
disabled={loading}
/>
</div>
{/* Blocked Providers */}
<div className="pt-4 border-t border-border/50">
<div className="mb-3">
<p className="font-medium">Blocked Providers</p>
<p className="text-sm text-text-muted">
Hide specific providers from the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
response. Blocked providers will not appear in model listings.
</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(AI_PROVIDERS).map((provider: any) => {
const isBlocked = blockedProviders.includes(provider.id);
return (
<button
key={provider.id}
onClick={() => toggleBlockedProvider(provider.id)}
disabled={loading}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isBlocked
? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`}
>
<span
className="material-symbols-outlined text-[14px]"
style={{ color: isBlocked ? undefined : provider.color }}
>
{isBlocked ? "block" : provider.icon}
</span>
{provider.name}
{isBlocked && (
<span className="material-symbols-outlined text-[12px] text-red-500">
close
</span>
)}
</button>
);
})}
</div>
{blockedProviders.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">warning</span>
{blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked
from /models
</p>
)}
</div>
</div>
</Card>
<SessionInfoCard />
<IPFilterSection />
</div>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
import SystemStorageTab from "./components/SystemStorageTab";
@@ -25,7 +26,10 @@ const tabs = [
];
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("general");
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const [userSelectedTab, setUserSelectedTab] = useState(null);
const activeTab = userSelectedTab || tabs.find((t) => t.id === tabParam)?.id || "general";
return (
<div className="max-w-2xl mx-auto">
@@ -42,7 +46,7 @@ export default function SettingsPage() {
role="tab"
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onClick={() => setUserSelectedTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all text-sm",
activeTab === tab.id
+1 -1
View File
@@ -57,7 +57,7 @@ export async function POST(request) {
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setExpirationTime("30d")
.sign(SECRET);
const cookieStore = await cookies();
+37 -15
View File
@@ -1,7 +1,8 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb";
import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
@@ -94,19 +95,39 @@ export async function OPTIONS() {
* GET /v1/models - OpenAI compatible models list
* Returns models from all active providers, combos, embeddings, and image models in OpenAI format
*/
export async function GET() {
export async function GET(request: Request) {
try {
// Issue #100: Optionally require API key for /models (security hardening)
// When enabled, unauthenticated requests get 404 to hide endpoint existence
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return new Response("Not Found", { status: 404 });
}
}
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
// Issue #96: Allow blocking specific providers from the models list
const blockedProviders: Set<string> = new Set(
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
);
// Get active provider connections
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
try {
connections = await getProviderConnections();
totalConnectionCount = connections.length;
// Filter to only active connections
connections = connections.filter((c) => c.isActive !== false);
} catch (e) {
// If database not available, return all models
console.log("Could not fetch providers, returning all models");
// If database not available, show no provider models (safe default)
console.log("Could not fetch providers, showing only combos/custom models");
}
// Get combos
@@ -129,8 +150,9 @@ export async function GET() {
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Add combos first (they appear at the top)
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false) continue;
models.push({
id: combo.name,
object: "model",
@@ -147,12 +169,11 @@ export async function GET() {
const providerId = aliasToProviderId[alias] || alias;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// If we have active providers, only include those; otherwise include all
if (
connections.length > 0 &&
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId)
) {
// Skip blocked providers (Issue #96)
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;
// Only include models from providers with active connections
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
continue;
}
@@ -186,7 +207,7 @@ export async function GET() {
// Helper: check if a provider is active (by provider id or alias)
const isProviderActive = (provider: string) => {
if (connections.length === 0) return true; // No connections configured = show all
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
return activeAliases.has(alias) || activeAliases.has(provider);
};
@@ -260,11 +281,12 @@ export async function GET() {
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
const alias = providerIdToAlias[providerId] || providerId;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Only include if provider is active (or no connections configured)
// Only include if provider is active — check alias, canonical ID, or raw providerId
// (raw check needed for OpenAI-compatible providers whose ID isn't in the alias map)
if (
connections.length > 0 &&
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId)
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId)
)
continue;
+189 -35
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Input } from "@/shared/components";
import { Button, Input } from "@/shared/components";
import { useRouter } from "next/navigation";
export default function LoginPage() {
@@ -9,9 +9,12 @@ export default function LoginPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [setupComplete, setSetupComplete] = useState(null);
const [mounted, setMounted] = useState(false);
const router = useRouter();
useEffect(() => {
setMounted(true);
async function checkAuth() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -31,13 +34,15 @@ export default function LoginPage() {
return;
}
setHasPassword(!!data.hasPassword);
setSetupComplete(!!data.setupComplete);
} else {
// Safe fallback on non-OK response to avoid infinite loading state.
setHasPassword(true);
setSetupComplete(true);
}
} catch (err) {
clearTimeout(timeoutId);
setHasPassword(true);
setSetupComplete(true);
}
}
checkAuth();
@@ -70,58 +75,207 @@ export default function LoginPage() {
}
};
// Show loading state while checking password
if (hasPassword === null) {
if (hasPassword === null || setupComplete === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-text-muted mt-4">Loading...</p>
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="w-10 h-10 border-2 border-primary/20 rounded-full"></div>
<div className="absolute inset-0 w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
<span className="text-sm text-text-muted">Loading...</span>
</div>
</div>
);
}
if (!hasPassword && !setupComplete) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
<div
className={`w-full max-w-md transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/10 mb-6">
<span className="material-symbols-outlined text-primary text-[40px]">
rocket_launch
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">Welcome</h1>
<p className="text-text-muted mt-2">
Let&apos;s get your OmniRoute instance configured
</p>
</div>
<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Run the onboarding wizard to set up your password and connect your first AI
provider.
</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/onboarding")}
>
Start Onboarding
</Button>
</div>
</div>
<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute Unified AI API Proxy
</p>
</div>
</div>
);
}
if (!hasPassword && setupComplete) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
<div
className={`w-full max-w-md transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-amber-500/10 to-amber-500/5 border border-amber-500/10 mb-6">
<span className="material-symbols-outlined text-amber-500 text-[40px]">
shield_person
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">
Secure Your Instance
</h1>
<p className="text-text-muted mt-2">Password protection is not enabled</p>
</div>
<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Set a password to protect your dashboard and secure your API endpoints from
unauthorized access.
</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/settings?tab=security")}
>
Configure Password
</Button>
</div>
</div>
<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute Unified AI API Proxy
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">OmniRoute</h1>
<p className="text-text-muted">Enter your password to access the dashboard</p>
</div>
<div className="min-h-screen flex bg-bg">
<div className="flex-1 flex items-center justify-center p-6">
<div
className={`w-full max-w-sm transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="mb-10">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center">
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
</div>
<span className="text-xl font-semibold text-text-main tracking-tight">OmniRoute</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">Sign in</h1>
<p className="text-text-muted mt-1.5">Enter your password to continue</p>
</div>
<Card>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Password</label>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">Password</label>
<Input
type="password"
placeholder="Enter password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
</div>
<Button type="submit" variant="primary" className="w-full" loading={loading}>
Login
<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
Continue
</Button>
{!hasPassword && (
<p className="text-xs text-center text-text-muted mt-2">
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
</p>
)}
<p className="text-xs text-center mt-1">
<a href="/forgot-password" className="text-primary hover:underline">
Forgot password?
</a>
</p>
</form>
</Card>
<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
Forgot your password?
</a>
</div>
</div>
</div>
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary/5 via-primary/3 to-transparent items-center justify-center p-12">
<div
className={`max-w-md transition-all duration-700 delay-200 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-text-main mb-3">Unified AI API Proxy</h2>
<p className="text-text-muted leading-relaxed">
Route requests to multiple AI providers through a single endpoint. Load balancing,
failover, and usage tracking built in.
</p>
</div>
<div className="space-y-4">
{[
{
icon: "swap_horiz",
title: "Multi-Provider",
desc: "OpenAI, Anthropic, Google, and more",
},
{
icon: "speed",
title: "Load Balancing",
desc: "Distribute requests intelligently",
},
{ icon: "analytics", title: "Usage Tracking", desc: "Monitor costs and tokens" },
].map((item) => (
<div
key={item.icon}
className="flex items-start gap-4 p-4 rounded-xl bg-surface/50 border border-border"
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary text-[20px]">
{item.icon}
</span>
</div>
<div>
<h3 className="font-medium text-text-main">{item.title}</h3>
<p className="text-sm text-text-muted">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
+5 -14
View File
@@ -1,38 +1,29 @@
"use client";
/**
* Custom Not Found Page FASE-04 Error Handling
*
* Displayed when a user navigates to a non-existent route.
*/
import Link from "next/link";
export default function NotFound() {
return (
<div
className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center"
className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main text-center"
role="main"
aria-labelledby="not-found-title"
>
<div
className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-[#6366f1] via-[#8b5cf6] to-[#a855f7] bg-clip-text text-transparent"
className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-primary to-primary-hover bg-clip-text text-transparent"
aria-hidden="true"
>
404
</div>
<h1
id="not-found-title"
className="text-2xl font-semibold mb-2"
>
<h1 id="not-found-title" className="text-2xl font-semibold mb-2">
Page not found
</h1>
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-8">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/dashboard"
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6] focus:outline-2 focus:outline-offset-2 focus:outline-[#6366f1]"
className="px-8 py-3 rounded-xl text-white text-sm font-medium no-underline transition-all duration-200 shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label="Return to dashboard"
>
Go to Dashboard
+11
View File
@@ -8,9 +8,20 @@
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
import crypto from "crypto";
function ensureJwtSecret(): void {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
const generated = crypto.randomBytes(48).toString("base64");
process.env.JWT_SECRET = generated;
console.log("[STARTUP] JWT_SECRET auto-generated (random 64-char secret)");
}
}
export async function register() {
// Only run on the server (not during build or in Edge runtime)
if (process.env.NEXT_RUNTIME === "nodejs") {
ensureJwtSecret();
// Console log file capture (must be first — before any logging occurs)
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
initConsoleInterceptor();
+20 -3
View File
@@ -14,9 +14,26 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type Database from "better-sqlite3";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
/**
* Resolve the migrations directory path safely across platforms.
* On Windows with global npm installs, `import.meta.url` may not be a valid
* `file://` URL, causing `fileURLToPath` to throw `ERR_INVALID_FILE_URL_PATH`.
*/
function resolveMigrationsDir(): string {
try {
const metaUrl = import.meta.url;
if (metaUrl && metaUrl.startsWith("file://")) {
const __filename = fileURLToPath(metaUrl);
return path.join(path.dirname(__filename), "migrations");
}
} catch {
// fileURLToPath failed (e.g. Windows global install) — use fallback
}
// Fallback: resolve relative to cwd (works for both dev and global installs)
return path.join(process.cwd(), "src", "lib", "db", "migrations");
}
const MIGRATIONS_DIR = resolveMigrationsDir();
/**
* Ensure the schema_migrations tracking table exists.
+20 -6
View File
@@ -44,10 +44,24 @@ export const codex = {
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
}),
mapTokens: (tokens) => {
// Extract email from id_token JWT to distinguish between accounts
let email = null;
if (tokens.id_token) {
try {
const payload = tokens.id_token.split(".")[1];
const decoded = JSON.parse(Buffer.from(payload, "base64").toString());
email = decoded.email || null;
} catch {
// Ignore JWT parsing errors
}
}
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
email,
};
},
};
+24 -6
View File
@@ -1,4 +1,5 @@
import { QWEN_CONFIG } from "../constants/oauth";
import { decodeJwt } from "jose";
export const qwen = {
config: QWEN_CONFIG,
@@ -45,10 +46,27 @@ export const qwen = {
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
mapTokens: (tokens) => {
let email = null;
let displayName = null;
if (tokens.id_token) {
try {
const decoded = decodeJwt(tokens.id_token);
email = decoded.email || decoded.preferred_username || null;
displayName = decoded.name || email;
} catch (e) {
// Ignore
}
}
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
idToken: tokens.id_token,
email,
displayName,
providerSpecificData: { resourceUrl: tokens.resource_url },
};
},
};
+38 -8
View File
@@ -1,17 +1,12 @@
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { jwtVerify, SignJWT } from "jose";
import { generateRequestId } from "./shared/utils/requestId";
import { getSettings } from "./lib/localDb";
import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth";
import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard";
import { isDraining } from "./lib/gracefulShutdown";
// FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured.
if (!process.env.JWT_SECRET) {
console.error("[SECURITY] JWT_SECRET is not set. Authentication will fail.");
}
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "");
export async function proxy(request) {
const { pathname } = request.nextUrl;
@@ -81,7 +76,42 @@ export async function proxy(request) {
if (token) {
try {
await jwtVerify(token, SECRET);
const { payload } = await jwtVerify(token, SECRET);
// Auto-refresh: if token expires within 7 days, issue a fresh 30-day token
const exp = payload.exp as number;
const now = Math.floor(Date.now() / 1000);
const REFRESH_WINDOW = 7 * 24 * 60 * 60; // 7 days in seconds
if (exp && exp - now < REFRESH_WINDOW) {
try {
const freshToken = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(SECRET);
// Detect secure context
const fwdProto = (request.headers.get("x-forwarded-proto") || "")
.split(",")[0]
.trim()
.toLowerCase();
const isHttps = fwdProto === "https" || request.nextUrl?.protocol === "https:";
const useSecure = process.env.AUTH_COOKIE_SECURE === "true" || isHttps;
response.cookies.set("auth_token", freshToken, {
httpOnly: true,
secure: useSecure,
sameSite: "lax",
path: "/",
});
console.log(
`[Middleware] JWT auto-refreshed for ${pathname} (was expiring in ${Math.round((exp - now) / 3600)}h)`
);
} catch (refreshErr) {
// Refresh failed — continue with existing valid token
console.error("[Middleware] JWT auto-refresh failed:", refreshErr.message);
}
}
return response;
} catch (err) {
// FASE-01: Log auth errors instead of silently redirecting
+66 -10
View File
@@ -34,18 +34,28 @@ export default function OAuthModal({
const callbackProcessedRef = useRef(false);
const flowStartedRef = useRef(false);
// Detect if running on localhost (client-side only)
// Detect if running on localhost or private/LAN IP (client-side only)
// Google OAuth rejects private IPs (192.168.x.x, 10.x.x.x, etc.) the same as localhost,
// requiring device_id/device_name. Treat them identically for redirect URI construction.
useEffect(() => {
if (typeof window !== "undefined") {
setIsLocalhost(
window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
);
const hostname = window.location.hostname;
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
setIsLocalhost(isLocal);
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
}
}, []);
// Define all useCallback hooks BEFORE the useEffects that reference them
// Google OAuth providers that only accept pre-registered localhost redirect URIs
const GOOGLE_OAUTH_PROVIDERS = ["antigravity", "gemini-cli"];
// Exchange tokens
const exchangeTokens = useCallback(
async (code, state) => {
@@ -68,10 +78,26 @@ export default function OAuthModal({
setStep("success");
onSuccess?.();
} catch (err) {
setError(err.message);
// Provide actionable guidance for redirect_uri_mismatch on Google OAuth providers
if (
err.message?.toLowerCase().includes("redirect_uri_mismatch") &&
GOOGLE_OAUTH_PROVIDERS.includes(provider)
) {
setError(
"redirect_uri_mismatch: As credenciais padrão do Google OAuth só funcionam em localhost. " +
"Para uso remoto, configure suas próprias credenciais OAuth nas variáveis de ambiente: " +
(provider === "antigravity"
? "ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET"
: "GEMINI_OAUTH_CLIENT_ID e GEMINI_OAUTH_CLIENT_SECRET") +
". Veja o README, seção 'OAuth em Servidor Remoto'."
);
} else {
setError(err.message);
}
setStep("error");
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[authData, provider, onSuccess]
);
@@ -209,15 +235,23 @@ export default function OAuthModal({
}
// Authorization code flow
// On localhost: use localhost callback so popup/BroadcastChannel/localStorage can relay code.
// On remote (behind nginx/reverse proxy): use the actual origin so the callback page loads.
// Codex (OpenAI) requires exactly http://localhost:1455/auth/callback — the registered URI.
// Redirect URI strategy:
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
// - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
// the built-in credentials. Remote users must configure their own credentials.
// - Other providers on remote: use actual origin (supports PUBLIC_URL env var)
// - Localhost: use localhost:port
let redirectUri: string;
if (provider === "codex" || provider === "openai") {
redirectUri = "http://localhost:1455/auth/callback";
} else if (GOOGLE_OAUTH_PROVIDERS.includes(provider)) {
// Google OAuth built-in credentials only accept localhost redirect URIs.
// Even in remote deployments we use localhost — user copies the callback URL manually.
const port = window.location.port || "20128";
redirectUri = `http://localhost:${port}/callback`;
} else if (!isLocalhost) {
// Behind reverse proxy: use actual origin (e.g., https://omniroute.example.com/callback)
// This ensures the OAuth provider redirects to the proxied URL where the callback page loads.
// Supports PUBLIC_URL env var override, or falls back to window.location.origin.
const publicUrl = process.env.NEXT_PUBLIC_BASE_URL;
const origin =
@@ -459,7 +493,29 @@ export default function OAuthModal({
{step === "input" && !isDeviceCode && (
<>
<div className="space-y-4">
{!isLocalhost && (
{/* Remote server info for Google OAuth providers */}
{!isLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
warning
</span>
<strong>Acesso remoto + Google OAuth:</strong> As credenciais padrão aceitam
redirect para <code>localhost</code>. Após autorizar, o browser tentará abrir
<code>localhost</code> copie essa URL completa e cole abaixo. Para uso
totalmente remoto sem esse passo manual,{" "}
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-em-servidor-remoto"
target="_blank"
rel="noreferrer"
className="underline"
>
configure suas próprias credenciais OAuth
</a>
.
</div>
)}
{/* Generic remote info for other providers */}
{!isLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
<strong>Remote access:</strong> Since you&apos;re accessing OmniRoute remotely,
+2 -2
View File
@@ -32,8 +32,8 @@ const SECRET_RULES = [
{
name: "JWT_SECRET",
minLength: 32,
required: true,
description: "JWT signing secret for dashboard authentication",
required: false,
description: "JWT signing secret for dashboard authentication (auto-generated if not set)",
generateHint: "openssl rand -base64 48",
},
{
+3
View File
@@ -69,6 +69,9 @@ export const updateSettingsSchema = z.object({
logRetentionDays: z.number().int().min(1).max(365).optional(),
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),
setupComplete: z.boolean().optional(),
requireAuthForModels: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
});
// ──── Auth Schemas ────