mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
345 lines
15 KiB
JavaScript
345 lines
15 KiB
JavaScript
import {
|
|
knownKeySetupEnvVars,
|
|
admitKeySetupRequest,
|
|
isKeySetupExternallyManaged,
|
|
keySetupStatus,
|
|
validateKeySetupUpdates,
|
|
upsertDotenvValues,
|
|
} from '../../src/keySetupCore.mjs';
|
|
import { defaultSourceRoot } from '../providers/common/source-root.js';
|
|
import path from 'node:path';
|
|
import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
|
|
import fs from 'node:fs';
|
|
import { parseEnv as parseDotenvText } from 'node:util';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { hardenCredentialFile } from './key-setup-hardening.mjs';
|
|
|
|
/**
|
|
* Which launcher started this process, captured at MODULE LOAD — before the
|
|
* config factory's loadEnv() copies dotenv files into process.env. Provider
|
|
* Settings uses this to decide which credential store it owns, so it must
|
|
* reflect the real launcher (scripts/pinokio-start.mjs sets it) and never a
|
|
* value a project `.env` could inject.
|
|
*/
|
|
const LAUNCHER_AT_BOOT = process.env.GEV_LAUNCHER;
|
|
|
|
/**
|
|
* Provider values present before Vite loads the checkout's dotenv files.
|
|
* Memoized on globalThis: a panel save sets its values live on process.env and
|
|
* then calls server.restart(), which re-evaluates this config IN-PROCESS.
|
|
* Recomputing the snapshot there would classify the panel's own keys as
|
|
* external (read-only) until the whole process is relaunched.
|
|
*/
|
|
const PROVIDER_ENV_AT_BOOT = (globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??=
|
|
Object.freeze(
|
|
Object.fromEntries(
|
|
[...knownKeySetupEnvVars()].map((name) => [
|
|
name,
|
|
String(process.env[name] ?? '').trim(),
|
|
]),
|
|
),
|
|
));
|
|
|
|
/**
|
|
* `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so
|
|
* it supplies an explicit names-only provenance marker for values inherited
|
|
* from its parent shell. Plain Vite launches use the raw boot snapshot above;
|
|
* Pinokio deliberately treats its app-scoped ENVIRONMENT as authoritative.
|
|
*/
|
|
const DEV_FRESH_EXTERNAL_KEYS_AT_BOOT = new Set(
|
|
String(process.env.GEV_KEY_SETUP_EXTERNAL_KEYS ?? '')
|
|
.split(',')
|
|
.map((name) => name.trim())
|
|
.filter((name) => knownKeySetupEnvVars().has(name)),
|
|
);
|
|
|
|
/**
|
|
* In-app key setup ("POWER UP" panel) — dev-server only.
|
|
*
|
|
* GET /api/setup/status → which keys are configured, as presence plus a
|
|
* source classification. Never a value or suffix. The panel renders itself entirely from
|
|
* this payload, so the key registry stays in one place (src/keySetupCore.mjs).
|
|
* POST /api/setup/keys → validate {ENV_VAR: value} pairs and upsert them into
|
|
* the repo-root .env (created if absent), set process.env live, then restart
|
|
* the dev server so the client-exposed defines re-inject and the page
|
|
* reloads itself. Pasting a key in the app IS the whole setup — no
|
|
* hand-edited env files.
|
|
*
|
|
* Loopback-only on purpose: with HOST=0.0.0.0 the app can be shared on a LAN,
|
|
* and a guest must be able to neither write the host's .env nor probe which
|
|
* keys exist. Prod builds never register this middleware (apply: 'serve'), so
|
|
* the panel's status fetch fails and the client removes the whole surface.
|
|
*/
|
|
function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
|
const respond = (res, statusCode, payload) => {
|
|
res.statusCode = statusCode;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
// A credential-status response must never be cached by a proxy or the disk
|
|
// cache, and the surface must never be framed (clickjacking a same-origin
|
|
// REMOVE/replace past the Origin check).
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
|
res.end(JSON.stringify(payload));
|
|
};
|
|
// Which store this launch owns. A Pinokio-managed launch (marker set by
|
|
// scripts/pinokio-start.mjs) writes the app-scoped pinokio/ENVIRONMENT that
|
|
// applyPinokioEnvironment() treats as authoritative; every other launch
|
|
// writes the repo-root .env that Vite's loadEnv reads. The panel never
|
|
// touches a store some other workflow owns.
|
|
// The launcher marker is read from the BOOT environment captured before
|
|
// Vite's loadEnv merges dotenv files into process.env — otherwise a stray
|
|
// `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a
|
|
// plain `npm run dev` to write the Pinokio store it never loaded.
|
|
const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio';
|
|
const storeName = () =>
|
|
pinokioManaged() ? 'pinokio-environment' : 'env-file';
|
|
const storePath = () =>
|
|
path.join(
|
|
sourceRoot,
|
|
...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']),
|
|
);
|
|
// Read the store, distinguishing "no store yet" from "cannot read this
|
|
// store". Only ENOENT means empty. Every other failure — a permission error,
|
|
// an I/O fault, an undecodable file — must ABORT the save: upserting into a
|
|
// wrongly-empty string and atomically replacing the file would destroy every
|
|
// other provider key the user had configured.
|
|
const readStore = () => {
|
|
try {
|
|
// The Pinokio launcher deliberately supports a UTF-16 ENVIRONMENT (a
|
|
// Windows editor or the native Configure panel can write one). Reuse its
|
|
// own encoding-aware decoder so a panel write can never mistake UTF-16
|
|
// bytes for UTF-8, corrupt the file, and wedge the next launch. We always
|
|
// write back UTF-8, which is exactly what the launcher normalizes to.
|
|
if (pinokioManaged()) return readPinokioEnvironmentSource(storePath());
|
|
return fs.readFileSync(storePath(), 'utf8');
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return ''; // The first saved key births the file.
|
|
const unreadable = new Error(
|
|
'the existing configuration could not be read, so nothing was changed',
|
|
);
|
|
unreadable.code = 'GEV_STORE_UNREADABLE';
|
|
throw unreadable;
|
|
}
|
|
};
|
|
// Status must never fail because the store is unreadable — it reports the
|
|
// LIVE environment, and an unreadable store only costs file/external
|
|
// attribution. Persistence uses readStore() directly and refuses instead.
|
|
const storeValues = () => {
|
|
try {
|
|
return parseDotenvText(readStore());
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
// The gate itself is pure and unit-tested (admitKeySetupRequest in
|
|
// src/keySetupCore.mjs) — this just feeds it the request.
|
|
const admit = (req) =>
|
|
admitKeySetupRequest({
|
|
method: req.method,
|
|
remoteAddress: req.socket?.remoteAddress,
|
|
hostHeader: req.headers?.host,
|
|
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
|
origin: req.headers?.origin,
|
|
contentType: req.headers?.['content-type'],
|
|
proxyHeaders: req.headers || {},
|
|
env: process.env,
|
|
});
|
|
// Is this env var supplied by a workflow OTHER than this panel's store? Boot
|
|
// provenance closes the equal-value ambiguity: an exported X remains
|
|
// external even when the editable store independently contains X.
|
|
const isExternallyManaged = (name, inStore) => {
|
|
const wasExternalAtBoot = pinokioManaged()
|
|
? false
|
|
: LAUNCHER_AT_BOOT === 'dev-fresh'
|
|
? DEV_FRESH_EXTERNAL_KEYS_AT_BOOT.has(name)
|
|
: PROVIDER_ENV_AT_BOOT[name] !== '';
|
|
return isKeySetupExternallyManaged({
|
|
effectiveValue: process.env[name],
|
|
storedValue: inStore[name],
|
|
wasExternalAtBoot,
|
|
});
|
|
};
|
|
const providerStatus = () => {
|
|
const inStore = storeValues();
|
|
const status = keySetupStatus(process.env);
|
|
for (const key of status.keys) {
|
|
// 'file' = this panel's own store holds exactly this value (replace/remove
|
|
// offered); 'external' = supplied by env/Keychain/another workflow
|
|
// (read-only — the panel must never rewrite or delete it).
|
|
key.managed = key.set
|
|
? key.envVars.some((name) => isExternallyManaged(name, inStore))
|
|
? 'external'
|
|
: 'file'
|
|
: null;
|
|
}
|
|
return { ...status, store: storeName() };
|
|
};
|
|
// Atomically replace the store's content: fresh same-dir temp created 0600
|
|
// with the exclusive flag, fsync, rename over the target. Closes the window
|
|
// where writeFileSync leaves a 0644 file holding a real key before any later
|
|
// chmod, and the truncate-in-place data-loss path.
|
|
const persistStore = (text) => {
|
|
const filepath = storePath();
|
|
// Never write THROUGH a symlink into a credential path.
|
|
try {
|
|
if (fs.lstatSync(filepath).isSymbolicLink()) {
|
|
throw new Error(
|
|
'refusing to write a credential store that is a symlink',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error; // absent is fine — first save.
|
|
}
|
|
// Random suffix, not the pid: a stale temp from a failed rename would
|
|
// otherwise make every later save in this process fail EEXIST forever.
|
|
const tmp = path.join(
|
|
path.dirname(filepath),
|
|
`.${path.basename(filepath)}.${randomUUID().slice(0, 8)}.tmp`,
|
|
);
|
|
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
let staged = false;
|
|
try {
|
|
// Restrict the EMPTY temp file BEFORE the secret touches it. On Windows
|
|
// a fresh file inherits the directory's ACL (world-readable under a
|
|
// C:-rooted Pinokio home) and the 0600 open mode is a no-op — and NTFS
|
|
// renames carry the file object's ACL with it, so hardening the temp IS
|
|
// hardening the final file. Ordering this before the write means a
|
|
// hardening failure aborts with the previous store fully intact and the
|
|
// secret never on disk unprotected — no rollback path to get wrong.
|
|
if (!hardenCredentialFile(tmp)) {
|
|
const error = new Error(
|
|
'could not restrict the credential file to your account; nothing was saved',
|
|
);
|
|
error.code = 'GEV_HARDEN_FAILED';
|
|
throw error;
|
|
}
|
|
// writeSync may write fewer bytes than asked; loop until the whole
|
|
// buffer lands or a truncated store gets fsynced and renamed into place.
|
|
const buffer = Buffer.from(text, 'utf8');
|
|
let written = 0;
|
|
while (written < buffer.length) {
|
|
written += fs.writeSync(fd, buffer, written, buffer.length - written);
|
|
}
|
|
fs.fsyncSync(fd);
|
|
staged = true;
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
if (!staged) fs.rmSync(tmp, { force: true });
|
|
}
|
|
try {
|
|
fs.renameSync(tmp, filepath);
|
|
} catch (error) {
|
|
// Never strand a staged secret on disk when the swap itself fails.
|
|
fs.rmSync(tmp, { force: true });
|
|
throw error;
|
|
}
|
|
};
|
|
return {
|
|
name: 'gev-key-setup',
|
|
// serve AND not preview: `vite preview` resolves with command 'serve' too,
|
|
// so a bare apply:'serve' would still configure under preview. The endpoints
|
|
// only install via configureServer (never configurePreviewServer), so they
|
|
// are absent from preview today — but pinning apply here makes that a
|
|
// guarantee rather than an accident of which hook a future edit uses.
|
|
apply: (_config, { command, isPreview }) =>
|
|
command === 'serve' && !isPreview,
|
|
configureServer(server) {
|
|
server.middlewares.use('/api/setup/status', (req, res) => {
|
|
if (req.method !== 'GET')
|
|
return respond(res, 405, { error: 'Method not allowed' });
|
|
const admission = admit(req);
|
|
if (!admission.ok)
|
|
return respond(res, admission.status, { error: admission.error });
|
|
respond(res, 200, providerStatus());
|
|
});
|
|
server.middlewares.use('/api/setup/keys', (req, res) => {
|
|
if (req.method !== 'POST')
|
|
return respond(res, 405, { error: 'Method not allowed' });
|
|
const admission = admit(req);
|
|
if (!admission.ok)
|
|
return respond(res, admission.status, { error: admission.error });
|
|
let body = '';
|
|
let overflowed = false;
|
|
req.on('data', (chunk) => {
|
|
body += chunk;
|
|
if (body.length > 8192) {
|
|
overflowed = true;
|
|
req.destroy();
|
|
}
|
|
});
|
|
req.on('end', () => {
|
|
if (overflowed)
|
|
return respond(res, 413, { error: 'Request too large' });
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(body || '{}');
|
|
} catch {
|
|
return respond(res, 400, { error: 'Invalid JSON' });
|
|
}
|
|
const verdict = validateKeySetupUpdates(parsed);
|
|
if (!verdict.ok) return respond(res, 400, { error: verdict.error });
|
|
// Neither a replace NOR a removal may touch an externally-supplied
|
|
// credential (shell env, Keychain, another workflow). This backs the
|
|
// UI's read-only "configured externally" state with a real contract —
|
|
// and it must guard replace too, not just remove: a clickjacked or
|
|
// scripted same-origin POST could otherwise overwrite the live value.
|
|
const inStore = storeValues();
|
|
for (const name of Object.keys(verdict.updates)) {
|
|
if (isExternallyManaged(name, inStore)) {
|
|
return respond(res, 409, {
|
|
error: `${name} is configured outside Provider Settings and can only be changed where it was set`,
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
persistStore(upsertDotenvValues(readStore(), verdict.updates));
|
|
} catch (error) {
|
|
// The hardening failure carries its own honest, path-free message —
|
|
// "saved world-readable" must never be reported as a generic write
|
|
// error. Everything else returns a fixed message (a raw filesystem
|
|
// error can carry an absolute path; that stays in the server log).
|
|
if (
|
|
error?.code === 'GEV_HARDEN_FAILED' ||
|
|
error?.code === 'GEV_STORE_UNREADABLE'
|
|
) {
|
|
return respond(res, 500, {
|
|
error: `The key was not saved: ${error.message}`,
|
|
});
|
|
}
|
|
return respond(res, 500, {
|
|
error: `Could not write the ${storeName()} store`,
|
|
});
|
|
}
|
|
// Live for the server-side proxies immediately; the restart below is
|
|
// what re-injects the client-exposed defines (Google, Cesium ion).
|
|
// Removal sets '' rather than deleting: an empty value stays falsy
|
|
// through loadEnv after restart, matching the Pinokio launcher's own
|
|
// blank-field semantics.
|
|
for (const [name, value] of Object.entries(verdict.updates)) {
|
|
process.env[name] = value === null ? '' : value;
|
|
}
|
|
respond(res, 200, {
|
|
ok: true,
|
|
saved: Object.keys(verdict.updates),
|
|
status: providerStatus(),
|
|
restarting: true,
|
|
});
|
|
// One deliberate restart, after the response has flushed. Vite's own
|
|
// .env watcher may fire too; a second queued restart is harmless.
|
|
setTimeout(() => {
|
|
server.restart().catch((error) => {
|
|
console.warn(
|
|
'[KeySetup] Dev-server restart failed:',
|
|
error?.message || error,
|
|
);
|
|
});
|
|
}, 250);
|
|
});
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export { keySetupEndpoint };
|