mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: API key gating for desktop cloud fallback + registration (#215)
* feat: API key gating for desktop cloud fallback + registration system Gate desktop cloud fallback behind WORLDMONITOR_API_KEY — desktop users need a valid key for cloud access, otherwise operate local-only (sidecar). Add email registration system via Convex DB for future key distribution. Client-side: installRuntimeFetchPatch() checks key presence before allowing cloud fallback, with secretsReady promise + 2s timeout. Server-side: origin-aware validation in sebuf gateway — desktop origins require key, web origins pass through. - Add WORLDMONITOR_API_KEY to 3-place secret system (Rust, TS, sidecar) - New "World Monitor" settings tab with key input + registration form - New api/_api-key.js server-side validation (origin-aware) - New api/register-interest.js edge function with rate limiting - Convex DB schema + mutation for email registration storage - CORS headers updated for X-WorldMonitor-Key + Authorization - E2E tests for key gate (blocked without key, allowed with key) - Deployment docs (API_KEY_DEPLOYMENT.md) + updated desktop config docs * fix: harden worldmonitor key + registration input handling * fix: show invalid WorldMonitor API key status * fix: simplify key validation, trim registration checks, add env example vars - Inline getValidKeys() in _api-key.js - Remove redundant type checks in register-interest.js - Simplify WorldMonitorTab status to present/missing - Add WORLDMONITOR_VALID_KEYS and CONVEX_URL to .env.example * feat(sidecar): integrate proto gateway bundle into desktop build The sidecar's buildRouteTable() only discovers .js files, so the proto gateway at api/[domain]/v1/[rpc].ts was invisible — all 45 sebuf RPCs returned 404 in the desktop app. Wire the existing build script into Tauri's build commands and add esbuild as an explicit devDependency.
This commit is contained in:
14
.env.example
14
.env.example
@@ -121,3 +121,17 @@ VITE_SENTRY_DSN=
|
||||
# - "flat" keeps pitch/rotation disabled (2D interaction)
|
||||
# - "3d" enables pitch/rotation interactions (default)
|
||||
VITE_MAP_INTERACTION_MODE=3d
|
||||
|
||||
|
||||
# ------ Desktop Cloud Fallback (Vercel) ------
|
||||
|
||||
# Comma-separated list of valid API keys for desktop cloud fallback.
|
||||
# Generate with: openssl rand -hex 24 | sed 's/^/wm_/'
|
||||
WORLDMONITOR_VALID_KEYS=
|
||||
|
||||
|
||||
# ------ Registration DB (Convex) ------
|
||||
|
||||
# Convex deployment URL for email registration storage.
|
||||
# Set up at: https://dashboard.convex.dev/
|
||||
CONVEX_URL=
|
||||
|
||||
@@ -9,6 +9,8 @@ export const config = { runtime: 'edge' };
|
||||
|
||||
import { createRouter } from '../../../server/router';
|
||||
import { getCorsHeaders, isDisallowedOrigin } from '../../../server/cors';
|
||||
// @ts-expect-error — JS module, no declaration file
|
||||
import { validateApiKey } from '../../_api-key.js';
|
||||
import { mapErrorToResponse } from '../../../server/error-mapper';
|
||||
import { createSeismologyServiceRoutes } from '../../../src/generated/server/worldmonitor/seismology/v1/service_server';
|
||||
import { seismologyHandler } from '../../../server/worldmonitor/seismology/v1/handler';
|
||||
@@ -92,6 +94,15 @@ export default async function handler(request: Request): Promise<Response> {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// API key validation (origin-aware)
|
||||
const keyCheck = validateApiKey(request);
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
// Route matching
|
||||
const matchedHandler = router.match(request);
|
||||
if (!matchedHandler) {
|
||||
|
||||
30
api/_api-key.js
Normal file
30
api/_api-key.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const DESKTOP_ORIGIN_PATTERNS = [
|
||||
/^https?:\/\/tauri\.localhost(:\d+)?$/,
|
||||
/^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
|
||||
/^tauri:\/\/localhost$/,
|
||||
/^asset:\/\/localhost$/,
|
||||
];
|
||||
|
||||
function isDesktopOrigin(origin) {
|
||||
return Boolean(origin) && DESKTOP_ORIGIN_PATTERNS.some(p => p.test(origin));
|
||||
}
|
||||
|
||||
export function validateApiKey(req) {
|
||||
const key = req.headers.get('X-WorldMonitor-Key');
|
||||
const origin = req.headers.get('Origin') || '';
|
||||
|
||||
if (isDesktopOrigin(origin)) {
|
||||
if (!key) return { valid: false, required: true, error: 'API key required for desktop access' };
|
||||
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
||||
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
|
||||
return { valid: true, required: true };
|
||||
}
|
||||
|
||||
if (key) {
|
||||
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
||||
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
|
||||
return { valid: true, required: true };
|
||||
}
|
||||
|
||||
return { valid: false, required: false };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function getCorsHeaders(req, methods = 'GET, OPTIONS') {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': methods,
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Vary': 'Origin',
|
||||
};
|
||||
|
||||
97
api/register-interest.js
Normal file
97
api/register-interest.js
Normal file
@@ -0,0 +1,97 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_EMAIL_LENGTH = 320;
|
||||
|
||||
const rateLimitMap = new Map();
|
||||
const RATE_LIMIT = 5;
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
function isRateLimited(ip) {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(ip);
|
||||
if (!entry || now - entry.windowStart > RATE_WINDOW_MS) {
|
||||
rateLimitMap.set(ip, { windowStart: now, count: 1 });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > RATE_LIMIT;
|
||||
}
|
||||
|
||||
export default async function handler(req) {
|
||||
if (isDisallowedOrigin(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const cors = getCorsHeaders(req, 'POST, OPTIONS');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
}
|
||||
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
|
||||
if (isRateLimited(ip)) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
}
|
||||
|
||||
const { email, source, appVersion } = body;
|
||||
if (!email || typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email address' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
}
|
||||
|
||||
const convexUrl = process.env.CONVEX_URL;
|
||||
if (!convexUrl) {
|
||||
return new Response(JSON.stringify({ error: 'Registration service unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
const result = await client.mutation('registerInterest:register', {
|
||||
email,
|
||||
source: source || 'unknown',
|
||||
appVersion: appVersion || 'unknown',
|
||||
});
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[register-interest] Convex error:', err);
|
||||
return new Response(JSON.stringify({ error: 'Registration failed' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
}
|
||||
}
|
||||
32
convex/registerInterest.ts
Normal file
32
convex/registerInterest.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const register = mutation({
|
||||
args: {
|
||||
email: v.string(),
|
||||
source: v.optional(v.string()),
|
||||
appVersion: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedEmail = args.email.trim().toLowerCase();
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("registrations")
|
||||
.withIndex("by_normalized_email", (q) => q.eq("normalizedEmail", normalizedEmail))
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
return { status: "already_registered" as const };
|
||||
}
|
||||
|
||||
await ctx.db.insert("registrations", {
|
||||
email: args.email.trim(),
|
||||
normalizedEmail,
|
||||
registeredAt: Date.now(),
|
||||
source: args.source ?? "unknown",
|
||||
appVersion: args.appVersion ?? "unknown",
|
||||
});
|
||||
|
||||
return { status: "registered" as const };
|
||||
},
|
||||
});
|
||||
12
convex/schema.ts
Normal file
12
convex/schema.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
registrations: defineTable({
|
||||
email: v.string(),
|
||||
normalizedEmail: v.string(),
|
||||
registeredAt: v.number(),
|
||||
source: v.optional(v.string()),
|
||||
appVersion: v.optional(v.string()),
|
||||
}).index("by_normalized_email", ["normalizedEmail"]),
|
||||
});
|
||||
14
convex/tsconfig.json
Normal file
14
convex/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ES2021"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"outDir": "./_generated"
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["./_generated"]
|
||||
}
|
||||
140
docs/API_KEY_DEPLOYMENT.md
Normal file
140
docs/API_KEY_DEPLOYMENT.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# API Key Gating & Registration — Deployment Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Desktop cloud fallback is gated on a `WORLDMONITOR_API_KEY`. Without a valid key, the desktop app operates local-only (sidecar). A registration form collects emails via Convex DB for future key distribution.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Desktop App Cloud (Vercel)
|
||||
┌──────────────────┐ ┌──────────────────────┐
|
||||
│ fetch('/api/...')│ │ api/[domain]/v1/[rpc]│
|
||||
│ │ │ │ │ │
|
||||
│ ┌──────▼───────┐ │ │ ┌──────▼───────┐ │
|
||||
│ │ sidecar try │ │ │ │ validateApiKey│ │
|
||||
│ │ (local-first)│ │ │ │ (origin-aware)│ │
|
||||
│ └──────┬───────┘ │ │ └──────┬───────┘ │
|
||||
│ fail │ │ │ 401 if invalid │
|
||||
│ ┌──────▼───────┐ │ fallback │ │
|
||||
│ │ WM key check │─┼──────────────►│ ┌──────────────┐ │
|
||||
│ │ (gate) │ │ +header │ │ route handler │ │
|
||||
│ └──────────────┘ │ │ └──────────────┘ │
|
||||
└──────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
### Vercel
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `WORLDMONITOR_VALID_KEYS` | Comma-separated list of valid API keys | `wm_abc123def456,wm_xyz789` |
|
||||
| `CONVEX_URL` | Convex deployment URL (from `npx convex deploy`) | `https://xyz-123.convex.cloud` |
|
||||
|
||||
### Generating API keys
|
||||
|
||||
Keys must be at least 16 characters (validated client-side). Recommended format:
|
||||
|
||||
```bash
|
||||
# Generate a key
|
||||
openssl rand -hex 24 | sed 's/^/wm_/'
|
||||
# Example output: wm_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6
|
||||
```
|
||||
|
||||
Add to `WORLDMONITOR_VALID_KEYS` in Vercel dashboard (comma-separated, no spaces).
|
||||
|
||||
## Convex Setup
|
||||
|
||||
### First-time deployment
|
||||
|
||||
```bash
|
||||
# 1. Install (already in package.json)
|
||||
npm install
|
||||
|
||||
# 2. Login to Convex
|
||||
npx convex login
|
||||
|
||||
# 3. Initialize project (creates .env.local with CONVEX_URL)
|
||||
npx convex init
|
||||
|
||||
# 4. Deploy schema and functions
|
||||
npx convex deploy
|
||||
|
||||
# 5. Copy the deployment URL to Vercel env vars
|
||||
# The URL is printed by `npx convex deploy` and saved in .env.local
|
||||
```
|
||||
|
||||
### Verify Convex deployment
|
||||
|
||||
```bash
|
||||
# Typecheck Convex functions
|
||||
npx convex dev --typecheck
|
||||
|
||||
# Open Convex dashboard to see registrations
|
||||
npx convex dashboard
|
||||
```
|
||||
|
||||
### Schema
|
||||
|
||||
The `registrations` table stores:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `email` | string | Original email (for display) |
|
||||
| `normalizedEmail` | string | Lowercased email (for dedup) |
|
||||
| `registeredAt` | number | Unix timestamp |
|
||||
| `source` | string? | Where the registration came from |
|
||||
| `appVersion` | string? | Desktop app version |
|
||||
|
||||
Indexed by `normalizedEmail` for duplicate detection.
|
||||
|
||||
## Security Model
|
||||
|
||||
### Client-side (desktop app)
|
||||
|
||||
- `installRuntimeFetchPatch()` checks `WORLDMONITOR_API_KEY` before allowing cloud fallback
|
||||
- Key must be present AND valid (min 16 chars)
|
||||
- `secretsReady` promise ensures secrets are loaded before first fetch (2s timeout)
|
||||
- Fail-closed: any error in key check blocks cloud fallback
|
||||
|
||||
### Server-side (Vercel edge)
|
||||
|
||||
- `api/_api-key.js` validates `X-WorldMonitor-Key` header on sebuf routes
|
||||
- **Origin-aware**: desktop origins (`tauri.localhost`, `tauri://`, `asset://`) require a key
|
||||
- Web origins (`worldmonitor.app`) pass through without a key
|
||||
- Non-desktop origin with key header: key is still validated
|
||||
- Invalid key returns `401 { error: "Invalid API key" }`
|
||||
|
||||
### CORS
|
||||
|
||||
`X-WorldMonitor-Key` is allowed in both `server/cors.ts` and `api/_cors.js`.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After deployment:
|
||||
|
||||
- [ ] Set `WORLDMONITOR_VALID_KEYS` in Vercel
|
||||
- [ ] Set `CONVEX_URL` in Vercel
|
||||
- [ ] Run `npx convex deploy` to push schema
|
||||
- [ ] Desktop without key: cloud fallback blocked (console shows `cloud fallback blocked`)
|
||||
- [ ] Desktop with invalid key: sebuf requests get `401`
|
||||
- [ ] Desktop with valid key: cloud fallback works as before
|
||||
- [ ] Web access: no key required, works normally
|
||||
- [ ] Registration form: submit email, check Convex dashboard
|
||||
- [ ] Duplicate email: shows "already registered"
|
||||
- [ ] Existing settings tabs (LLMs, API Keys, Debug) unchanged
|
||||
|
||||
## Files Reference
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/services/runtime.ts` | Client-side key gate + header attachment |
|
||||
| `src/services/runtime-config.ts` | `WORLDMONITOR_API_KEY` type, validation, `secretsReady` |
|
||||
| `api/_api-key.js` | Server-side key validation (origin-aware) |
|
||||
| `api/[domain]/v1/[rpc].ts` | Sebuf gateway — calls `validateApiKey` |
|
||||
| `api/register-interest.js` | Registration endpoint → Convex |
|
||||
| `server/cors.ts` / `api/_cors.js` | CORS headers with `X-WorldMonitor-Key` |
|
||||
| `src/components/WorldMonitorTab.ts` | Settings UI for key + registration |
|
||||
| `convex/schema.ts` | Convex DB schema |
|
||||
| `convex/registerInterest.ts` | Convex mutation |
|
||||
@@ -4,7 +4,7 @@ World Monitor desktop now uses a runtime configuration schema with per-feature t
|
||||
|
||||
## Secret keys
|
||||
|
||||
The desktop vault schema supports the following 17 keys used by services and relays:
|
||||
The desktop vault schema (Rust `SUPPORTED_SECRET_KEYS`) supports the following 21 keys:
|
||||
|
||||
- `GROQ_API_KEY`
|
||||
- `OPENROUTER_API_KEY`
|
||||
@@ -18,11 +18,17 @@ The desktop vault schema supports the following 17 keys used by services and rel
|
||||
- `ABUSEIPDB_API_KEY`
|
||||
- `NASA_FIRMS_API_KEY`
|
||||
- `WINGBITS_API_KEY`
|
||||
- `WS_RELAY_URL`
|
||||
- `VITE_WS_RELAY_URL`
|
||||
- `VITE_OPENSKY_RELAY_URL`
|
||||
- `OPENSKY_CLIENT_ID`
|
||||
- `OPENSKY_CLIENT_SECRET`
|
||||
- `AISSTREAM_API_KEY`
|
||||
- `VITE_WS_RELAY_URL`
|
||||
- `OLLAMA_API_URL`
|
||||
- `OLLAMA_MODEL`
|
||||
- `WORLDMONITOR_API_KEY` — gates cloud fallback access (min 16 chars)
|
||||
|
||||
Note: `UC_DP_KEY` exists in the TypeScript `RuntimeSecretKey` union but is not in the desktop Rust keychain or sidecar.
|
||||
|
||||
## Feature schema
|
||||
|
||||
@@ -51,3 +57,4 @@ If required secrets are missing/disabled:
|
||||
- NASA FIRMS: satellite fire detection returns empty state.
|
||||
- Wingbits: flight enrichment disabled, heuristic-only flight classification remains.
|
||||
- AIS / OpenSky relay: live tracking features are disabled cleanly.
|
||||
- WorldMonitor API key: cloud fallback is blocked; desktop operates local-only.
|
||||
|
||||
@@ -73,6 +73,7 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtime = await import('/src/services/runtime.ts');
|
||||
const runtimeConfig = await import('/src/services/runtime-config.ts');
|
||||
const globalWindow = window as unknown as Record<string, unknown>;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
@@ -114,6 +115,9 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
globalWindow.__TAURI__ = { core: { invoke: () => Promise.resolve(null) } };
|
||||
delete globalWindow.__wmFetchPatched;
|
||||
|
||||
// Set a valid WM API key so cloud fallback is allowed
|
||||
await runtimeConfig.setSecretValue('WORLDMONITOR_API_KEY' as import('/src/services/runtime-config.ts').RuntimeSecretKey, 'wm_test_key_1234567890abcdef');
|
||||
|
||||
try {
|
||||
runtime.installRuntimeFetchPatch();
|
||||
|
||||
@@ -138,6 +142,7 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
} else {
|
||||
globalWindow.__TAURI__ = previousTauri;
|
||||
}
|
||||
await runtimeConfig.setSecretValue('WORLDMONITOR_API_KEY' as import('/src/services/runtime-config.ts').RuntimeSecretKey, '');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -716,6 +721,156 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
expect(result.hasIso3Field).toBe(false);
|
||||
});
|
||||
|
||||
test('cloud fallback blocked without WorldMonitor API key', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtime = await import('/src/services/runtime.ts');
|
||||
const globalWindow = window as unknown as Record<string, unknown>;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
const calls: string[] = [];
|
||||
const responseJson = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
|
||||
calls.push(url);
|
||||
|
||||
if (url.includes('127.0.0.1:46123/api/fred-data')) {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}
|
||||
if (url.includes('worldmonitor.app/api/fred-data')) {
|
||||
return responseJson({ observations: [{ value: '999' }] }, 200);
|
||||
}
|
||||
return responseJson({ ok: true }, 200);
|
||||
}) as typeof window.fetch;
|
||||
|
||||
const previousTauri = globalWindow.__TAURI__;
|
||||
globalWindow.__TAURI__ = { core: { invoke: () => Promise.resolve(null) } };
|
||||
delete globalWindow.__wmFetchPatched;
|
||||
|
||||
try {
|
||||
runtime.installRuntimeFetchPatch();
|
||||
|
||||
let fetchError: string | null = null;
|
||||
try {
|
||||
await window.fetch('/api/fred-data?series_id=CPIAUCSL');
|
||||
} catch (err) {
|
||||
fetchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const cloudCalls = calls.filter(u => u.includes('worldmonitor.app'));
|
||||
|
||||
return {
|
||||
fetchError,
|
||||
cloudCalls: cloudCalls.length,
|
||||
localCalls: calls.filter(u => u.includes('127.0.0.1')).length,
|
||||
};
|
||||
} finally {
|
||||
window.fetch = originalFetch;
|
||||
delete globalWindow.__wmFetchPatched;
|
||||
if (previousTauri === undefined) {
|
||||
delete globalWindow.__TAURI__;
|
||||
} else {
|
||||
globalWindow.__TAURI__ = previousTauri;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.fetchError).not.toBeNull();
|
||||
expect(result.cloudCalls).toBe(0);
|
||||
expect(result.localCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('cloud fallback allowed with valid WorldMonitor API key', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtime = await import('/src/services/runtime.ts');
|
||||
const runtimeConfig = await import('/src/services/runtime-config.ts');
|
||||
const globalWindow = window as unknown as Record<string, unknown>;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
const calls: string[] = [];
|
||||
const capturedHeaders: Record<string, string> = {};
|
||||
const responseJson = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
|
||||
calls.push(url);
|
||||
|
||||
if (url.includes('worldmonitor.app') && init?.headers) {
|
||||
const h = new Headers(init.headers);
|
||||
const wmKey = h.get('X-WorldMonitor-Key');
|
||||
if (wmKey) capturedHeaders['X-WorldMonitor-Key'] = wmKey;
|
||||
}
|
||||
|
||||
if (url.includes('127.0.0.1:46123/api/market/v1/test')) {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}
|
||||
if (url.includes('worldmonitor.app/api/market/v1/test')) {
|
||||
return responseJson({ quotes: [] }, 200);
|
||||
}
|
||||
return responseJson({ ok: true }, 200);
|
||||
}) as typeof window.fetch;
|
||||
|
||||
const previousTauri = globalWindow.__TAURI__;
|
||||
globalWindow.__TAURI__ = { core: { invoke: () => Promise.resolve(null) } };
|
||||
delete globalWindow.__wmFetchPatched;
|
||||
|
||||
const testKey = 'wm_test_key_1234567890abcdef';
|
||||
await runtimeConfig.setSecretValue('WORLDMONITOR_API_KEY' as import('/src/services/runtime-config.ts').RuntimeSecretKey, testKey);
|
||||
|
||||
try {
|
||||
runtime.installRuntimeFetchPatch();
|
||||
|
||||
const response = await window.fetch('/api/market/v1/test');
|
||||
const body = await response.json() as { quotes?: unknown[] };
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
hasQuotes: Array.isArray(body.quotes),
|
||||
cloudCalls: calls.filter(u => u.includes('worldmonitor.app')).length,
|
||||
wmKeyHeader: capturedHeaders['X-WorldMonitor-Key'] || null,
|
||||
};
|
||||
} finally {
|
||||
window.fetch = originalFetch;
|
||||
delete globalWindow.__wmFetchPatched;
|
||||
if (previousTauri === undefined) {
|
||||
delete globalWindow.__TAURI__;
|
||||
} else {
|
||||
globalWindow.__TAURI__ = previousTauri;
|
||||
}
|
||||
await runtimeConfig.setSecretValue('WORLDMONITOR_API_KEY' as import('/src/services/runtime-config.ts').RuntimeSecretKey, '');
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.hasQuotes).toBe(true);
|
||||
expect(result.cloudCalls).toBe(1);
|
||||
expect(result.wmKeyHeader).toBe('wm_test_key_1234567890abcdef');
|
||||
});
|
||||
|
||||
test('country-instability HAPI fallback ignores eventsCivilianTargeting in score', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
|
||||
1017
package-lock.json
generated
1017
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@
|
||||
"dev:tech": "VITE_VARIANT=tech vite",
|
||||
"dev:finance": "VITE_VARIANT=finance vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs",
|
||||
"build:desktop": "node scripts/build-sidecar-sebuf.mjs && tsc && vite build",
|
||||
"build:full": "VITE_VARIANT=full tsc && VITE_VARIANT=full vite build",
|
||||
"build:tech": "VITE_VARIANT=tech tsc && VITE_VARIANT=tech vite build",
|
||||
"build:finance": "VITE_VARIANT=finance tsc && VITE_VARIANT=finance vite build",
|
||||
@@ -52,6 +54,7 @@
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
"esbuild": "^0.27.3",
|
||||
"markdownlint-cli2": "^0.20.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
@@ -68,6 +71,7 @@
|
||||
"@upstash/redis": "^1.36.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"convex": "^1.32.0",
|
||||
"d3": "^7.9.0",
|
||||
"deck.gl": "^9.2.6",
|
||||
"fast-xml-parser": "^5.3.7",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function getCorsHeaders(req: Request): Record<string, string> {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Vary': 'Origin',
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<button class="settings-tab active" role="tab" aria-selected="true" aria-controls="tabPanelLLMs" data-tab="llms">LLMs</button>
|
||||
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelKeys" data-tab="keys">API Keys</button>
|
||||
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelDebug" data-tab="debug">Debug & Logs</button>
|
||||
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelWorldMonitor" data-tab="worldmonitor">World Monitor</button>
|
||||
</div>
|
||||
<p id="settingsActionStatus" class="settings-action-status" aria-live="polite"></p>
|
||||
<div class="settings-tab-panels">
|
||||
@@ -45,6 +46,9 @@
|
||||
<div id="trafficLog" class="diag-traffic-log"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="tabPanelWorldMonitor" class="settings-tab-panel" role="tabpanel">
|
||||
<main id="worldmonitorApp" class="settings-content"></main>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="settings-footer">
|
||||
<button id="cancelBtn" type="button" class="settings-btn settings-btn-secondary">Cancel</button>
|
||||
|
||||
@@ -103,7 +103,7 @@ const ALLOWED_ENV_KEYS = new Set([
|
||||
'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL',
|
||||
'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET',
|
||||
'AISSTREAM_API_KEY', 'VITE_WS_RELAY_URL', 'FINNHUB_API_KEY', 'NASA_FIRMS_API_KEY',
|
||||
'OLLAMA_API_URL', 'OLLAMA_MODEL',
|
||||
'OLLAMA_API_URL', 'OLLAMA_MODEL', 'WORLDMONITOR_API_KEY',
|
||||
]);
|
||||
|
||||
function json(data, status = 200, extraHeaders = {}) {
|
||||
|
||||
@@ -25,7 +25,7 @@ const DESKTOP_LOG_FILE: &str = "desktop.log";
|
||||
const MENU_FILE_SETTINGS_ID: &str = "file.settings";
|
||||
const MENU_HELP_GITHUB_ID: &str = "help.github";
|
||||
const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools";
|
||||
const SUPPORTED_SECRET_KEYS: [&str; 20] = [
|
||||
const SUPPORTED_SECRET_KEYS: [&str; 21] = [
|
||||
"GROQ_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"FRED_API_KEY",
|
||||
@@ -46,6 +46,7 @@ const SUPPORTED_SECRET_KEYS: [&str; 20] = [
|
||||
"NASA_FIRMS_API_KEY",
|
||||
"OLLAMA_API_URL",
|
||||
"OLLAMA_MODEL",
|
||||
"WORLDMONITOR_API_KEY",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"version": "2.5.2",
|
||||
"identifier": "app.worldmonitor.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run build:sidecar-sebuf && npm run dev",
|
||||
"beforeBuildCommand": "npm run build:desktop",
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173"
|
||||
},
|
||||
|
||||
131
src/components/WorldMonitorTab.ts
Normal file
131
src/components/WorldMonitorTab.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { getSecretState, setSecretValue, type RuntimeSecretKey } from '@/services/runtime-config';
|
||||
import { getRemoteApiBaseUrl } from '@/services/runtime';
|
||||
import { t } from '@/services/i18n';
|
||||
|
||||
const WM_KEY: RuntimeSecretKey = 'WORLDMONITOR_API_KEY';
|
||||
|
||||
export class WorldMonitorTab {
|
||||
private el: HTMLElement;
|
||||
private keyInput!: HTMLInputElement;
|
||||
private emailInput!: HTMLInputElement;
|
||||
private regStatus!: HTMLElement;
|
||||
private keyBadge!: HTMLElement;
|
||||
private pendingKeyValue: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'wm-tab';
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const state = getSecretState(WM_KEY);
|
||||
const statusText = state.present
|
||||
? t('modals.settingsWindow.worldMonitor.apiKey.statusValid')
|
||||
: t('modals.settingsWindow.worldMonitor.apiKey.statusMissing');
|
||||
const statusClass = state.present ? 'ok' : 'warn';
|
||||
|
||||
this.el.innerHTML = `
|
||||
<section class="wm-section">
|
||||
<h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.apiKey.title')}</h2>
|
||||
<p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.apiKey.description')}</p>
|
||||
<div class="wm-key-row">
|
||||
<div class="wm-input-wrap">
|
||||
<input type="password" class="wm-input" data-wm-key-input
|
||||
placeholder="${t('modals.settingsWindow.worldMonitor.apiKey.placeholder')}" autocomplete="off" spellcheck="false" />
|
||||
<button type="button" class="wm-toggle-vis" data-wm-toggle title="Show/hide">👁</button>
|
||||
</div>
|
||||
<span class="wm-badge ${statusClass}" data-wm-badge>${statusText}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="wm-section">
|
||||
<h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.register.title')}</h2>
|
||||
<p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.register.description')}</p>
|
||||
<div class="wm-register-row">
|
||||
<input type="email" class="wm-input wm-email" data-wm-email
|
||||
placeholder="${t('modals.settingsWindow.worldMonitor.register.emailPlaceholder')}" />
|
||||
<button type="button" class="wm-submit-btn" data-wm-register>${t('modals.settingsWindow.worldMonitor.register.submitBtn')}</button>
|
||||
</div>
|
||||
<p class="wm-reg-status" data-wm-reg-status></p>
|
||||
</section>
|
||||
`;
|
||||
|
||||
this.keyInput = this.el.querySelector('[data-wm-key-input]')!;
|
||||
this.emailInput = this.el.querySelector('[data-wm-email]')!;
|
||||
this.regStatus = this.el.querySelector('[data-wm-reg-status]')!;
|
||||
this.keyBadge = this.el.querySelector('[data-wm-badge]')!;
|
||||
|
||||
this.keyInput.addEventListener('input', () => {
|
||||
this.pendingKeyValue = this.keyInput.value;
|
||||
});
|
||||
|
||||
this.el.querySelector('[data-wm-toggle]')!.addEventListener('click', () => {
|
||||
this.keyInput.type = this.keyInput.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
|
||||
this.el.querySelector('[data-wm-register]')!.addEventListener('click', () => {
|
||||
void this.submitRegistration();
|
||||
});
|
||||
}
|
||||
|
||||
private async submitRegistration(): Promise<void> {
|
||||
const email = this.emailInput.value.trim();
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.error');
|
||||
this.regStatus.className = 'wm-reg-status error';
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = this.el.querySelector('[data-wm-register]') as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitting');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getRemoteApiBaseUrl()}/api/register-interest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, source: 'desktop-settings' }),
|
||||
});
|
||||
const data = await res.json() as { status?: string; error?: string };
|
||||
if (data.status === 'already_registered') {
|
||||
this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.alreadyRegistered');
|
||||
this.regStatus.className = 'wm-reg-status ok';
|
||||
} else if (data.status === 'registered') {
|
||||
this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.success');
|
||||
this.regStatus.className = 'wm-reg-status ok';
|
||||
} else {
|
||||
this.regStatus.textContent = data.error || t('modals.settingsWindow.worldMonitor.register.error');
|
||||
this.regStatus.className = 'wm-reg-status error';
|
||||
}
|
||||
} catch {
|
||||
this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.error');
|
||||
this.regStatus.className = 'wm-reg-status error';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitBtn');
|
||||
}
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.pendingKeyValue !== null && this.pendingKeyValue.length > 0;
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.pendingKeyValue === null) return;
|
||||
await setSecretValue(WM_KEY, this.pendingKeyValue);
|
||||
this.pendingKeyValue = null;
|
||||
const state = getSecretState(WM_KEY);
|
||||
this.keyBadge.textContent = state.present
|
||||
? t('modals.settingsWindow.worldMonitor.apiKey.statusValid')
|
||||
: t('modals.settingsWindow.worldMonitor.apiKey.statusMissing');
|
||||
this.keyBadge.className = `wm-badge ${state.present ? 'ok' : 'warn'}`;
|
||||
}
|
||||
|
||||
getElement(): HTMLElement {
|
||||
return this.el;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el.innerHTML = '';
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,26 @@
|
||||
"noTraffic": "No traffic recorded yet.",
|
||||
"sidecarUnreachable": "Sidecar not reachable.",
|
||||
"logCleared": "Log cleared.",
|
||||
"worldMonitor": {
|
||||
"tabLabel": "World Monitor",
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
"placeholder": "Enter your WorldMonitor API key",
|
||||
"description": "Required for cloud fallback access from the desktop app.",
|
||||
"statusValid": "Key set",
|
||||
"statusMissing": "No key configured"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register Interest",
|
||||
"description": "Sign up to receive a WorldMonitor API key when available.",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"submitBtn": "Register Interest",
|
||||
"submitting": "Submitting...",
|
||||
"success": "Registered! We'll be in touch.",
|
||||
"alreadyRegistered": "You're already registered.",
|
||||
"error": "Registration failed. Please try again."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"time": "Time",
|
||||
"method": "Method",
|
||||
|
||||
@@ -21,7 +21,8 @@ export type RuntimeSecretKey =
|
||||
| 'NASA_FIRMS_API_KEY'
|
||||
| 'UC_DP_KEY'
|
||||
| 'OLLAMA_API_URL'
|
||||
| 'OLLAMA_MODEL';
|
||||
| 'OLLAMA_MODEL'
|
||||
| 'WORLDMONITOR_API_KEY';
|
||||
|
||||
export type RuntimeFeatureId =
|
||||
| 'aiGroq'
|
||||
@@ -240,9 +241,19 @@ export function validateSecret(key: RuntimeSecretKey, value: string): { valid: b
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'WORLDMONITOR_API_KEY') {
|
||||
if (trimmed.length < 16) return { valid: false, hint: 'API key must be at least 16 characters' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let secretsReadyResolve!: () => void;
|
||||
export const secretsReady = new Promise<void>(r => { secretsReadyResolve = r; });
|
||||
|
||||
if (!isDesktopRuntime()) secretsReadyResolve();
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const runtimeConfig: RuntimeConfig = {
|
||||
@@ -488,5 +499,7 @@ export async function loadDesktopSecrets(): Promise<void> {
|
||||
notifyConfigChanged();
|
||||
} catch (error) {
|
||||
console.warn('[runtime-config] Failed to load desktop secrets from vault', error);
|
||||
} finally {
|
||||
secretsReadyResolve();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,15 +218,37 @@ export function installRuntimeFetchPatch(): void {
|
||||
|
||||
const localUrl = `${localBase}${target}`;
|
||||
if (debug) console.log(`[fetch] intercept → ${target}`);
|
||||
const allowCloudFallback = !isLocalOnlyApiTarget(target);
|
||||
let allowCloudFallback = !isLocalOnlyApiTarget(target);
|
||||
|
||||
if (allowCloudFallback) {
|
||||
try {
|
||||
const { getSecretState, secretsReady } = await import('@/services/runtime-config');
|
||||
await Promise.race([secretsReady, new Promise<void>(r => setTimeout(r, 2000))]);
|
||||
const wmKey = getSecretState('WORLDMONITOR_API_KEY' as import('@/services/runtime-config').RuntimeSecretKey);
|
||||
if (!wmKey.present || !wmKey.valid) {
|
||||
allowCloudFallback = false;
|
||||
if (debug) console.log(`[fetch] cloud fallback blocked — no WorldMonitor API key`);
|
||||
}
|
||||
} catch {
|
||||
allowCloudFallback = false;
|
||||
}
|
||||
}
|
||||
|
||||
const cloudFallback = async () => {
|
||||
if (!allowCloudFallback) {
|
||||
throw new Error(`Cloud fallback blocked for local-only endpoint: ${target}`);
|
||||
throw new Error(`Cloud fallback blocked for ${target}`);
|
||||
}
|
||||
const cloudUrl = `${getRemoteApiBaseUrl()}${target}`;
|
||||
if (debug) console.log(`[fetch] cloud fallback → ${cloudUrl}`);
|
||||
return nativeFetch(cloudUrl, init);
|
||||
const cloudHeaders = new Headers(init?.headers);
|
||||
if (/^\/api\/[^/]+\/v1\//.test(target)) {
|
||||
const { getRuntimeConfigSnapshot } = await import('@/services/runtime-config');
|
||||
const wmKeyValue = getRuntimeConfigSnapshot().secrets['WORLDMONITOR_API_KEY']?.value;
|
||||
if (wmKeyValue) {
|
||||
cloudHeaders.set('X-WorldMonitor-Key', wmKeyValue);
|
||||
}
|
||||
}
|
||||
return nativeFetch(cloudUrl, { ...init, headers: cloudHeaders });
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './styles/main.css';
|
||||
import './styles/settings-window.css';
|
||||
import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel';
|
||||
import { WorldMonitorTab } from '@/components/WorldMonitorTab';
|
||||
import { RUNTIME_FEATURES, loadDesktopSecrets } from '@/services/runtime-config';
|
||||
import { tryInvokeTauri } from '@/services/tauri-bridge';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
@@ -84,6 +85,7 @@ async function initSettingsWindow(): Promise<void> {
|
||||
|
||||
const llmMount = document.getElementById('llmApp');
|
||||
const apiMount = document.getElementById('apiKeysApp');
|
||||
const wmMount = document.getElementById('worldmonitorApp');
|
||||
if (!llmMount || !apiMount) return;
|
||||
|
||||
const llmPanel = new RuntimeConfigPanel({ mode: 'full', buffered: true, featureFilter: LLM_FEATURES });
|
||||
@@ -96,17 +98,28 @@ async function initSettingsWindow(): Promise<void> {
|
||||
mountPanel(llmPanel, llmMount);
|
||||
mountPanel(apiPanel, apiMount);
|
||||
|
||||
const wmTab = new WorldMonitorTab();
|
||||
if (wmMount) {
|
||||
wmMount.innerHTML = '';
|
||||
wmMount.appendChild(wmTab.getElement());
|
||||
}
|
||||
|
||||
const panels = [llmPanel, apiPanel];
|
||||
|
||||
window.addEventListener('beforeunload', () => panels.forEach(p => p.destroy()));
|
||||
window.addEventListener('beforeunload', () => {
|
||||
panels.forEach(p => p.destroy());
|
||||
wmTab.destroy();
|
||||
});
|
||||
|
||||
document.getElementById('okBtn')?.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (!panels.some(p => p.hasPendingChanges())) {
|
||||
const hasWmChanges = wmTab.hasPendingChanges();
|
||||
if (!panels.some(p => p.hasPendingChanges()) && !hasWmChanges) {
|
||||
closeSettingsWindow();
|
||||
return;
|
||||
}
|
||||
if (hasWmChanges) await wmTab.save();
|
||||
setActionStatus(t('modals.settingsWindow.validating'), 'ok');
|
||||
const missingRequired = panels.flatMap(p => p.getMissingRequiredSecrets());
|
||||
if (missingRequired.length > 0) {
|
||||
|
||||
@@ -596,6 +596,148 @@ tr.diag-ok td { color: var(--settings-text-secondary); }
|
||||
tr.diag-warn td { color: var(--settings-yellow); }
|
||||
tr.diag-err td { color: var(--settings-red); }
|
||||
|
||||
/* ── World Monitor tab ── */
|
||||
.wm-tab {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.wm-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.wm-section-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--settings-text);
|
||||
}
|
||||
|
||||
.wm-section-desc {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--settings-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wm-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wm-input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wm-input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--settings-border-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--settings-text);
|
||||
padding: 8px 36px 8px 12px;
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wm-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--settings-accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.wm-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.wm-toggle-vis {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--settings-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.wm-toggle-vis:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wm-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wm-badge.ok {
|
||||
color: var(--settings-green);
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
|
||||
.wm-badge.warn {
|
||||
color: var(--settings-yellow);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
|
||||
.wm-register-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wm-email {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wm-submit-btn {
|
||||
background: var(--settings-accent);
|
||||
border: 1px solid var(--settings-accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wm-submit-btn:hover {
|
||||
background: color-mix(in srgb, var(--settings-accent) 85%, white);
|
||||
}
|
||||
|
||||
.wm-submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wm-reg-status {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.wm-reg-status.ok {
|
||||
color: var(--settings-green);
|
||||
}
|
||||
|
||||
.wm-reg-status.error {
|
||||
color: var(--settings-red);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.debug-actions {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["api", "src/generated", "server"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user