mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
e9b6214ec4
commit
205c58efbd
21 files changed
+282
-28
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 || ""}}',
|
||||
|
||||
@@ -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 || ""}}',
|
||||
|
||||
@@ -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 || ""}}',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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']] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
+23
-4
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user