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. // `code`/`params` pick the panel's sentence (src/keySetup.js); `error` is for logs. const refusal = ({ error, code, params }) => ({ error, code, params }); 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', code: 'method', }); const admission = admit(req); if (!admission.ok) return respond(res, admission.status, refusal(admission)); respond(res, 200, providerStatus()); }); server.middlewares.use('/api/setup/keys', (req, res) => { if (req.method !== 'POST') return respond(res, 405, { error: 'Method not allowed', code: 'method', }); const admission = admit(req); if (!admission.ok) return respond(res, admission.status, refusal(admission)); 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', code: 'too-large', }); let parsed; try { parsed = JSON.parse(body || '{}'); } catch { return respond(res, 400, { error: 'Invalid JSON', code: 'invalid-json', }); } const verdict = validateKeySetupUpdates(parsed); if (!verdict.ok) return respond(res, 400, refusal(verdict)); // 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`, code: 'external', params: { name }, }); } } 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}`, code: error.code === 'GEV_HARDEN_FAILED' ? 'harden-failed' : 'store-unreadable', }); } return respond(res, 500, { error: `Could not write the ${storeName()} store`, code: 'write-failed', params: { store: storeName() }, }); } // 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 };