diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md new file mode 100644 index 000000000..1f3e96ab3 --- /dev/null +++ b/.changeset/young-carrots-cheer.md @@ -0,0 +1,6 @@ +--- +"ensrainbow": patch +"@ensnode/ensrainbow-sdk": patch +--- + +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/package.json b/apps/ensrainbow/package.json index 6a3400165..ea836c348 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..c1408eb22 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -4,9 +4,9 @@ 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"; +import { createCLI } from "./cli"; // Path to test fixtures const TEST_FIXTURES_DIR = join(__dirname, "..", "test", "fixtures"); @@ -37,43 +37,86 @@ describe("CLI", () => { await rm(tempDir, { recursive: true, force: true }); }); - describe("getEnvPort", () => { - it("should return DEFAULT_PORT when PORT is not set", () => { - expect(getEnvPort()).toBe(DEFAULT_PORT); - }); + 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 serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); - it("should return port from environment variable", () => { - const customPort = 4000; - process.env.PORT = customPort.toString(); - expect(getEnvPort()).toBe(customPort); - }); + // Simulate PORT being set in the environment + vi.stubEnv("PORT", "3000"); + + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithPort = createCLIFresh({ exitProcess: false }); - 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', - ); + // 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"); }); - 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 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"); + } }); - }); - describe("validatePortConfiguration", () => { - it("should not throw when PORT env var is not set", () => { - expect(() => validatePortConfiguration(3000)).not.toThrow(); + 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 not throw when PORT matches CLI port", () => { - process.env.PORT = "3000"; - expect(() => validatePortConfiguration(3000)).not.toThrow(); + 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 throw when PORT conflicts with CLI port", () => { - process.env.PORT = "3000"; - expect(() => validatePortConfiguration(4000)).toThrow("Port conflict"); + 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"); }); }); @@ -512,8 +555,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`); @@ -524,13 +567,40 @@ 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; - 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,10 +609,10 @@ 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)); + // 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`); @@ -586,11 +656,41 @@ describe("CLI", () => { await serverPromise; }); - it("should throw on port conflict", async () => { - process.env.PORT = "5000"; - await expect( - cli.parse(["serve", "--port", "4000", "--data-dir", testDataDir]), - ).rejects.toThrow("Port conflict"); + 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 }); + + // 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 730fa79a0..d48822649 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"; @@ -6,6 +8,7 @@ import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; +import { PortSchemaBase } from "@ensnode/ensnode-sdk/internal"; import { convertCommand } from "@/commands/convert-command-sql"; import { convertCsvCommand } from "@/commands/convert-csv-command"; @@ -14,17 +17,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 { getDefaultDataSubDir, getEnvPort } from "@/lib/env"; - -export function validatePortConfiguration(cliPort: number): void { - const envPort = process.env.PORT; - if (envPort !== undefined && cliPort !== getEnvPort()) { - throw new Error( - `Port conflict: Command line argument (${cliPort}) differs from PORT environment variable (${envPort}). ` + - `Please use only one method to specify the port.`, - ); - } -} // interface IngestArgs { // "input-file": string; @@ -36,6 +28,11 @@ interface IngestProtobufArgs { "data-dir": string; } +/** + * Arguments for the 'serve' command. + * + * Note: CLI arguments take precedence over environment variables. + */ interface ServeArgs { port: number; "data-dir": string; @@ -89,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) => { @@ -112,7 +109,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataSubDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { @@ -129,17 +126,24 @@ export function createCLI(options: CLIOptions = {}) { return yargs .option("port", { type: "number", - description: "Port to listen on", - default: getEnvPort(), + description: "Port to listen on (overrides PORT env var if both are set)", + default: config.port, + coerce: (port: number) => { + const result = PortSchemaBase.safeParse(port); + if (!result.success) { + const firstError = result.error.issues[0]; + throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); + } + return result.data; + }, }) .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { - validatePortConfiguration(argv.port); await serverCommand({ port: argv.port, dataDir: argv["data-dir"], @@ -154,7 +158,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: config.dataDir, }) .option("lite", { type: "boolean", @@ -177,7 +181,7 @@ export function createCLI(options: CLIOptions = {}) { return yargs.option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index d6d04e911..386da34b0 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -167,6 +167,40 @@ describe("Server Command Tests", () => { }); }); + describe("GET /v1/config", () => { + 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(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 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`); + 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 changing the DB doesn't affect it + expect(data.recordsCount).toBe(0); + }); + }); + describe("CORS headers for /v1/* routes", () => { it("should return CORS headers for /v1/* routes", async () => { const validLabel = "test-label"; @@ -188,6 +222,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/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 05c0f42f7..0b7ed300d 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,5 +1,9 @@ +import config from "@/config"; + import { serve } from "@hono/node-server"; +import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; + import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; @@ -17,11 +21,31 @@ 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 environment config:"); + console.log(prettyPrintJson(config)); + logger.info(`ENS Rainbow server starting on port ${options.port}...`); const db = await ENSRainbowDB.open(options.dataDir); try { + // Check if the database is empty (no precalculated count) + // This prevents starting a server that can't serve any data + try { + await db.getPrecalculatedRainbowRecordCount(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("No precalculated count found")) { + logger.error("Cannot start server: database is empty or uninitialized."); + logger.error("The database must contain ingested data before the server can start."); + logger.error("Please run the ingestion command first to populate the database."); + throw new Error("Database is empty or uninitialized. Cannot start server."); + } + logger.error(error, "Cannot start server: failed to read precalculated record count."); + throw error; + } + const app = await createServer(db); const server = serve({ 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..c33cbe691 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -0,0 +1,310 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import { isAbsolute, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +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"; + +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)); + }); + }); +}); + +describe("buildENSRainbowPublicConfig", () => { + describe("Success cases", () => { + it("returns a valid ENSRainbow public config with correct structure", () => { + const mockConfig: ENSRainbowEnvConfig = { + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: getDefaultDataDir(), + dbSchemaVersion: DB_SCHEMA_VERSION, + }; + const labelSet: EnsRainbowServerLabelSet = { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }; + const recordsCount = 1000; + + const result = buildENSRainbowPublicConfig(mockConfig, labelSet, recordsCount); + + expect(result).toStrictEqual({ + version: packageJson.version, + labelSet, + recordsCount, + }); + }); + }); +}); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts new file mode 100644 index 000000000..21414f4e3 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -0,0 +1,70 @@ +import { isAbsolute, resolve } from "node:path"; + +import { prettifyError, ZodError, z } from "zod/v4"; + +import { PortSchema } from "@ensnode/ensnode-sdk/internal"; + +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; +import type { ENSRainbowEnvironment } from "@/config/environment"; +import type { ENSRainbowEnvConfig } from "@/config/types"; +import { invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +export type { ENSRainbowEnvConfig }; + +export const AbsolutePathSchemaBase = z + .string() + .trim() + .min(1, { + error: "Path must be a non-empty string.", + }) + .transform((path: string) => { + if (isAbsolute(path)) { + return path; + } + return resolve(process.cwd(), path); + }); + +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." }); + +const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); + +const ENSRainbowConfigBaseSchema = z.object({ + port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), + dataDir: DataDirSchema.default(() => getDefaultDataDir()), + dbSchemaVersion: DbSchemaVersionSchema, +}); + +const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); + +export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { + try { + const envToConfigSchema = z + .object({ + PORT: z.string().optional(), + DATA_DIR: z.string().optional(), + DB_SCHEMA_VERSION: z.string().optional(), + }) + .transform((env) => { + return { + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + }; + }); + + 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/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..66dff8dc3 --- /dev/null +++ b/apps/ensrainbow/src/config/environment.ts @@ -0,0 +1,12 @@ +import type { LogLevelEnvironment, PortEnvironment } from "@ensnode/ensnode-sdk/internal"; + +/** + * Raw, unvalidated environment variables for ENSRainbow. + */ +export type ENSRainbowEnvironment = PortEnvironment & + LogLevelEnvironment & { + DATA_DIR?: string; + DB_SCHEMA_VERSION?: string; + LABEL_SET_ID?: string; + 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..bdfa2e1e8 --- /dev/null +++ b/apps/ensrainbow/src/config/index.ts @@ -0,0 +1,9 @@ +import { buildConfigFromEnvironment } from "./config.schema"; + +export type { ENSRainbowEnvConfig } 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/config/types.ts b/apps/ensrainbow/src/config/types.ts new file mode 100644 index 000000000..12efb2e6b --- /dev/null +++ b/apps/ensrainbow/src/config/types.ts @@ -0,0 +1,26 @@ +import type { z } from "zod/v4"; + +import type { PortNumber } from "@ensnode/ensnode-sdk/internal"; + +import type { AbsolutePathSchemaBase, DbSchemaVersionSchemaBase } from "./config.schema"; + +/** + * Absolute filesystem path. + * Inferred from {@link AbsolutePathSchemaBase} - see that schema for invariants. + */ +export type AbsolutePath = z.infer; + +/** + * Database schema version number. + * Inferred from {@link DbSchemaVersionSchemaBase} - see that schema for invariants. + */ +export type DbSchemaVersion = z.infer; + +/** + * Configuration derived from environment variables for ENSRainbow. + */ +export interface ENSRainbowEnvConfig { + port: PortNumber; + dataDir: AbsolutePath; + dbSchemaVersion: DbSchemaVersion; +} diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts new file mode 100644 index 000000000..601181e12 --- /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"; + +export function invariant_dbSchemaVersionMatch( + ctx: ZodCheckFnInput<{ + port: number; + dataDir: string; + dbSchemaVersion: number; + labelSet?: { labelSetId: string; labelSetVersion: number }; + }>, +): void { + const { value: config } = ctx; + + if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { + ctx.issues.push({ + code: "custom", + path: ["dbSchemaVersion"], + input: config.dbSchemaVersion, + 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/api.ts b/apps/ensrainbow/src/lib/api.ts index 767b180aa..6426e207b 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, { buildENSRainbowPublicConfig } from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -12,6 +13,7 @@ import { type LabelSetId, type LabelSetVersion, } from "@ensnode/ensnode-sdk"; +import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; @@ -26,6 +28,25 @@ export async function createApi(db: ENSRainbowDB): 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/*", @@ -78,31 +99,28 @@ 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", (c: HonoContext) => { + return c.json(cachedPublicConfig); + }); + + /** + * @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: { @@ -111,7 +129,6 @@ export async function createApi(db: ENSRainbowDB): Promise { labelSet: server.getServerLabelSet(), }, }; - logger.debug(result, `Version result`); return c.json(result); }); diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts deleted file mode 100644 index 048f47ae4..000000000 --- a/apps/ensrainbow/src/lib/env.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { join } from "node:path"; - -import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk"; - -import { logger } from "@/utils/logger"; - -export const getDefaultDataSubDir = () => join(process.cwd(), "data"); - -export const DEFAULT_PORT = 3223; -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); - } -} 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 {} } } diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index 3fe843c11..e2ba18ae8 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,5 @@ export type DatabaseUrl = UrlString; export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = z.infer; + +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 aa99edb64..b0e6bf431 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -58,13 +58,12 @@ 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. - */ -export const PortSchema = z.coerce +export const PortSchemaBase = 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" }) - .optional(); + .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" }); + +export const PortSchema = PortSchemaBase.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..c216d27a2 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -16,6 +16,11 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; + /** + * Get the public configuration of the ENSRainbow service + */ + config(): Promise; + /** * Heal a labelhash to its original label * @param labelHash The labelhash to heal @@ -24,6 +29,11 @@ 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. + */ version(): Promise; getOptions(): Readonly; @@ -118,6 +128,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 +150,38 @@ 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. + */ + labelSet: EnsRainbowServerLabelSet; + + /** + * The total count of records managed by the ENSRainbow service. + */ + recordsCount: number; + } } export interface EnsRainbowApiClientOptions { @@ -351,9 +390,26 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return response.json() as Promise; } + /** + * Get 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 3dd605a4b..fd82a7f7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -539,6 +539,9 @@ importers: yargs: specifier: ^17.7.2 version: 17.7.2 + zod: + specifier: 'catalog:' + version: 4.3.6 devDependencies: '@ensnode/shared-configs': specifier: workspace:*