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 <[email protected]>
Co-authored-by: Sameh Khamis <[email protected]>
This commit is contained in:
Tom-NeverwinterandSameh Khamis authored and GitHub committed 2026-09-11 15:08:23 -07:00
1 parent e9b6214ec4
commit 205c58efbd
21 files changed
+282 -28

No files matched your search

+13
View File
@@ -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 # God's Eye View — environment variables
# Copy to .env and fill in your keys. On macOS the launcher can also read keys # 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. # 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. # available in the map tray and as the automatic provider-failure fallback.
# CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud. # CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud.
GOOGLE_MAPS_API_KEY= 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 # Optional: opt-in per-IP rate limit for the Google Places cost endpoint
# (/api/google/nearby-places), in requests per minute. # (/api/google/nearby-places), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior. # DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
+6
View File
@@ -15,6 +15,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Fixed ### 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 - Complete the first-run, view-target prewarm, cockpit-plates and floor-hold
browser harness renderer portability fixes contributed by Tom-Neverwinter. browser harness renderer portability fixes contributed by Tom-Neverwinter.
macOS retains Metal; other platforms default to SwiftShader. Cockpit renderer macOS retains Metal; other platforms default to SwiftShader. Cockpit renderer
+15 -1
View File
@@ -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 | | `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 | | `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` | | 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 ### 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. 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. 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. 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 plaintext, not encrypted storage. The macOS Keychain remains the stronger local
option when launching through `./scripts/dev-fresh.sh`. 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 ## Server-side proxy hardening
The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay: The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay:
+12
View File
@@ -1,5 +1,17 @@
# God's Eye View Current State # 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 ## Infrastructure marker visibility
Datacenter/dam registration uses fresh reusable factories with the application's Datacenter/dam registration uses fresh reusable factories with the application's
+2
View File
@@ -9,7 +9,9 @@
# Do not enter credentials in Pinokio 8.0.40's native Configure panel: that # 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. # 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_API_KEY=
# GOOGLE_MAPS_SERVER_API_KEY=
# CESIUM_ION_TOKEN= # CESIUM_ION_TOKEN=
# OPENAI_API_KEY= # OPENAI_API_KEY=
# AISSTREAM_API_KEY= # AISSTREAM_API_KEY=
+1
View File
@@ -16,6 +16,7 @@ module.exports = {
// child also reads the raw app ENVIRONMENT file because Pinokio removes // child also reads the raw app ENVIRONMENT file because Pinokio removes
// blank fields before constructing this merged template environment. // blank fields before constructing this merged template environment.
env: { env: {
GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}',
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
env: { env: {
HOST: '127.0.0.1', HOST: '127.0.0.1',
PORT: '{{port}}', PORT: '{{port}}',
GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}',
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
+1
View File
@@ -7,6 +7,7 @@ module.exports = {
// Update reuses the install doctor. The child re-reads raw app // Update reuses the install doctor. The child re-reads raw app
// ENVIRONMENT so blank fields override Pinokio-global values too. // ENVIRONMENT so blank fields override Pinokio-global values too.
env: { env: {
GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}',
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
+1
View File
@@ -30,6 +30,7 @@ CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
# the same value, without misclassifying values that dev-fresh loaded from .env. # the same value, without misclassifying values that dev-fresh loaded from .env.
KEY_SETUP_EXTERNAL_KEYS=() KEY_SETUP_EXTERNAL_KEYS=()
[[ -n "${GOOGLE_MAPS_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_API_KEY) [[ -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 "${CESIUM_ION_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(CESIUM_ION_TOKEN)
[[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY) [[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY)
[[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY) [[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY)
+5
View File
@@ -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');
}
+1
View File
@@ -8,6 +8,7 @@ const DEFAULT_ENVIRONMENT_FILE = path.join(ROOT, 'pinokio', 'ENVIRONMENT');
export const PINOKIO_CONFIG_FIELDS = Object.freeze([ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
'GOOGLE_MAPS_API_KEY', 'GOOGLE_MAPS_API_KEY',
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN', 'CESIUM_ION_TOKEN',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'AISSTREAM_API_KEY', 'AISSTREAM_API_KEY',
+1
View File
@@ -10,6 +10,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const CREDENTIALS = Object.freeze([ 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_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: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] },
{ name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] }, { name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] },
{ name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] }, { name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] },
+4
View File
@@ -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 () => { 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 previousKey = process.env.GOOGLE_MAPS_API_KEY;
const previousLimit = process.env.GEV_RATELIMIT_GOOGLE_PER_MIN; const previousLimit = process.env.GEV_RATELIMIT_GOOGLE_PER_MIN;
process.env.GOOGLE_MAPS_API_KEY = ''; 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); assert.deepEqual(textResponse.body, KEYLESS_PAYLOAD);
} }
} finally { } 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; if (previousKey === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = previousKey; else process.env.GOOGLE_MAPS_API_KEY = previousKey;
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_GOOGLE_PER_MIN; if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_GOOGLE_PER_MIN;
+124
View File
@@ -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')));
});
});
+10 -2
View File
@@ -14,7 +14,7 @@
/** Longest accepted key/token value. Real provider keys are all far shorter. */ /** Longest accepted key/token value. Real provider keys are all far shorter. */
export const KEY_SETUP_VALUE_LIMIT = 512; 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; export const KEY_SETUP_UPDATE_LIMIT = 16;
/** Header line written above keys the panel appends to a .env file. */ /** 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([ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'google-maps', id: 'google-maps',
title: 'GOOGLE MAPS', title: 'GOOGLE MAPS — BROWSER',
unlocks: 'The photorealistic 3D planet + place search', unlocks: 'The photorealistic 3D planet + place search',
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key', getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']), envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
tier: 'metered', tier: 'metered',
clientExposed: true, 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({ Object.freeze({
id: 'openai', id: 'openai',
title: 'OPENAI', title: 'OPENAI',
+13
View File
@@ -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`); 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));
});
+20
View File
@@ -19,6 +19,7 @@ function encodeUtf16be(source) {
const PROVIDER_FIELDS = [ const PROVIDER_FIELDS = [
'GOOGLE_MAPS_API_KEY', 'GOOGLE_MAPS_API_KEY',
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN', 'CESIUM_ION_TOKEN',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'AISSTREAM_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 });
}
});
+1
View File
@@ -12,6 +12,7 @@ const require = createRequire(import.meta.url);
const PROVIDER_FIELDS = [ const PROVIDER_FIELDS = [
'GOOGLE_MAPS_API_KEY', 'GOOGLE_MAPS_API_KEY',
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN', 'CESIUM_ION_TOKEN',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'AISSTREAM_API_KEY', 'AISSTREAM_API_KEY',
+3
View File
@@ -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', () => { test('doctor describes the credential ladder without exposing values', () => {
const credentials = { const credentials = {
GOOGLE_MAPS_API_KEY: { configured: false }, GOOGLE_MAPS_API_KEY: { configured: false },
GOOGLE_MAPS_SERVER_API_KEY: { configured: false },
CESIUM_ION_TOKEN: { configured: true, source: 'environment' }, CESIUM_ION_TOKEN: { configured: true, source: 'environment' },
OPENAI_API_KEY: { configured: true, source: 'dotenv files' }, OPENAI_API_KEY: { configured: true, source: 'dotenv files' },
AISSTREAM_API_KEY: { configured: false }, 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', () => { test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as presence only', () => {
const credentials = Object.fromEntries([ const credentials = Object.fromEntries([
'GOOGLE_MAPS_API_KEY', 'GOOGLE_MAPS_API_KEY',
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN', 'CESIUM_ION_TOKEN',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'AISSTREAM_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', () => { test('doctor never calls a dependency-missing setup ready', () => {
const credentials = Object.fromEntries([ const credentials = Object.fromEntries([
'GOOGLE_MAPS_API_KEY', 'GOOGLE_MAPS_API_KEY',
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN', 'CESIUM_ION_TOKEN',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'AISSTREAM_API_KEY', 'AISSTREAM_API_KEY',
+25 -21
View File
@@ -24,6 +24,8 @@
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path'; import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url'; 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 __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..'); const PROJECT_ROOT = resolve(__dirname, '..');
@@ -57,25 +59,25 @@ function parseArgs() {
return opts; 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 (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 { try {
const envPath = join(PROJECT_ROOT, '.env'); fromDotenv = parseEnv(readFileSync(envPath, 'utf8'));
const envContent = readFileSync(envPath, 'utf8'); } catch (error) {
for (const line of envContent.split('\n')) { if (error.code !== 'ENOENT') throw new Error('Could not read the Street View tool environment file');
const trimmed = line.trim(); }
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue; const key = resolveGoogleServerKey(environment, fromDotenv);
const eqIdx = trimmed.indexOf('='); if (key) return key;
const key = trimmed.slice(0, eqIdx).trim(); throw new Error('No API key found. Set GOOGLE_MAPS_SERVER_API_KEY (or GOOGLE_MAPS_API_KEY) in .env or pass --key.');
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);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -253,7 +255,9 @@ async function main() {
console.log('\nDone.'); console.log('\nDone.');
} }
main().catch((err) => { if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
console.error('\nError:', err.message); main().catch((err) => {
process.exit(1); console.error('\nError:', err.message);
}); process.exitCode = 1;
});
}
+23 -4
View File
@@ -25,6 +25,7 @@
* @module vite.config * @module vite.config
*/ */
import { resolveGoogleServerKey } from './scripts/google-server-key.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { promises as fsp } from 'node:fs'; 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 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; if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
try { try {
const sv = new URL('https://maps.googleapis.com/maps/api/streetview'); 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. * 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 // Keyless place context has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the HUD // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD
// summary route). // summary route).
const apiKey = process.env.GOOGLE_MAPS_API_KEY; const apiKey = googleServerApiKey();
const keyless = keylessGooglePlacesResponse(apiKey); const keyless = keylessGooglePlacesResponse(apiKey);
if (keyless) { if (keyless) {
res.statusCode = keyless.statusCode; res.statusCode = keyless.statusCode;
@@ -5527,7 +5546,7 @@ export function googlePlacesContextProxy() {
// Keyless place context has no provider cost, so it resolves before the // Keyless place context has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the HUD // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD
// summary route). // summary route).
const apiKey = process.env.GOOGLE_MAPS_API_KEY; const apiKey = googleServerApiKey();
const keyless = keylessGooglePlacesResponse(apiKey); const keyless = keylessGooglePlacesResponse(apiKey);
if (keyless) { if (keyless) {
res.statusCode = keyless.statusCode; res.statusCode = keyless.statusCode;