Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c34c0bd
feat(config): add configuration schema and environment handling for E…
djstrong Dec 22, 2025
bbe487e
chore: update dependencies and clean up imports in ENSRainbow configu…
djstrong Dec 22, 2025
6acc196
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Jan 21, 2026
22a81f0
feat(config): build and export ENSRainbowConfig from environment vari…
djstrong Jan 21, 2026
03722ac
refactor(tests): update CLI tests to use vi.stubEnv and async imports…
djstrong Jan 21, 2026
ec75cfe
fix(cli): improve port validation logic to use configured port instea…
djstrong Jan 21, 2026
887aecc
fix lint
djstrong Jan 21, 2026
bd1dd1c
refactor(cli): update CLI to use configured port and improve test imp…
djstrong Jan 23, 2026
1ddfc33
refactor(config): enhance path resolution and improve error handling …
djstrong Jan 23, 2026
3b5c7cc
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Jan 23, 2026
782e329
test(config): add comprehensive tests for buildConfigFromEnvironment …
djstrong Jan 23, 2026
2e1f9d9
feat(api): add public configuration endpoint and enhance ENSRainbowAp…
djstrong Jan 23, 2026
ddaaf29
test(config): remove redundant test for DB_SCHEMA_VERSION handling in…
djstrong Jan 23, 2026
a43b125
refactor(config): improve environment configuration validation and er…
djstrong Jan 23, 2026
c57e7f6
Create young-carrots-cheer.md
djstrong Jan 23, 2026
e3a6c90
refactor(config): remove unused imports from config schema files to s…
djstrong Jan 23, 2026
8dc1b6e
test(server): add tests for GET /v1/config endpoint to validate error…
djstrong Jan 26, 2026
eac29d6
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Jan 26, 2026
d165cd5
refactor(validations): simplify invariant_dbSchemaVersionMatch functi…
djstrong Jan 26, 2026
79cc9ad
fix(deps): update misconfigured lockfile
tk-o Jan 26, 2026
b9a41e9
fix(ci): clear linter warnings
tk-o Jan 26, 2026
096049a
feat(api): Update DB_SCHEMA_VERSION handling in configuration and te…
djstrong Jan 28, 2026
f4859d7
refactor(config): enhance error handling and validation in buildConfi…
djstrong Jan 28, 2026
3d9399f
refactor(cli): streamline port configuration handling and enhance CLI…
djstrong Jan 28, 2026
e735747
refactor(api): remove debug logging from various API endpoints for cl…
djstrong Feb 2, 2026
5bafcdb
revert
djstrong Feb 2, 2026
7a646e0
refactor(config): remove label set validation function and enhance sc…
djstrong Feb 2, 2026
9d12088
feat(server): log configuration details on server startup for improve…
djstrong Feb 2, 2026
89ff961
feat(server): add database initialization check to prevent server sta…
djstrong Feb 2, 2026
832abff
feat(config): export ENSRainbowConfig type for improved type safety i…
djstrong Feb 2, 2026
363208c
refactor(config): introduce hasValue helper function for string valid…
djstrong Feb 2, 2026
30873a0
refactor(config): update parameter naming in buildENSRainbowPublicCon…
djstrong Feb 2, 2026
7d7a1f6
docs(cli): add detailed comments for 'serve' command arguments and cl…
djstrong Feb 2, 2026
2d7207d
refactor(api): implement caching for public config to optimize /v1/co…
djstrong Feb 2, 2026
100cda3
test(config): add unit tests for buildENSRainbowPublicConfig to valid…
djstrong Feb 2, 2026
9a00f37
feat(cli): add port validation in CLI arguments to ensure valid port …
djstrong Feb 3, 2026
e834196
refactor(config): rename ENSRainbowConfig to ENSRainbowEnvConfig for …
djstrong Feb 3, 2026
75e72f8
refactor(config): remove labelSet from configuration schema and relat…
djstrong Feb 3, 2026
70389d4
refactor(api): remove redundant comments from /v1/config endpoint and…
djstrong Feb 9, 2026
163f873
refactor(config): move buildENSRainbowPublicConfig to a separate file…
djstrong Feb 9, 2026
71a2885
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Feb 9, 2026
4cca4f7
refactor(cli): update port validation to use PortSchemaBase and impro…
djstrong Feb 9, 2026
54bf472
fix(server): enhance error handling for server startup by checking sp…
djstrong Feb 9, 2026
aeb14c9
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/young-carrots-cheer.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion apps/ensrainbow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
182 changes: 141 additions & 41 deletions apps/ensrainbow/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
});

Expand Down Expand Up @@ -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));

Comment on lines +558 to 560
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Avoid fixed sleeps when waiting for server startup.

Fixed delays can be flaky under load; polling the health endpoint with a timeout will make these tests more reliable.

♻️ Suggested refactor for reliable startup waits
@@
 const TEST_FIXTURES_DIR = join(__dirname, "..", "test", "fixtures");
+
+async function waitForHealth(port: number, timeoutMs = 5000) {
+  const deadline = Date.now() + timeoutMs;
+  while (Date.now() < deadline) {
+    try {
+      const res = await fetch(`http://localhost:${port}/health`);
+      if (res.ok) return;
+    } catch {
+      // Ignore transient connection errors while the server boots
+    }
+    await new Promise((resolve) => setTimeout(resolve, 50));
+  }
+  throw new Error(`Server did not become healthy within ${timeoutMs}ms`);
+}
@@
-        await new Promise((resolve) => setTimeout(resolve, 500));
+        await waitForHealth(customPort);
@@
-        await new Promise((resolve) => setTimeout(resolve, 500));
+        await waitForHealth(customPort);
@@
-        await new Promise((resolve) => setTimeout(resolve, 500));
+        await waitForHealth(4000);

Also applies to: 556-558, 626-628

🤖 Prompt for AI Agents
In `@apps/ensrainbow/src/cli.test.ts` around lines 500 - 502, Replace the fixed
sleep (await new Promise((resolve) => setTimeout(resolve, 500));) with a polling
helper that queries the server health endpoint until it returns success or a
timeout is reached; implement a reusable async function (e.g.,
waitForServerHealth or waitForHealthEndpoint) that repeatedly fetches /health
(or the app's health route) with a short interval and total timeout, throwing on
timeout, and use that helper in place of the fixed sleep at the three locations
(the current occurrence and the ones around lines 556-558 and 626-628) so tests
wait deterministically for server readiness.

// Make a request to health endpoint
const response = await fetch(`http://localhost:${customPort}/health`);
Expand All @@ -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,
Expand All @@ -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`);
Expand Down Expand Up @@ -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;
});
});

Expand Down
42 changes: 23 additions & 19 deletions apps/ensrainbow/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import config from "@/config";

Comment on lines +1 to +2
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing the default config at module load means env validation runs before yargs parses CLI args. If a user has an invalid PORT/DATA_DIR/DB_SCHEMA_VERSION in the environment, the CLI will exit even when --port/--data-dir would otherwise override those values, which contradicts the “CLI arguments take precedence” note. Consider building the env config lazily (after argv parsing) or validating only the subset of env vars that are actually used as defaults for the chosen command.

Copilot uses AI. Check for mistakes.
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -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";
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<IngestArgs>) => {
Expand All @@ -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<IngestProtobufArgs>) => {
Expand All @@ -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;
},
Comment on lines 131 to 138
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coerce handler is validating with PortSchema, but PortSchema is explicitly optional. This makes the extra result.data === undefined branch necessary and contradicts the comment about using a non-optional schema. Prefer validating with the non-optional PortSchemaBase (or a .required()/non-optional variant) so the return type is always a number and the undefined branch + comment mismatch can be removed.

Copilot uses AI. Check for mistakes.
})
.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: config.dataDir,
});
},
async (argv: ArgumentsCamelCase<ServeArgs>) => {
validatePortConfiguration(argv.port);
await serverCommand({
port: argv.port,
dataDir: argv["data-dir"],
Expand All @@ -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",
Expand All @@ -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<PurgeArgs>) => {
Expand Down
Loading