feat(settings): set the Google Maps key through PLACES_API_KEY

The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.

GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.

Addresses discussion #1881.
This commit is contained in:
Maurice
2026-09-24 16:17:54 +02:00
parent 79cd3095e0
commit 8906b66bc8
45 changed files with 308 additions and 36 deletions
@@ -38,7 +38,7 @@ describe('OVERPASS_TIMEOUT_MS in the templates', () => {
describe('the Places and Amap switches in the templates', () => {
// The index is asked by default and the only way to stop that is this
// variable, so an operator has to be able to find it where they look.
const documented = ['TREK_PLACES_ENABLED', 'TREK_PLACES_URL', 'AMAP_API_KEY', 'AMAP_API_SECRET', 'AMAP_API_BASE'];
const documented = ['TREK_PLACES_ENABLED', 'TREK_PLACES_URL', 'PLACES_API_KEY', 'AMAP_API_KEY', 'AMAP_API_SECRET', 'AMAP_API_BASE'];
it('.env.example documents every one of them', () => {
for (const name of documented) {
@@ -66,12 +66,12 @@ describe('the Places and Amap switches in the templates', () => {
expect(helmConfigMap).toContain('{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}');
});
it('the Helm chart carries the Amap credentials in the Secret, never in the ConfigMap', () => {
it('the Helm chart carries the Google and Amap credentials in the Secret, never in the ConfigMap', () => {
// A ConfigMap is readable by anyone who can read the namespace; the key and
// its signing secret belong next to the other credentials, in both the
// plain and the generated branch of the Secret, and the pod reads them from
// there like it reads the Unsplash key.
for (const name of ['AMAP_API_KEY', 'AMAP_API_SECRET']) {
for (const name of ['PLACES_API_KEY', 'AMAP_API_KEY', 'AMAP_API_SECRET']) {
expect(helmValues).toContain(` ${name}: ""`);
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} | b64enc | quote }}`);
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} }}`);
@@ -32,6 +32,7 @@ import {
readInstanceApiKey,
writeInstanceApiKey,
resolveApiKey,
operatorKeyVariables,
} from '../../../../src/nest/settings/instance-api-keys';
const db = new DatabaseService(testDb);
@@ -137,3 +138,43 @@ describe('instance API keys', () => {
expect(resolveApiKey(db, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null });
});
});
describe('operatorKeyVariables (#1881)', () => {
const VARS = ['PLACES_API_KEY', 'UNSPLASH_ACCESS_KEY', 'AMAP_API_KEY'] as const;
const saved: Partial<Record<(typeof VARS)[number], string>> = {};
beforeEach(() => {
for (const v of VARS) {
saved[v] = process.env[v];
delete process.env[v];
}
});
afterAll(() => {
for (const v of VARS) {
if (saved[v] === undefined) delete process.env[v];
else process.env[v] = saved[v];
}
});
it('INSTKEY-010: names nothing while no variable is set', () => {
expect(operatorKeyVariables()).toEqual({});
});
it('INSTKEY-011: names the variable behind each key that has one, never its value', () => {
process.env.PLACES_API_KEY = 'google-from-env';
process.env.UNSPLASH_ACCESS_KEY = 'unsplash-from-env';
process.env.AMAP_API_KEY = 'amap-from-env';
const set = operatorKeyVariables();
expect(set).toEqual({
maps_api_key: 'PLACES_API_KEY',
unsplash_api_key: 'UNSPLASH_ACCESS_KEY',
amap_api_key: 'AMAP_API_KEY',
});
expect(JSON.stringify(set)).not.toContain('from-env');
});
it('INSTKEY-012: a blank variable does not count as set', () => {
process.env.PLACES_API_KEY = '';
process.env.UNSPLASH_ACCESS_KEY = ' ';
expect(operatorKeyVariables()).toEqual({});
});
});
@@ -157,6 +157,30 @@ describe('getSettings', () => {
// getSettings returns the stored key to the admin.
expect(profile.getSettings(user.id).settings?.unsplash_api_key).toBe('unsplash-secret-key');
});
it('AUTH-DB-010c: a key set in the environment comes back as its variable name, not a value (#1881)', () => {
const prev = process.env.PLACES_API_KEY;
process.env.PLACES_API_KEY = 'google-from-env';
try {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'stored-but-overridden', unsplash_api_key: 'stored-unsplash' });
const settings = profile.getSettings(user.id).settings;
// Neither the stored value nobody searches with nor the operator's own.
expect(settings?.maps_api_key).toBeNull();
expect(settings?.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' });
expect(JSON.stringify(settings)).not.toContain('google-from-env');
// A key without a variable reads as before.
expect(settings?.unsplash_api_key).toBe('stored-unsplash');
} finally {
if (prev === undefined) delete process.env.PLACES_API_KEY;
else process.env.PLACES_API_KEY = prev;
}
});
it('AUTH-DB-010d: without a variable env_keys is empty', () => {
const { user } = createAdmin(testDb);
expect(profile.getSettings(user.id).settings?.env_keys).toEqual({});
});
});
// ---------------------------------------------------------------------------