release: v0.1.0 — one-click install, keyless boot, in-app Provider Settings (#142)

The first formal release.

- One-click install via Pinokio; keyless boot lands on a live Esri World
  Imagery satellite globe with keyless terrain, with automatic OSM fallback
  and graceful degradation when terrain is unavailable.
- Provider Settings (the POWER UP panel): add, replace, or remove API keys
  inside the app; credential files made owner-only before any secret is
  written; external keys shown read-only; the panel refuses shared or proxied
  servers.
- Keyless capability responses for the optional HUD summary and place search.
- Aircraft-identity voice answers cover operator, type, and route, and say so
  plainly when enrichment is unavailable.
- README rewritten keyless-first; concise 0.1.0 changelog section; CI
  workflow included.

Gates: 2,672 unit tests pass / 0 fail, build clean, tracking regression
108/108, map-source tray QA pass, setup doctor ready.
This commit is contained in:
Bilawal Sidhu authored and GitHub committed 2026-08-31 17:49:45 -05:00
1 parent 6d83bb6008
commit b6da93b8ac
170 files changed
+5800 -853

No files matched your search

+381 -32
View File
@@ -26,7 +26,9 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import { promises as fsp } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import path from 'node:path';
import { Readable } from 'node:stream';
@@ -53,6 +55,18 @@ import {
import { normalizeAdsbLolPointResponse } from './src/data/adsbLolFallback.js';
import { createAisStreamAdapter, isRecognizedAisEnvelope } from './src/data/aisStreamAdapter.js';
import { parseSilenceTimeoutEnv } from './src/data/aisWatchdog.js';
import { keylessHudSummaryResponse } from './src/hudSummaryResponse.js';
import { parseEnv as parseDotenvText } from 'node:util';
import { readEnvironmentSource as readPinokioEnvironmentSource } from './scripts/pinokio-environment.mjs';
import {
admitKeySetupRequest,
isKeySetupExternallyManaged,
keySetupStatus,
knownKeySetupEnvVars,
upsertDotenvValues,
validateKeySetupUpdates,
} from './src/keySetupCore.mjs';
import { hardenCredentialFile } from './src/keySetupHardening.mjs';
import {
fetchTerrainChunkWithRetry,
parseTerrainPoints,
@@ -65,6 +79,39 @@ import { VOICE_MODELS, isKnownVoiceTier, resolveVoiceModel } from './src/voice/v
/** Resolve __dirname for ESM context. */
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Which launcher started this process, captured at MODULE LOAD — before the
* config factory's loadEnv() copies dotenv files into process.env. Provider
* Settings uses this to decide which credential store it owns, so it must
* reflect the real launcher (scripts/pinokio-start.mjs sets it) and never a
* value a project `.env` could inject.
*/
const LAUNCHER_AT_BOOT = process.env.GEV_LAUNCHER;
/**
* Provider values present before Vite loads the checkout's dotenv files.
* Memoized on globalThis: a panel save sets its values live on process.env and
* then calls server.restart(), which re-evaluates this config IN-PROCESS.
* Recomputing the snapshot there would classify the panel's own keys as
* external (read-only) until the whole process is relaunched.
*/
const PROVIDER_ENV_AT_BOOT = globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??= Object.freeze(Object.fromEntries(
[...knownKeySetupEnvVars()].map((name) => [name, String(process.env[name] ?? '').trim()]),
));
/**
* `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so
* it supplies an explicit names-only provenance marker for values inherited
* from its parent shell. Plain Vite launches use the raw boot snapshot above;
* Pinokio deliberately treats its app-scoped ENVIRONMENT as authoritative.
*/
const DEV_FRESH_EXTERNAL_KEYS_AT_BOOT = new Set(
String(process.env.GEV_KEY_SETUP_EXTERNAL_KEYS ?? '')
.split(',')
.map((name) => name.trim())
.filter((name) => knownKeySetupEnvVars().has(name)),
);
// ---------------------------------------------------------------------------
// OpenSky OAuth2 token + response cache state
// ---------------------------------------------------------------------------
@@ -4046,7 +4093,7 @@ async function loadTflSourcesFromOpenData() {
rangeM: 145,
mountHeightM: 8,
groundElevationM: 15, // Thames-basin prior; one-shot snap corrects.
feedType: 'image', // stills-first (product rule); props.videoUrl deliberately unused
feedType: 'image', // stills-first (owner decision); props.videoUrl deliberately unused
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tfl-open-data',
@@ -4957,7 +5004,7 @@ function trackBackfillProxies() {
* Keeps OPENAI_API_KEY server-side while the browser connects to the
* Realtime API over WebRTC with a short-lived secret.
*/
function openAiRealtimeProxy() {
export function openAiRealtimeProxy() {
function install(middlewares) {
middlewares.use('/api/openai/hud-summary', async (req, res) => {
if (req.method !== 'POST') {
@@ -4967,17 +5014,21 @@ function openAiRealtimeProxy() {
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
const keyless = keylessHudSummaryResponse(apiKey);
if (keyless) {
res.statusCode = keyless.statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(keyless.payload));
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
// fallback has no provider cost and resolves above without consuming a
// paid-endpoint quota slot.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
try {
const body = await readRequestBody(req, 64 * 1024);
const context = JSON.parse(body || '{}');
@@ -5156,7 +5207,7 @@ function openAiRealtimeProxy() {
// string is the whole rollback.
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
@@ -5264,6 +5315,19 @@ function readRequestBody(req, maxBytes = 1024 * 1024) {
});
}
/**
* Optional Google place context is an empty capability when no key is present,
* not a server outage. Returning 200 keeps a deliberately keyless session out
* of the browser error console while preserving an explicit configured flag.
*/
export function keylessGooglePlacesResponse(apiKey) {
if (String(apiKey ?? '').trim()) return null;
return {
statusCode: 200,
payload: { configured: false, error: null, places: [] },
};
}
/**
* Vite plugin: nearby Google place labels for Realtime scene context.
*
@@ -5271,7 +5335,7 @@ function readRequestBody(req, maxBytes = 1024 * 1024) {
* Cesium feature metadata. Nearby Search supplies the names around the actual
* screen-space target without exposing the Google API key in the request.
*/
function googlePlacesContextProxy() {
export function googlePlacesContextProxy() {
function install(middlewares) {
middlewares.use('/api/google/nearby-places', async (req, res) => {
if (req.method !== 'GET') {
@@ -5281,6 +5345,19 @@ function googlePlacesContextProxy() {
return;
}
// Keyless place context has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the HUD
// summary route).
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
const keyless = keylessGooglePlacesResponse(apiKey);
if (keyless) {
res.statusCode = keyless.statusCode;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(keyless.payload));
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
// `places: []` contract that the client expects on every error response.
@@ -5293,14 +5370,6 @@ function googlePlacesContextProxy() {
return;
}
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'GOOGLE_MAPS_API_KEY is not set', places: [] }));
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const latitude = Number(requestUrl.searchParams.get('lat'));
const longitude = Number(requestUrl.searchParams.get('lon'));
@@ -5395,6 +5464,19 @@ function googlePlacesContextProxy() {
return;
}
// Keyless place context has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the HUD
// summary route).
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
const keyless = keylessGooglePlacesResponse(apiKey);
if (keyless) {
res.statusCode = keyless.statusCode;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(keyless.payload));
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
// contract the client expects on every error response.
@@ -5407,14 +5489,6 @@ function googlePlacesContextProxy() {
return;
}
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'GOOGLE_MAPS_API_KEY is not set', places: [] }));
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const textQuery = String(requestUrl.searchParams.get('q') || '').trim();
const latitude = Number(requestUrl.searchParams.get('lat'));
@@ -5848,8 +5922,8 @@ const GEV_REALTIME_TOOLS = [
properties: {
stack: {
type: 'string',
enum: ['photoreal', 'bing-aerial', 'bing-labels', 'osm'],
description: 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap.',
enum: ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
description: 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.',
},
},
required: ['stack'],
@@ -6573,7 +6647,7 @@ export const MILITARY_INSTALLATION_ELEMENT_CAP = 700;
/**
* Disk-cache TTL for mapped installations (ms) — 30 days.
*
* Field test 2026-08-18: "search nearby sites" was slow because every look
* Owner playtest 2026-08-18: "search nearby sites" was slow because every look
* around paid a live Overpass round trip, and the 5-minute in-memory tier died
* with the dev server. Mapped military features change on a survey timescale,
* not a session one, so a month-old answer is still the right answer — the same
@@ -7323,6 +7397,264 @@ function normalizeAisTimestamp(value) {
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
}
/**
* In-app key setup ("POWER UP" panel) — dev-server only.
*
* GET /api/setup/status → which keys are configured, as presence plus a
* source classification. Never a value or suffix. The panel renders itself entirely from
* this payload, so the key registry stays in one place (src/keySetupCore.mjs).
* POST /api/setup/keys → validate {ENV_VAR: value} pairs and upsert them into
* the repo-root .env (created if absent), set process.env live, then restart
* the dev server so the client-exposed defines re-inject and the page
* reloads itself. Pasting a key in the app IS the whole setup — no
* hand-edited env files.
*
* Loopback-only on purpose: with HOST=0.0.0.0 the app can be shared on a LAN,
* and a guest must be able to neither write the host's .env nor probe which
* keys exist. Prod builds never register this middleware (apply: 'serve'), so
* the panel's status fetch fails and the client removes the whole surface.
*/
function keySetupEndpoint() {
const respond = (res, statusCode, payload) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json');
// A credential-status response must never be cached by a proxy or the disk
// cache, and the surface must never be framed (clickjacking a same-origin
// REMOVE/replace past the Origin check).
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res.end(JSON.stringify(payload));
};
// Which store this launch owns. A Pinokio-managed launch (marker set by
// scripts/pinokio-start.mjs) writes the app-scoped pinokio/ENVIRONMENT that
// applyPinokioEnvironment() treats as authoritative; every other launch
// writes the repo-root .env that Vite's loadEnv reads. The panel never
// touches a store some other workflow owns.
// The launcher marker is read from the BOOT environment captured before
// Vite's loadEnv merges dotenv files into process.env — otherwise a stray
// `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a
// plain `npm run dev` to write the Pinokio store it never loaded.
const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio';
const storeName = () => (pinokioManaged() ? 'pinokio-environment' : 'env-file');
const storePath = () => path.join(__dirname, ...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']));
// Read the store, distinguishing "no store yet" from "cannot read this
// store". Only ENOENT means empty. Every other failure — a permission error,
// an I/O fault, an undecodable file — must ABORT the save: upserting into a
// wrongly-empty string and atomically replacing the file would destroy every
// other provider key the user had configured.
const readStore = () => {
try {
// The Pinokio launcher deliberately supports a UTF-16 ENVIRONMENT (a
// Windows editor or the native Configure panel can write one). Reuse its
// own encoding-aware decoder so a panel write can never mistake UTF-16
// bytes for UTF-8, corrupt the file, and wedge the next launch. We always
// write back UTF-8, which is exactly what the launcher normalizes to.
if (pinokioManaged()) return readPinokioEnvironmentSource(storePath());
return fs.readFileSync(storePath(), 'utf8');
} catch (error) {
if (error?.code === 'ENOENT') return ''; // The first saved key births the file.
const unreadable = new Error('the existing configuration could not be read, so nothing was changed');
unreadable.code = 'GEV_STORE_UNREADABLE';
throw unreadable;
}
};
// Status must never fail because the store is unreadable — it reports the
// LIVE environment, and an unreadable store only costs file/external
// attribution. Persistence uses readStore() directly and refuses instead.
const storeValues = () => {
try {
return parseDotenvText(readStore());
} catch {
return {};
}
};
// The gate itself is pure and unit-tested (admitKeySetupRequest in
// src/keySetupCore.mjs) — this just feeds it the request.
const admit = (req) => admitKeySetupRequest({
method: req.method,
remoteAddress: req.socket?.remoteAddress,
hostHeader: req.headers?.host,
protocol: req.socket?.encrypted ? 'https:' : 'http:',
origin: req.headers?.origin,
contentType: req.headers?.['content-type'],
proxyHeaders: req.headers || {},
env: process.env,
});
// Is this env var supplied by a workflow OTHER than this panel's store? Boot
// provenance closes the equal-value ambiguity: an exported X remains
// external even when the editable store independently contains X.
const isExternallyManaged = (name, inStore) => {
const wasExternalAtBoot = pinokioManaged()
? false
: LAUNCHER_AT_BOOT === 'dev-fresh'
? DEV_FRESH_EXTERNAL_KEYS_AT_BOOT.has(name)
: PROVIDER_ENV_AT_BOOT[name] !== '';
return isKeySetupExternallyManaged({
effectiveValue: process.env[name],
storedValue: inStore[name],
wasExternalAtBoot,
});
};
const providerStatus = () => {
const inStore = storeValues();
const status = keySetupStatus(process.env);
for (const key of status.keys) {
// 'file' = this panel's own store holds exactly this value (replace/remove
// offered); 'external' = supplied by env/Keychain/another workflow
// (read-only — the panel must never rewrite or delete it).
key.managed = key.set
? (key.envVars.some((name) => isExternallyManaged(name, inStore)) ? 'external' : 'file')
: null;
}
return { ...status, store: storeName() };
};
// Atomically replace the store's content: fresh same-dir temp created 0600
// with the exclusive flag, fsync, rename over the target. Closes the window
// where writeFileSync leaves a 0644 file holding a real key before any later
// chmod, and the truncate-in-place data-loss path.
const persistStore = (text) => {
const filepath = storePath();
// Never write THROUGH a symlink into a credential path.
try {
if (fs.lstatSync(filepath).isSymbolicLink()) {
throw new Error('refusing to write a credential store that is a symlink');
}
} catch (error) {
if (error.code !== 'ENOENT') throw error; // absent is fine — first save.
}
// Random suffix, not the pid: a stale temp from a failed rename would
// otherwise make every later save in this process fail EEXIST forever.
const tmp = path.join(
path.dirname(filepath),
`.${path.basename(filepath)}.${randomUUID().slice(0, 8)}.tmp`,
);
const fd = fs.openSync(tmp, 'wx', 0o600);
let staged = false;
try {
// Restrict the EMPTY temp file BEFORE the secret touches it. On Windows
// a fresh file inherits the directory's ACL (world-readable under a
// C:-rooted Pinokio home) and the 0600 open mode is a no-op — and NTFS
// renames carry the file object's ACL with it, so hardening the temp IS
// hardening the final file. Ordering this before the write means a
// hardening failure aborts with the previous store fully intact and the
// secret never on disk unprotected — no rollback path to get wrong.
if (!hardenCredentialFile(tmp)) {
const error = new Error('could not restrict the credential file to your account; nothing was saved');
error.code = 'GEV_HARDEN_FAILED';
throw error;
}
// writeSync may write fewer bytes than asked; loop until the whole
// buffer lands or a truncated store gets fsynced and renamed into place.
const buffer = Buffer.from(text, 'utf8');
let written = 0;
while (written < buffer.length) {
written += fs.writeSync(fd, buffer, written, buffer.length - written);
}
fs.fsyncSync(fd);
staged = true;
} finally {
fs.closeSync(fd);
if (!staged) fs.rmSync(tmp, { force: true });
}
try {
fs.renameSync(tmp, filepath);
} catch (error) {
// Never strand a staged secret on disk when the swap itself fails.
fs.rmSync(tmp, { force: true });
throw error;
}
};
return {
name: 'gev-key-setup',
// serve AND not preview: `vite preview` resolves with command 'serve' too,
// so a bare apply:'serve' would still configure under preview. The endpoints
// only install via configureServer (never configurePreviewServer), so they
// are absent from preview today — but pinning apply here makes that a
// guarantee rather than an accident of which hook a future edit uses.
apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview,
configureServer(server) {
server.middlewares.use('/api/setup/status', (req, res) => {
if (req.method !== 'GET') return respond(res, 405, { error: 'Method not allowed' });
const admission = admit(req);
if (!admission.ok) return respond(res, admission.status, { error: admission.error });
respond(res, 200, providerStatus());
});
server.middlewares.use('/api/setup/keys', (req, res) => {
if (req.method !== 'POST') return respond(res, 405, { error: 'Method not allowed' });
const admission = admit(req);
if (!admission.ok) return respond(res, admission.status, { error: admission.error });
let body = '';
let overflowed = false;
req.on('data', (chunk) => {
body += chunk;
if (body.length > 8192) {
overflowed = true;
req.destroy();
}
});
req.on('end', () => {
if (overflowed) return respond(res, 413, { error: 'Request too large' });
let parsed;
try {
parsed = JSON.parse(body || '{}');
} catch {
return respond(res, 400, { error: 'Invalid JSON' });
}
const verdict = validateKeySetupUpdates(parsed);
if (!verdict.ok) return respond(res, 400, { error: verdict.error });
// Neither a replace NOR a removal may touch an externally-supplied
// credential (shell env, Keychain, another workflow). This backs the
// UI's read-only "configured externally" state with a real contract —
// and it must guard replace too, not just remove: a clickjacked or
// scripted same-origin POST could otherwise overwrite the live value.
const inStore = storeValues();
for (const name of Object.keys(verdict.updates)) {
if (isExternallyManaged(name, inStore)) {
return respond(res, 409, {
error: `${name} is configured outside Provider Settings and can only be changed where it was set`,
});
}
}
try {
persistStore(upsertDotenvValues(readStore(), verdict.updates));
} catch (error) {
// The hardening failure carries its own honest, path-free message —
// "saved world-readable" must never be reported as a generic write
// error. Everything else returns a fixed message (a raw filesystem
// error can carry an absolute path; that stays in the server log).
if (error?.code === 'GEV_HARDEN_FAILED' || error?.code === 'GEV_STORE_UNREADABLE') {
return respond(res, 500, { error: `The key was not saved: ${error.message}` });
}
return respond(res, 500, { error: `Could not write the ${storeName()} store` });
}
// Live for the server-side proxies immediately; the restart below is
// what re-injects the client-exposed defines (Google, Cesium ion).
// Removal sets '' rather than deleting: an empty value stays falsy
// through loadEnv after restart, matching the Pinokio launcher's own
// blank-field semantics.
for (const [name, value] of Object.entries(verdict.updates)) {
process.env[name] = value === null ? '' : value;
}
respond(res, 200, {
ok: true,
saved: Object.keys(verdict.updates),
status: providerStatus(),
restarting: true,
});
// One deliberate restart, after the response has flushed. Vite's own
// .env watcher may fire too; a second queued restart is harmless.
setTimeout(() => {
server.restart().catch((error) => {
console.warn('[KeySetup] Dev-server restart failed:', error?.message || error);
});
}, 250);
});
});
},
};
}
/**
* Main Vite configuration factory.
*
@@ -7338,6 +7670,7 @@ export default defineConfig(({ mode }) => {
if (process.env[key] === undefined) process.env[key] = val;
}
const env = { ...process.env };
const localAllowedHosts = ['localhost', '127.0.0.1', '.local'];
return {
plugins: [
cesium(),
@@ -7360,14 +7693,30 @@ export default defineConfig(({ mode }) => {
trackBackfillProxies(),
openAiRealtimeProxy(),
googlePlacesContextProxy(),
keySetupEndpoint(),
],
server: {
host: env.HOST || 'localhost',
port: parseInt(env.PORT, 10) || 5173,
port: parseInt(env.PORT, 10) || 4173,
// When binding to all interfaces, allow any host; otherwise restrict to local names
allowedHosts: (env.HOST === '0.0.0.0' || env.HOST === '::')
? true
: ['localhost', '127.0.0.1', '.local'],
: localAllowedHosts,
fs: {
// Pinokio keeps optional credentials in this ignored local file.
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
},
// Framing protection belongs on the APP DOCUMENT, not on API responses:
// a browser evaluates frame-ancestors against the framed page's own
// navigation response. Without this, a hostile page could frame
// `/?setup=1`, align a lure over Provider Settings, and have the framed
// app issue a perfectly same-origin credential write that passes every
// Host/Origin check. These headers apply to everything this dev server
// serves, which is what makes that attack impossible rather than unlikely.
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
},
},
// Expose selected API keys to the browser via import.meta.env.*
define: {