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:
Elie Habib
2026-02-21 10:36:23 +00:00
committed by GitHub
parent 417b7d275d
commit a388afe400
25 changed files with 1892 additions and 17 deletions

View File

@@ -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=

View File

@@ -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
View 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 };
}

View File

@@ -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
View 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 },
});
}
}

View 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
View 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
View 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
View 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 |

View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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',
};

View File

@@ -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 &amp; 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>

View File

@@ -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 = {}) {

View File

@@ -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)]

View File

@@ -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"
},

View 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">&#x1f441;</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 = '';
}
}

View File

@@ -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",

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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%;

View File

@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
"types": ["vite/client"]
},
"include": ["api", "src/generated", "server"]
}