From c34c0bda80410277550c6ddeadbf9346e8365f45 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 22 Dec 2025 14:06:34 +0100 Subject: [PATCH 01/14] feat(config): add configuration schema and environment handling for ENSRainbow application --- apps/ensrainbow/src/cli.ts | 13 +-- apps/ensrainbow/src/config/config.schema.ts | 85 +++++++++++++++++++ apps/ensrainbow/src/config/defaults.ts | 5 ++ apps/ensrainbow/src/config/environment.ts | 31 +++++++ apps/ensrainbow/src/config/index.ts | 4 + apps/ensrainbow/src/config/types.ts | 1 + apps/ensrainbow/src/config/validations.ts | 25 ++++++ apps/ensrainbow/src/lib/env.ts | 34 +++----- .../src/shared/config/zod-schemas.ts | 2 + 9 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 apps/ensrainbow/src/config/config.schema.ts create mode 100644 apps/ensrainbow/src/config/defaults.ts create mode 100644 apps/ensrainbow/src/config/environment.ts create mode 100644 apps/ensrainbow/src/config/index.ts create mode 100644 apps/ensrainbow/src/config/types.ts create mode 100644 apps/ensrainbow/src/config/validations.ts diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 3fdc0d530..cc721c2d6 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -18,7 +18,8 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command"; import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; -import { getDefaultDataSubDir, getEnvPort } from "@/lib/env"; +import { getDefaultDataDir } from "@/config/defaults"; +import { getEnvPort } from "@/lib/env"; export function validatePortConfiguration(cliPort: number): void { const envPort = process.env.PORT; @@ -85,7 +86,7 @@ export function createCLI(options: CLIOptions = {}) { // .option("data-dir", { // type: "string", // description: "Directory to store LevelDB data", - // default: getDefaultDataSubDir(), + // default: getDefaultDataDir(), // }); // }, // async (argv: ArgumentsCamelCase) => { @@ -108,7 +109,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { @@ -131,7 +132,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { @@ -150,7 +151,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }) .option("lite", { type: "boolean", @@ -173,7 +174,7 @@ export function createCLI(options: CLIOptions = {}) { return yargs.option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts new file mode 100644 index 000000000..d8844f2cf --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; + +import { prettifyError, ZodError, z } from "zod/v4"; + +import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; + +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; +import type { ENSRainbowEnvironment } from "@/config/environment"; +import { invariant_dataDirValid, invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { logger } from "@/utils/logger"; + +const DataDirSchema = z + .string() + .trim() + .min(1, { + error: "DATA_DIR must be a non-empty string.", + }) + .transform((path: string) => { + // Resolve relative paths to absolute paths + if (path.startsWith("/")) { + return path; + } + return join(process.cwd(), path); + }); + +const DbSchemaVersionSchema = z.coerce + .number({ error: "DB_SCHEMA_VERSION must be a number." }) + .int({ error: "DB_SCHEMA_VERSION must be an integer." }) + .optional(); + +const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); + +const ENSRainbowConfigSchema = z + .object({ + port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), + dataDir: DataDirSchema.default(getDefaultDataDir()), + dbSchemaVersion: DbSchemaVersionSchema, + labelSet: LabelSetSchema.optional(), + }) + /** + * Invariant enforcement + * + * We enforce invariants across multiple values parsed with `ENSRainbowConfigSchema` + * by calling `.check()` function with relevant invariant-enforcing logic. + * Each such function has access to config values that were already parsed. + */ + .check(invariant_dataDirValid) + .check(invariant_dbSchemaVersionMatch); + +export type ENSRainbowConfig = z.infer; + +/** + * Builds the ENSRainbow configuration object from an ENSRainbowEnvironment object. + * + * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. + * + * @returns A validated ENSRainbowConfig object + * @throws Error with formatted validation messages if environment parsing fails + */ +export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { + try { + return ENSRainbowConfigSchema.parse({ + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + labelSet: + env.LABEL_SET_ID || env.LABEL_SET_VERSION + ? { + labelSetId: env.LABEL_SET_ID, + labelSetVersion: env.LABEL_SET_VERSION, + } + : undefined, + }); + } catch (error) { + if (error instanceof ZodError) { + logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); + } else if (error instanceof Error) { + logger.error(error, `Failed to build ENSRainbowConfig`); + } else { + logger.error(`Unknown Error`); + } + + process.exit(1); + } +} diff --git a/apps/ensrainbow/src/config/defaults.ts b/apps/ensrainbow/src/config/defaults.ts new file mode 100644 index 000000000..528376734 --- /dev/null +++ b/apps/ensrainbow/src/config/defaults.ts @@ -0,0 +1,5 @@ +import { join } from "node:path"; + +export const ENSRAINBOW_DEFAULT_PORT = 3223; + +export const getDefaultDataDir = () => join(process.cwd(), "data"); diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts new file mode 100644 index 000000000..eed970cf5 --- /dev/null +++ b/apps/ensrainbow/src/config/environment.ts @@ -0,0 +1,31 @@ +import type { LogLevelEnvironment, PortEnvironment } from "@ensnode/ensnode-sdk/internal"; + +/** + * Represents the raw, unvalidated environment variables for the ENSRainbow application. + * + * Keys correspond to the environment variable names, and all values are optional strings, reflecting + * their state in `process.env`. This interface is intended to be the source type which then gets + * mapped/parsed into a structured configuration object like `ENSRainbowConfig`. + */ +export type ENSRainbowEnvironment = PortEnvironment & + LogLevelEnvironment & { + /** + * Directory path where the LevelDB database is stored. + */ + DATA_DIR?: string; + + /** + * Expected Database Schema Version. + */ + DB_SCHEMA_VERSION?: string; + + /** + * Expected Label Set ID. + */ + LABEL_SET_ID?: string; + + /** + * Expected Label Set Version. + */ + LABEL_SET_VERSION?: string; + }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts new file mode 100644 index 000000000..6404675c9 --- /dev/null +++ b/apps/ensrainbow/src/config/index.ts @@ -0,0 +1,4 @@ +export type { ENSRainbowConfig } from "./config.schema"; +export { buildConfigFromEnvironment } from "./config.schema"; +export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +export type { ENSRainbowEnvironment } from "./environment"; diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts new file mode 100644 index 000000000..cbf9c57be --- /dev/null +++ b/apps/ensrainbow/src/config/types.ts @@ -0,0 +1 @@ +export type { ENSRainbowConfig } from "./config.schema"; diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts new file mode 100644 index 000000000..dfc57a061 --- /dev/null +++ b/apps/ensrainbow/src/config/validations.ts @@ -0,0 +1,25 @@ +import type { z } from "zod/v4"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +import type { ENSRainbowConfig } from "./config.schema"; + +/** + * Zod `.check()` function input. + */ +type ZodCheckFnInput = z.core.ParsePayload; + +/** + * Invariant: dbSchemaVersion must match the version expected by the code. + */ +export function invariant_dbSchemaVersionMatch( + ctx: ZodCheckFnInput>, +): void { + const { value: config } = ctx; + + if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) { + throw new Error( + `DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`, + ); + } +} diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index 048f47ae4..d7ab219e1 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,24 +1,18 @@ -import { join } from "node:path"; +import { buildConfigFromEnvironment } from "@/config/config.schema"; +import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; +import type { ENSRainbowEnvironment } from "@/config/environment"; -import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk"; +/** + * @deprecated Use buildConfigFromEnvironment() instead. This constant is kept for backward compatibility. + */ +export const DEFAULT_PORT = ENSRAINBOW_DEFAULT_PORT; -import { logger } from "@/utils/logger"; - -export const getDefaultDataSubDir = () => join(process.cwd(), "data"); - -export const DEFAULT_PORT = 3223; +/** + * Gets the port from environment variables. + * + * @deprecated Use buildConfigFromEnvironment() instead. This function is kept for backward compatibility. + */ export function getEnvPort(): number { - const envPort = process.env.PORT; - if (!envPort) { - return DEFAULT_PORT; - } - - try { - const port = parseNonNegativeInteger(envPort); - return port; - } catch (_error: unknown) { - const errorMessage = `Invalid PORT value "${envPort}": must be a non-negative integer`; - logger.error(errorMessage); - throw new Error(errorMessage); - } + const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); + return config.port; } diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index aa99edb64..95ddad2b5 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -60,9 +60,11 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { /** * Parses a numeric value as a port number. + * Ensures the value is an integer (not a float) within the valid port range. */ export const PortSchema = z.coerce .number({ error: "PORT must be a number." }) + .int({ error: "PORT must be an integer." }) .min(1, { error: "PORT must be greater than 1." }) .max(65535, { error: "PORT must be less than 65535" }) .optional(); From bbe487e56d5df365f72171f85cc8b62032669a84 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 22 Dec 2025 14:41:35 +0100 Subject: [PATCH 02/14] chore: update dependencies and clean up imports in ENSRainbow configuration files --- apps/ensrainbow/package.json | 3 ++- apps/ensrainbow/src/cli.test.ts | 21 ++++++++++++++------- apps/ensrainbow/src/config/config.schema.ts | 3 +-- apps/ensrainbow/src/lib/env.ts | 8 -------- pnpm-lock.yaml | 3 +++ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/ensrainbow/package.json b/apps/ensrainbow/package.json index 88e149cc8..f4700b111 100644 --- a/apps/ensrainbow/package.json +++ b/apps/ensrainbow/package.json @@ -38,7 +38,8 @@ "progress": "^2.0.3", "protobufjs": "^7.4.0", "viem": "catalog:", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "catalog:" }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index ff9364a32..c3f3cdbaf 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_PORT, getEnvPort } from "@/lib/env"; +import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; +import { getEnvPort } from "@/lib/env"; import { createCLI, validatePortConfiguration } from "./cli"; @@ -38,8 +39,8 @@ describe("CLI", () => { }); describe("getEnvPort", () => { - it("should return DEFAULT_PORT when PORT is not set", () => { - expect(getEnvPort()).toBe(DEFAULT_PORT); + it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", () => { + expect(getEnvPort()).toBe(ENSRAINBOW_DEFAULT_PORT); }); it("should return port from environment variable", () => { @@ -50,14 +51,20 @@ describe("CLI", () => { it("should throw error for invalid port number", () => { process.env.PORT = "invalid"; - expect(() => getEnvPort()).toThrow( - 'Invalid PORT value "invalid": must be a non-negative integer', - ); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + expect(() => getEnvPort()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); }); it("should throw error for negative port number", () => { process.env.PORT = "-1"; - expect(() => getEnvPort()).toThrow('Invalid PORT value "-1": must be a non-negative integer'); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + expect(() => getEnvPort()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index d8844f2cf..e74f166c7 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -6,7 +6,7 @@ import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/ import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import { invariant_dataDirValid, invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { logger } from "@/utils/logger"; const DataDirSchema = z @@ -44,7 +44,6 @@ const ENSRainbowConfigSchema = z * by calling `.check()` function with relevant invariant-enforcing logic. * Each such function has access to config values that were already parsed. */ - .check(invariant_dataDirValid) .check(invariant_dbSchemaVersionMatch); export type ENSRainbowConfig = z.infer; diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index d7ab219e1..4de34ea46 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,16 +1,8 @@ import { buildConfigFromEnvironment } from "@/config/config.schema"; -import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -/** - * @deprecated Use buildConfigFromEnvironment() instead. This constant is kept for backward compatibility. - */ -export const DEFAULT_PORT = ENSRAINBOW_DEFAULT_PORT; - /** * Gets the port from environment variables. - * - * @deprecated Use buildConfigFromEnvironment() instead. This function is kept for backward compatibility. */ export function getEnvPort(): number { const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fec97a1..99c484151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: yargs: specifier: ^17.7.2 version: 17.7.2 + zod: + specifier: 'catalog:' + version: 3.25.76 devDependencies: '@ensnode/shared-configs': specifier: workspace:* From 22a81f061eefd0806490650700f16027f0b388d5 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 14:48:01 +0100 Subject: [PATCH 03/14] feat(config): build and export ENSRainbowConfig from environment variables --- apps/ensrainbow/src/config/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 6404675c9..00d40f5f3 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,4 +1,10 @@ +import { buildConfigFromEnvironment } from "./config.schema"; +import type { ENSRainbowEnvironment } from "./environment"; + export type { ENSRainbowConfig } from "./config.schema"; export { buildConfigFromEnvironment } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; + +// build, validate, and export the ENSRainbowConfig from process.env +export default buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); From 03722acbc3b81cd332f47bbe25c4bc801d1a76c8 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 16:27:42 +0100 Subject: [PATCH 04/14] refactor(tests): update CLI tests to use vi.stubEnv and async imports for environment variable handling --- apps/ensrainbow/src/cli.test.ts | 54 +++++++++++++++++++++------------ apps/ensrainbow/src/lib/env.ts | 4 +-- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index a8d674a47..650cb7216 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -43,27 +43,31 @@ describe("CLI", () => { expect(getEnvPort()).toBe(ENSRAINBOW_DEFAULT_PORT); }); - it("should return port from environment variable", () => { + it("should return port from environment variable", async () => { const customPort = 4000; - process.env.PORT = customPort.toString(); - expect(getEnvPort()).toBe(customPort); + vi.stubEnv("PORT", customPort.toString()); + vi.resetModules(); + const { getEnvPort: getEnvPortFresh } = await import("@/lib/env"); + expect(getEnvPortFresh()).toBe(customPort); }); - it("should throw error for invalid port number", () => { - process.env.PORT = "invalid"; + it("should throw error for invalid port number", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit called"); }) as never); - expect(() => getEnvPort()).toThrow(); + vi.stubEnv("PORT", "invalid"); + vi.resetModules(); + await expect(import("@/lib/env")).rejects.toThrow("process.exit called"); expect(exitSpy).toHaveBeenCalledWith(1); }); - it("should throw error for negative port number", () => { - process.env.PORT = "-1"; + it("should throw error for negative port number", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit called"); }) as never); - expect(() => getEnvPort()).toThrow(); + vi.stubEnv("PORT", "-1"); + vi.resetModules(); + await expect(import("@/lib/env")).rejects.toThrow("process.exit called"); expect(exitSpy).toHaveBeenCalledWith(1); }); }); @@ -73,14 +77,18 @@ describe("CLI", () => { expect(() => validatePortConfiguration(3000)).not.toThrow(); }); - it("should not throw when PORT matches CLI port", () => { - process.env.PORT = "3000"; - expect(() => validatePortConfiguration(3000)).not.toThrow(); + it("should not throw when PORT matches CLI port", async () => { + vi.stubEnv("PORT", "3000"); + vi.resetModules(); + const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); + expect(() => validatePortConfigurationFresh(3000)).not.toThrow(); }); - it("should throw when PORT conflicts with CLI port", () => { - process.env.PORT = "3000"; - expect(() => validatePortConfiguration(4000)).toThrow("Port conflict"); + it("should throw when PORT conflicts with CLI port", async () => { + vi.stubEnv("PORT", "3000"); + vi.resetModules(); + const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); + expect(() => validatePortConfigurationFresh(4000)).toThrow("Port conflict"); }); }); @@ -533,11 +541,14 @@ describe("CLI", () => { it("should respect PORT environment variable", async () => { const customPort = 5115; - process.env.PORT = customPort.toString(); + vi.stubEnv("PORT", customPort.toString()); + vi.resetModules(); + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithCustomPort = createCLIFresh({ exitProcess: false }); // First ingest some test data const ensrainbowOutputFile = join(TEST_FIXTURES_DIR, "test_ens_names_0.ensrainbow"); - await cli.parse([ + await cliWithCustomPort.parse([ "ingest-ensrainbow", "--input-file", ensrainbowOutputFile, @@ -546,7 +557,7 @@ describe("CLI", () => { ]); // Start server - const serverPromise = cli.parse(["serve", "--data-dir", testDataDir]); + const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]); // Give server time to start await new Promise((resolve) => setTimeout(resolve, 100)); @@ -594,9 +605,12 @@ describe("CLI", () => { }); it("should throw on port conflict", async () => { - process.env.PORT = "5000"; + vi.stubEnv("PORT", "5000"); + vi.resetModules(); + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithPort = createCLIFresh({ exitProcess: false }); await expect( - cli.parse(["serve", "--port", "4000", "--data-dir", testDataDir]), + cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]), ).rejects.toThrow("Port conflict"); }); }); diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index 4de34ea46..e58ccc55e 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,10 +1,8 @@ -import { buildConfigFromEnvironment } from "@/config/config.schema"; -import type { ENSRainbowEnvironment } from "@/config/environment"; +import config from "@/config"; /** * Gets the port from environment variables. */ export function getEnvPort(): number { - const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); return config.port; } From ec75cfef2f456a405dc3dade1e0e9c93993300a2 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 16:45:02 +0100 Subject: [PATCH 05/14] fix(cli): improve port validation logic to use configured port instead of environment variable --- apps/ensrainbow/src/cli.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 110086122..c4a85334e 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -14,14 +14,16 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command"; import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; +import config from "@/config"; import { getDefaultDataDir } from "@/config/defaults"; import { getEnvPort } from "@/lib/env"; export function validatePortConfiguration(cliPort: number): void { - const envPort = process.env.PORT; - if (envPort !== undefined && cliPort !== getEnvPort()) { + // Only validate if PORT was explicitly set in the environment + // If PORT is not set, CLI port can override the default + if (process.env.PORT !== undefined && cliPort !== config.port) { throw new Error( - `Port conflict: Command line argument (${cliPort}) differs from PORT environment variable (${envPort}). ` + + `Port conflict: Command line argument (${cliPort}) differs from configured port (${config.port}). ` + `Please use only one method to specify the port.`, ); } From 887aeccff5e3ec6d1fb157ebc0a8fb88db5ec3b2 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 16:48:23 +0100 Subject: [PATCH 06/14] fix lint --- apps/ensrainbow/src/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index c4a85334e..96b33d67f 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,3 +1,5 @@ +import config from "@/config"; + import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,7 +16,6 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command"; import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; -import config from "@/config"; import { getDefaultDataDir } from "@/config/defaults"; import { getEnvPort } from "@/lib/env"; From bd1dd1c36066634843ca4ce23319d5880b1850a1 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 14:12:07 +0100 Subject: [PATCH 07/14] refactor(cli): update CLI to use configured port and improve test imports for environment variables --- apps/ensrainbow/src/cli.test.ts | 6 ++++-- apps/ensrainbow/src/cli.ts | 5 ++--- apps/ensrainbow/src/config/validations.ts | 7 +------ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 650cb7216..a12073f28 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -39,8 +39,10 @@ describe("CLI", () => { }); describe("getEnvPort", () => { - it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", () => { - expect(getEnvPort()).toBe(ENSRAINBOW_DEFAULT_PORT); + it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", async () => { + vi.resetModules(); + const { getEnvPort: getEnvPortFresh } = await import("@/lib/env"); + expect(getEnvPortFresh()).toBe(ENSRAINBOW_DEFAULT_PORT); }); it("should return port from environment variable", async () => { diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 96b33d67f..48bf0ae95 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import config from "@/config"; +import config, { getDefaultDataDir } from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,7 +16,6 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command"; import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; -import { getDefaultDataDir } from "@/config/defaults"; import { getEnvPort } from "@/lib/env"; export function validatePortConfiguration(cliPort: number): void { @@ -134,7 +133,7 @@ export function createCLI(options: CLIOptions = {}) { .option("port", { type: "number", description: "Port to listen on", - default: getEnvPort(), + default: config.port, }) .option("data-dir", { type: "string", diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index dfc57a061..82f54bc74 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,14 +1,9 @@ -import type { z } from "zod/v4"; +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; import { DB_SCHEMA_VERSION } from "@/lib/database"; import type { ENSRainbowConfig } from "./config.schema"; -/** - * Zod `.check()` function input. - */ -type ZodCheckFnInput = z.core.ParsePayload; - /** * Invariant: dbSchemaVersion must match the version expected by the code. */ From 1ddfc33e5e49bbfc591b6b8fa2510f2b41b5bb30 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 14:42:47 +0100 Subject: [PATCH 08/14] refactor(config): enhance path resolution and improve error handling in validations --- apps/ensrainbow/src/cli.test.ts | 7 ++++--- apps/ensrainbow/src/cli.ts | 1 - apps/ensrainbow/src/config/config.schema.ts | 8 ++++---- apps/ensrainbow/src/config/validations.ts | 9 ++++++--- packages/ensnode-sdk/src/shared/config/zod-schemas.ts | 4 ++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index a12073f28..820f082f1 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; -import { getEnvPort } from "@/lib/env"; import { createCLI, validatePortConfiguration } from "./cli"; @@ -75,8 +74,10 @@ describe("CLI", () => { }); describe("validatePortConfiguration", () => { - it("should not throw when PORT env var is not set", () => { - expect(() => validatePortConfiguration(3000)).not.toThrow(); + it("should not throw when PORT env var is not set", async () => { + vi.resetModules(); + const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); + expect(() => validatePortConfigurationFresh(3000)).not.toThrow(); }); it("should not throw when PORT matches CLI port", async () => { diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 48bf0ae95..81547ef5c 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -16,7 +16,6 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command"; import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; -import { getEnvPort } from "@/lib/env"; export function validatePortConfiguration(cliPort: number): void { // Only validate if PORT was explicitly set in the environment diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index e74f166c7..f3f5be73a 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; @@ -16,11 +16,11 @@ const DataDirSchema = z error: "DATA_DIR must be a non-empty string.", }) .transform((path: string) => { - // Resolve relative paths to absolute paths - if (path.startsWith("/")) { + // Resolve relative paths to absolute paths (cross-platform) + if (isAbsolute(path)) { return path; } - return join(process.cwd(), path); + return resolve(process.cwd(), path); }); const DbSchemaVersionSchema = z.coerce diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 82f54bc74..0de092eb1 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -13,8 +13,11 @@ export function invariant_dbSchemaVersionMatch( const { value: config } = ctx; if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) { - throw new Error( - `DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`, - ); + ctx.issues.push({ + code: "custom", + path: ["dbSchemaVersion"], + input: config.dbSchemaVersion, + message: `DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`, + }); } } diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 95ddad2b5..49490c74a 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -65,8 +65,8 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { export const PortSchema = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) - .min(1, { error: "PORT must be greater than 1." }) - .max(65535, { error: "PORT must be less than 65535" }) + .min(1, { error: "PORT must be greater than or equal to 1." }) + .max(65535, { error: "PORT must be less than or equal to 65535" }) .optional(); export const TheGraphApiKeySchema = z.string().optional(); From 782e32963eafdaeda4d00b72a8f3525b4447c992 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 14:54:30 +0100 Subject: [PATCH 09/14] test(config): add comprehensive tests for buildConfigFromEnvironment function to validate environment variable handling --- .../src/config/config.schema.test.ts | 450 ++++++++++++++++++ .../src/shared/config/zod-schemas.ts | 2 +- 2 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 apps/ensrainbow/src/config/config.schema.test.ts diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts new file mode 100644 index 000000000..d208241cc --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -0,0 +1,450 @@ +import { isAbsolute, resolve } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; +import { logger } from "@/utils/logger"; + +import { buildConfigFromEnvironment } from "./config.schema"; +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +import type { ENSRainbowEnvironment } from "./environment"; + +vi.mock("@/utils/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("buildConfigFromEnvironment", () => { + // Mock process.exit to prevent actual exit + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + mockExit.mockClear(); + }); + + describe("Success cases", () => { + it("returns a valid config with all defaults when environment is empty", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: getDefaultDataDir(), + dbSchemaVersion: undefined, + labelSet: undefined, + }); + }); + + it("applies custom port when PORT is set", () => { + const env: ENSRainbowEnvironment = { + PORT: "5000", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(5000); + expect(config.dataDir).toBe(getDefaultDataDir()); + }); + + it("applies custom DATA_DIR when set", () => { + const customDataDir = "/var/lib/ensrainbow/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: customDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(customDataDir); + }); + + it("normalizes relative DATA_DIR to absolute path", () => { + const relativeDataDir = "my-data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("resolves nested relative DATA_DIR correctly", () => { + const relativeDataDir = "./data/ensrainbow/db"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("preserves absolute DATA_DIR", () => { + const absoluteDataDir = "/absolute/path/to/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: absoluteDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(absoluteDataDir); + }); + + it("applies DB_SCHEMA_VERSION when set and matches code version", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("allows DB_SCHEMA_VERSION to be undefined", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBeUndefined(); + }); + + it("applies full label set configuration when both ID and version are set", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "0", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.labelSet).toStrictEqual({ + labelSetId: "subgraph", + labelSetVersion: 0, + }); + }); + + it("handles all valid configuration options together", () => { + const env: ENSRainbowEnvironment = { + PORT: "4444", + DATA_DIR: "/opt/ensrainbow/data", + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + LABEL_SET_ID: "ens-normalize-latest", + LABEL_SET_VERSION: "2", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: 4444, + dataDir: "/opt/ensrainbow/data", + dbSchemaVersion: DB_SCHEMA_VERSION, + labelSet: { + labelSetId: "ens-normalize-latest", + labelSetVersion: 2, + }, + }); + }); + }); + + describe("Validation errors", () => { + it("fails when PORT is not a number", () => { + const env: ENSRainbowEnvironment = { + PORT: "not-a-number", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is a float", () => { + const env: ENSRainbowEnvironment = { + PORT: "3000.5", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is less than 1", () => { + const env: ENSRainbowEnvironment = { + PORT: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is negative", () => { + const env: ENSRainbowEnvironment = { + PORT: "-100", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is greater than 65535", () => { + const env: ENSRainbowEnvironment = { + PORT: "65536", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DATA_DIR is empty string", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: "", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DATA_DIR is only whitespace", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: " ", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DB_SCHEMA_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "not-a-number", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DB_SCHEMA_VERSION is a float", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "3.5", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when LABEL_SET_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "not-a-number", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when LABEL_SET_VERSION is negative", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "-1", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when LABEL_SET_ID is empty", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "", + LABEL_SET_VERSION: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when only LABEL_SET_ID is set (both ID and version required)", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when only LABEL_SET_VERSION is set (both ID and version required)", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_VERSION: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); + + describe("Invariant: DB_SCHEMA_VERSION must match code version", () => { + it("fails when DB_SCHEMA_VERSION does not match code version", () => { + const wrongVersion = DB_SCHEMA_VERSION + 1; + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: wrongVersion.toString(), + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + // Verify the error message mentions version mismatch + const errorCall = vi.mocked(logger.error).mock.calls[0]; + expect(errorCall[0]).toContain("Failed to parse environment configuration"); + expect(errorCall[0]).toContain("DB_SCHEMA_VERSION mismatch"); + expect(errorCall[0]).toContain(DB_SCHEMA_VERSION.toString()); + expect(errorCall[0]).toContain(wrongVersion.toString()); + }); + + it("passes when DB_SCHEMA_VERSION matches code version", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + expect(logger.error).not.toHaveBeenCalled(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it("passes when DB_SCHEMA_VERSION is undefined", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBeUndefined(); + expect(logger.error).not.toHaveBeenCalled(); + expect(process.exit).not.toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("handles PORT at minimum valid value (1)", () => { + const env: ENSRainbowEnvironment = { + PORT: "1", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(1); + }); + + it("handles PORT at maximum valid value (65535)", () => { + const env: ENSRainbowEnvironment = { + PORT: "65535", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(65535); + }); + + it("handles DB_SCHEMA_VERSION of 0", () => { + // This test assumes 0 is not the current DB_SCHEMA_VERSION + // If DB_SCHEMA_VERSION is 0, this test would pass which is correct + if (DB_SCHEMA_VERSION === 0) { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "0", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(0); + } else { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + } + }); + + it("handles LABEL_SET_VERSION of 0", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "0", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.labelSet?.labelSetVersion).toBe(0); + }); + + it("trims whitespace from DATA_DIR", () => { + const dataDir = "/my/path/to/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: ` ${dataDir} `, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(dataDir); + }); + + it("handles DATA_DIR with .. (parent directory)", () => { + const relativeDataDir = "../data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("handles DATA_DIR with ~ (not expanded, treated as relative)", () => { + // Note: The config schema does NOT expand ~ to home directory + // It would be treated as a relative path + const tildeDataDir = "~/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: tildeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + // ~ is treated as a directory name, not home expansion + expect(config.dataDir).toBe(resolve(process.cwd(), tildeDataDir)); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 49490c74a..1c4fd5f7b 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -65,7 +65,7 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { export const PortSchema = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) - .min(1, { error: "PORT must be greater than or equal to 1." }) + .min(1, { error: "PORT must be greater than or equal to 1" }) .max(65535, { error: "PORT must be less than or equal to 65535" }) .optional(); From 2e1f9d9df89f6eefb75f6292720c459bf052cd1a Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 15:03:04 +0100 Subject: [PATCH 10/14] feat(api): add public configuration endpoint and enhance ENSRainbowApiClient with config method --- apps/ensrainbow/src/config/config.schema.ts | 24 ++++++++ apps/ensrainbow/src/config/index.ts | 2 +- apps/ensrainbow/src/lib/api.ts | 29 ++++++++++ packages/ensrainbow-sdk/src/client.ts | 64 +++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index f3f5be73a..1fe85fcc0 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -1,8 +1,12 @@ +import packageJson from "@/../package.json" with { type: "json" }; + import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; +import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; @@ -82,3 +86,23 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb process.exit(1); } } + +/** + * Builds the ENSRainbow public configuration from an ENSRainbowConfig object and server state. + * + * @param config - The validated ENSRainbowConfig object + * @param labelSet - The label set managed by the ENSRainbow server + * @param recordsCount - The total count of records managed by the ENSRainbow service + * @returns A complete ENSRainbowPublicConfig object + */ +export function buildENSRainbowPublicConfig( + config: ENSRainbowConfig, + labelSet: EnsRainbowServerLabelSet, + recordsCount: number, +): EnsRainbow.ENSRainbowPublicConfig { + return { + version: packageJson.version, + labelSet, + recordsCount, + }; +} diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 00d40f5f3..83c99cb89 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -2,7 +2,7 @@ import { buildConfigFromEnvironment } from "./config.schema"; import type { ENSRainbowEnvironment } from "./environment"; export type { ENSRainbowConfig } from "./config.schema"; -export { buildConfigFromEnvironment } from "./config.schema"; +export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 767b180aa..220af6990 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,4 +1,5 @@ import packageJson from "@/../package.json"; +import config from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -14,6 +15,7 @@ import { } from "@ensnode/ensnode-sdk"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; +import { buildENSRainbowPublicConfig } from "@/config/config.schema"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; import { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; @@ -101,6 +103,33 @@ export async function createApi(db: ENSRainbowDB): Promise { return c.json(result, result.errorCode); }); + api.get("/v1/config", async (c: HonoContext) => { + logger.debug("Config request"); + const countResult = await server.labelCount(); + if (countResult.status === StatusCode.Error) { + logger.error("Failed to get records count for config endpoint"); + return c.json( + { + status: StatusCode.Error, + error: countResult.error, + errorCode: countResult.errorCode, + }, + 500, + ); + } + + const publicConfig = buildENSRainbowPublicConfig( + config, + server.getServerLabelSet(), + countResult.count, + ); + logger.debug(publicConfig, `Config result`); + return c.json(publicConfig); + }); + + /** + * @deprecated Use GET /v1/config instead. This endpoint will be removed in a future version. + */ api.get("/v1/version", (c: HonoContext) => { logger.debug("Version request"); const result: EnsRainbow.VersionResponse = { diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 87182d976..308ed8436 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -16,6 +16,13 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; + /** + * Get the public configuration of the ENSRainbow service + * + * @returns the public configuration of the ENSRainbow service + */ + config(): Promise; + /** * Heal a labelhash to its original label * @param labelHash The labelhash to heal @@ -24,6 +31,12 @@ export namespace EnsRainbow { health(): Promise; + /** + * Get the version information of the ENSRainbow service + * + * @deprecated Use {@link ApiClient.config} instead. This method will be removed in a future version. + * @returns the version information of the ENSRainbow service + */ version(): Promise; getOptions(): Readonly; @@ -118,6 +131,8 @@ export namespace EnsRainbow { /** * ENSRainbow version information. + * + * @deprecated Use {@link ENSRainbowPublicConfig} instead. This type will be removed in a future version. */ export interface VersionInfo { /** @@ -138,11 +153,41 @@ export namespace EnsRainbow { /** * Interface for the version endpoint response + * + * @deprecated Use {@link ENSRainbowPublicConfig} instead. This type will be removed in a future version. */ export interface VersionResponse { status: typeof StatusCode.Success; versionInfo: VersionInfo; } + + /** + * Complete public configuration object for ENSRainbow. + * + * Contains all public configuration information about the ENSRainbow service instance, + * including version, label set information, and record counts. + */ + export interface ENSRainbowPublicConfig { + /** + * ENSRainbow service version + * + * @see https://ghcr.io/namehash/ensnode/ensrainbow + */ + version: string; + + /** + * The label set reference managed by the ENSRainbow server. + * This includes both the label set ID and the highest label set version available. + */ + labelSet: EnsRainbowServerLabelSet; + + /** + * The total count of records managed by the ENSRainbow service. + * This represents the number of rainbow records that can be healed. + * Always a non-negative integer. + */ + recordsCount: number; + } } export interface EnsRainbowApiClientOptions { @@ -351,9 +396,28 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return response.json() as Promise; } + /** + * Get the public configuration of the ENSRainbow service + * + * @returns the public configuration of the ENSRainbow service + */ + async config(): Promise { + const response = await fetch(new URL("/v1/config", this.options.endpointUrl)); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string; errorCode?: number }; + throw new Error( + errorData.error ?? `Failed to fetch ENSRainbow config: ${response.statusText}`, + ); + } + + return response.json() as Promise; + } + /** * Get the version information of the ENSRainbow service * + * @deprecated Use {@link EnsRainbowApiClient.config} instead. This method will be removed in a future version. * @returns the version information of the ENSRainbow service * @throws if the request fails due to network failures, DNS lookup failures, request * timeouts, CORS violations, or invalid URLs From ddaaf29dfe7a4a950b3110b2b2437980f017f2e8 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 15:07:56 +0100 Subject: [PATCH 11/14] test(config): remove redundant test for DB_SCHEMA_VERSION handling in buildConfigFromEnvironment --- .../src/config/config.schema.test.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index d208241cc..1387cdd14 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -375,29 +375,6 @@ describe("buildConfigFromEnvironment", () => { expect(config.port).toBe(65535); }); - it("handles DB_SCHEMA_VERSION of 0", () => { - // This test assumes 0 is not the current DB_SCHEMA_VERSION - // If DB_SCHEMA_VERSION is 0, this test would pass which is correct - if (DB_SCHEMA_VERSION === 0) { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "0", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(0); - } else { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "0", - }; - - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); - } - }); - it("handles LABEL_SET_VERSION of 0", () => { const env: ENSRainbowEnvironment = { LABEL_SET_ID: "subgraph", From a43b125d948d2a5817c2347c4b258d4d39cc0289 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 16:00:38 +0100 Subject: [PATCH 12/14] refactor(config): improve environment configuration validation and error handling in buildConfigFromEnvironment --- .../src/config/config.schema.test.ts | 100 ++++-------------- apps/ensrainbow/src/config/config.schema.ts | 68 ++++++++---- apps/ensrainbow/src/config/environment.ts | 2 + apps/ensrainbow/src/config/index.ts | 11 +- 4 files changed, 79 insertions(+), 102 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 1387cdd14..899758048 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -16,17 +16,6 @@ vi.mock("@/utils/logger", () => ({ })); describe("buildConfigFromEnvironment", () => { - // Mock process.exit to prevent actual exit - const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - mockExit.mockClear(); - }); - describe("Success cases", () => { it("returns a valid config with all defaults when environment is empty", () => { const env: ENSRainbowEnvironment = {}; @@ -159,10 +148,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "not-a-number", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is a float", () => { @@ -170,10 +156,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "3000.5", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is less than 1", () => { @@ -181,10 +164,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "0", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is negative", () => { @@ -192,10 +172,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "-100", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is greater than 65535", () => { @@ -203,10 +180,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "65536", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DATA_DIR is empty string", () => { @@ -214,10 +188,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: "", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DATA_DIR is only whitespace", () => { @@ -225,10 +196,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: " ", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DB_SCHEMA_VERSION is not a number", () => { @@ -236,10 +204,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: "not-a-number", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DB_SCHEMA_VERSION is a float", () => { @@ -247,10 +212,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: "3.5", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when LABEL_SET_VERSION is not a number", () => { @@ -259,10 +221,7 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "not-a-number", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when LABEL_SET_VERSION is negative", () => { @@ -271,10 +230,7 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "-1", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when LABEL_SET_ID is empty", () => { @@ -283,10 +239,7 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "0", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when only LABEL_SET_ID is set (both ID and version required)", () => { @@ -294,10 +247,9 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_ID: "subgraph", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow( + "LABEL_SET_ID is set but LABEL_SET_VERSION is missing", + ); }); it("fails when only LABEL_SET_VERSION is set (both ID and version required)", () => { @@ -305,10 +257,9 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "0", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow( + "LABEL_SET_VERSION is set but LABEL_SET_ID is missing", + ); }); }); @@ -319,16 +270,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: wrongVersion.toString(), }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); - // Verify the error message mentions version mismatch - const errorCall = vi.mocked(logger.error).mock.calls[0]; - expect(errorCall[0]).toContain("Failed to parse environment configuration"); - expect(errorCall[0]).toContain("DB_SCHEMA_VERSION mismatch"); - expect(errorCall[0]).toContain(DB_SCHEMA_VERSION.toString()); - expect(errorCall[0]).toContain(wrongVersion.toString()); + expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); }); it("passes when DB_SCHEMA_VERSION matches code version", () => { @@ -339,8 +281,6 @@ describe("buildConfigFromEnvironment", () => { const config = buildConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - expect(logger.error).not.toHaveBeenCalled(); - expect(process.exit).not.toHaveBeenCalled(); }); it("passes when DB_SCHEMA_VERSION is undefined", () => { @@ -349,8 +289,6 @@ describe("buildConfigFromEnvironment", () => { const config = buildConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBeUndefined(); - expect(logger.error).not.toHaveBeenCalled(); - expect(process.exit).not.toHaveBeenCalled(); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 1fe85fcc0..130a39d0a 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -58,32 +58,60 @@ export type ENSRainbowConfig = z.infer; * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. * * @returns A validated ENSRainbowConfig object - * @throws Error with formatted validation messages if environment parsing fails + * @throws ZodError with detailed validation messages if environment parsing fails */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { - try { - return ENSRainbowConfigSchema.parse({ - port: env.PORT, - dataDir: env.DATA_DIR, - dbSchemaVersion: env.DB_SCHEMA_VERSION, - labelSet: - env.LABEL_SET_ID || env.LABEL_SET_VERSION - ? { - labelSetId: env.LABEL_SET_ID, - labelSetVersion: env.LABEL_SET_VERSION, - } - : undefined, + // Transform environment variables into config shape with validation + const envToConfigSchema = z + .object({ + PORT: z.string().optional(), + DATA_DIR: z.string().optional(), + DB_SCHEMA_VERSION: z.string().optional(), + LABEL_SET_ID: z.string().optional(), + LABEL_SET_VERSION: z.string().optional(), + }) + .transform((env) => { + // Validate label set configuration: both must be provided together, or neither + const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; + const hasLabelSetVersion = + env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + + if (hasLabelSetId && !hasLabelSetVersion) { + throw new Error( + `LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, + ); + } + + if (!hasLabelSetId && hasLabelSetVersion) { + throw new Error( + `LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, + ); + } + + return { + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + labelSet: + hasLabelSetId && hasLabelSetVersion + ? { + labelSetId: env.LABEL_SET_ID, + labelSetVersion: env.LABEL_SET_VERSION, + } + : undefined, + }; }); + + try { + const configInput = envToConfigSchema.parse(env); + return ENSRainbowConfigSchema.parse(configInput); } catch (error) { if (error instanceof ZodError) { - logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); - } else if (error instanceof Error) { - logger.error(error, `Failed to build ENSRainbowConfig`); - } else { - logger.error(`Unknown Error`); + // Re-throw ZodError to preserve structured error information + throw error; } - - process.exit(1); + // Re-throw other errors (like our custom label set validation errors) + throw error; } } diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts index eed970cf5..c07579aa9 100644 --- a/apps/ensrainbow/src/config/environment.ts +++ b/apps/ensrainbow/src/config/environment.ts @@ -21,11 +21,13 @@ export type ENSRainbowEnvironment = PortEnvironment & /** * Expected Label Set ID. + * Must be provided together with LABEL_SET_VERSION, or neither should be set. */ LABEL_SET_ID?: string; /** * Expected Label Set Version. + * Must be provided together with LABEL_SET_ID, or neither should be set. */ LABEL_SET_VERSION?: string; }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 83c99cb89..6dffbafe8 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -7,4 +7,13 @@ export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; // build, validate, and export the ENSRainbowConfig from process.env -export default buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); +let config: ReturnType; +try { + config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); +} catch (error) { + // For CLI applications, invalid configuration should exit the process + console.error("Configuration error:", error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +export default config; From c57e7f676e8f165f256e683f6542a1d5ce8a9df4 Mon Sep 17 00:00:00 2001 From: "kwrobel.eth" Date: Fri, 23 Jan 2026 16:25:48 +0100 Subject: [PATCH 13/14] Create young-carrots-cheer.md --- .changeset/young-carrots-cheer.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/young-carrots-cheer.md diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md new file mode 100644 index 000000000..819d19d75 --- /dev/null +++ b/.changeset/young-carrots-cheer.md @@ -0,0 +1,7 @@ +--- +"ensrainbow": patch +"@ensnode/ensnode-sdk": patch +"@ensnode/ensrainbow-sdk": patch +--- + +Build ENSRainbow config From e3a6c909c48a75f1eb27d324de9198bb9512c3e9 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 16:28:54 +0100 Subject: [PATCH 14/14] refactor(config): remove unused imports from config schema files to streamline code --- apps/ensrainbow/src/config/config.schema.test.ts | 3 +-- apps/ensrainbow/src/config/config.schema.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 899758048..4e80f5724 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -1,9 +1,8 @@ import { isAbsolute, resolve } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { logger } from "@/utils/logger"; import { buildConfigFromEnvironment } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 130a39d0a..7d6200995 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -2,7 +2,7 @@ import packageJson from "@/../package.json" with { type: "json" }; import { isAbsolute, resolve } from "node:path"; -import { prettifyError, ZodError, z } from "zod/v4"; +import { ZodError, z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; @@ -11,7 +11,6 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; -import { logger } from "@/utils/logger"; const DataDirSchema = z .string()