Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/quiet-referrals-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@namehash/ens-referrals": minor
"ensapi": minor
---

Removed the hardcoded "default" referral program edition config set and renamed `CUSTOM_REFERRAL_PROGRAM_EDITIONS` to `REFERRAL_PROGRAM_EDITIONS`. An unset `REFERRAL_PROGRAM_EDITIONS` now means the referral program has zero configured editions, so ENSApi performs no referral-related work against ENSDb. The editions array may also now be empty.
16 changes: 9 additions & 7 deletions apps/ensapi/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ ENSINDEXER_SCHEMA_NAME=ensindexer_0
# it receives to The Graph's hosted subgraphs using this API key.
# THEGRAPH_API_KEY=

# Custom Referral Program Edition Config Set Definition (optional)
# URL that returns JSON for a custom referral program edition config set.
# If not set, the default edition config set for the namespace is used.
# Referral Program Edition Config Set Definition (optional)
# URL that returns JSON for a referral program edition config set.
# If not set, ENSApi treats the referral program as having zero configured editions:
# the editions endpoint returns an empty list, edition-specific queries (leaderboard,
# referrer detail) return 404, and no referral-related work is performed against ENSDb.
#
# JSON Structure:
# The JSON must be an array of edition config objects (SerializedReferralProgramEditionConfig[]).
Expand All @@ -127,12 +129,12 @@ ENSINDEXER_SCHEMA_NAME=ensindexer_0
# * Error is logged
# * ENSApi continues running
# * Failed state is cached for 1 minute, then retried on subsequent requests
# * API requests receive error responses until successful load
# * Referral analytics API requests (/v1/ensanalytics/*) receive error responses
# until successful load; other API endpoints are unaffected.
#
# Configuration Notes:
# - Setting CUSTOM_REFERRAL_PROGRAM_EDITIONS completely replaces the default edition config set
# - Include all edition configs you want active in the JSON
# - Array must contain at least one edition config
# - The array may be empty
# - All edition slugs must be unique
#
# CUSTOM_REFERRAL_PROGRAM_EDITIONS=https://example.com/custom-editions.json
# REFERRAL_PROGRAM_EDITIONS=https://example.com/editions.json
82 changes: 45 additions & 37 deletions apps/ensapi/src/cache/referral-program-edition-set.cache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import config from "@/config";

import {
buildReferralProgramEditionConfigSet,
ENSReferralsClient,
getDefaultReferralProgramEditionConfigSet,
ReferralProgramAwardModels,
type ReferralProgramEditionConfigSet,
} from "@namehash/ens-referrals/v1";
Expand All @@ -16,52 +16,60 @@ import { makeLogger } from "@/lib/logger";
const logger = makeLogger("referral-program-edition-set-cache");

/**
* Loads the referral program edition config set from custom URL or defaults.
* Returns `origin + pathname`, stripping any credentials or query params
* so the URL is theoretically safer to include in log messages and error strings.
*/
function partiallyRedactUrl(url: URL): string {
return `${url.origin}${url.pathname}`;
}

/**
* Loads the referral program edition config set from the configured URL.
*
* If no URL is configured, the referral program is treated as having zero configured editions
* and no network or ENSDb work is performed.
*/
async function loadReferralProgramEditionConfigSet(
_cachedResult?: CachedResult<ReferralProgramEditionConfigSet>,
): Promise<ReferralProgramEditionConfigSet> {
// Check if custom URL is configured
if (config.customReferralProgramEditionConfigSetUrl) {
// If no URL is configured, treat the referral program as having zero editions.
if (!config.referralProgramEditionConfigSetUrl) {
logger.info(
`Loading custom referral program edition config set from: ${config.customReferralProgramEditionConfigSetUrl.href}`,
"REFERRAL_PROGRAM_EDITIONS is not set; referral program edition config set is empty",
);
try {
const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(
config.customReferralProgramEditionConfigSetUrl,
);
return buildReferralProgramEditionConfigSet([]);
}

// Strip any unrecognized editions immediately — they are client-side forward-compatibility
// placeholders that must never enter the server's operational config set (they can't be
// serialized and would cause API handlers to crash).
for (const [slug, editionConfig] of editionConfigSet) {
if (editionConfig.rules.awardModel === ReferralProgramAwardModels.Unrecognized) {
logger.warn(
{ editionSlug: slug, originalAwardModel: editionConfig.rules.originalAwardModel },
`Skipping custom edition with unrecognized award model`,
);
editionConfigSet.delete(slug);
}
}
const logSafeUrl = partiallyRedactUrl(config.referralProgramEditionConfigSetUrl);

logger.info(`Successfully loaded ${editionConfigSet.size} custom referral program editions`);
return editionConfigSet;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, "Error occurred while loading referral program edition config set");
throw new Error(
`Failed to load custom referral program edition config set from ${config.customReferralProgramEditionConfigSetUrl.href}: ${errorMessage}`,
);
logger.info(`Loading referral program edition config set from: ${logSafeUrl}`);
try {
const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(
config.referralProgramEditionConfigSetUrl,
);

// Strip any unrecognized editions immediately — they are client-side forward-compatibility
// placeholders that must never enter the server's operational config set (they can't be
// serialized and would cause API handlers to crash).
for (const [slug, editionConfig] of editionConfigSet) {
if (editionConfig.rules.awardModel === ReferralProgramAwardModels.Unrecognized) {
logger.warn(
{ editionSlug: slug, originalAwardModel: editionConfig.rules.originalAwardModel },
`Skipping edition with unrecognized award model`,
);
editionConfigSet.delete(slug);
}
}
}

// Use default edition config set for the namespace
logger.info(
`Loading default referral program edition config set for namespace: ${config.namespace}`,
);
const editionConfigSet = getDefaultReferralProgramEditionConfigSet(config.namespace);
logger.info(`Successfully loaded ${editionConfigSet.size} default referral program editions`);
return editionConfigSet;
logger.info(`Successfully loaded ${editionConfigSet.size} referral program editions`);
return editionConfigSet;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, "Error occurred while loading referral program edition config set");
throw new Error(
`Failed to load referral program edition config set from ${logSafeUrl}: ${errorMessage}`,
);
}
}

type ReferralProgramEditionConfigSetCache = SWRCache<ReferralProgramEditionConfigSet>;
Expand Down
22 changes: 11 additions & 11 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,19 @@ describe("buildConfigFromEnvironment", () => {
} satisfies RpcConfig,
],
]),
customReferralProgramEditionConfigSetUrl: undefined,
referralProgramEditionConfigSetUrl: undefined,
});
});

it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", async () => {
const customUrl = "https://example.com/editions.json";
it("parses REFERRAL_PROGRAM_EDITIONS as a URL object", async () => {
const editionsUrl = "https://example.com/editions.json";

const config = await buildConfigFromEnvironment({
...BASE_ENV,
CUSTOM_REFERRAL_PROGRAM_EDITIONS: customUrl,
REFERRAL_PROGRAM_EDITIONS: editionsUrl,
});

expect(config.customReferralProgramEditionConfigSetUrl).toEqual(new URL(customUrl));
expect(config.referralProgramEditionConfigSetUrl).toEqual(new URL(editionsUrl));
});

describe("Useful error messages", () => {
Expand All @@ -106,14 +106,14 @@ describe("buildConfigFromEnvironment", () => {

const TEST_ENV: EnsApiEnvironment = structuredClone(BASE_ENV);

it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => {
it("logs error and exits when REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => {
await buildConfigFromEnvironment({
...TEST_ENV,
CUSTOM_REFERRAL_PROGRAM_EDITIONS: "not-a-url",
REFERRAL_PROGRAM_EDITIONS: "not-a-url",
});

expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL: not-a-url"),
expect.stringContaining("REFERRAL_PROGRAM_EDITIONS is not a valid URL: not-a-url"),
);
expect(process.exit).toHaveBeenCalledWith(1);
});
Expand Down Expand Up @@ -167,7 +167,7 @@ describe("buildEnsApiPublicConfig", () => {
} satisfies RpcConfig,
],
]),
customReferralProgramEditionConfigSetUrl: undefined,
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
Expand All @@ -190,7 +190,7 @@ describe("buildEnsApiPublicConfig", () => {
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
rpcConfigs: new Map(),
customReferralProgramEditionConfigSetUrl: undefined,
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
Expand Down Expand Up @@ -223,7 +223,7 @@ describe("buildEnsApiPublicConfig", () => {
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
rpcConfigs: new Map(),
customReferralProgramEditionConfigSetUrl: undefined,
referralProgramEditionConfigSetUrl: undefined,
theGraphApiKey: "secret-api-key",
};

Expand Down
10 changes: 5 additions & 5 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,17 @@ import logger from "@/lib/logger";
import { ensApiVersionInfo } from "@/lib/version-info";

/**
* Schema for validating custom referral program edition config set URL.
* Schema for validating the referral program edition config set URL.
*/
const CustomReferralProgramEditionConfigSetUrlSchema = z
const ReferralProgramEditionConfigSetUrlSchema = z
.string()
.transform((val, ctx) => {
try {
return new URL(val);
} catch {
ctx.addIssue({
code: "custom",
message: `CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL: ${val}`,
message: `REFERRAL_PROGRAM_EDITIONS is not a valid URL: ${val}`,
});
return z.NEVER;
}
Expand All @@ -46,7 +46,7 @@ const EnsApiConfigSchema = z
namespace: ENSNamespaceSchema,
rpcConfigs: RpcConfigsSchema,
ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"),
customReferralProgramEditionConfigSetUrl: CustomReferralProgramEditionConfigSetUrlSchema,
referralProgramEditionConfigSetUrl: ReferralProgramEditionConfigSetUrlSchema,

// include the ENSDbConfig params in the EnsApiConfigSchema
ensDbUrl: z.string(),
Expand Down Expand Up @@ -96,7 +96,7 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs,
customReferralProgramEditionConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_EDITIONS,
referralProgramEditionConfigSetUrl: env.REFERRAL_PROGRAM_EDITIONS,

// include the validated ENSDb config values in the parsed EnsApiConfig
ensDbUrl: ensDbConfig.ensDbUrl,
Expand Down
6 changes: 4 additions & 2 deletions apps/ensapi/src/config/redact.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { redactRpcConfigs, redactString } from "@ensnode/ensnode-sdk/internal";
import { redactRpcConfigs, redactString, redactUrl } from "@ensnode/ensnode-sdk/internal";

import type { EnsApiConfig } from "@/config/config.schema";

Expand All @@ -9,7 +9,9 @@ export function redactEnsApiConfig(config: EnsApiConfig) {
return {
port: config.port,
namespace: config.namespace,
customReferralProgramEditionConfigSetUrl: config.customReferralProgramEditionConfigSetUrl,
referralProgramEditionConfigSetUrl: config.referralProgramEditionConfigSetUrl
? redactUrl(config.referralProgramEditionConfigSetUrl)
: undefined,
ensIndexerPublicConfig: config.ensIndexerPublicConfig,
ensDbUrl: redactString(config.ensDbUrl),
rpcConfigs: redactRpcConfigs(config.rpcConfigs),
Expand Down
8 changes: 7 additions & 1 deletion packages/ens-referrals/src/v1/api/zod-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => {
expect(() => schema.parse([pieSplitEdition, malformedUnrecognized])).toThrow();
});

it("fails when the result list would be empty", () => {
it("accepts an empty array", () => {
const result = schema.parse([]);

expect(result).toEqual([]);
});

it("fails when an unrecognized edition has entirely missing base fields", () => {
const malformedUnrecognized = {
slug: "2026-03",
displayName: "March 2026",
Expand Down
13 changes: 2 additions & 11 deletions packages/ens-referrals/src/v1/api/zod-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,14 @@ export const makeReferralProgramEditionConfigSchema = (
* Downstream code (e.g., leaderboard cache setup) is responsible for skipping unrecognized
* editions with a warning log rather than crashing.
*
* The list must not be empty after processing all items. Duplicate slugs are not allowed.
* The list may be empty. Duplicate slugs are not allowed.
*
* Two-pass approach:
* 1. Each item is loosely parsed (based on `rules.awardModel` field).
* - Known award models are fully validated with {@link makeReferralProgramEditionConfigSchema}.
* - Unknown award models are parsed with {@link makeBaseReferralProgramRulesSchema} and wrapped as
* `ReferralProgramRulesUnrecognized`.
* 2. After processing all items, the result must be non-empty and have no duplicate slugs.
* 2. After processing all items, the result must have no duplicate slugs.
*/
export const makeReferralProgramEditionConfigSetArraySchema = (
valueLabel: string = "ReferralProgramEditionConfigSetArray",
Expand Down Expand Up @@ -384,15 +384,6 @@ export const makeReferralProgramEditionConfigSetArraySchema = (
}
}

if (result.length === 0) {
ctx.addIssue({
code: "custom",
message: `${valueLabel} must contain at least one edition`,
});
// Issue above causes the overall parse to fail; this value is never used.
return [];
}

const slugs = new Set<string>();
for (const edition of result) {
if (slugs.has(edition.slug)) {
Expand Down
63 changes: 0 additions & 63 deletions packages/ens-referrals/src/v1/edition-defaults.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/ens-referrals/src/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export * from "./award-models/shared/score";
export * from "./award-models/shared/status";
export * from "./client";
export * from "./edition";
export * from "./edition-defaults";
export * from "./edition-metrics";
export * from "./edition-summary";
export * from "./leaderboard";
Expand Down
Loading
Loading