mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44: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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user