From 30766e33e29d4f1ce94c773ee21ecba67b703fd7 Mon Sep 17 00:00:00 2001 From: Manav Jaiswal Date: Fri, 1 May 2026 18:36:15 -0400 Subject: [PATCH] Share auth keys between supervise and user create --- .../docs/cli/commands-and-runtime-params.md | 2 +- commands/lib/supervisor/AGENTS.md | 7 +- commands/lib/supervisor/auth_keys.js | 93 +++---------------- commands/supervise.js | 4 +- tests/supervise_command_test.mjs | 36 +++++++ 5 files changed, 54 insertions(+), 88 deletions(-) diff --git a/app/L0/_all/mod/_core/documentation/docs/cli/commands-and-runtime-params.md b/app/L0/_all/mod/_core/documentation/docs/cli/commands-and-runtime-params.md index eac0c9c8..98e621b4 100644 --- a/app/L0/_all/mod/_core/documentation/docs/cli/commands-and-runtime-params.md +++ b/app/L0/_all/mod/_core/documentation/docs/cli/commands-and-runtime-params.md @@ -134,7 +134,7 @@ Current supervisor options: Supervisor state: - default state directory: `/supervisor` -- shared child auth keys: `auth/auth_keys.json`, unless `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` are already injected +- shared child auth keys: the same server-side auth store used by `node space user create`, which defaults to `server/data/auth_keys.json` unless `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` are already injected - staged source releases: `releases//` The supervisor intentionally avoids changing `server/` lifecycle code. Its only runtime assumptions about a child are that `node space serve` prints the existing listening URL line and that `/api/health` succeeds after startup. diff --git a/commands/lib/supervisor/AGENTS.md b/commands/lib/supervisor/AGENTS.md index b076c091..b0ddc2df 100644 --- a/commands/lib/supervisor/AGENTS.md +++ b/commands/lib/supervisor/AGENTS.md @@ -10,7 +10,7 @@ The supervisor keeps `server/` agnostic: it does not require server-owned cluste Current files: -- `auth_keys.js`: supervisor-owned shared auth-key storage and environment injection for child servers +- `auth_keys.js`: supervisor auth-key loading and environment injection for child servers - `child_process.js`: `space serve` child startup, readiness detection, health checking, crash/stop handling, and proxied-stream activity tracking - `git_releases.js`: Git remote polling, release cloning, dependency installation, and release metadata - `http_proxy.js`: public HTTP and upgrade proxying to the active child @@ -32,7 +32,7 @@ Stable behavior: - supervisor state defaults to `/supervisor` - staged releases live under that project-root supervisor directory, separate from both the live source files and `CUSTOMWARE_PATH` - auto-update polling uses `--auto-update-interval`, defaults to `300` seconds, and is disabled when the interval is less than or equal to `0` -- auth keys are either inherited from `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` or generated once under supervisor state and injected into every child +- auth keys are either inherited from `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` or loaded from the shared server-side auth store and injected into every child - `supervise` should stay independent from server runtime-param parsing so new `space serve` flags can flow through without a supervisor-specific change - the watched update repository is resolved in shared order: `--remote-url`, then `GIT_URL`, then the local `origin` remote URL, then the canonical fallback - GitHub update checks and staged release clones use the same `SPACE_GITHUB_TOKEN` auth rule as `node space update`, and send no GitHub auth header when that variable is unset @@ -49,9 +49,10 @@ The public command owns argument parsing. Helper modules should receive normaliz Runtime state written by this subtree: -- `/supervisor/auth/auth_keys.json` by default, unless auth keys are injected through environment variables - `/supervisor/releases//` release directories by default +Shared auth keys are sourced from the same loader used by the server-side auth subsystem, so the supervisor reuses `server/data/auth_keys.json` by default unless environment-injected secrets are already present. + External commands used by this subtree: - `git` for source checkout discovery, remote polling, cloning, and exact checkout diff --git a/commands/lib/supervisor/auth_keys.js b/commands/lib/supervisor/auth_keys.js index 14721f12..dd44e465 100644 --- a/commands/lib/supervisor/auth_keys.js +++ b/commands/lib/supervisor/auth_keys.js @@ -1,22 +1,11 @@ -import { randomBytes } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; +import { loadAuthKeys } from "../../../server/lib/auth/keys_manage.js"; -const AUTH_KEYS_FILENAME = "auth_keys.json"; const PASSWORD_SEAL_KEY_ENV_NAME = "SPACE_AUTH_PASSWORD_SEAL_KEY"; const SESSION_HMAC_KEY_ENV_NAME = "SPACE_AUTH_SESSION_HMAC_KEY"; const PASSWORD_SEAL_KEY_NAME = "password_seal_key"; const SESSION_HMAC_KEY_NAME = "session_hmac_key"; const SECRET_KEY_LENGTH = 32; -function encodeBase64Url(value) { - return Buffer.from(value).toString("base64url"); -} - -function decodeBase64Url(value) { - return Buffer.from(String(value || ""), "base64url"); -} - function parseSecretKey(record, fieldName, sourceName) { const rawValue = String(record?.[fieldName] || "").trim(); @@ -24,78 +13,18 @@ function parseSecretKey(record, fieldName, sourceName) { throw new Error(`Missing ${fieldName} in ${sourceName}.`); } - const decoded = decodeBase64Url(rawValue); - if (decoded.length !== SECRET_KEY_LENGTH) { + if (Buffer.from(rawValue, "base64url").length !== SECRET_KEY_LENGTH) { throw new Error(`Invalid ${fieldName} length in ${sourceName}.`); } return rawValue; } -function createAuthKeysPayload() { - return { - created_at: new Date().toISOString(), - [PASSWORD_SEAL_KEY_NAME]: encodeBase64Url(randomBytes(SECRET_KEY_LENGTH)), - [SESSION_HMAC_KEY_NAME]: encodeBase64Url(randomBytes(SECRET_KEY_LENGTH)) - }; -} - -async function readJsonFile(filePath) { - return JSON.parse(await fs.readFile(filePath, "utf8")); -} - -async function readOrCreateSupervisorAuthKeys(stateDir) { - const dataDir = path.join(stateDir, "auth"); - const filePath = path.join(dataDir, AUTH_KEYS_FILENAME); - - await fs.mkdir(dataDir, { - mode: 0o700, - recursive: true - }); - await fs.chmod(dataDir, 0o700).catch(() => {}); - - try { - const payload = await readJsonFile(filePath); - - return { - filePath, - passwordSealKey: parseSecretKey(payload, PASSWORD_SEAL_KEY_NAME, filePath), - sessionHmacKey: parseSecretKey(payload, SESSION_HMAC_KEY_NAME, filePath), - source: filePath - }; - } catch (error) { - if (error.code !== "ENOENT") { - throw error; - } - } - - const payload = createAuthKeysPayload(); - const sourceText = `${JSON.stringify(payload, null, 2)}\n`; - - try { - await fs.writeFile(filePath, sourceText, { - encoding: "utf8", - flag: "wx", - mode: 0o600 - }); - await fs.chmod(filePath, 0o600).catch(() => {}); - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - } - - const storedPayload = await readJsonFile(filePath); - - return { - filePath, - passwordSealKey: parseSecretKey(storedPayload, PASSWORD_SEAL_KEY_NAME, filePath), - sessionHmacKey: parseSecretKey(storedPayload, SESSION_HMAC_KEY_NAME, filePath), - source: filePath - }; +function encodeAuthKey(value) { + return Buffer.isBuffer(value) ? Buffer.from(value).toString("base64url") : String(value || ""); } -async function loadSupervisorAuthEnv({ env = process.env, stateDir }) { +async function loadSupervisorAuthEnv({ env = process.env, projectRoot }) { const passwordSealKey = String(env[PASSWORD_SEAL_KEY_ENV_NAME] || "").trim(); const sessionHmacKey = String(env[SESSION_HMAC_KEY_ENV_NAME] || "").trim(); @@ -115,17 +44,17 @@ async function loadSupervisorAuthEnv({ env = process.env, stateDir }) { [SESSION_HMAC_KEY_ENV_NAME]: sessionHmacKey }, source: "process.env" - }; - } + }; + } - const keys = await readOrCreateSupervisorAuthKeys(stateDir); + const keys = loadAuthKeys(projectRoot, env); return { env: { - [PASSWORD_SEAL_KEY_ENV_NAME]: keys.passwordSealKey, - [SESSION_HMAC_KEY_ENV_NAME]: keys.sessionHmacKey + [PASSWORD_SEAL_KEY_ENV_NAME]: encodeAuthKey(keys.passwordSealKey), + [SESSION_HMAC_KEY_ENV_NAME]: encodeAuthKey(keys.sessionHmacKey) }, - source: keys.source + source: keys.filePath || keys.source || "server/data/auth_keys.json" }; } diff --git a/commands/supervise.js b/commands/supervise.js index 753f3d35..0596f1a7 100644 --- a/commands/supervise.js +++ b/commands/supervise.js @@ -341,7 +341,7 @@ export const help = { "node space supervise --auto-update-interval 0 CUSTOMWARE_PATH=/srv/space/customware" ], description: - "Starts a production-ready public reverse-proxy supervisor, runs real space serve children on private loopback ports, periodically stages source updates in release directories when the auto-update interval is greater than zero, and switches to a healthy replacement child. The supervisor only owns its own flags plus the public bind host and port; every other CLI argument is forwarded to space serve unchanged except that child HOST and PORT are forced to loopback and CUSTOMWARE_PATH is normalized to an absolute shared-state path.", + "Starts a production-ready public reverse-proxy supervisor, runs real space serve children on private loopback ports, periodically stages source updates in release directories when the auto-update interval is greater than zero, and switches to a healthy replacement child. The supervisor reuses the same local auth-key store as the server-side auth loader unless auth secrets are injected directly. It only owns its own flags plus the public bind host and port; every other CLI argument is forwarded to space serve unchanged except that child HOST and PORT are forced to loopback and CUSTOMWARE_PATH is normalized to an absolute shared-state path.", options: [ { flag: "--branch ", @@ -395,7 +395,7 @@ export async function execute(context) { const releasesDir = path.join(stateDir, "releases"); const auth = await loadSupervisorAuthEnv({ env: process.env, - stateDir + projectRoot: context.projectRoot }); const updateSource = await resolveSupervisorSource( { diff --git a/tests/supervise_command_test.mjs b/tests/supervise_command_test.mjs index 5045961f..941b4749 100644 --- a/tests/supervise_command_test.mjs +++ b/tests/supervise_command_test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { loadSupervisorAuthEnv } from "../commands/lib/supervisor/auth_keys.js"; import { __test as superviseTest } from "../commands/supervise.js"; import { buildServeProcessTitle, buildSupervisorProcessTitle } from "../server/lib/utils/process_title.js"; @@ -78,6 +81,39 @@ test("supervise defaults state dir to project-root supervisor folder", () => { ); }); +test("supervise shares the server data auth store with user creation by default", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "space-agent-")); + const authDir = path.join(projectRoot, "server", "data"); + const authFile = path.join(authDir, "auth_keys.json"); + const passwordSealKey = Buffer.alloc(32, 1).toString("base64url"); + const sessionHmacKey = Buffer.alloc(32, 2).toString("base64url"); + + try { + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + authFile, + `${JSON.stringify( + { + created_at: "2026-05-01T00:00:00.000Z", + password_seal_key: passwordSealKey, + session_hmac_key: sessionHmacKey + }, + null, + 2 + )}\n`, + "utf8" + ); + + const auth = await loadSupervisorAuthEnv({ env: {}, projectRoot }); + + assert.equal(auth.source, authFile); + assert.equal(auth.env.SPACE_AUTH_PASSWORD_SEAL_KEY, passwordSealKey); + assert.equal(auth.env.SPACE_AUTH_SESSION_HMAC_KEY, sessionHmacKey); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } +}); + test("runtime process titles stay distinct and short enough for htop-style listings", () => { assert.equal(buildSupervisorProcessTitle(), "space-supervise"); assert.equal(buildServeProcessTitle(), "space-serve");