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/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..e74f166c7 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -0,0 +1,84 @@ +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_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_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..4de34ea46 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,24 +1,10 @@ -import { join } from "node:path"; +import { buildConfigFromEnvironment } from "@/config/config.schema"; +import type { ENSRainbowEnvironment } from "@/config/environment"; -import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk"; - -import { logger } from "@/utils/logger"; - -export const getDefaultDataSubDir = () => join(process.cwd(), "data"); - -export const DEFAULT_PORT = 3223; +/** + * Gets the port from environment variables. + */ 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(); 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:*