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 diff --git a/apps/ensrainbow/package.json b/apps/ensrainbow/package.json index ce895a651..8a14158ae 100644 --- a/apps/ensrainbow/package.json +++ b/apps/ensrainbow/package.json @@ -40,7 +40,8 @@ "protobufjs": "^7.4.0", "viem": "catalog:", "yargs": "^17.7.2", - "@fast-csv/parse": "^5.0.0" + "@fast-csv/parse": "^5.0.0", + "zod": "catalog:" }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index dedf1b88a..820f082f1 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -4,7 +4,7 @@ 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 { createCLI, validatePortConfiguration } from "./cli"; @@ -38,42 +38,60 @@ 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", async () => { + vi.resetModules(); + const { getEnvPort: getEnvPortFresh } = await import("@/lib/env"); + expect(getEnvPortFresh()).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"; - expect(() => getEnvPort()).toThrow( - 'Invalid PORT value "invalid": must be a non-negative integer', - ); + 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", () => { - process.env.PORT = "-1"; - expect(() => getEnvPort()).toThrow('Invalid PORT value "-1": must be a non-negative integer'); + 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", () => { - 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", () => { - 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"); }); }); @@ -526,11 +544,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, @@ -539,7 +560,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)); @@ -587,9 +608,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/cli.ts b/apps/ensrainbow/src/cli.ts index 730fa79a0..81547ef5c 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,3 +1,5 @@ +import config, { getDefaultDataDir } from "@/config"; + import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,13 +16,13 @@ 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"; 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.`, ); } @@ -89,7 +91,7 @@ export function createCLI(options: CLIOptions = {}) { // .option("data-dir", { // type: "string", // description: "Directory to store LevelDB data", - // default: getDefaultDataSubDir(), + // default: getDefaultDataDir(), // }); // }, // async (argv: ArgumentsCamelCase) => { @@ -112,7 +114,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { @@ -130,12 +132,12 @@ export function createCLI(options: CLIOptions = {}) { .option("port", { type: "number", description: "Port to listen on", - default: getEnvPort(), + default: config.port, }) .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { @@ -154,7 +156,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }) .option("lite", { type: "boolean", @@ -177,7 +179,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.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts new file mode 100644 index 000000000..4e80f5724 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -0,0 +1,364 @@ +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: 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", + }; + + 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(); + }); + + 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", () => { + 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 is undefined", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBeUndefined(); + }); + }); + + 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 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/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts new file mode 100644 index 000000000..7d6200995 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -0,0 +1,135 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import { isAbsolute, resolve } from "node:path"; + +import { 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"; +import { invariant_dbSchemaVersionMatch } from "@/config/validations"; + +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 (cross-platform) + if (isAbsolute(path)) { + return path; + } + return resolve(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 ZodError with detailed 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) => { + // 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) { + // Re-throw ZodError to preserve structured error information + throw error; + } + // Re-throw other errors (like our custom label set validation errors) + throw error; + } +} + +/** + * 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/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..c07579aa9 --- /dev/null +++ b/apps/ensrainbow/src/config/environment.ts @@ -0,0 +1,33 @@ +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. + * 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 new file mode 100644 index 000000000..6dffbafe8 --- /dev/null +++ b/apps/ensrainbow/src/config/index.ts @@ -0,0 +1,19 @@ +import { buildConfigFromEnvironment } from "./config.schema"; +import type { ENSRainbowEnvironment } from "./environment"; + +export type { ENSRainbowConfig } from "./config.schema"; +export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; +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 + console.error("Configuration error:", error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +export default config; 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..0de092eb1 --- /dev/null +++ b/apps/ensrainbow/src/config/validations.ts @@ -0,0 +1,23 @@ +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 { + const { value: config } = ctx; + + if (config.dbSchemaVersion !== undefined && 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.`, + }); + } +} 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/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index 048f47ae4..e58ccc55e 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,24 +1,8 @@ -import { join } from "node:path"; +import config from "@/config"; -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); - } + 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..1c4fd5f7b 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -60,11 +60,13 @@ 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." }) - .min(1, { error: "PORT must be greater than 1." }) - .max(65535, { error: "PORT must be less than 65535" }) + .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(); export const TheGraphApiKeySchema = z.string().optional(); 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7f19b0d1..42c2b90a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,9 @@ importers: yargs: specifier: ^17.7.2 version: 17.7.2 + zod: + specifier: 'catalog:' + version: 3.25.76 devDependencies: '@ensnode/shared-configs': specifier: workspace:*