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

+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 () => {
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;
+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. */
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',
+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`);
}
});
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 = [
'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 });
}
});
+1
View File
@@ -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',
+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', () => {
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',