From c34c0bda80410277550c6ddeadbf9346e8365f45 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 22 Dec 2025 14:06:34 +0100 Subject: [PATCH 01/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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() From 8dc1b6e3641eb17c75240449b93d589ab2840787 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 26 Jan 2026 15:47:14 +0100 Subject: [PATCH 15/39] test(server): add tests for GET /v1/config endpoint to validate error handling and successful responses --- .../src/commands/server-command.test.ts | 36 +++++++++ apps/ensrainbow/src/config/config.schema.ts | 79 +++++++++++-------- apps/ensrainbow/src/config/index.ts | 14 +++- 3 files changed, 93 insertions(+), 36 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index d6d04e911..173f9cbb7 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -167,6 +167,39 @@ describe("Server Command Tests", () => { }); }); + describe("GET /v1/config", () => { + it("should return an error when database is empty", async () => { + const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); + expect(response.status).toBe(500); + const data = (await response.json()) as { + status: typeof StatusCode.Error; + error: string; + errorCode: typeof ErrorCode.ServerError; + }; + const expectedData = { + status: StatusCode.Error, + error: "Label count not initialized. Check the validate command.", + errorCode: ErrorCode.ServerError, + }; + expect(data).toEqual(expectedData); + }); + + it("should return correct config when label count is initialized", async () => { + // Set a specific precalculated rainbow record count in the database + await db.setPrecalculatedRainbowRecordCount(42); + + const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); + expect(response.status).toBe(200); + const data = (await response.json()) as EnsRainbow.ENSRainbowPublicConfig; + + expect(typeof data.version).toBe("string"); + expect(data.version.length).toBeGreaterThan(0); + expect(data.labelSet.labelSetId).toBe("test-label-set-id"); + expect(data.labelSet.highestLabelSetVersion).toBe(0); + expect(data.recordsCount).toBe(42); + }); + }); + describe("CORS headers for /v1/* routes", () => { it("should return CORS headers for /v1/* routes", async () => { const validLabel = "test-label"; @@ -188,6 +221,9 @@ describe("Server Command Tests", () => { fetch(`http://localhost:${nonDefaultPort}/v1/labels/count`, { method: "OPTIONS", }), + fetch(`http://localhost:${nonDefaultPort}/v1/config`, { + method: "OPTIONS", + }), fetch(`http://localhost:${nonDefaultPort}/v1/version`, { method: "OPTIONS", }), diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 7d6200995..24cb220fb 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -12,6 +12,44 @@ import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; +/** + * Validates and extracts label set configuration from environment variables. + * + * Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither should be set. + * + * @param labelSetId - The raw LABEL_SET_ID environment variable + * @param labelSetVersion - The raw LABEL_SET_VERSION environment variable + * @returns The validated label set configuration object, or undefined if neither is set + * @throws Error if only one of the label set variables is provided + */ +function validateLabelSetConfiguration( + labelSetId: string | undefined, + labelSetVersion: string | undefined, +): { labelSetId: string; labelSetVersion: string } | undefined { + // Validate label set configuration: both must be provided together, or neither + const hasLabelSetId = labelSetId !== undefined && labelSetId.trim() !== ""; + const hasLabelSetVersion = labelSetVersion !== undefined && labelSetVersion.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 hasLabelSetId && hasLabelSetVersion + ? { + labelSetId, + labelSetVersion, + } + : undefined; +} + const DataDirSchema = z .string() .trim() @@ -57,7 +95,8 @@ export type ENSRainbowConfig = z.infer; * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. * * @returns A validated ENSRainbowConfig object - * @throws ZodError with detailed validation messages if environment parsing fails + * @throws {ZodError} with detailed validation messages if environment parsing fails + * @throws {Error} if label set configuration is invalid (e.g., only one of LABEL_SET_ID or LABEL_SET_VERSION is provided) */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { // Transform environment variables into config shape with validation @@ -70,48 +109,18 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb 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.`, - ); - } + const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); 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, + labelSet, }; }); - try { - const configInput = envToConfigSchema.parse(env); - return ENSRainbowConfigSchema.parse(configInput); - } catch (error) { - if (error instanceof ZodError) { - // Re-throw ZodError to preserve structured error information - throw error; - } - // Re-throw other errors (like our custom label set validation errors) - throw error; - } + const configInput = envToConfigSchema.parse(env); + return ENSRainbowConfigSchema.parse(configInput); } /** diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 6dffbafe8..873ce0281 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,3 +1,7 @@ +import { prettifyError, ZodError } from "zod/v4"; + +import { logger } from "@/utils/logger"; + import { buildConfigFromEnvironment } from "./config.schema"; import type { ENSRainbowEnvironment } from "./environment"; @@ -12,7 +16,15 @@ 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)); + if (error instanceof ZodError) { + logger.error( + `Failed to parse ENSRainbow environment configuration: \n${prettifyError(error)}\n`, + ); + } else if (error instanceof Error) { + logger.error(error, "Failed to build ENSRainbowConfig"); + } else { + logger.error("Unknown error occurred during configuration"); + } process.exit(1); } From d165cd5c05425e8d0920dad78a0448c6205ae6a3 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 26 Jan 2026 16:07:57 +0100 Subject: [PATCH 16/39] refactor(validations): simplify invariant_dbSchemaVersionMatch function signature for improved clarity --- apps/ensrainbow/src/config/validations.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 0de092eb1..7f72559a0 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -7,9 +7,7 @@ import type { ENSRainbowConfig } from "./config.schema"; /** * Invariant: dbSchemaVersion must match the version expected by the code. */ -export function invariant_dbSchemaVersionMatch( - ctx: ZodCheckFnInput>, -): void { +export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { const { value: config } = ctx; if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) { From 79cc9addb6c43f5c2717221284603c137bc79c6f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 26 Jan 2026 16:54:32 +0100 Subject: [PATCH 17/39] fix(deps): update misconfigured lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d36f6cdf..7e8a066f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,7 +536,7 @@ importers: version: 17.7.2 zod: specifier: 'catalog:' - version: 3.25.76 + version: 4.3.6 devDependencies: '@ensnode/shared-configs': specifier: workspace:* From b9a41e91e8daffd6f43be6a5364d75a5ffbb0da1 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 26 Jan 2026 16:55:01 +0100 Subject: [PATCH 18/39] fix(ci): clear linter warnings --- apps/ensrainbow/src/cli.test.ts | 2 +- apps/ensrainbow/src/config/config.schema.ts | 3 +-- apps/ensrainbow/src/lib/api.ts | 7 +------ 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 820f082f1..184ab16ed 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; -import { createCLI, validatePortConfiguration } from "./cli"; +import { createCLI } from "./cli"; // Path to test fixtures const TEST_FIXTURES_DIR = join(__dirname, "..", "test", "fixtures"); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 24cb220fb..4b50c0a8a 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 { ZodError, z } from "zod/v4"; +import { z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; @@ -132,7 +132,6 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb * @returns A complete ENSRainbowPublicConfig object */ export function buildENSRainbowPublicConfig( - config: ENSRainbowConfig, labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 220af6990..91e5739ec 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,5 +1,4 @@ import packageJson from "@/../package.json"; -import config from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -118,11 +117,7 @@ export async function createApi(db: ENSRainbowDB): Promise { ); } - const publicConfig = buildENSRainbowPublicConfig( - config, - server.getServerLabelSet(), - countResult.count, - ); + const publicConfig = buildENSRainbowPublicConfig(server.getServerLabelSet(), countResult.count); logger.debug(publicConfig, `Config result`); return c.json(publicConfig); }); From 096049a8ba826664fd214c64b555fae2d8e56038 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 28 Jan 2026 14:34:03 +0100 Subject: [PATCH 19/39] feat(api): Update DB_SCHEMA_VERSION handling in configuration and tests. --- .changeset/young-carrots-cheer.md | 3 +- apps/ensrainbow/src/cli.test.ts | 36 ------------------- .../src/config/config.schema.test.ts | 10 +++--- apps/ensrainbow/src/config/config.schema.ts | 3 +- apps/ensrainbow/src/config/validations.ts | 4 +-- apps/ensrainbow/src/lib/env.ts | 8 ----- 6 files changed, 10 insertions(+), 54 deletions(-) delete mode 100644 apps/ensrainbow/src/lib/env.ts diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md index 819d19d75..1f3e96ab3 100644 --- a/.changeset/young-carrots-cheer.md +++ b/.changeset/young-carrots-cheer.md @@ -1,7 +1,6 @@ --- "ensrainbow": patch -"@ensnode/ensnode-sdk": patch "@ensnode/ensrainbow-sdk": patch --- -Build ENSRainbow config +Adds `/v1/config` endpoint to ENSRainbow API returning public configuration (version, label set, records count) and deprecates `/v1/version` endpoint. The new endpoint provides comprehensive service discovery capabilities for clients. diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 184ab16ed..8fcb8c8b2 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -37,42 +37,6 @@ describe("CLI", () => { await rm(tempDir, { recursive: true, force: true }); }); - describe("getEnvPort", () => { - 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 () => { - const customPort = 4000; - 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", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { - throw new Error("process.exit called"); - }) as never); - 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", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { - throw new Error("process.exit called"); - }) as never); - vi.stubEnv("PORT", "-1"); - vi.resetModules(); - await expect(import("@/lib/env")).rejects.toThrow("process.exit called"); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - }); - describe("validatePortConfiguration", () => { it("should not throw when PORT env var is not set", async () => { vi.resetModules(); diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 4e80f5724..9b791ebd9 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -24,7 +24,7 @@ describe("buildConfigFromEnvironment", () => { expect(config).toStrictEqual({ port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), - dbSchemaVersion: undefined, + dbSchemaVersion: DB_SCHEMA_VERSION, labelSet: undefined, }); }); @@ -96,12 +96,12 @@ describe("buildConfigFromEnvironment", () => { expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); - it("allows DB_SCHEMA_VERSION to be undefined", () => { + it("defaults DB_SCHEMA_VERSION to code version when not set", () => { const env: ENSRainbowEnvironment = {}; const config = buildConfigFromEnvironment(env); - expect(config.dbSchemaVersion).toBeUndefined(); + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); it("applies full label set configuration when both ID and version are set", () => { @@ -282,12 +282,12 @@ describe("buildConfigFromEnvironment", () => { expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); - it("passes when DB_SCHEMA_VERSION is undefined", () => { + it("passes when DB_SCHEMA_VERSION defaults to code version", () => { const env: ENSRainbowEnvironment = {}; const config = buildConfigFromEnvironment(env); - expect(config.dbSchemaVersion).toBeUndefined(); + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 4b50c0a8a..e12be2eee 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,6 +10,7 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; /** @@ -67,7 +68,7 @@ const DataDirSchema = z const DbSchemaVersionSchema = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) - .optional(); + .default(DB_SCHEMA_VERSION); const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 7f72559a0..4171a6a65 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -10,12 +10,12 @@ import type { ENSRainbowConfig } from "./config.schema"; export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { const { value: config } = ctx; - if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) { + if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { 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.`, + message: `DB_SCHEMA_VERSION mismatch! Code expects version ${DB_SCHEMA_VERSION}, but found ${config.dbSchemaVersion} in environment variables.`, }); } } diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts deleted file mode 100644 index e58ccc55e..000000000 --- a/apps/ensrainbow/src/lib/env.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from "@/config"; - -/** - * Gets the port from environment variables. - */ -export function getEnvPort(): number { - return config.port; -} From f4859d78f62fe42231afe1aa4b576a59dab8ea5e Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 28 Jan 2026 14:41:06 +0100 Subject: [PATCH 20/39] refactor(config): enhance error handling and validation in buildConfigFromEnvironment function --- apps/ensrainbow/src/config/config.schema.ts | 57 ++++++++++++--------- apps/ensrainbow/src/config/index.ts | 24 +-------- apps/ensrainbow/types/env.d.ts | 4 +- 3 files changed, 35 insertions(+), 50 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index e12be2eee..fcf4c3acd 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 { z } from "zod/v4"; +import { prettifyError, ZodError, z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; @@ -96,32 +96,39 @@ export type ENSRainbowConfig = z.infer; * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. * * @returns A validated ENSRainbowConfig object - * @throws {ZodError} with detailed validation messages if environment parsing fails - * @throws {Error} if label set configuration is invalid (e.g., only one of LABEL_SET_ID or LABEL_SET_VERSION is provided) + * @throws Error with formatted validation messages if environment parsing fails */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { - // 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) => { - const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); - - return { - port: env.PORT, - dataDir: env.DATA_DIR, - dbSchemaVersion: env.DB_SCHEMA_VERSION, - labelSet, - }; - }); - - const configInput = envToConfigSchema.parse(env); - return ENSRainbowConfigSchema.parse(configInput); + try { + // 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) => { + const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); + + return { + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + labelSet, + }; + }); + + const configInput = envToConfigSchema.parse(env); + return ENSRainbowConfigSchema.parse(configInput); + } catch (error) { + if (error instanceof ZodError) { + throw new Error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); + } + + throw error; + } } /** diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 873ce0281..666e1870d 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,9 +1,4 @@ -import { prettifyError, ZodError } from "zod/v4"; - -import { logger } from "@/utils/logger"; - import { buildConfigFromEnvironment } from "./config.schema"; -import type { ENSRainbowEnvironment } from "./environment"; export type { ENSRainbowConfig } from "./config.schema"; export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; @@ -11,21 +6,4 @@ export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; // build, validate, and export the ENSRainbowConfig from process.env -let config: ReturnType; -try { - config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); -} catch (error) { - // For CLI applications, invalid configuration should exit the process - if (error instanceof ZodError) { - logger.error( - `Failed to parse ENSRainbow environment configuration: \n${prettifyError(error)}\n`, - ); - } else if (error instanceof Error) { - logger.error(error, "Failed to build ENSRainbowConfig"); - } else { - logger.error("Unknown error occurred during configuration"); - } - process.exit(1); -} - -export default config; +export default buildConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/types/env.d.ts b/apps/ensrainbow/types/env.d.ts index dd956c600..55fd948b3 100644 --- a/apps/ensrainbow/types/env.d.ts +++ b/apps/ensrainbow/types/env.d.ts @@ -1,7 +1,7 @@ -import type { LogLevelEnvironment } from "@ensnode/ensnode-sdk/internal"; +import type { ENSRainbowEnvironment } from "@/config/environment"; declare global { namespace NodeJS { - interface ProcessEnv extends LogLevelEnvironment {} + interface ProcessEnv extends ENSRainbowEnvironment {} } } From 3d9399f5becc0da37276b47fbccfa8ea2b19132c Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 28 Jan 2026 16:51:40 +0100 Subject: [PATCH 21/39] refactor(cli): streamline port configuration handling and enhance CLI tests for port overrides --- apps/ensrainbow/src/cli.test.ts | 100 +++++++++++++++----- apps/ensrainbow/src/cli.ts | 22 +---- apps/ensrainbow/src/config/config.schema.ts | 2 +- apps/ensrainbow/src/config/index.ts | 2 +- 4 files changed, 84 insertions(+), 42 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 8fcb8c8b2..789b416a7 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -37,25 +37,28 @@ describe("CLI", () => { await rm(tempDir, { recursive: true, force: true }); }); - describe("validatePortConfiguration", () => { - it("should not throw when PORT env var is not set", async () => { + describe("port configuration", () => { + it("should allow CLI port to override PORT env var", async () => { + // Mock serverCommand so we only test argument resolution here vi.resetModules(); - const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); - expect(() => validatePortConfigurationFresh(3000)).not.toThrow(); - }); + const serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); - it("should not throw when PORT matches CLI port", async () => { + // Simulate PORT being set in the environment 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", async () => { - vi.stubEnv("PORT", "3000"); - vi.resetModules(); - const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); - expect(() => validatePortConfigurationFresh(4000)).toThrow("Port conflict"); + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithPort = createCLIFresh({ exitProcess: false }); + + // CLI port should override env PORT + await cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]); + + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 4000 })); + + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); }); }); @@ -494,8 +497,8 @@ describe("CLI", () => { testDataDir, ]); - // Give server time to start - await new Promise((resolve) => setTimeout(resolve, 100)); + // Give server time to start (DB open + validation can take a bit) + await new Promise((resolve) => setTimeout(resolve, 500)); // Make a request to health endpoint const response = await fetch(`http://localhost:${customPort}/health`); @@ -506,6 +509,30 @@ describe("CLI", () => { await serverPromise; }); + it("should use the default port when PORT env var is not set", async () => { + // Mock serverCommand so we don't actually start a server or touch the DB here + vi.resetModules(); + const serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); + + // PORT is cleared in beforeEach; no CLI --port is provided + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithDefaultPort = createCLIFresh({ exitProcess: false }); + + // Invoke serve without specifying --port + await cliWithDefaultPort.parse(["serve", "--data-dir", testDataDir]); + + // Assert that serverCommand was called with the default port + expect(serverCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ port: ENSRAINBOW_DEFAULT_PORT }), + ); + + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); + }); + it("should respect PORT environment variable", async () => { const customPort = 5115; vi.stubEnv("PORT", customPort.toString()); @@ -526,8 +553,8 @@ describe("CLI", () => { // Start server const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]); - // Give server time to start - await new Promise((resolve) => setTimeout(resolve, 100)); + // Give server time to start (DB open + validation can take a bit) + await new Promise((resolve) => setTimeout(resolve, 500)); // Make a request to health endpoint const response = await fetch(`http://localhost:${customPort}/health`); @@ -571,14 +598,41 @@ describe("CLI", () => { await serverPromise; }); - it("should throw on port conflict", async () => { + it("should allow CLI port to override PORT env var", async () => { vi.stubEnv("PORT", "5000"); vi.resetModules(); const { createCLI: createCLIFresh } = await import("./cli"); const cliWithPort = createCLIFresh({ exitProcess: false }); - await expect( - cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]), - ).rejects.toThrow("Port conflict"); + + // First ingest data + const ensrainbowOutputFile = join(TEST_FIXTURES_DIR, "test_ens_names_0.ensrainbow"); + await cliWithPort.parse([ + "ingest-ensrainbow", + "--input-file", + ensrainbowOutputFile, + "--data-dir", + testDataDir, + ]); + + // CLI port should override env PORT without error + const serverPromise = cliWithPort.parse([ + "serve", + "--port", + "4000", + "--data-dir", + testDataDir, + ]); + + // Give server time to start (DB open + validation can take a bit) + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify server is running on the CLI port (not env port) + const response = await fetch("http://localhost:4000/health"); + expect(response.status).toBe(200); + + // Cleanup - send SIGINT to stop server + process.emit("SIGINT", "SIGINT"); + await serverPromise; }); }); diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 81547ef5c..3bf0c160f 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import config, { getDefaultDataDir } from "@/config"; +import config from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,17 +17,6 @@ import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; -export function validatePortConfiguration(cliPort: number): void { - // 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 configured port (${config.port}). ` + - `Please use only one method to specify the port.`, - ); - } -} - // interface IngestArgs { // "input-file": string; // "data-dir": string; @@ -114,7 +103,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { @@ -137,11 +126,10 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { - validatePortConfiguration(argv.port); await serverCommand({ port: argv.port, dataDir: argv["data-dir"], @@ -156,7 +144,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }) .option("lite", { type: "boolean", @@ -179,7 +167,7 @@ export function createCLI(options: CLIOptions = {}) { return yargs.option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index fcf4c3acd..6fc160457 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,8 +10,8 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import { DB_SCHEMA_VERSION } from "@/lib/database"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; /** * Validates and extracts label set configuration from environment variables. diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 666e1870d..533794107 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -2,7 +2,7 @@ import { buildConfigFromEnvironment } from "./config.schema"; export type { ENSRainbowConfig } from "./config.schema"; export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; -export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; // build, validate, and export the ENSRainbowConfig from process.env From e735747a57c9dc5127e94c7f8c699ec00a6bdd84 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 14:58:56 +0100 Subject: [PATCH 22/39] refactor(api): remove debug logging from various API endpoints for cleaner output --- apps/ensrainbow/src/lib/api.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 91e5739ec..12b2381ab 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -79,31 +79,21 @@ export async function createApi(db: ENSRainbowDB): Promise { ); } - logger.debug( - `Healing request for labelhash: ${labelhash}, with labelSet: ${JSON.stringify( - clientLabelSet, - )}`, - ); const result = await server.heal(labelhash, clientLabelSet); - logger.debug(result, `Heal result:`); return c.json(result, result.errorCode); }); api.get("/health", (c: HonoContext) => { - logger.debug("Health check request"); const result: EnsRainbow.HealthResponse = { status: "ok" }; return c.json(result); }); api.get("/v1/labels/count", async (c: HonoContext) => { - logger.debug("Label count request"); const result = await server.labelCount(); - logger.debug(result, `Count result`); 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"); @@ -118,7 +108,6 @@ export async function createApi(db: ENSRainbowDB): Promise { } const publicConfig = buildENSRainbowPublicConfig(server.getServerLabelSet(), countResult.count); - logger.debug(publicConfig, `Config result`); return c.json(publicConfig); }); @@ -126,7 +115,6 @@ export async function createApi(db: ENSRainbowDB): Promise { * @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 = { status: StatusCode.Success, versionInfo: { @@ -135,7 +123,6 @@ export async function createApi(db: ENSRainbowDB): Promise { labelSet: server.getServerLabelSet(), }, }; - logger.debug(result, `Version result`); return c.json(result); }); From 5bafcdb5a72e5753cdaea4ce37415a42bef3a971 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 15:49:48 +0100 Subject: [PATCH 23/39] revert --- apps/ensrainbow/src/config/config.schema.ts | 1 + apps/ensrainbow/src/lib/api.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 6fc160457..53c2f8205 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -140,6 +140,7 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb * @returns A complete ENSRainbowPublicConfig object */ export function buildENSRainbowPublicConfig( + config: ENSRainbowConfig, labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 12b2381ab..7449ba8f4 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"; @@ -107,7 +108,11 @@ export async function createApi(db: ENSRainbowDB): Promise { ); } - const publicConfig = buildENSRainbowPublicConfig(server.getServerLabelSet(), countResult.count); + const publicConfig = buildENSRainbowPublicConfig( + config, + server.getServerLabelSet(), + countResult.count, + ); return c.json(publicConfig); }); From 7a646e097c70db22f1d949f95148ddc75e825bdd Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:15:15 +0100 Subject: [PATCH 24/39] refactor(config): remove label set validation function and enhance schema validation for environment variables --- apps/ensrainbow/src/config/config.schema.ts | 103 ++++++++++---------- apps/ensrainbow/src/config/types.ts | 85 +++++++++++++++- apps/ensrainbow/src/config/validations.ts | 6 +- 3 files changed, 139 insertions(+), 55 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 53c2f8205..131c48aab 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,47 +10,10 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; +import type { ENSRainbowConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -/** - * Validates and extracts label set configuration from environment variables. - * - * Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither should be set. - * - * @param labelSetId - The raw LABEL_SET_ID environment variable - * @param labelSetVersion - The raw LABEL_SET_VERSION environment variable - * @returns The validated label set configuration object, or undefined if neither is set - * @throws Error if only one of the label set variables is provided - */ -function validateLabelSetConfiguration( - labelSetId: string | undefined, - labelSetVersion: string | undefined, -): { labelSetId: string; labelSetVersion: string } | undefined { - // Validate label set configuration: both must be provided together, or neither - const hasLabelSetId = labelSetId !== undefined && labelSetId.trim() !== ""; - const hasLabelSetVersion = labelSetVersion !== undefined && labelSetVersion.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 hasLabelSetId && hasLabelSetVersion - ? { - labelSetId, - labelSetVersion, - } - : undefined; -} - const DataDirSchema = z .string() .trim() @@ -68,17 +31,19 @@ const DataDirSchema = z const DbSchemaVersionSchema = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) + .positive({ error: "DB_SCHEMA_VERSION must be greater than 0." }) .default(DB_SCHEMA_VERSION); const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); -const ENSRainbowConfigSchema = z - .object({ - port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), - dataDir: DataDirSchema.default(getDefaultDataDir()), - dbSchemaVersion: DbSchemaVersionSchema, - labelSet: LabelSetSchema.optional(), - }) +const ENSRainbowConfigBaseSchema = z.object({ + port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), + dataDir: DataDirSchema.default(getDefaultDataDir()), + dbSchemaVersion: DbSchemaVersionSchema, + labelSet: LabelSetSchema.optional(), +}); + +const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema /** * Invariant enforcement * @@ -88,8 +53,6 @@ const ENSRainbowConfigSchema = z */ .check(invariant_dbSchemaVersionMatch); -export type ENSRainbowConfig = z.infer; - /** * Builds the ENSRainbow configuration object from an ENSRainbowEnvironment object. * @@ -100,7 +63,7 @@ export type ENSRainbowConfig = z.infer; */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { try { - // Transform environment variables into config shape with validation + // Transform environment variables into config shape const envToConfigSchema = z .object({ PORT: z.string().optional(), @@ -109,8 +72,50 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb LABEL_SET_ID: z.string().optional(), LABEL_SET_VERSION: z.string().optional(), }) + /** + * Invariant enforcement on environment variables + * + * We check that LABEL_SET_ID and LABEL_SET_VERSION are provided together, or neither. + * This check happens before transformation to ensure we don't create invalid config objects. + */ + .check((ctx) => { + const { value: env } = ctx; + 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) { + ctx.issues.push({ + code: "custom", + path: ["LABEL_SET_VERSION"], + input: env, + message: + "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) { + ctx.issues.push({ + code: "custom", + path: ["LABEL_SET_ID"], + input: env, + message: + "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.", + }); + } + }) .transform((env) => { - const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); + const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; + const hasLabelSetVersion = + env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + + const labelSet = + hasLabelSetId && hasLabelSetVersion + ? { + labelSetId: env.LABEL_SET_ID, + labelSetVersion: env.LABEL_SET_VERSION, + } + : undefined; return { port: env.PORT, diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index cbf9c57be..2b0c379dd 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1 +1,84 @@ -export type { ENSRainbowConfig } from "./config.schema"; +import type { EnsRainbowClientLabelSet } from "@ensnode/ensnode-sdk"; + +/** + * The complete runtime configuration for an ENSRainbow instance. + * + * This interface defines all configuration parameters needed to run an ENSRainbow server, + * which provides label healing services for ENS names. + */ +export interface ENSRainbowConfig { + /** + * The port number on which the ENSRainbow server listens. + * + * The HTTP server will bind to this port and serve the ENSRainbow API. + * + * Default: 3223 (defined by {@link ENSRAINBOW_DEFAULT_PORT}) + * + * Invariants: + * - Must be a valid port number (1-65535) + * - Must not be already in use by another process + */ + port: number; + + /** + * The absolute path to the data directory where ENSRainbow stores its database and other files. + * + * This directory will contain: + * - The SQLite database file with label set data + * - Any temporary files created during operation + * + * If a relative path is provided in the environment variable, it will be resolved to an + * absolute path relative to the current working directory. + * + * Default: `{cwd}/ensrainbow-data` or `/data` in Docker + * + * Invariants: + * - Must be a non-empty string + * - Must be an absolute path after resolution + * - The process must have read/write permissions to this directory + */ + dataDir: string; + + /** + * The database schema version expected by the code. + * + * This version number corresponds to the structure of the SQLite database that ENSRainbow + * uses to store label set data. If the version in the environment doesn't match the version + * expected by the code, the application will fail to start. + * + * This prevents version mismatches between the codebase and the database schema, which could + * lead to data corruption or runtime errors. + * + * Default: {@link DB_SCHEMA_VERSION} (currently 3) + * + * Invariants: + * - Must be a positive integer + * - Must match {@link DB_SCHEMA_VERSION} exactly + */ + dbSchemaVersion: number; + + /** + * Optional label set configuration that specifies which label set to use. + * + * A label set defines which labels (domain name segments) are available for label healing. + * Both `labelSetId` and `labelSetVersion` must be provided together to create a "fully pinned" + * label set reference, ensuring deterministic and reproducible label healing. + * + * If not provided, ENSRainbow will start without any label set loaded, and label healing + * requests will fail until a label set is loaded via the management API. + * + * Examples: + * - `{ labelSetId: "subgraph", labelSetVersion: 0 }` - The legacy subgraph label set + * - `{ labelSetId: "ensip-15", labelSetVersion: 1 }` - ENSIP-15 normalized labels + * + * Default: undefined (no label set) + * + * Invariants: + * - If provided, both `labelSetId` and `labelSetVersion` must be defined + * - `labelSetId` must be 1-50 characters, containing only lowercase letters (a-z) and hyphens (-) + * - `labelSetVersion` must be a non-negative integer + * - If only one of LABEL_SET_ID or LABEL_SET_VERSION is provided in the environment, + * configuration parsing will fail with a clear error message + */ + labelSet?: Required; +} diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 4171a6a65..280084955 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,13 +1,9 @@ -import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; - import { DB_SCHEMA_VERSION } from "@/lib/database"; -import type { ENSRainbowConfig } from "./config.schema"; - /** * Invariant: dbSchemaVersion must match the version expected by the code. */ -export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { +export function invariant_dbSchemaVersionMatch(ctx: any): void { const { value: config } = ctx; if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { From 9d1208868d9958e67619eeeded60f4cd48b2f08f Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:20:17 +0100 Subject: [PATCH 25/39] feat(server): log configuration details on server startup for improved visibility --- apps/ensrainbow/src/commands/server-command.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 05c0f42f7..c979d3a64 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,5 +1,8 @@ import { serve } from "@hono/node-server"; +import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; + +import config from "@/config"; import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; @@ -17,6 +20,10 @@ export async function createServer(db: ENSRainbowDB) { } export async function serverCommand(options: ServerCommandOptions): Promise { + // Log the config that ENSRainbow is running with + console.log("ENSRainbow running with config:"); + console.log(prettyPrintJson(config)); + logger.info(`ENS Rainbow server starting on port ${options.port}...`); const db = await ENSRainbowDB.open(options.dataDir); From 89ff961200013a271c1b0cf6637c5453e5aba0e7 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:26:21 +0100 Subject: [PATCH 26/39] feat(server): add database initialization check to prevent server startup with empty database --- apps/ensrainbow/src/commands/server-command.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index c979d3a64..cd163cd35 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,8 +1,9 @@ +import config from "@/config"; + import { serve } from "@hono/node-server"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; -import config from "@/config"; import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; @@ -29,6 +30,19 @@ export async function serverCommand(options: ServerCommandOptions): Promise Date: Mon, 2 Feb 2026 16:29:36 +0100 Subject: [PATCH 27/39] feat(config): export ENSRainbowConfig type for improved type safety in configuration schema --- apps/ensrainbow/src/config/config.schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 131c48aab..753a838b2 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -14,6 +14,8 @@ import type { ENSRainbowConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; +export type { ENSRainbowConfig }; + const DataDirSchema = z .string() .trim() From 363208c0f7de168b5939d0def10191d3e97c4823 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:40:26 +0100 Subject: [PATCH 28/39] refactor(config): introduce hasValue helper function for string validation and update environment variable checks --- apps/ensrainbow/src/config/config.schema.ts | 20 ++++++++++++++------ apps/ensrainbow/src/config/types.ts | 6 +++--- apps/ensrainbow/src/config/validations.ts | 6 +++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 753a838b2..4601b3bff 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -45,6 +45,16 @@ const ENSRainbowConfigBaseSchema = z.object({ labelSet: LabelSetSchema.optional(), }); +/** + * Helper function to check if a string value is present (not undefined and not empty after trimming). + * + * @param str - The string value to check + * @returns true if the string is defined and has non-whitespace content after trimming + */ +const hasValue = (str: string | undefined): boolean => { + return str !== undefined && str.trim() !== ""; +}; + const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema /** * Invariant enforcement @@ -82,9 +92,8 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb */ .check((ctx) => { const { value: env } = ctx; - const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; - const hasLabelSetVersion = - env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + const hasLabelSetId = hasValue(env.LABEL_SET_ID); + const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); if (hasLabelSetId && !hasLabelSetVersion) { ctx.issues.push({ @@ -107,9 +116,8 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb } }) .transform((env) => { - const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; - const hasLabelSetVersion = - env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + const hasLabelSetId = hasValue(env.LABEL_SET_ID); + const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); const labelSet = hasLabelSetId && hasLabelSetVersion diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 2b0c379dd..45f279d89 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -24,13 +24,13 @@ export interface ENSRainbowConfig { * The absolute path to the data directory where ENSRainbow stores its database and other files. * * This directory will contain: - * - The SQLite database file with label set data + * - The LevelDB database file with label set data * - Any temporary files created during operation * * If a relative path is provided in the environment variable, it will be resolved to an * absolute path relative to the current working directory. * - * Default: `{cwd}/ensrainbow-data` or `/data` in Docker + * Default: `{cwd}/data` * * Invariants: * - Must be a non-empty string @@ -42,7 +42,7 @@ export interface ENSRainbowConfig { /** * The database schema version expected by the code. * - * This version number corresponds to the structure of the SQLite database that ENSRainbow + * This version number corresponds to the structure of the LevelDB database that ENSRainbow * uses to store label set data. If the version in the environment doesn't match the version * expected by the code, the application will fail to start. * diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 280084955..d5f02d22e 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,9 +1,13 @@ +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; + import { DB_SCHEMA_VERSION } from "@/lib/database"; +import type { ENSRainbowConfig } from "./types"; + /** * Invariant: dbSchemaVersion must match the version expected by the code. */ -export function invariant_dbSchemaVersionMatch(ctx: any): void { +export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { const { value: config } = ctx; if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { From 30873a0d9c6e4d9f4a3d67ec54aa932e362530b0 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 17:32:59 +0100 Subject: [PATCH 29/39] refactor(config): update parameter naming in buildENSRainbowPublicConfig for clarity --- apps/ensrainbow/src/config/config.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 4601b3bff..c92fa5214 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -155,7 +155,7 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb * @returns A complete ENSRainbowPublicConfig object */ export function buildENSRainbowPublicConfig( - config: ENSRainbowConfig, + _config: ENSRainbowConfig, // kept for semantic purposes, not used in the function labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { From 7d7a1f617a779d4e30f13097a02ba8fe66bf2426 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 17:46:00 +0100 Subject: [PATCH 30/39] docs(cli): add detailed comments for 'serve' command arguments and clarify port option behavior --- apps/ensrainbow/src/cli.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 3bf0c160f..c1535077d 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -27,6 +27,12 @@ interface IngestProtobufArgs { "data-dir": string; } +/** + * Arguments for the 'serve' command. + * + * Note: CLI arguments take precedence over environment variables. + * If both --port and PORT are set, --port will be used and a warning will be logged. + */ interface ServeArgs { port: number; "data-dir": string; @@ -120,7 +126,7 @@ export function createCLI(options: CLIOptions = {}) { return yargs .option("port", { type: "number", - description: "Port to listen on", + description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, }) .option("data-dir", { From 2d7207d966c7d624aace55693bec3bc9b14f7028 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 18:09:37 +0100 Subject: [PATCH 31/39] refactor(api): implement caching for public config to optimize /v1/config endpoint and update related tests --- apps/ensrainbow/src/cli.ts | 1 - .../src/commands/server-command.test.ts | 33 +++++++------- .../ensrainbow/src/commands/server-command.ts | 1 - apps/ensrainbow/src/lib/api.ts | 44 ++++++++++--------- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index c1535077d..34e551354 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -31,7 +31,6 @@ interface IngestProtobufArgs { * Arguments for the 'serve' command. * * Note: CLI arguments take precedence over environment variables. - * If both --port and PORT are set, --port will be used and a warning will be logged. */ interface ServeArgs { port: number; diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 173f9cbb7..386da34b0 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -168,24 +168,24 @@ describe("Server Command Tests", () => { }); describe("GET /v1/config", () => { - it("should return an error when database is empty", async () => { + it("should return cached config from startup", async () => { + // The config is cached on startup with count = 0 (set in beforeAll) + // Even if the database is cleared in beforeEach, the cached config is returned const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); - expect(response.status).toBe(500); - const data = (await response.json()) as { - status: typeof StatusCode.Error; - error: string; - errorCode: typeof ErrorCode.ServerError; - }; - const expectedData = { - status: StatusCode.Error, - error: "Label count not initialized. Check the validate command.", - errorCode: ErrorCode.ServerError, - }; - expect(data).toEqual(expectedData); + expect(response.status).toBe(200); + const data = (await response.json()) as EnsRainbow.ENSRainbowPublicConfig; + + expect(typeof data.version).toBe("string"); + expect(data.version.length).toBeGreaterThan(0); + expect(data.labelSet.labelSetId).toBe("test-label-set-id"); + expect(data.labelSet.highestLabelSetVersion).toBe(0); + // Config is cached on startup with count = 0, so it returns the cached value + expect(data.recordsCount).toBe(0); }); - it("should return correct config when label count is initialized", async () => { - // Set a specific precalculated rainbow record count in the database + it("should return cached config even if database count changes", async () => { + // Set a different count in the database + // However, the config is cached on startup, so it will still return the cached value await db.setPrecalculatedRainbowRecordCount(42); const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); @@ -196,7 +196,8 @@ describe("Server Command Tests", () => { expect(data.version.length).toBeGreaterThan(0); expect(data.labelSet.labelSetId).toBe("test-label-set-id"); expect(data.labelSet.highestLabelSetVersion).toBe(0); - expect(data.recordsCount).toBe(42); + // Config is cached on startup with count = 0, so changing the DB doesn't affect it + expect(data.recordsCount).toBe(0); }); }); diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index cd163cd35..b380fe1d7 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -35,7 +35,6 @@ export async function serverCommand(options: ServerCommandOptions): Promise { const api = new Hono(); const server = await ENSRainbowServer.init(db); + // Build and cache the public config once on startup + // This avoids calling labelCount() on every /v1/config request + const countResult = await server.labelCount(); + if (countResult.status === StatusCode.Error) { + logger.error("Failed to get records count during API initialization"); + throw new Error( + `Cannot initialize API: ${countResult.error} (errorCode: ${countResult.errorCode})`, + ); + } + + const cachedPublicConfig = buildENSRainbowPublicConfig( + config, + server.getServerLabelSet(), + countResult.count, + ); + + console.log("ENSRainbow public config:"); + console.log(prettyPrintJson(cachedPublicConfig)); + // Enable CORS for all versioned API routes api.use( "/v1/*", @@ -94,26 +114,10 @@ export async function createApi(db: ENSRainbowDB): Promise { return c.json(result, result.errorCode); }); - api.get("/v1/config", async (c: HonoContext) => { - 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, - ); - return c.json(publicConfig); + api.get("/v1/config", (c: HonoContext) => { + // Return the cached public config built on startup + // This avoids database queries on every request + return c.json(cachedPublicConfig); }); /** From 100cda3c4ce8ed967a0c19e735a0adc3e2080309 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 18:39:21 +0100 Subject: [PATCH 32/39] test(config): add unit tests for buildENSRainbowPublicConfig to validate output structure --- .../src/config/config.schema.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 9b791ebd9..3ab3b7cb9 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -1,12 +1,17 @@ +import packageJson from "@/../package.json" with { type: "json" }; + import { isAbsolute, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; + import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildConfigFromEnvironment } from "./config.schema"; +import { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; +import type { ENSRainbowConfig } from "./types"; vi.mock("@/utils/logger", () => ({ logger: { @@ -362,3 +367,29 @@ describe("buildConfigFromEnvironment", () => { }); }); }); + +describe("buildENSRainbowPublicConfig", () => { + describe("Success cases", () => { + it("returns a valid ENSRainbow public config with correct structure", () => { + const mockConfig: ENSRainbowConfig = { + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: getDefaultDataDir(), + dbSchemaVersion: DB_SCHEMA_VERSION, + labelSet: undefined, + }; + const labelSet: EnsRainbowServerLabelSet = { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }; + const recordsCount = 1000; + + const result = buildENSRainbowPublicConfig(mockConfig, labelSet, recordsCount); + + expect(result).toStrictEqual({ + version: packageJson.version, + labelSet, + recordsCount, + }); + }); + }); +}); From 9a00f37ad0f18e7fa7b7ab5ce6823b1ba1a83f27 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 3 Feb 2026 16:30:50 +0100 Subject: [PATCH 33/39] feat(cli): add port validation in CLI arguments to ensure valid port numbers --- apps/ensrainbow/src/cli.test.ts | 58 +++++++++++++++++++++ apps/ensrainbow/src/cli.ts | 13 +++++ apps/ensrainbow/src/config/config.schema.ts | 2 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 789b416a7..c1408eb22 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -60,6 +60,64 @@ describe("CLI", () => { // Restore real implementation for subsequent tests vi.doUnmock("@/commands/server-command"); }); + + it("should reject port less than 1", async () => { + // Validation happens during argument parsing, before command handler is called + try { + await cli.parse(["serve", "--port", "0", "--data-dir", testDataDir]); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("Invalid port"); + } + }); + + it("should reject negative port", async () => { + // Validation happens during argument parsing, before command handler is called + try { + await cli.parse(["serve", "--port", "-1", "--data-dir", testDataDir]); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("Invalid port"); + } + }); + + it("should reject port greater than 65535", async () => { + // Validation happens during argument parsing, before command handler is called + try { + await cli.parse(["serve", "--port", "65536", "--data-dir", testDataDir]); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("Invalid port"); + } + }); + + it("should accept valid port numbers", async () => { + // Mock serverCommand so we only test argument resolution here + vi.resetModules(); + const serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); + + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithPort = createCLIFresh({ exitProcess: false }); + + // Test valid ports + await cliWithPort.parse(["serve", "--port", "1", "--data-dir", testDataDir]); + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 1 })); + + await cliWithPort.parse(["serve", "--port", "65535", "--data-dir", testDataDir]); + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 65535 })); + + await cliWithPort.parse(["serve", "--port", "3223", "--data-dir", testDataDir]); + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 3223 })); + + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); + }); }); describe("purge command", () => { diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 34e551354..b2a3810a5 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -8,6 +8,7 @@ import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; +import { PortSchema } from "@ensnode/ensnode-sdk/internal"; import { convertCommand } from "@/commands/convert-command-sql"; import { convertCsvCommand } from "@/commands/convert-csv-command"; @@ -127,6 +128,18 @@ export function createCLI(options: CLIOptions = {}) { type: "number", description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, + coerce: (port: number) => { + // Validate port using PortSchema (make it required by parsing with a non-optional schema) + const result = PortSchema.safeParse(port); + if (!result.success) { + const firstError = result.error.issues[0]; + throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); + } + if (result.data === undefined) { + throw new Error("Invalid port: port is required"); + } + return result.data; + }, }) .option("data-dir", { type: "string", diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index c92fa5214..73ae7f3b3 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -40,7 +40,7 @@ const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); const ENSRainbowConfigBaseSchema = z.object({ port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), - dataDir: DataDirSchema.default(getDefaultDataDir()), + dataDir: DataDirSchema.default(() => getDefaultDataDir()), dbSchemaVersion: DbSchemaVersionSchema, labelSet: LabelSetSchema.optional(), }); From e834196418ea709294b1451fe9423af63a983d68 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 3 Feb 2026 21:39:06 +0100 Subject: [PATCH 34/39] refactor(config): rename ENSRainbowConfig to ENSRainbowEnvConfig for clarity and update related types and functions --- .../src/config/config.schema.test.ts | 4 +- apps/ensrainbow/src/config/config.schema.ts | 63 +++--------- apps/ensrainbow/src/config/environment.ts | 23 +---- apps/ensrainbow/src/config/index.ts | 3 +- apps/ensrainbow/src/config/types.ts | 96 ++++--------------- apps/ensrainbow/src/config/validations.ts | 16 ++-- .../ensnode-sdk/src/shared/config/types.ts | 8 ++ .../src/shared/config/zod-schemas.ts | 9 +- 8 files changed, 60 insertions(+), 162 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 3ab3b7cb9..832afce12 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -11,7 +11,7 @@ import { DB_SCHEMA_VERSION } from "@/lib/database"; import { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; -import type { ENSRainbowConfig } from "./types"; +import type { ENSRainbowEnvConfig } from "./types"; vi.mock("@/utils/logger", () => ({ logger: { @@ -371,7 +371,7 @@ describe("buildConfigFromEnvironment", () => { describe("buildENSRainbowPublicConfig", () => { describe("Success cases", () => { it("returns a valid ENSRainbow public config with correct structure", () => { - const mockConfig: ENSRainbowConfig = { + const mockConfig: ENSRainbowEnvConfig = { port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 73ae7f3b3..4c8bb67f8 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,31 +10,33 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import type { ENSRainbowConfig } from "@/config/types"; +import type { ENSRainbowEnvConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -export type { ENSRainbowConfig }; +export type { ENSRainbowEnvConfig }; -const DataDirSchema = z +export const AbsolutePathSchemaBase = z .string() .trim() .min(1, { - error: "DATA_DIR must be a non-empty string.", + error: "Path must be a non-empty string.", }) .transform((path: string) => { - // Resolve relative paths to absolute paths (cross-platform) if (isAbsolute(path)) { return path; } return resolve(process.cwd(), path); }); -const DbSchemaVersionSchema = z.coerce +const DataDirSchema = AbsolutePathSchemaBase; + +export const DbSchemaVersionSchemaBase = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) - .positive({ error: "DB_SCHEMA_VERSION must be greater than 0." }) - .default(DB_SCHEMA_VERSION); + .positive({ error: "DB_SCHEMA_VERSION must be greater than 0." }); + +const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); @@ -45,37 +47,14 @@ const ENSRainbowConfigBaseSchema = z.object({ labelSet: LabelSetSchema.optional(), }); -/** - * Helper function to check if a string value is present (not undefined and not empty after trimming). - * - * @param str - The string value to check - * @returns true if the string is defined and has non-whitespace content after trimming - */ const hasValue = (str: string | undefined): boolean => { return str !== undefined && str.trim() !== ""; }; -const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema - /** - * 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_dbSchemaVersionMatch); - -/** - * 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 { +const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); + +export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { try { - // Transform environment variables into config shape const envToConfigSchema = z .object({ PORT: z.string().optional(), @@ -84,12 +63,6 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb LABEL_SET_ID: z.string().optional(), LABEL_SET_VERSION: z.string().optional(), }) - /** - * Invariant enforcement on environment variables - * - * We check that LABEL_SET_ID and LABEL_SET_VERSION are provided together, or neither. - * This check happens before transformation to ensure we don't create invalid config objects. - */ .check((ctx) => { const { value: env } = ctx; const hasLabelSetId = hasValue(env.LABEL_SET_ID); @@ -146,16 +119,8 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb } } -/** - * 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, // kept for semantic purposes, not used in the function + _config: ENSRainbowEnvConfig, labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts index c07579aa9..66dff8dc3 100644 --- a/apps/ensrainbow/src/config/environment.ts +++ b/apps/ensrainbow/src/config/environment.ts @@ -1,33 +1,12 @@ 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`. + * Raw, unvalidated environment variables for ENSRainbow. */ 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. - * 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 533794107..115a51b7b 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,9 +1,8 @@ import { buildConfigFromEnvironment } from "./config.schema"; -export type { ENSRainbowConfig } from "./config.schema"; +export type { ENSRainbowEnvConfig } from "./config.schema"; export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; -// build, validate, and export the ENSRainbowConfig from process.env export default buildConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 45f279d89..8aa5fd268 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1,84 +1,28 @@ +import type { z } from "zod/v4"; + import type { EnsRainbowClientLabelSet } from "@ensnode/ensnode-sdk"; +import type { PortNumber } from "@ensnode/ensnode-sdk/internal"; + +import type { AbsolutePathSchemaBase, DbSchemaVersionSchemaBase } from "./config.schema"; /** - * The complete runtime configuration for an ENSRainbow instance. - * - * This interface defines all configuration parameters needed to run an ENSRainbow server, - * which provides label healing services for ENS names. + * Absolute filesystem path. + * Inferred from {@link AbsolutePathSchemaBase} - see that schema for invariants. */ -export interface ENSRainbowConfig { - /** - * The port number on which the ENSRainbow server listens. - * - * The HTTP server will bind to this port and serve the ENSRainbow API. - * - * Default: 3223 (defined by {@link ENSRAINBOW_DEFAULT_PORT}) - * - * Invariants: - * - Must be a valid port number (1-65535) - * - Must not be already in use by another process - */ - port: number; +export type AbsolutePath = z.infer; - /** - * The absolute path to the data directory where ENSRainbow stores its database and other files. - * - * This directory will contain: - * - The LevelDB database file with label set data - * - Any temporary files created during operation - * - * If a relative path is provided in the environment variable, it will be resolved to an - * absolute path relative to the current working directory. - * - * Default: `{cwd}/data` - * - * Invariants: - * - Must be a non-empty string - * - Must be an absolute path after resolution - * - The process must have read/write permissions to this directory - */ - dataDir: string; - - /** - * The database schema version expected by the code. - * - * This version number corresponds to the structure of the LevelDB database that ENSRainbow - * uses to store label set data. If the version in the environment doesn't match the version - * expected by the code, the application will fail to start. - * - * This prevents version mismatches between the codebase and the database schema, which could - * lead to data corruption or runtime errors. - * - * Default: {@link DB_SCHEMA_VERSION} (currently 3) - * - * Invariants: - * - Must be a positive integer - * - Must match {@link DB_SCHEMA_VERSION} exactly - */ - dbSchemaVersion: number; +/** + * Database schema version number. + * Inferred from {@link DbSchemaVersionSchemaBase} - see that schema for invariants. + */ +export type DbSchemaVersion = z.infer; - /** - * Optional label set configuration that specifies which label set to use. - * - * A label set defines which labels (domain name segments) are available for label healing. - * Both `labelSetId` and `labelSetVersion` must be provided together to create a "fully pinned" - * label set reference, ensuring deterministic and reproducible label healing. - * - * If not provided, ENSRainbow will start without any label set loaded, and label healing - * requests will fail until a label set is loaded via the management API. - * - * Examples: - * - `{ labelSetId: "subgraph", labelSetVersion: 0 }` - The legacy subgraph label set - * - `{ labelSetId: "ensip-15", labelSetVersion: 1 }` - ENSIP-15 normalized labels - * - * Default: undefined (no label set) - * - * Invariants: - * - If provided, both `labelSetId` and `labelSetVersion` must be defined - * - `labelSetId` must be 1-50 characters, containing only lowercase letters (a-z) and hyphens (-) - * - `labelSetVersion` must be a non-negative integer - * - If only one of LABEL_SET_ID or LABEL_SET_VERSION is provided in the environment, - * configuration parsing will fail with a clear error message - */ +/** + * Configuration derived from environment variables for ENSRainbow. + */ +export interface ENSRainbowEnvConfig { + port: PortNumber; + dataDir: AbsolutePath; + dbSchemaVersion: DbSchemaVersion; labelSet?: Required; } diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index d5f02d22e..74be01899 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,13 +1,15 @@ -import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; +import type { z } from "zod/v4"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import type { ENSRainbowConfig } from "./types"; - -/** - * Invariant: dbSchemaVersion must match the version expected by the code. - */ -export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { +export function invariant_dbSchemaVersionMatch( + ctx: z.core.ParsePayload<{ + port: number; + dataDir: string; + dbSchemaVersion: number; + labelSet?: { labelSetId: string; labelSetVersion: number }; + }>, +): void { const { value: config } = ctx; if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index 3fe843c11..b1081a702 100644 --- a/packages/ensnode-sdk/src/shared/config/types.ts +++ b/packages/ensnode-sdk/src/shared/config/types.ts @@ -5,6 +5,7 @@ import type { ChainId } from "../types"; import type { DatabaseSchemaNameSchema, EnsIndexerUrlSchema, + PortSchemaBase, TheGraphApiKeySchema, } from "./zod-schemas"; @@ -48,3 +49,10 @@ export type DatabaseUrl = UrlString; export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = z.infer; + +/** + * Port number for network services. + * + * Inferred from {@link PortSchemaBase} - see that schema for invariants. + */ +export type PortNumber = z.infer; diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 1c4fd5f7b..27cc9485d 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -60,13 +60,14 @@ 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. + * Ensures the value is an integer (not a float) within the valid port range (1-65535). */ -export const PortSchema = z.coerce +export const PortSchemaBase = 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" }) - .max(65535, { error: "PORT must be less than or equal to 65535" }) - .optional(); + .max(65535, { error: "PORT must be less than or equal to 65535" }); + +export const PortSchema = PortSchemaBase.optional(); export const TheGraphApiKeySchema = z.string().optional(); From 75e72f89643d38c8d530a04215f3a32efc879fab Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 3 Feb 2026 22:46:51 +0100 Subject: [PATCH 35/39] refactor(config): remove labelSet from configuration schema and related tests for simplification --- .../src/config/config.schema.test.ts | 80 ----- apps/ensrainbow/src/config/config.schema.ts | 48 +-- apps/ensrainbow/src/config/env-config.test.ts | 285 ++++++++++++++++++ apps/ensrainbow/src/config/types.ts | 2 - 4 files changed, 286 insertions(+), 129 deletions(-) create mode 100644 apps/ensrainbow/src/config/env-config.test.ts diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 832afce12..bcd148b93 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -30,7 +30,6 @@ describe("buildConfigFromEnvironment", () => { port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, - labelSet: undefined, }); }); @@ -109,27 +108,11 @@ describe("buildConfigFromEnvironment", () => { expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); - 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); @@ -138,10 +121,6 @@ describe("buildConfigFromEnvironment", () => { port: 4444, dataDir: "/opt/ensrainbow/data", dbSchemaVersion: DB_SCHEMA_VERSION, - labelSet: { - labelSetId: "ens-normalize-latest", - labelSetVersion: 2, - }, }); }); }); @@ -218,53 +197,6 @@ describe("buildConfigFromEnvironment", () => { expect(() => buildConfigFromEnvironment(env)).toThrow(); }); - - it("fails when LABEL_SET_VERSION is not a number", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - LABEL_SET_VERSION: "not-a-number", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when LABEL_SET_VERSION is negative", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - LABEL_SET_VERSION: "-1", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when LABEL_SET_ID is empty", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "", - LABEL_SET_VERSION: "0", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when only LABEL_SET_ID is set (both ID and version required)", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - }; - - 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)", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_VERSION: "0", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow( - "LABEL_SET_VERSION is set but LABEL_SET_ID is missing", - ); - }); }); describe("Invariant: DB_SCHEMA_VERSION must match code version", () => { @@ -317,17 +249,6 @@ describe("buildConfigFromEnvironment", () => { expect(config.port).toBe(65535); }); - 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 = { @@ -375,7 +296,6 @@ describe("buildENSRainbowPublicConfig", () => { port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, - labelSet: undefined, }; const labelSet: EnsRainbowServerLabelSet = { labelSetId: "subgraph", diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 4c8bb67f8..359af6000 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -5,7 +5,7 @@ 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 { PortSchema } from "@ensnode/ensnode-sdk/internal"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; @@ -38,19 +38,12 @@ export const DbSchemaVersionSchemaBase = z.coerce const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); -const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); - const ENSRainbowConfigBaseSchema = z.object({ port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), dataDir: DataDirSchema.default(() => getDefaultDataDir()), dbSchemaVersion: DbSchemaVersionSchema, - labelSet: LabelSetSchema.optional(), }); -const hasValue = (str: string | undefined): boolean => { - return str !== undefined && str.trim() !== ""; -}; - const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { @@ -60,51 +53,12 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb 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(), - }) - .check((ctx) => { - const { value: env } = ctx; - const hasLabelSetId = hasValue(env.LABEL_SET_ID); - const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); - - if (hasLabelSetId && !hasLabelSetVersion) { - ctx.issues.push({ - code: "custom", - path: ["LABEL_SET_VERSION"], - input: env, - message: - "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) { - ctx.issues.push({ - code: "custom", - path: ["LABEL_SET_ID"], - input: env, - message: - "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.", - }); - } }) .transform((env) => { - const hasLabelSetId = hasValue(env.LABEL_SET_ID); - const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); - - const labelSet = - hasLabelSetId && hasLabelSetVersion - ? { - labelSetId: env.LABEL_SET_ID, - labelSetVersion: env.LABEL_SET_VERSION, - } - : undefined; - return { port: env.PORT, dataDir: env.DATA_DIR, dbSchemaVersion: env.DB_SCHEMA_VERSION, - labelSet, }; }); diff --git a/apps/ensrainbow/src/config/env-config.test.ts b/apps/ensrainbow/src/config/env-config.test.ts new file mode 100644 index 000000000..fb8f619ac --- /dev/null +++ b/apps/ensrainbow/src/config/env-config.test.ts @@ -0,0 +1,285 @@ +import { isAbsolute, resolve } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +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", () => { + 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: DB_SCHEMA_VERSION, + }); + }); + + 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("defaults DB_SCHEMA_VERSION to code version when not set", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("handles all valid configuration options together", () => { + const env: ENSRainbowEnvironment = { + PORT: "4444", + DATA_DIR: "/opt/ensrainbow/data", + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: 4444, + dataDir: "/opt/ensrainbow/data", + dbSchemaVersion: DB_SCHEMA_VERSION, + }); + }); + }); + + describe("Validation errors", () => { + it("fails when PORT is not a number", () => { + const env: ENSRainbowEnvironment = { + PORT: "not-a-number", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is a float", () => { + const env: ENSRainbowEnvironment = { + PORT: "3000.5", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is less than 1", () => { + const env: ENSRainbowEnvironment = { + PORT: "0", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is negative", () => { + const env: ENSRainbowEnvironment = { + PORT: "-100", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is greater than 65535", () => { + const env: ENSRainbowEnvironment = { + PORT: "65536", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DATA_DIR is empty string", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: "", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DATA_DIR is only whitespace", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: " ", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DB_SCHEMA_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "not-a-number", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DB_SCHEMA_VERSION is a float", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "3.5", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + }); + + 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(), + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); + }); + + 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); + }); + + it("passes when DB_SCHEMA_VERSION defaults to code version", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + }); + + 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("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/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 8aa5fd268..12efb2e6b 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1,6 +1,5 @@ import type { z } from "zod/v4"; -import type { EnsRainbowClientLabelSet } from "@ensnode/ensnode-sdk"; import type { PortNumber } from "@ensnode/ensnode-sdk/internal"; import type { AbsolutePathSchemaBase, DbSchemaVersionSchemaBase } from "./config.schema"; @@ -24,5 +23,4 @@ export interface ENSRainbowEnvConfig { port: PortNumber; dataDir: AbsolutePath; dbSchemaVersion: DbSchemaVersion; - labelSet?: Required; } From 70389d4865d2ab624af8242394a9d7d5a8634b82 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 21:18:03 +0100 Subject: [PATCH 36/39] refactor(api): remove redundant comments from /v1/config endpoint and clean up related code --- apps/ensrainbow/src/lib/api.ts | 2 -- packages/ensnode-sdk/src/shared/config/types.ts | 5 ----- packages/ensnode-sdk/src/shared/config/zod-schemas.ts | 4 ---- packages/ensrainbow-sdk/src/client.ts | 10 +--------- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index fcb737d37..f509212af 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -115,8 +115,6 @@ export async function createApi(db: ENSRainbowDB): Promise { }); api.get("/v1/config", (c: HonoContext) => { - // Return the cached public config built on startup - // This avoids database queries on every request return c.json(cachedPublicConfig); }); diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index b1081a702..e2ba18ae8 100644 --- a/packages/ensnode-sdk/src/shared/config/types.ts +++ b/packages/ensnode-sdk/src/shared/config/types.ts @@ -50,9 +50,4 @@ export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = z.infer; -/** - * Port number for network services. - * - * Inferred from {@link PortSchemaBase} - see that schema for invariants. - */ export type PortNumber = z.infer; diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 27cc9485d..b0e6bf431 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -58,10 +58,6 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { `Invalid NAMESPACE. Got '${input}', but supported ENS namespaces are: ${Object.keys(ENSNamespaceIds).join(", ")}`, }); -/** - * Parses a numeric value as a port number. - * Ensures the value is an integer (not a float) within the valid port range (1-65535). - */ export const PortSchemaBase = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 308ed8436..c216d27a2 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -18,8 +18,6 @@ export namespace EnsRainbow { /** * Get the public configuration of the ENSRainbow service - * - * @returns the public configuration of the ENSRainbow service */ config(): Promise; @@ -35,7 +33,6 @@ export namespace EnsRainbow { * 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; @@ -177,14 +174,11 @@ export namespace EnsRainbow { /** * 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; } @@ -397,9 +391,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { } /** - * Get the public configuration of the ENSRainbow service - * - * @returns the public configuration of the ENSRainbow service + * Get the public configuration of the ENSRainbow service. */ async config(): Promise { const response = await fetch(new URL("/v1/config", this.options.endpointUrl)); From 163f873cd6232ede2e054979e26f331e0b0fa8b6 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 21:37:02 +0100 Subject: [PATCH 37/39] refactor(config): move buildENSRainbowPublicConfig to a separate file and update imports accordingly --- .../src/config/config.schema.test.ts | 3 ++- apps/ensrainbow/src/config/config.schema.ts | 16 ---------------- apps/ensrainbow/src/config/index.ts | 3 ++- apps/ensrainbow/src/config/public.ts | 18 ++++++++++++++++++ apps/ensrainbow/src/lib/api.ts | 3 +-- 5 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 apps/ensrainbow/src/config/public.ts diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index bcd148b93..d9c00be3c 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -8,9 +8,10 @@ import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; +import { buildConfigFromEnvironment } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; +import { buildENSRainbowPublicConfig } from "./public"; import type { ENSRainbowEnvConfig } from "./types"; vi.mock("@/utils/logger", () => ({ diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 359af6000..21414f4e3 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -1,12 +1,8 @@ -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 { 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"; @@ -72,15 +68,3 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb throw error; } } - -export function buildENSRainbowPublicConfig( - _config: ENSRainbowEnvConfig, - 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 115a51b7b..bdfa2e1e8 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,8 +1,9 @@ import { buildConfigFromEnvironment } from "./config.schema"; export type { ENSRainbowEnvConfig } from "./config.schema"; -export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; +export { buildConfigFromEnvironment } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; +export { buildENSRainbowPublicConfig } from "./public"; export default buildConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/src/config/public.ts b/apps/ensrainbow/src/config/public.ts new file mode 100644 index 000000000..54fccbf71 --- /dev/null +++ b/apps/ensrainbow/src/config/public.ts @@ -0,0 +1,18 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; +import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; + +import type { ENSRainbowEnvConfig } from "./types"; + +export function buildENSRainbowPublicConfig( + _config: ENSRainbowEnvConfig, // kept for semantic purposes + labelSet: EnsRainbowServerLabelSet, + recordsCount: number, +): EnsRainbow.ENSRainbowPublicConfig { + return { + version: packageJson.version, + labelSet, + recordsCount, + }; +} diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index f509212af..6426e207b 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,5 +1,5 @@ import packageJson from "@/../package.json"; -import config from "@/config"; +import config, { buildENSRainbowPublicConfig } from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -16,7 +16,6 @@ import { import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; 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"; From 4cca4f7584a833428fc5ef1b21486998e27cb7b5 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 21:58:39 +0100 Subject: [PATCH 38/39] refactor(cli): update port validation to use PortSchemaBase and improve logging in server command --- apps/ensrainbow/src/cli.ts | 8 +- .../ensrainbow/src/commands/server-command.ts | 2 +- apps/ensrainbow/src/config/env-config.test.ts | 285 ------------------ 3 files changed, 3 insertions(+), 292 deletions(-) delete mode 100644 apps/ensrainbow/src/config/env-config.test.ts diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index b2a3810a5..d48822649 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -8,7 +8,7 @@ import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; -import { PortSchema } from "@ensnode/ensnode-sdk/internal"; +import { PortSchemaBase } from "@ensnode/ensnode-sdk/internal"; import { convertCommand } from "@/commands/convert-command-sql"; import { convertCsvCommand } from "@/commands/convert-csv-command"; @@ -129,15 +129,11 @@ export function createCLI(options: CLIOptions = {}) { description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, coerce: (port: number) => { - // Validate port using PortSchema (make it required by parsing with a non-optional schema) - const result = PortSchema.safeParse(port); + const result = PortSchemaBase.safeParse(port); if (!result.success) { const firstError = result.error.issues[0]; throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); } - if (result.data === undefined) { - throw new Error("Invalid port: port is required"); - } return result.data; }, }) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index b380fe1d7..729a32045 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -22,7 +22,7 @@ export async function createServer(db: ENSRainbowDB) { export async function serverCommand(options: ServerCommandOptions): Promise { // Log the config that ENSRainbow is running with - console.log("ENSRainbow running with config:"); + console.log("ENSRainbow running with environment config:"); console.log(prettyPrintJson(config)); logger.info(`ENS Rainbow server starting on port ${options.port}...`); diff --git a/apps/ensrainbow/src/config/env-config.test.ts b/apps/ensrainbow/src/config/env-config.test.ts deleted file mode 100644 index fb8f619ac..000000000 --- a/apps/ensrainbow/src/config/env-config.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { isAbsolute, resolve } from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { DB_SCHEMA_VERSION } from "@/lib/database"; - -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", () => { - 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: DB_SCHEMA_VERSION, - }); - }); - - 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("defaults DB_SCHEMA_VERSION to code version when not set", () => { - const env: ENSRainbowEnvironment = {}; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - }); - - it("handles all valid configuration options together", () => { - const env: ENSRainbowEnvironment = { - PORT: "4444", - DATA_DIR: "/opt/ensrainbow/data", - DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), - }; - - const config = buildConfigFromEnvironment(env); - - expect(config).toStrictEqual({ - port: 4444, - dataDir: "/opt/ensrainbow/data", - dbSchemaVersion: DB_SCHEMA_VERSION, - }); - }); - }); - - describe("Validation errors", () => { - it("fails when PORT is not a number", () => { - const env: ENSRainbowEnvironment = { - PORT: "not-a-number", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is a float", () => { - const env: ENSRainbowEnvironment = { - PORT: "3000.5", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is less than 1", () => { - const env: ENSRainbowEnvironment = { - PORT: "0", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is negative", () => { - const env: ENSRainbowEnvironment = { - PORT: "-100", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is greater than 65535", () => { - const env: ENSRainbowEnvironment = { - PORT: "65536", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DATA_DIR is empty string", () => { - const env: ENSRainbowEnvironment = { - DATA_DIR: "", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DATA_DIR is only whitespace", () => { - const env: ENSRainbowEnvironment = { - DATA_DIR: " ", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DB_SCHEMA_VERSION is not a number", () => { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "not-a-number", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DB_SCHEMA_VERSION is a float", () => { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "3.5", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - }); - - 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(), - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); - }); - - 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); - }); - - it("passes when DB_SCHEMA_VERSION defaults to code version", () => { - const env: ENSRainbowEnvironment = {}; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - }); - }); - - 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("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)); - }); - }); -}); From 54bf4724fb01c7e7cfc568ddbd9416ac6f0301eb Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 23:46:24 +0100 Subject: [PATCH 39/39] fix(server): enhance error handling for server startup by checking specific database error messages --- apps/ensrainbow/src/commands/server-command.ts | 16 ++++++++++------ apps/ensrainbow/src/config/config.schema.test.ts | 8 +------- apps/ensrainbow/src/config/validations.ts | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 729a32045..0b7ed300d 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -34,12 +34,16 @@ export async function serverCommand(options: ServerCommandOptions): Promise ({ - logger: { - error: vi.fn(), - }, -})); - describe("buildConfigFromEnvironment", () => { describe("Success cases", () => { it("returns a valid config with all defaults when environment is empty", () => { diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 74be01899..601181e12 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,9 +1,9 @@ -import type { z } from "zod/v4"; +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; import { DB_SCHEMA_VERSION } from "@/lib/database"; export function invariant_dbSchemaVersionMatch( - ctx: z.core.ParsePayload<{ + ctx: ZodCheckFnInput<{ port: number; dataDir: string; dbSchemaVersion: number;