From 205c58efbdac8de6e3f2e0121330f013fa9318cc Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:08:23 -0500 Subject: [PATCH] fix: separate Google browser and server keys (#110) Use a separate optional server key for Places and Street View, with consistent local and Pinokio configuration, server-first CLI selection, and credential isolation checks. Original contribution by Tom-Neverwinter; reviewed and completed by Sameh Khamis. Co-authored-by: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Co-authored-by: Sameh Khamis --- .env.example | 13 +++ CHANGELOG.md | 6 ++ SECURITY.md | 16 +++- docs/CURRENT-STATE.md | 12 +++ pinokio/_ENVIRONMENT | 2 + pinokio/install.js | 1 + pinokio/start.js | 1 + pinokio/update.js | 1 + scripts/dev-fresh.sh | 1 + scripts/google-server-key.mjs | 5 ++ scripts/pinokio-environment.mjs | 1 + scripts/setup-doctor.mjs | 1 + src/googlePlacesKeyless.test.mjs | 4 + src/googleServerKey.test.mjs | 124 +++++++++++++++++++++++++++ src/keySetupCore.mjs | 12 ++- src/keySetupCore.test.mjs | 13 +++ src/pinokioEnvironment.test.mjs | 20 +++++ src/pinokioLauncherContract.test.mjs | 1 + src/setupDoctor.test.mjs | 3 + tools/streetview-headings.mjs | 46 +++++----- vite.config.js | 27 +++++- 21 files changed, 282 insertions(+), 28 deletions(-) create mode 100644 scripts/google-server-key.mjs create mode 100644 src/googleServerKey.test.mjs diff --git a/.env.example b/.env.example index 2a5479514..a62d4e73f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Local development uses one file for browser and server keys. +# Copy to .env (ignored); this example contains names and placeholders only. +# Only explicitly selected browser keys are injected into the client bundle. # God's Eye View — environment variables # Copy to .env and fill in your keys. On macOS the launcher can also read keys # from the Keychain (see README); on Linux/Windows use this file or env vars. @@ -21,6 +24,16 @@ # available in the map tray and as the automatic provider-failure fallback. # CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud. GOOGLE_MAPS_API_KEY= + +# Optional: a SEPARATE Google API key for the SERVER-SIDE Google calls — Places +# (/api/google/nearby-places, /api/google/text-search) and the CCTV Street View +# fallback (#33). It never reaches the browser, so it can be restricted by +# server IP (not HTTP referrer) and scoped to just Places API + Street View +# Static API, letting the client-exposed key above stay narrowly scoped to Map +# Tiles + Geocoding. Unset, these calls fall back to GOOGLE_MAPS_API_KEY, so a +# single shared key keeps working — splitting is opt-in. +# SERVER-SIDE ONLY. +GOOGLE_MAPS_SERVER_API_KEY= # Optional: opt-in per-IP rate limit for the Google Places cost endpoint # (/api/google/nearby-places), in requests per minute. # DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index e71f98787..7f3415c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Separate optional Google server credentials for Places and Street View from + the browser key, contributed by Tom-Neverwinter (#110). Provider Settings, + Pinokio's app-specific credential handling and setup diagnostics recognize + both keys. The Street View tool prefers the server key across environment + and `.env` sources. Existing single-key and keyless setups remain supported. + - Complete the first-run, view-target prewarm, cockpit-plates and floor-hold browser harness renderer portability fixes contributed by Tom-Neverwinter. macOS retains Metal; other platforms default to SwiftShader. Cockpit renderer diff --git a/SECURITY.md b/SECURITY.md index 64689ca55..8ee9a1aff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,6 +20,7 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Two deliberately client-side keys — restrict them @@ -28,7 +29,9 @@ These are designed to be used directly in the browser (like a Mapbox public toke 1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. -> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side. +> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side. + +**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file. @@ -42,6 +45,17 @@ launcher layout and logs the submitted values. The ignored file is local plaintext, not encrypted storage. The macOS Keychain remains the stronger local option when launching through `./scripts/dev-fresh.sh`. +### Configuring separate Google keys locally + +Terminal development uses one ignored repository-root `.env` for both +`GOOGLE_MAPS_API_KEY` (browser) and `GOOGLE_MAPS_SERVER_API_KEY` (server). +The tracked `.env.example` documents both without credentials. Vite injects +only the browser key; sharing an environment file does not expose the server +key. Both entries are available in Provider Settings. Pinokio saves them in +its ignored `pinokio/ENVIRONMENT` instead, with app values and blanks taking +precedence over inherited global values. An absent server key retains the +browser-key fallback for existing single-key setups. + ## Server-side proxy hardening The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay: diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7353449ad..4485b1026 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,17 @@ # God's Eye View Current State +## Google browser and server keys + +Local Places nearby/text search and the CCTV Street View fallback prefer +`GOOGLE_MAPS_SERVER_API_KEY`, falling back to `GOOGLE_MAPS_API_KEY` when the +server key is blank or absent. Only the browser key is injected into client +code. Both are optional and configured in the same ignored root `.env`, or +Pinokio's ignored `pinokio/ENVIRONMENT`, through Provider Settings or manual +editing. `.env.example` and `pinokio/_ENVIRONMENT` document the two entries. +The Street View headings tool uses the same server-first selection after +resolving environment overrides per variable; its explicit `--key` wins. + + ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's diff --git a/pinokio/_ENVIRONMENT b/pinokio/_ENVIRONMENT index 0cb4302bb..243f60615 100644 --- a/pinokio/_ENVIRONMENT +++ b/pinokio/_ENVIRONMENT @@ -9,7 +9,9 @@ # Do not enter credentials in Pinokio 8.0.40's native Configure panel: that # release saves this nested layout to the wrong path and logs submitted values. +# Browser: 3D tiles and geocoding; server: Places and Street View. # GOOGLE_MAPS_API_KEY= +# GOOGLE_MAPS_SERVER_API_KEY= # CESIUM_ION_TOKEN= # OPENAI_API_KEY= # AISSTREAM_API_KEY= diff --git a/pinokio/install.js b/pinokio/install.js index a774a72a0..3e9b72d7c 100644 --- a/pinokio/install.js +++ b/pinokio/install.js @@ -16,6 +16,7 @@ module.exports = { // child also reads the raw app ENVIRONMENT file because Pinokio removes // blank fields before constructing this merged template environment. env: { + GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', diff --git a/pinokio/start.js b/pinokio/start.js index 21a2deec7..a3ae847d3 100644 --- a/pinokio/start.js +++ b/pinokio/start.js @@ -8,6 +8,7 @@ module.exports = { env: { HOST: '127.0.0.1', PORT: '{{port}}', + GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', diff --git a/pinokio/update.js b/pinokio/update.js index 89a09316c..7d9a9a457 100644 --- a/pinokio/update.js +++ b/pinokio/update.js @@ -7,6 +7,7 @@ module.exports = { // Update reuses the install doctor. The child re-reads raw app // ENVIRONMENT so blank fields override Pinokio-global values too. env: { + GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index 0b019321f..ed4e1e108 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -30,6 +30,7 @@ CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}" # the same value, without misclassifying values that dev-fresh loaded from .env. KEY_SETUP_EXTERNAL_KEYS=() [[ -n "${GOOGLE_MAPS_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_API_KEY) +[[ -n "${GOOGLE_MAPS_SERVER_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_SERVER_API_KEY) [[ -n "${CESIUM_ION_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(CESIUM_ION_TOKEN) [[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY) [[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY) diff --git a/scripts/google-server-key.mjs b/scripts/google-server-key.mjs new file mode 100644 index 000000000..fe5531a7a --- /dev/null +++ b/scripts/google-server-key.mjs @@ -0,0 +1,5 @@ +/** Resolve server credentials after applying environment overrides per key. */ +export function resolveGoogleServerKey(environment = {}, defaults = {}) { + const value = (name) => String(environment[name] ?? defaults[name] ?? '').trim(); + return value('GOOGLE_MAPS_SERVER_API_KEY') || value('GOOGLE_MAPS_API_KEY'); +} diff --git a/scripts/pinokio-environment.mjs b/scripts/pinokio-environment.mjs index 745e23d9d..b32b7c18b 100644 --- a/scripts/pinokio-environment.mjs +++ b/scripts/pinokio-environment.mjs @@ -8,6 +8,7 @@ const DEFAULT_ENVIRONMENT_FILE = path.join(ROOT, 'pinokio', 'ENVIRONMENT'); export const PINOKIO_CONFIG_FIELDS = Object.freeze([ 'GOOGLE_MAPS_API_KEY', + 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index ddad3aa65..37c01156e 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -10,6 +10,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export const CREDENTIALS = Object.freeze([ { name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] }, + { name: 'GOOGLE_MAPS_SERVER_API_KEY', label: 'Google Places / Street View server', keychain: [] }, { name: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] }, { name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] }, { name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] }, diff --git a/src/googlePlacesKeyless.test.mjs b/src/googlePlacesKeyless.test.mjs index fb702f556..8e8a3dd43 100644 --- a/src/googlePlacesKeyless.test.mjs +++ b/src/googlePlacesKeyless.test.mjs @@ -55,6 +55,8 @@ test('builds the keyless capability response only for a blank key', () => { }); test('keyless Places routes stay successful after the Google quota is exhausted', async () => { + const previousServerKey = process.env.GOOGLE_MAPS_SERVER_API_KEY; + process.env.GOOGLE_MAPS_SERVER_API_KEY = ''; const previousKey = process.env.GOOGLE_MAPS_API_KEY; const previousLimit = process.env.GEV_RATELIMIT_GOOGLE_PER_MIN; process.env.GOOGLE_MAPS_API_KEY = ''; @@ -81,6 +83,8 @@ test('keyless Places routes stay successful after the Google quota is exhausted' assert.deepEqual(textResponse.body, KEYLESS_PAYLOAD); } } finally { + if (previousServerKey === undefined) delete process.env.GOOGLE_MAPS_SERVER_API_KEY; + else process.env.GOOGLE_MAPS_SERVER_API_KEY = previousServerKey; if (previousKey === undefined) delete process.env.GOOGLE_MAPS_API_KEY; else process.env.GOOGLE_MAPS_API_KEY = previousKey; if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_GOOGLE_PER_MIN; diff --git a/src/googleServerKey.test.mjs b/src/googleServerKey.test.mjs new file mode 100644 index 000000000..0851afcd5 --- /dev/null +++ b/src/googleServerKey.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import config, { googleServerApiKey, googlePlacesContextProxy } from '../vite.config.js'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadApiKey } from '../tools/streetview-headings.mjs'; + +/** Run fn with the two Google key env vars set to the given values, then restore. */ +function withKeys({ server, browser }, fn) { + const previous = { + GOOGLE_MAPS_SERVER_API_KEY: process.env.GOOGLE_MAPS_SERVER_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + }; + const apply = (name, value) => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + }; + apply('GOOGLE_MAPS_SERVER_API_KEY', server); + apply('GOOGLE_MAPS_API_KEY', browser); + try { + return fn(); + } finally { + for (const [name, value] of Object.entries(previous)) apply(name, value); + } +} + +test('server-side Google calls prefer the server-only key', () => { + withKeys({ server: 'server-key', browser: 'browser-key' }, () => { + assert.equal(googleServerApiKey(), 'server-key'); + }); +}); + +test('unsplit setups still work: falls back to the browser key', () => { + // The whole point of #33 being opt-in — one shared GOOGLE_MAPS_API_KEY must + // keep serving Places/Street View exactly as before. + withKeys({ server: undefined, browser: 'browser-key' }, () => { + assert.equal(googleServerApiKey(), 'browser-key'); + }); + withKeys({ server: '', browser: 'browser-key' }, () => { + assert.equal(googleServerApiKey(), 'browser-key'); + }); +}); + +test('keyless stays keyless', () => { + withKeys({ server: undefined, browser: undefined }, () => { + assert.ok(!googleServerApiKey()); + }); +}); + +test('the Street View tool resolves per-variable overrides before preferring the server key', () => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-streetview-key-')); + const envPath = path.join(root, '.env'); + try { + writeFileSync(envPath, 'GOOGLE_MAPS_API_KEY=file-browser\nGOOGLE_MAPS_SERVER_API_KEY="file-server" # separate key\n'); + assert.equal(loadApiKey(null, { envPath, environment: { GOOGLE_MAPS_API_KEY: 'shell-browser' } }), 'file-server'); + assert.equal(loadApiKey(null, { envPath, environment: { GOOGLE_MAPS_SERVER_API_KEY: 'shell-server' } }), 'shell-server'); + assert.equal(loadApiKey(null, { envPath, environment: { GOOGLE_MAPS_SERVER_API_KEY: '' } }), 'file-browser'); + assert.equal(loadApiKey('explicit-key', { envPath, environment: {} }), 'explicit-key'); + assert.equal(loadApiKey(null, { envPath: root, environment: { GOOGLE_MAPS_SERVER_API_KEY: 'shell-server' } }), 'shell-server'); + writeFileSync(envPath, 'GOOGLE_MAPS_API_KEY=file-browser\n'); + assert.equal(loadApiKey(null, { envPath, environment: {} }), 'file-browser'); + writeFileSync(envPath, ''); + assert.throws(() => loadApiKey(null, { envPath, environment: {} }), /No API key found/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('both Places routes select the intended key and keep it out of responses', async (t) => { + const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN }; + const calls = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url, key: options.headers['X-Goog-Api-Key'] }); + return Response.json({ places: [] }); + }); + try { + process.env.GEV_RATELIMIT_GOOGLE_PER_MIN = ''; + for (const [server, browser, expected] of [ + ['server-secret', 'browser-public', 'server-secret'], + ['server-secret', '', 'server-secret'], + ['', 'browser-public', 'browser-public'], + [' ', 'browser-public', 'browser-public'], + ['', '', null], + ]) { + process.env.GOOGLE_MAPS_SERVER_API_KEY = server; + process.env.GOOGLE_MAPS_API_KEY = browser; + for (const install of ['configureServer', 'configurePreviewServer']) { + const routes = new Map(); + googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } }); + assert.equal(routes.size, 2); + for (const handler of routes.values()) { + const before = calls.length; + let body; + const res = { setHeader() {}, end(value) { body = value; } }; + await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); + assert.equal(res.statusCode, 200); + assert.ok(!body.includes('server-secret')); + if (expected) { + assert.equal(calls.length, before + 1); + assert.equal(calls.at(-1).key, expected); + } else { + assert.equal(calls.length, before); + assert.equal(JSON.parse(body).configured, false); + } + } + } + } + } finally { + for (const [name, value] of Object.entries({ GOOGLE_MAPS_SERVER_API_KEY: original.server, GOOGLE_MAPS_API_KEY: original.browser, GEV_RATELIMIT_GOOGLE_PER_MIN: original.limit })) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +}); + +test('browser defines contain the browser key and exclude the server key', () => { + withKeys({ server: 'server-secret', browser: 'browser-public' }, () => { + const defines = config({ mode: 'test' }).define; + assert.equal(defines['import.meta.env.GOOGLE_MAPS_API_KEY'], '"browser-public"'); + assert.ok(!JSON.stringify(defines).includes('server-secret')); + assert.ok(!Object.keys(defines).some((key) => key.includes('SERVER_API_KEY'))); + }); +}); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 030565a92..33e200a69 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -14,7 +14,7 @@ /** Longest accepted key/token value. Real provider keys are all far shorter. */ export const KEY_SETUP_VALUE_LIMIT = 512; -/** Most env vars accepted in one save. The registry defines nine. */ +/** Most env vars accepted in one save. The registry defines ten. */ export const KEY_SETUP_UPDATE_LIMIT = 16; /** Header line written above keys the panel appends to a .env file. */ @@ -30,13 +30,21 @@ export const KEY_SETUP_APPEND_HEADER = '# Keys added by the in-app POWER UP pane export const KEY_SETUP_KEYS = Object.freeze([ Object.freeze({ id: 'google-maps', - title: 'GOOGLE MAPS', + title: 'GOOGLE MAPS — BROWSER', unlocks: 'The photorealistic 3D planet + place search', getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key', envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']), tier: 'metered', clientExposed: true, }), + Object.freeze({ + id: 'google-maps-server', + title: 'GOOGLE MAPS — SERVER', + unlocks: 'Places context + Street View fallback; optional separate key', + getUrl: 'https://developers.google.com/maps/documentation/places/web-service/get-api-key', + envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']), + tier: 'metered', + }), Object.freeze({ id: 'openai', title: 'OPENAI', diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs index 3624e8490..10430efc9 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -341,3 +341,16 @@ test('validation rejects dotenv metacharacters that would round-trip wrong', () assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, true, `${good} accepted`); } }); + +test('server Google key can be saved and removed without appearing in status values', () => { + const secret = 'server-key-fixture'; + assert.deepEqual(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }), { + ok: true, updates: { GOOGLE_MAPS_SERVER_API_KEY: secret }, + }); + assert.equal(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok, true); + const status = keySetupStatus({ GOOGLE_MAPS_SERVER_API_KEY: secret }); + const entry = status.keys.find((key) => key.id === 'google-maps-server'); + assert.equal(entry.set, true); + assert.ok(!entry.clientExposed); + assert.ok(!JSON.stringify(status).includes(secret)); +}); diff --git a/src/pinokioEnvironment.test.mjs b/src/pinokioEnvironment.test.mjs index 5702f7334..817774c8d 100644 --- a/src/pinokioEnvironment.test.mjs +++ b/src/pinokioEnvironment.test.mjs @@ -19,6 +19,7 @@ function encodeUtf16be(source) { const PROVIDER_FIELDS = [ 'GOOGLE_MAPS_API_KEY', + 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', @@ -206,3 +207,22 @@ for (const fixture of [ } }); } + +test('server Google key follows app values, blanks and absence instead of inherited credentials', () => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-pinokio-server-key-')); + try { + const filepath = path.join(root, 'ENVIRONMENT'); + for (const [source, expected] of [ + ['GOOGLE_MAPS_SERVER_API_KEY=app-server\n', 'app-server'], + ['GOOGLE_MAPS_SERVER_API_KEY=\n', ''], + ['', ''], + ]) { + writeFileSync(filepath, source); + const environment = { GOOGLE_MAPS_SERVER_API_KEY: 'global-server' }; + applyPinokioEnvironment({ environment, filepath }); + assert.equal(environment.GOOGLE_MAPS_SERVER_API_KEY, expected); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index d4fe88965..24c9a9908 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -12,6 +12,7 @@ const require = createRequire(import.meta.url); const PROVIDER_FIELDS = [ 'GOOGLE_MAPS_API_KEY', + 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 263d3b4e5..857a96c78 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -148,6 +148,7 @@ test('doctor reads the dotenv ladder without requiring Vite to be installed', () test('doctor describes the credential ladder without exposing values', () => { const credentials = { GOOGLE_MAPS_API_KEY: { configured: false }, + GOOGLE_MAPS_SERVER_API_KEY: { configured: false }, CESIUM_ION_TOKEN: { configured: true, source: 'environment' }, OPENAI_API_KEY: { configured: true, source: 'dotenv files' }, AISSTREAM_API_KEY: { configured: false }, @@ -191,6 +192,7 @@ test('doctor describes the credential ladder without exposing values', () => { test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as presence only', () => { const credentials = Object.fromEntries([ 'GOOGLE_MAPS_API_KEY', + 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', @@ -222,6 +224,7 @@ test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as test('doctor never calls a dependency-missing setup ready', () => { const credentials = Object.fromEntries([ 'GOOGLE_MAPS_API_KEY', + 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', diff --git a/tools/streetview-headings.mjs b/tools/streetview-headings.mjs index f69ed8d60..60a33f85b 100644 --- a/tools/streetview-headings.mjs +++ b/tools/streetview-headings.mjs @@ -24,6 +24,8 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parseEnv } from 'node:util'; +import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); @@ -57,25 +59,25 @@ function parseArgs() { return opts; } -function loadApiKey(overrideKey) { +/** The explicit CLI key wins; otherwise prefer the server key across both stores. */ +export function loadApiKey(overrideKey, { + environment = process.env, + envPath = join(PROJECT_ROOT, '.env'), +} = {}) { if (overrideKey) return overrideKey; - if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY; - + const serverKey = resolveGoogleServerKey({ + GOOGLE_MAPS_SERVER_API_KEY: environment.GOOGLE_MAPS_SERVER_API_KEY, + }); + if (serverKey) return serverKey; + let fromDotenv = {}; try { - const envPath = join(PROJECT_ROOT, '.env'); - const envContent = readFileSync(envPath, 'utf8'); - for (const line of envContent.split('\n')) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') || !trimmed.includes('=')) continue; - const eqIdx = trimmed.indexOf('='); - const key = trimmed.slice(0, eqIdx).trim(); - const val = trimmed.slice(eqIdx + 1).trim(); - if (key === 'GOOGLE_MAPS_API_KEY' && val) return val; - } - } catch { /* ignore */ } - - console.error('Error: No API key found. Set GOOGLE_MAPS_API_KEY in .env or pass --key.'); - process.exit(1); + fromDotenv = parseEnv(readFileSync(envPath, 'utf8')); + } catch (error) { + if (error.code !== 'ENOENT') throw new Error('Could not read the Street View tool environment file'); + } + const key = resolveGoogleServerKey(environment, fromDotenv); + if (key) return key; + throw new Error('No API key found. Set GOOGLE_MAPS_SERVER_API_KEY (or GOOGLE_MAPS_API_KEY) in .env or pass --key.'); } // --------------------------------------------------------------------------- @@ -253,7 +255,9 @@ async function main() { console.log('\nDone.'); } -main().catch((err) => { - console.error('\nError:', err.message); - process.exit(1); -}); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error('\nError:', err.message); + process.exitCode = 1; + }); +} diff --git a/vite.config.js b/vite.config.js index 73a296247..32601acab 100644 --- a/vite.config.js +++ b/vite.config.js @@ -25,6 +25,7 @@ * @module vite.config */ +import { resolveGoogleServerKey } from './scripts/google-server-key.mjs'; import fs from 'node:fs'; import os from 'node:os'; import { promises as fsp } from 'node:fs'; @@ -4563,9 +4564,15 @@ function cctvProxy() { }; }; - /** Fetch a Google Street View static image as a fallback frame. Requires GOOGLE_MAPS_API_KEY. */ + /** + * Fetch a Google Street View static image as a fallback frame. Server-side + * call, never reaches the browser — prefers GOOGLE_MAPS_SERVER_API_KEY + * (#33: a key scoped to Street View Static/Places, restricted by server IP + * rather than HTTP referrer) and falls back to the browser-exposed + * GOOGLE_MAPS_API_KEY for setups that haven't split the two yet. + */ const streetViewFallback = async ({ lat, lon, heading, fov, pitch }) => { - const streetViewKey = process.env.GOOGLE_MAPS_API_KEY; + const streetViewKey = googleServerApiKey(); if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon)) return null; try { const sv = new URL('https://maps.googleapis.com/maps/api/streetview'); @@ -5388,6 +5395,18 @@ export function keylessGooglePlacesResponse(apiKey) { }; } +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, the + * CCTV Street View fallback). These never reach the browser, so this key can + * be restricted by server IP and scoped to Places API + Street View Static + * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + + * Geocoding for the browser (#33). Splitting them is opt-in: unset, this + * falls back to the shared browser key and nothing changes. + */ +export function googleServerApiKey() { + return resolveGoogleServerKey(process.env); +} + /** * Vite plugin: nearby Google place labels for Realtime scene context. * @@ -5408,7 +5427,7 @@ export function googlePlacesContextProxy() { // 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 apiKey = googleServerApiKey(); const keyless = keylessGooglePlacesResponse(apiKey); if (keyless) { res.statusCode = keyless.statusCode; @@ -5527,7 +5546,7 @@ export function googlePlacesContextProxy() { // 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 apiKey = googleServerApiKey(); const keyless = keylessGooglePlacesResponse(apiKey); if (keyless) { res.statusCode = keyless.statusCode;