Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 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
6570429
feat(config): introduce ArgsConfig for merging CLI args and environme…
djstrong Feb 11, 2026
6bdf9c8
refactor(database): introduce NoPrecalculatedCountError for better er…
djstrong Feb 11, 2026
a0b9809
feat(api): add /v1/config endpoint for public configuration and depre…
djstrong Feb 11, 2026
70444ac
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Feb 11, 2026
0de1576
feat(config): implement buildServeArgsConfig function to normalize an…
djstrong Feb 11, 2026
d5ad075
refactor(server-command): remove unnecessary error handling for datab…
djstrong Feb 11, 2026
7225063
refactor(api): cache label count response on startup to ensure consis…
djstrong Feb 11, 2026
ca9709a
refactor(api): update imports for public configuration and type defin…
djstrong Feb 11, 2026
5092048
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Feb 11, 2026
bf1892e
refactor(cli.test): replace manual health check with waitForHealth fu…
djstrong Feb 16, 2026
3acaf85
refactor(config): replace PortSchema with OptionalPortNumberSchema ac…
djstrong Feb 16, 2026
8b942a9
refactor(config): rename buildConfigFromEnvironment to buildEnvConfig…
djstrong Feb 16, 2026
9f27809
refactor(cli): update command argument interfaces and configuration h…
djstrong Feb 16, 2026
9abbac9
refactor(config): rename ArgsConfig to ServeCommandConfig for improve…
djstrong Feb 16, 2026
d5d8b16
refactor(server): enhance labelCount method to handle NoPrecalculated…
djstrong Feb 16, 2026
64c73c1
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Feb 16, 2026
b5cf60c
refactor(logging): add console.log statements in server and API comma…
djstrong Feb 16, 2026
98ca18f
refactor(cli): update command argument interfaces to enhance type saf…
djstrong Feb 16, 2026
55bb40a
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Feb 16, 2026
44c7d6e
refactor(server): simplify server creation by removing unnecessary co…
djstrong Feb 17, 2026
7e242fa
refactor(server): replace createServer function with direct createApi…
djstrong Feb 17, 2026
0d92675
refactor(server): enhance server initialization by integrating public…
djstrong Feb 17, 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
8 changes: 8 additions & 0 deletions .changeset/young-carrots-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"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.

Server startup now requires an initialized database (with a precalculated record count). Run ingestion before starting the server so `/v1/config` is accurate and the service is ready to serve. If the database is empty or uninitialized, startup fails with a clear error directing you to run ingestion first.
4 changes: 2 additions & 2 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
EnsIndexerUrlSchema,
invariant_rpcConfigsSpecifiedForRootChain,
makeENSIndexerPublicConfigSchema,
PortSchema,
OptionalPortNumberSchema,
RpcConfigsSchema,
TheGraphApiKeySchema,
} from "@ensnode/ensnode-sdk/internal";
Expand Down Expand Up @@ -62,7 +62,7 @@ const CustomReferralProgramEditionConfigSetUrlSchema = z

const EnsApiConfigSchema = z
.object({
port: PortSchema.default(ENSApi_DEFAULT_PORT),
port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT),
databaseUrl: DatabaseUrlSchema,
databaseSchemaName: DatabaseSchemaNameSchema,
ensIndexerUrl: EnsIndexerUrlSchema,
Expand Down
5 changes: 4 additions & 1 deletion apps/ensrainbow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ ENSRainbow is an ENSNode service for healing ENS labels. It provides a simple AP

For detailed documentation and guides, see the [ENSRainbow Documentation](https://ensnode.io/ensrainbow).

The initial release of ENSRainbow focuses on backwards compatibility with the ENS Subgraph, providing the same label healing capabilities that ENS ecosystem tools rely on today. However, we're actively working on significant enhancements that will expand ENSRainbow's healing capabilities far beyond what's currently possible with the ENS Subgraph. These upcoming features will allow ENSRainbow to heal many previously unknown labels, making it an even more powerful tool for ENS data analysis and integration.
### Configuration

- **Environment Config**: Built from environment variables (PORT, DATA_DIR, DB_SCHEMA_VERSION) and validated at module load time.
- **Serve Command Config**: Built from CLI arguments and environment config for the `serve` command. CLI arguments override environment variables. The server builds the public config (GET /v1/config) from the database and command config at startup.

## Special Thanks

Expand Down
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
200 changes: 153 additions & 47 deletions apps/ensrainbow/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@ 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");

/** Polls url until GET returns 200 or timeout. Rejects on timeout. */
async function waitForHealth(
url: string,
{ intervalMs = 50, timeoutMs = 5000 }: { intervalMs?: number; timeoutMs?: number } = {},
): Promise<Response> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.status === 200) return response;
} catch {
// Connection refused or other error; retry
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Health check did not return 200 within ${timeoutMs}ms: ${url}`);
}

describe("CLI", () => {
let tempDir: string;
let testDataDir: string;
Expand All @@ -37,43 +55,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 });

// CLI port should override env PORT
await cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]);

expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 4000 }));

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',
);
// 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,25 +573,48 @@ describe("CLI", () => {
testDataDir,
]);

// Give server time to start
await new Promise((resolve) => setTimeout(resolve, 100));

// Make a request to health endpoint
const response = await fetch(`http://localhost:${customPort}/health`);
const response = await waitForHealth(`http://localhost:${customPort}/health`);
expect(response.status).toBe(200);

// Cleanup - send SIGINT to stop server
process.emit("SIGINT", "SIGINT");
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,13 +623,9 @@ describe("CLI", () => {
]);

// Start server
const serverPromise = cli.parse(["serve", "--data-dir", testDataDir]);

// Give server time to start
await new Promise((resolve) => setTimeout(resolve, 100));
const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]);

// Make a request to health endpoint
const response = await fetch(`http://localhost:${customPort}/health`);
const response = await waitForHealth(`http://localhost:${customPort}/health`);
expect(response.status).toBe(200);

// Make a request to count endpoint
Expand Down Expand Up @@ -586,11 +666,37 @@ 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,
]);

const response = await waitForHealth("http://localhost:4000/health");
expect(response.status).toBe(200);

// Cleanup - send SIGINT to stop server
process.emit("SIGINT", "SIGINT");
await serverPromise;
});
});

Expand Down
Loading