Files
gods-eye-view/src/keySetupCore.mjs
T

641 lines
23 KiB
JavaScript

/**
* Key setup ("POWER UP") — the pure core.
*
* One registry and pure functions; it imports only the proxy-header check
* shared with the other local gates (localRequestGate.mjs) and the msg()
* marker (src/i18n/marker.js). The dev server's
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
* are both thin shells over this module, so what a key is called, what it
* unlocks, and how a .env line is written each live in exactly one place.
*
* Nothing here touches the filesystem, the network, or process.env — callers
* pass environments in and write text out, which is also what makes every
* behavior below unit-testable.
*/
import { msg } from './i18n/marker.js';
import { hasProxySignals } from './localRequestGate.mjs';
/** 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. */
export const KEY_SETUP_UPDATE_LIMIT = 16;
/** Header line written above keys the panel appends to a .env file. */
export const KEY_SETUP_APPEND_HEADER =
'# Keys added by the in-app POWER UP panel';
/**
* Provider credentials, in display order — most magic per
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
* billing-enabled account, 'free' (🟡) is a register-and-paste key, and
* 'paid' (🔴) is an optional paid higher-rate token.
* `clientExposed` marks the keys that are injected into the browser
* bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count.
*/
export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps',
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
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', // i18n-ignore: provider product name; row is hidden
unlocks: msg(
'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',
hidden: true,
}),
Object.freeze({
id: 'openai',
title: 'OPENAI', // i18n-ignore: provider product name
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered',
}),
Object.freeze({
id: 'gemini',
title: 'GEMINI', // i18n-ignore: provider product name
unlocks: msg('Gemini Live voice control'),
getUrl: 'https://aistudio.google.com/apikey',
envVars: Object.freeze(['GEMINI_API_KEY']),
tier: 'metered',
}),
Object.freeze({
id: 'aisstream',
title: 'AISSTREAM', // i18n-ignore: provider product name
unlocks: msg('Live ships, worldwide'),
getUrl: 'https://aisstream.io',
envVars: Object.freeze(['AISSTREAM_API_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'firms',
title: 'NASA FIRMS', // i18n-ignore: provider product name
unlocks: msg('Live active-fire detections'),
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
envVars: Object.freeze(['FIRMS_MAP_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'tomtom',
title: 'TOMTOM', // i18n-ignore: provider product name
unlocks: msg('Real live traffic (keyless runs a simulation)'),
getUrl: 'https://my.tomtom.com/keys',
envVars: Object.freeze(['TOMTOM_API_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'cesium-ion',
title: 'CESIUM ION', // i18n-ignore: provider product name
unlocks: msg('Bing imagery map stacks + world terrain'),
getUrl: 'https://ion.cesium.com/tokens',
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
tier: 'free',
clientExposed: true,
}),
Object.freeze({
id: 'opensky',
title: 'OPENSKY', // i18n-ignore: provider product name
unlocks: msg('More flight-polling credits (anonymous works without)'),
getUrl: 'https://opensky-network.org',
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
tier: 'free',
}),
Object.freeze({
id: 'mapillary',
title: 'MAPILLARY', // i18n-ignore: provider product name
unlocks: msg(
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
),
getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free',
clientExposed: true,
}),
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
unlocks: msg(
'Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.',
),
getUrl: 'https://thespacedevs.com/supportus',
envVars: Object.freeze(['LL2_API_TOKEN']),
tier: 'paid',
}),
]);
/** Hostnames a Provider Settings request may arrive under or originate from. */
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/** Socket addresses that count as this machine. */
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/**
* Whether a socket address is one the operator has explicitly declared to be
* this machine through GEV_KEY_SETUP_TRUSTED_PEERS: a comma-separated list of
* EXACT addresses, never wildcards, prefixes or hostnames. Unset trusts
* nothing, which is every launcher but one.
*
* The shipped use is the Docker launcher (scripts/docker-start.mjs). A port
* published on the host's loopback still reaches the container FROM the
* container network's gateway, never from 127.0.0.1, so the loopback check
* alone refuses the very machine running the server. Admitting that one
* address relaxes nothing else: the sharing, Host, Origin, Content-Type and
* proxy-header checks all still apply to it.
*/
function isTrustedPeerAddress(remoteAddress, env) {
const normalize = (value) =>
String(value || '')
.trim()
.toLowerCase()
.replace(/^::ffff:/, '');
const address = normalize(remoteAddress);
if (!address) return false;
return String(env?.GEV_KEY_SETUP_TRUSTED_PEERS || '')
.split(',')
.some((entry) => entry.trim() !== '' && normalize(entry) === address);
}
/** Parse an exact local request authority from a Host header. */
function localAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '')
.trim()
.toLowerCase();
const scheme = String(protocol || '').toLowerCase();
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw))
return null;
try {
const parsed = new URL(`${scheme}//${raw}`);
return LOCAL_HOSTNAMES.has(parsed.hostname.toLowerCase())
? parsed.origin
: null;
} catch {
return null;
}
}
/** True only for a subprocess that exited normally and successfully. */
export function commandCompletedSuccessfully(result) {
return !!result && !result.error && !result.signal && result.status === 0;
}
/** Parse one RFC-4180-shaped CSV record, sufficient for `whoami /fo csv`. */
function parseCsvRecord(text) {
const source = String(text || '')
.replace(/^\uFEFF/, '')
.trim();
if (!source || /[\r\n]/.test(source)) return null;
const fields = [];
let field = '';
let quoted = false;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
if (quoted) {
if (char === '"' && source[i + 1] === '"') {
field += '"';
i += 1;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
} else if (char === '"' && field === '') {
quoted = true;
} else if (char === ',') {
fields.push(field);
field = '';
} else {
field += char;
}
}
if (quoted) return null;
fields.push(field);
return fields;
}
/**
* Extract the current token's user SID from `whoami /user /fo csv /nh`.
* The SID must be the second CSV field and a user-shaped local/domain or Entra
* SID; matching an SID-looking account name or a broad group SID is forbidden.
*/
export function parseWindowsUserSid(stdout) {
const fields = parseCsvRecord(stdout);
if (!fields || fields.length !== 2) return null;
const sid = fields[1].trim();
return /^(?:S-1-5-21-(?:\d+-){3}\d+|S-1-12-1-(?:\d+-){3}\d+)$/i.test(sid)
? sid
: null;
}
/**
* Whether the launcher has sharing on. Every sharing signal the launcher
* recognizes (scripts/pinokio-preflight.mjs) counts, so the two sets cannot
* drift apart. One DELIBERATE divergence: preflight is a boot check that
* treats an empty PINOKIO_SHARE_VAR as sharing-on (fail closed before Start),
* but here an empty/unset value is the NORMAL git-clone and Pinokio state, so
* a bare/sentinel value is not sharing; only a real tunnel var is.
*/
export function isSharingEnabled(env = {}) {
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
return (
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
/^(1|true)$/i.test(String(env[name] || '').trim()),
) ||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__')
);
}
/**
* The admission gate for the Provider Settings endpoints — pure, exported so
* every refusal below is pinned by a unit assertion rather than a review note.
*
* Why each check exists:
* - sharing signals: any tunnel/LAN sharing mode disables the surface
* outright — a credential-writing endpoint has no business existing on a
* shared instance, and tunnel traffic reaches the server FROM loopback, so
* the socket check below cannot carry that boundary alone;
* - loopback socket: refuses LAN peers when the server is bound wide. The
* only exception is an exact address listed in
* GEV_KEY_SETUP_TRUSTED_PEERS, and every check below still applies to it;
* - local Host header: tunnel and DNS-rebinding traffic carries a foreign
* Host even when the socket says loopback;
* - exact same Origin on POST: a hostile web page can make a browser POST to
* localhost, and a non-browser caller must not bypass that boundary merely
* by omitting the header;
* - JSON Content-Type on POST: forces cross-origin browsers into a CORS
* preflight this server never answers, closing the simple-request CSRF
* write primitive.
*
* A refusal also carries `code`, which the panel turns into its own localized
* sentence (keySetup.js); `error` stays the English for logs and API callers.
* @returns {{ok: true} | {ok: false, status: number, error: string, code: string}}
*/
export function admitKeySetupRequest({
method,
remoteAddress,
hostHeader,
protocol = 'http:',
origin,
contentType,
proxyHeaders = {},
env = {},
} = {}) {
// A request carrying reverse-proxy / CDN forwarding headers did not originate
// on this machine, whatever its socket says. Refuse them outright as defense
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
// only appear when someone has deliberately fronted the dev server.
if (hasProxySignals(proxyHeaders)) {
return {
ok: false,
status: 403,
error: 'Provider Settings does not answer proxied requests',
code: 'proxied',
};
}
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
// also disables this surface — so the gate's set is complete, not a subset the
// two files could drift apart on. One DELIBERATE divergence: preflight is a
// boot check that treats an empty PINOKIO_SHARE_VAR as sharing-on (fail closed
// before Start), but here an empty/unset value is the NORMAL git-clone and
// Pinokio state — treating it as sharing would disable Provider Settings for
// every ordinary launch. So a bare/sentinel value is not sharing; only a real
// tunnel var is. This is defense in depth regardless: the loopback+Host checks
// below independently refuse LAN/tunnel traffic, and under Pinokio the launcher
// refuses to boot at all when sharing is genuinely on.
if (isSharingEnabled(env)) {
return {
ok: false,
status: 403,
error: 'Provider Settings is disabled while sharing is enabled',
code: 'sharing',
};
}
if (
!LOOPBACK_ADDRESSES.has(String(remoteAddress || '')) &&
!isTrustedPeerAddress(remoteAddress, env)
) {
return {
ok: false,
status: 403,
error: 'Provider Settings answers only the machine running the server',
code: 'not-loopback',
};
}
const authority = localAuthority(hostHeader, protocol);
if (!authority) {
return {
ok: false,
status: 403,
error: 'Provider Settings answers only local hostnames',
code: 'not-local-host',
};
}
if (
method === 'POST' &&
(origin === undefined || origin === null || origin === '')
) {
return {
ok: false,
status: 403,
error: 'Provider Settings requires an exact local Origin',
code: 'origin-required',
};
}
if (origin !== undefined && origin !== null && origin !== '') {
let parsedOrigin;
try {
parsedOrigin = new URL(String(origin));
} catch {
return {
ok: false,
status: 403,
error: 'Unrecognized Origin refused',
code: 'origin-unrecognized',
};
}
const exactOrigin =
parsedOrigin.username === '' &&
parsedOrigin.password === '' &&
parsedOrigin.pathname === '/' &&
parsedOrigin.search === '' &&
parsedOrigin.hash === '' &&
parsedOrigin.origin === authority;
if (!exactOrigin) {
return {
ok: false,
status: 403,
error: 'Cross-origin requests are refused',
code: 'cross-origin',
};
}
}
if (
method === 'POST' &&
!String(contentType || '')
.toLowerCase()
.startsWith('application/json')
) {
return {
ok: false,
status: 415,
error: 'Content-Type must be application/json',
code: 'content-type',
};
}
return { ok: true };
}
/** @returns {Set<string>} every env var the panel is allowed to write. */
export function knownKeySetupEnvVars() {
const names = new Set();
for (const entry of KEY_SETUP_KEYS) {
for (const envVar of entry.envVars) names.add(envVar);
}
return names;
}
/**
* Env-var names a registry entry needs, joined with ' + ', for callers that
* build the guidance sentence in the UI language (src/ui/keyGuidance.js).
* @param {string} id Registry entry id.
* @returns {string|null} null for an unknown or hidden entry.
*/
export function keySetupRequirementEnvVars(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
if (!entry || entry.hidden) return null;
return entry.envVars.join(' + ');
}
/** English tooltip guidance for a control gated by one registry entry. */
export function keySetupRequirement(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
if (!entry || entry.hidden) return '';
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
}
/**
* Key guidance that also names switched-off sources whose key is already set.
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
*/
export function keySetupWithAlternatives(requirement, alternatives = []) {
const options = alternatives.map(({ label, requiresKeyId }) => {
const entry = KEY_SETUP_KEYS.find(
(candidate) => candidate.id === requiresKeyId,
);
const name = entry
? entry.title
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
: null;
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
});
if (!options.length) return requirement;
const offer = `or ${options.join(', or ')}`;
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
}
/**
* Decide whether a live provider value belongs to a source outside the store
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
* provenance without carrying the credential itself; it closes the otherwise
* undecidable case where an exported value and a dotenv assignment happen to
* contain the same bytes.
* @param {{effectiveValue: unknown, storedValue: unknown, wasExternalAtBoot?: boolean}} input
*/
export function isKeySetupExternallyManaged({
effectiveValue,
storedValue,
wasExternalAtBoot = false,
} = {}) {
const effective = String(effectiveValue ?? '').trim();
const stored = String(storedValue ?? '').trim();
return effective !== '' && (wasExternalAtBoot || effective !== stored);
}
/**
* Build the status payload the panel renders from: the registry, plus
* per-entry `set` resolved against the given environment. It never includes
* a value, suffix, or other credential material.
* @param {Record<string, string|undefined>} env e.g. process.env
*/
export function keySetupStatus(env = {}) {
const keys = KEY_SETUP_KEYS.filter((entry) => !entry.hidden).map((entry) => {
const values = entry.envVars.map((name) => String(env[name] ?? '').trim());
const set = values.every((value) => value.length > 0);
return {
id: entry.id,
title: entry.title,
unlocks: entry.unlocks,
getUrl: entry.getUrl,
envVars: [...entry.envVars],
tier: entry.tier,
clientExposed: Boolean(entry.clientExposed),
set,
};
});
return {
keys,
setCount: keys.filter((key) => key.set).length,
total: keys.length,
};
}
/**
* Validate a POST body into a clean {ENV_VAR: value} map, or say exactly why
* not. Values must be single-line printable ASCII with no spaces — every real
* provider credential is — which is also what makes the raw `KEY=value` line
* below safe to write without quoting rules. A `null` value means REMOVE:
* the writer comments the assignment back out, returning the file to its
* template state for that key.
* A refusal carries `code` and `params` for the panel's localized sentence
* (keySetup.js) besides the English `error`.
* @param {unknown} body Parsed JSON from the request.
*/
export function validateKeySetupUpdates(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return {
ok: false,
error: 'Body must be a JSON object of {ENV_VAR: value}',
code: 'not-object',
};
}
const entries = Object.entries(body);
if (entries.length === 0)
return { ok: false, error: 'No keys provided', code: 'no-keys' };
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
return {
ok: false,
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
code: 'too-many',
params: { limit: KEY_SETUP_UPDATE_LIMIT },
};
}
const known = knownKeySetupEnvVars();
const updates = {};
for (const [name, raw] of entries) {
if (!known.has(name))
return {
ok: false,
error: `Unknown key: ${name}`,
code: 'unknown-key',
params: { name },
};
if (raw === null) {
updates[name] = null;
continue;
}
if (typeof raw !== 'string')
return {
ok: false,
error: `${name} must be a string`,
code: 'not-string',
params: { name },
};
const value = raw.trim();
if (!value)
return {
ok: false,
error: `${name} is empty`,
code: 'empty',
params: { name },
};
if (value.length > KEY_SETUP_VALUE_LIMIT) {
return {
ok: false,
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
code: 'too-long',
params: { name, limit: KEY_SETUP_VALUE_LIMIT },
};
}
if (!/^[\x21-\x7e]+$/.test(value)) {
return {
ok: false,
error: `${name} may only contain printable characters with no spaces`,
code: 'not-printable',
params: { name },
};
}
// Reject the dotenv metacharacters that would round-trip WRONG when written
// unquoted (# starts a comment, quotes redelimit, $ expands, backslash and
// backtick are escapes) — so a saved value can never differ from what Node's
// parseEnv and Vite's expansion read back. Real provider keys never contain
// these; they are base64url / hex / JWT alphabets.
if (/[#"'$\\`]/.test(value)) {
return {
ok: false,
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
code: 'invalid-char',
params: { name },
};
}
updates[name] = value;
}
return { ok: true, updates };
}
/**
* Upsert `KEY=value` lines into dotenv text, disturbing nothing else.
*
* Placement, per key: the LAST active assignment is replaced in place (last
* is what dotenv parsing lets win); failing that, the last commented-out
* assignment is uncommented in place, so a file copied from .env.example
* keeps its curated shape; failing both, the line is appended at the end
* under one shared header. Every untouched line — comments, blanks, other
* keys — survives byte for byte, and the result always ends in a newline.
*
* A `null` value REMOVES: every active assignment for that key is commented
* back out (`# KEY=`), returning the file to its template shape; a key with
* no active assignment is left untouched.
* @param {string} text Existing file content ('' births a new file).
* @param {Record<string, string|null>} updates Validated {ENV_VAR: value} map.
*/
export function upsertDotenvValues(text, updates) {
const source = typeof text === 'string' ? text : '';
const lines = source.length ? source.split(/\r?\n/) : [];
const additions = [];
for (const [name, value] of Object.entries(updates)) {
const active = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`);
const commented = new RegExp(`^\\s*#\\s*(?:export\\s+)?${name}\\s*=`);
if (value === null) {
lines.forEach((line, index) => {
if (active.test(line)) lines[index] = `# ${name}=`;
});
continue;
}
const assignment = `${name}=${value}`;
let lastActive = -1;
let lastCommented = -1;
lines.forEach((line, index) => {
if (active.test(line)) lastActive = index;
else if (commented.test(line)) lastCommented = index;
});
if (lastActive >= 0) lines[lastActive] = assignment;
else if (lastCommented >= 0) lines[lastCommented] = assignment;
else additions.push(assignment);
}
if (additions.length) {
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
if (!lines.some((line) => line.trim() === KEY_SETUP_APPEND_HEADER)) {
if (lines.length) lines.push('');
lines.push(KEY_SETUP_APPEND_HEADER);
}
lines.push(...additions);
}
const joined = lines.join('\n');
if (!joined) return '';
return joined.endsWith('\n') ? joined : `${joined}\n`;
}