diff --git a/README.md b/README.md index 0e2f724..45e04ae 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ npm install # Set up environment variables cp .env.example .env.local # Edit .env.local as needed (mock mode requires no changes) + +# Verify your local environment before starting Next.js +npm run check-env ``` ### Run in mock / demo mode @@ -110,6 +113,10 @@ All configuration is read and validated at startup by [`lib/config.ts`](./lib/co Invalid values produce a clear `ConfigError` in development so broken configuration is caught immediately rather than at runtime. +Run `npm run check-env` after creating `.env.local` to validate the same app +and wallet rules before the dev server starts. `npm run dev` also runs this +check automatically via the `predev` script. + | Variable | Required | Description | | ---- | ------- | ----------- | | `NEXT_PUBLIC_MOCK_MODE` | No | Set `true` for in-memory mock API; SIWE fully simulated | @@ -189,6 +196,7 @@ Modules that are experimental or not yet production-ready are controlled by envi ```bash npm run dev # Start Next.js dev server (http://localhost:3000) +npm run check-env # Validate .env.local before the dev server starts npm run build # Production build npm run start # Start production server (after build) npm run lint # Lint via Next.js ESLint config diff --git a/lib/config-validation.d.ts b/lib/config-validation.d.ts new file mode 100644 index 0000000..882d685 --- /dev/null +++ b/lib/config-validation.d.ts @@ -0,0 +1,55 @@ +export type EnvSource = Record + +export type ApiMode = 'mock' | 'live' + +export interface SiweConfig { + domain: string + statement: string + warningThresholdSeconds: number +} + +export type FeatureFlagKey = + | 'adminPolicies' + | 'adminSettings' + | 'events' + | 'analytics' + | 'resources' + | 'governance' + | 'rewards' + | 'multiCommunity' + | 'profiles' + +export type FeatureFlags = Record + +export interface IntegrationGatewayConfig { + allowedOrigin?: string +} + +export interface AppConfig { + apiMode: ApiMode + apiUrl: string + siwe: SiweConfig + features: FeatureFlags + integrationGateway: IntegrationGatewayConfig + apiValidationLogOnly: boolean +} + +export class ConfigError extends Error { + constructor(message: string) +} + +export const SIWE_STATEMENT_MAX_LENGTH: 200 +export const CONTROL_CHARS: RegExp + +export function buildAppConfig(source?: EnvSource): AppConfig +export function buildFeatureFlags( + source?: EnvSource, + apiMode?: ApiMode, +): FeatureFlags +export function coreApiUrlRequiredMessage(): string +export function flag(source: EnvSource, varName: string, defaultVal: boolean): boolean +export function parseApiModeFromEnv(source?: EnvSource): ApiMode +export function resolveCoreApiUrl(source?: EnvSource, apiMode?: ApiMode): string +export function resolveWarningThresholdSeconds(source?: EnvSource): number +export function validateSiweStatement(value: string): string +export function validateUrl(value: string, name: string): string diff --git a/lib/config-validation.js b/lib/config-validation.js new file mode 100644 index 0000000..591986f --- /dev/null +++ b/lib/config-validation.js @@ -0,0 +1,152 @@ +const SIWE_STATEMENT_MAX_LENGTH = 200 +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/ + +class ConfigError extends Error { + constructor(message) { + super(message) + this.name = 'ConfigError' + } +} + +function env(source, name) { + return source[name] +} + +function parseApiModeFromEnv(source = process.env) { + const mock = env(source, 'NEXT_PUBLIC_MOCK_MODE') + const demo = env(source, 'NEXT_PUBLIC_DEMO_MODE') + return mock === 'true' || demo === 'true' ? 'mock' : 'live' +} + +function coreApiUrlRequiredMessage() { + return [ + 'NEXT_PUBLIC_CORE_API_URL is required when API mode is "live".', + '', + ' Either set NEXT_PUBLIC_CORE_API_URL to the base URL of your', + ' guildpass-core instance (e.g. http://localhost:4000), or set', + ' NEXT_PUBLIC_MOCK_MODE=true for local development without a backend.', + '', + ' See .env.example for details.', + ].join('\n') +} + +function requireEnv(source, name, message) { + const value = env(source, name) + if (!value) { + throw new ConfigError(message) + } + return value +} + +function validateUrl(value, name) { + try { + new URL(value) + return value + } catch { + throw new ConfigError(`${name} must be a valid URL, got "${value}"`) + } +} + +function resolveCoreApiUrl(source = process.env, apiMode = parseApiModeFromEnv(source)) { + if (apiMode === 'live') { + const url = requireEnv( + source, + 'NEXT_PUBLIC_CORE_API_URL', + coreApiUrlRequiredMessage(), + ) + return validateUrl(url, 'NEXT_PUBLIC_CORE_API_URL') + } + + return env(source, 'NEXT_PUBLIC_CORE_API_URL') || 'http://localhost:4000' +} + +function validateSiweStatement(value) { + if (CONTROL_CHARS.test(value)) { + throw new ConfigError( + 'NEXT_PUBLIC_SIWE_STATEMENT must be a single line without control ' + + 'characters (no \\n, \\r, tabs, etc.) - the EIP-4361 statement field ' + + 'is single-line.', + ) + } + if (value.length > SIWE_STATEMENT_MAX_LENGTH) { + throw new ConfigError( + `NEXT_PUBLIC_SIWE_STATEMENT must be at most ${SIWE_STATEMENT_MAX_LENGTH} ` + + `characters (got ${value.length}) so the signing message stays ` + + 'readable in wallet UIs.', + ) + } + return value +} + +function resolveWarningThresholdSeconds(source = process.env) { + const warningMinutesEnv = env(source, 'NEXT_PUBLIC_SIWE_WARNING_MINUTES') + const warningSecondsEnv = env(source, 'NEXT_PUBLIC_SIWE_WARNING_SECONDS') + const warningThresholdSeconds = warningSecondsEnv + ? Number(warningSecondsEnv) + : warningMinutesEnv + ? Number(warningMinutesEnv) * 60 + : 120 + + return Number.isNaN(warningThresholdSeconds) ? 120 : warningThresholdSeconds +} + +function flag(source, varName, defaultVal) { + const val = env(source, varName) + if (val === undefined || val === '') return defaultVal + return val === 'true' +} + +function buildFeatureFlags(source = process.env, apiMode = parseApiModeFromEnv(source)) { + const isMock = apiMode === 'mock' + + return { + adminPolicies: flag(source, 'NEXT_PUBLIC_FEATURE_ADMIN_POLICIES', true), + adminSettings: flag(source, 'NEXT_PUBLIC_FEATURE_ADMIN_SETTINGS', isMock), + events: flag(source, 'NEXT_PUBLIC_FEATURE_EVENTS', isMock), + analytics: flag(source, 'NEXT_PUBLIC_FEATURE_ANALYTICS', false), + resources: flag(source, 'NEXT_PUBLIC_FEATURE_RESOURCES', true), + governance: flag(source, 'NEXT_PUBLIC_FEATURE_GOVERNANCE', false), + rewards: flag(source, 'NEXT_PUBLIC_FEATURE_REWARDS', false), + multiCommunity: flag(source, 'NEXT_PUBLIC_FEATURE_MULTI_COMMUNITY', false), + profiles: flag(source, 'NEXT_PUBLIC_FEATURE_PROFILES', false), + } +} + +function buildAppConfig(source = process.env) { + const apiMode = parseApiModeFromEnv(source) + const siwe = Object.freeze({ + domain: env(source, 'NEXT_PUBLIC_SIWE_DOMAIN') ?? 'localhost:3000', + statement: validateSiweStatement( + env(source, 'NEXT_PUBLIC_SIWE_STATEMENT') ?? 'Sign in to GuildPass Admin', + ), + warningThresholdSeconds: resolveWarningThresholdSeconds(source), + }) + + return Object.freeze({ + apiMode, + apiUrl: resolveCoreApiUrl(source, apiMode), + siwe, + features: Object.freeze(buildFeatureFlags(source, apiMode)), + integrationGateway: Object.freeze({ + allowedOrigin: env(source, 'INTEGRATION_ALLOWED_ORIGIN'), + }), + get apiValidationLogOnly() { + return flag(source, 'NEXT_PUBLIC_API_VALIDATION_LOG_ONLY', false) + }, + }) +} + +module.exports = { + ConfigError, + SIWE_STATEMENT_MAX_LENGTH, + CONTROL_CHARS, + buildAppConfig, + buildFeatureFlags, + coreApiUrlRequiredMessage, + flag, + parseApiModeFromEnv, + resolveCoreApiUrl, + resolveWarningThresholdSeconds, + validateSiweStatement, + validateUrl, +} diff --git a/lib/config.ts b/lib/config.ts index e90958e..000b19a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -11,6 +11,12 @@ * if (config.apiMode === 'live') { ... } */ +import { + ConfigError, + buildAppConfig as buildValidatedAppConfig, + type EnvSource, +} from './config-validation.js' + export type ApiMode = 'mock' | 'live' export interface SiweConfig { @@ -56,155 +62,12 @@ export interface AppConfig { apiValidationLogOnly: boolean } -// ── Error type ──────────────────────────────────────────────────────────────── - -export class ConfigError extends Error { - constructor(message: string) { - super(message) - this.name = 'ConfigError' - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function env(name: string): string | undefined { - return process.env[name] -} - -function isDev(): boolean { - return process.env.NODE_ENV === 'development' -} - -function requireEnv(name: string, message: string): string { - const value = env(name) - if (!value) { - throw new ConfigError(message) - } - return value -} - -function validateUrl(value: string, name: string): string { - try { - new URL(value) - return value - } catch { - throw new ConfigError(`${name} must be a valid URL, got "${value}"`) - } -} - -/** - * The EIP-4361 statement field is a single line embedded in the message the - * user signs. Newlines/control characters would break the message format, and - * an excessively long statement is unreadable in wallet UIs. - */ -const SIWE_STATEMENT_MAX_LENGTH = 200 -// eslint-disable-next-line no-control-regex -const CONTROL_CHARS = /[\u0000-\u001f\u007f]/ - -function validateSiweStatement(value: string): string { - if (CONTROL_CHARS.test(value)) { - throw new ConfigError( - 'NEXT_PUBLIC_SIWE_STATEMENT must be a single line without control ' + - 'characters (no \\n, \\r, tabs, etc.) — the EIP-4361 statement field ' + - 'is single-line.', - ) - } - if (value.length > SIWE_STATEMENT_MAX_LENGTH) { - throw new ConfigError( - `NEXT_PUBLIC_SIWE_STATEMENT must be at most ${SIWE_STATEMENT_MAX_LENGTH} ` + - `characters (got ${value.length}) so the signing message stays ` + - 'readable in wallet UIs.', - ) - } - return value -} - -// ── Mode ────────────────────────────────────────────────────────────────────── - -function parseApiMode(): ApiMode { - const mock = env('NEXT_PUBLIC_MOCK_MODE') - const demo = env('NEXT_PUBLIC_DEMO_MODE') - return mock === 'true' || demo === 'true' ? 'mock' : 'live' -} +export { ConfigError } // ── Build config ────────────────────────────────────────────────────────────── -const apiMode = parseApiMode() - -const apiUrl: string = (() => { - if (apiMode === 'live') { - const url = requireEnv( - 'NEXT_PUBLIC_CORE_API_URL', - [ - 'NEXT_PUBLIC_CORE_API_URL is required when API mode is "live".', - '', - ' Either set NEXT_PUBLIC_CORE_API_URL to the base URL of your', - ' guildpass-core instance (e.g. http://localhost:4000), or set', - ' NEXT_PUBLIC_MOCK_MODE=true for local development without a backend.', - '', - ' See .env.example for details.', - ].join('\n'), - ) - return validateUrl(url, 'NEXT_PUBLIC_CORE_API_URL') - } - return env('NEXT_PUBLIC_CORE_API_URL') || 'http://localhost:4000' -})() - -const warningMinutesEnv = env('NEXT_PUBLIC_SIWE_WARNING_MINUTES') -const warningSecondsEnv = env('NEXT_PUBLIC_SIWE_WARNING_SECONDS') -const warningThresholdSeconds = warningSecondsEnv - ? Number(warningSecondsEnv) - : warningMinutesEnv - ? Number(warningMinutesEnv) * 60 - : 120 - -const siwe: SiweConfig = { - domain: env('NEXT_PUBLIC_SIWE_DOMAIN') ?? 'localhost:3000', - statement: validateSiweStatement( - env('NEXT_PUBLIC_SIWE_STATEMENT') ?? 'Sign in to GuildPass Admin', - ), - warningThresholdSeconds: Number.isNaN(warningThresholdSeconds) ? 120 : warningThresholdSeconds, -} - -const isMock = apiMode === 'mock' - -function flag(varName: string, defaultVal: boolean): boolean { - const val = env(varName) - if (val === undefined || val === '') return defaultVal - return val === 'true' -} - -const integrationGateway: IntegrationGatewayConfig = { - allowedOrigin: env('INTEGRATION_ALLOWED_ORIGIN'), -} - -const features: FeatureFlags = { - adminPolicies: flag('NEXT_PUBLIC_FEATURE_ADMIN_POLICIES', true), - // Advanced admin tooling (community settings). Persistence is deferred for the - // MVP, so this defaults on only in mock/demo mode and stays off in live until - // the settings backend ships. - adminSettings: flag('NEXT_PUBLIC_FEATURE_ADMIN_SETTINGS', isMock), - events: flag('NEXT_PUBLIC_FEATURE_EVENTS', isMock), - analytics: flag('NEXT_PUBLIC_FEATURE_ANALYTICS', false), - resources: flag('NEXT_PUBLIC_FEATURE_RESOURCES', true), - governance: flag('NEXT_PUBLIC_FEATURE_GOVERNANCE', false), - rewards: flag('NEXT_PUBLIC_FEATURE_REWARDS', false), - // Multi-community support is not implemented — this only reserves nav - // space with a disabled switcher stub. Keep false in every environment - // until real multi-community logic ships. - multiCommunity: flag('NEXT_PUBLIC_FEATURE_MULTI_COMMUNITY', false), - // Rich profile customization / public profile view (#254) — deferred module, - // off in every environment (including mock) until explicitly enabled. - profiles: flag('NEXT_PUBLIC_FEATURE_PROFILES', false), +export function buildAppConfig(source: EnvSource = process.env): AppConfig { + return buildValidatedAppConfig(source) as AppConfig } -export const config: AppConfig = Object.freeze({ - apiMode, - apiUrl, - siwe: Object.freeze(siwe), - features: Object.freeze(features), - integrationGateway: Object.freeze(integrationGateway), - get apiValidationLogOnly() { - return flag('NEXT_PUBLIC_API_VALIDATION_LOG_ONLY', false) - }, -}) +export const config: AppConfig = buildAppConfig() diff --git a/lib/wallet/config.ts b/lib/wallet/config.ts index f3ea913..ed32f2e 100644 --- a/lib/wallet/config.ts +++ b/lib/wallet/config.ts @@ -5,9 +5,15 @@ import type { CreateConnectorFn } from 'wagmi' import type { Transport } from 'viem' import { config as appConfig, ConfigError } from '@/lib/config' import { + DEFAULT_CHAIN_NAMES, + SUPPORTED_CHAIN_NAMES, parseConnectorNames as parseConnectorNamesCsv, + parseWalletChainNames, + rpcEnvNameFromChainName, + validateBrowserUrl, + type SupportedWalletChainName, type WalletConnectorName, -} from '@/lib/wallet/connectors' +} from '@/lib/wallet/validation.js' export const supportedWalletChains = { mainnet, @@ -15,8 +21,6 @@ export const supportedWalletChains = { sepolia, } as const -type SupportedWalletChainName = keyof typeof supportedWalletChains - type SupportedWalletChain = (typeof supportedWalletChains)[SupportedWalletChainName] export interface WalletRuntimeConfig { @@ -26,9 +30,6 @@ export interface WalletRuntimeConfig { connectorNames: readonly WalletConnectorName[] } -const DEFAULT_CHAIN_NAMES: SupportedWalletChainName[] = ['mainnet', 'base', 'sepolia'] -const SUPPORTED_CHAIN_NAMES = Object.keys(supportedWalletChains) as SupportedWalletChainName[] - function env(name: string): string | undefined { return process.env[name] } @@ -37,57 +38,18 @@ function isDevelopment(): boolean { return process.env.NODE_ENV === 'development' } -function splitCsv(value: string | undefined): string[] { - return value - ?.split(',') - .map((part) => part.trim()) - .filter(Boolean) ?? [] -} - -function fail(message: string): never { - throw new ConfigError(message) -} - -function validateBrowserUrl(value: string, envName: string): string { - try { - const url = new URL(value) - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - fail(`${envName} must use http:// or https://, got "${value}"`) - } - return value - } catch (error) { - if (error instanceof ConfigError) throw error - fail(`${envName} must be a valid absolute RPC URL, got "${value}"`) - } -} - function parseChains(): readonly [SupportedWalletChain, ...SupportedWalletChain[]] { - const configuredNames = splitCsv(env('NEXT_PUBLIC_WALLET_CHAINS')) - const names = configuredNames.length > 0 ? configuredNames : DEFAULT_CHAIN_NAMES - const chains = names.map((name) => { - if (!SUPPORTED_CHAIN_NAMES.includes(name as SupportedWalletChainName)) { - fail( - [ - `NEXT_PUBLIC_WALLET_CHAINS contains unsupported chain "${name}".`, - `Supported values: ${SUPPORTED_CHAIN_NAMES.join(', ')}.`, - ].join(' '), - ) - } + const chainNames = parseWalletChainNames(env('NEXT_PUBLIC_WALLET_CHAINS')) + const chains = chainNames.map((name) => { return supportedWalletChains[name as SupportedWalletChainName] }) - const uniqueChains = chains.filter((chain, index, all) => all.findIndex((item) => item.id === chain.id) === index) - - if (uniqueChains.length === 0) { - fail('NEXT_PUBLIC_WALLET_CHAINS must include at least one supported chain.') - } - - return uniqueChains as [SupportedWalletChain, ...SupportedWalletChain[]] + return chains as [SupportedWalletChain, ...SupportedWalletChain[]] } function rpcEnvName(chain: Chain): string { const name = SUPPORTED_CHAIN_NAMES.find((candidate) => supportedWalletChains[candidate].id === chain.id) - return `NEXT_PUBLIC_WALLET_RPC_${(name ?? String(chain.id)).toUpperCase()}` + return rpcEnvNameFromChainName(name ?? String(chain.id)) } function buildTransports(chains: readonly [SupportedWalletChain, ...SupportedWalletChain[]]): WalletRuntimeConfig['transports'] { diff --git a/lib/wallet/connectors.ts b/lib/wallet/connectors.ts index f7f9fab..d8e23da 100644 --- a/lib/wallet/connectors.ts +++ b/lib/wallet/connectors.ts @@ -11,46 +11,10 @@ * 3. Document it in README.md ("Wallet connectors") and .env.example. */ -import { ConfigError } from '../config' - -export const SUPPORTED_CONNECTOR_NAMES = ['injected'] as const - -export type WalletConnectorName = (typeof SUPPORTED_CONNECTOR_NAMES)[number] - -export const CONNECTOR_DOCS_URL = - 'https://github.com/Adamantine-Guild/guildpass-integrations#wallet-connectors' - -export function unsupportedConnectorMessage(name: string): string { - return [ - `NEXT_PUBLIC_WALLET_CONNECTORS contains unsupported connector "${name}".`, - '', - ` Supported values: ${SUPPORTED_CONNECTOR_NAMES.join(', ')}.`, - '', - ' To add support for a new connector, see the "Wallet connectors"', - ` section of the README (${CONNECTOR_DOCS_URL})`, - ' and extend lib/wallet/config.ts.', - ].join('\n') -} - -/** - * Parse the comma-separated NEXT_PUBLIC_WALLET_CONNECTORS value into a list - * of supported connector names. Defaults to ['injected'] when unset/empty; - * throws a ConfigError naming the offending value for anything unsupported. - */ -export function parseConnectorNames( - csv: string | undefined, -): readonly WalletConnectorName[] { - const configuredNames = - csv - ?.split(',') - .map((part) => part.trim()) - .filter(Boolean) ?? [] - const names = configuredNames.length > 0 ? configuredNames : ['injected'] - - return names.map((name) => { - if (!(SUPPORTED_CONNECTOR_NAMES as readonly string[]).includes(name)) { - throw new ConfigError(unsupportedConnectorMessage(name)) - } - return name as WalletConnectorName - }) -} +export { + CONNECTOR_DOCS_URL, + SUPPORTED_CONNECTOR_NAMES, + parseConnectorNames, + unsupportedConnectorMessage, +} from './validation.js' +export type { WalletConnectorName } from './validation.js' diff --git a/lib/wallet/validation.d.ts b/lib/wallet/validation.d.ts new file mode 100644 index 0000000..783c506 --- /dev/null +++ b/lib/wallet/validation.d.ts @@ -0,0 +1,27 @@ +export const DEFAULT_CHAIN_NAMES: readonly ['mainnet', 'base', 'sepolia'] +export const SUPPORTED_CHAIN_NAMES: readonly ['mainnet', 'base', 'sepolia'] + +export type SupportedWalletChainName = (typeof SUPPORTED_CHAIN_NAMES)[number] + +export const SUPPORTED_CONNECTOR_NAMES: readonly ['injected'] +export type WalletConnectorName = (typeof SUPPORTED_CONNECTOR_NAMES)[number] + +export const CONNECTOR_DOCS_URL: string + +export interface WalletEnvValidation { + chainNames: readonly [SupportedWalletChainName, ...SupportedWalletChainName[]] + connectorNames: readonly WalletConnectorName[] + rpcUrls: Readonly> +} + +export function parseConnectorNames(csv: string | undefined): readonly WalletConnectorName[] +export function parseWalletChainNames( + csv: string | undefined, +): readonly [SupportedWalletChainName, ...SupportedWalletChainName[]] +export function rpcEnvNameFromChainName(name: string): string +export function splitCsv(value: string | undefined): string[] +export function unsupportedConnectorMessage(name: string): string +export function validateBrowserUrl(value: string, envName: string): string +export function validateWalletEnv( + source?: Record, +): WalletEnvValidation diff --git a/lib/wallet/validation.js b/lib/wallet/validation.js new file mode 100644 index 0000000..d2b7c2a --- /dev/null +++ b/lib/wallet/validation.js @@ -0,0 +1,120 @@ +const { ConfigError } = require('../config-validation.js') + +const DEFAULT_CHAIN_NAMES = Object.freeze(['mainnet', 'base', 'sepolia']) +const SUPPORTED_CHAIN_NAMES = DEFAULT_CHAIN_NAMES +const SUPPORTED_CONNECTOR_NAMES = Object.freeze(['injected']) +const CONNECTOR_DOCS_URL = + 'https://github.com/Adamantine-Guild/guildpass-integrations#wallet-connectors' + +function splitCsv(value) { + return ( + value + ?.split(',') + .map((part) => part.trim()) + .filter(Boolean) ?? [] + ) +} + +function fail(message) { + throw new ConfigError(message) +} + +function unsupportedConnectorMessage(name) { + return [ + `NEXT_PUBLIC_WALLET_CONNECTORS contains unsupported connector "${name}".`, + '', + ` Supported values: ${SUPPORTED_CONNECTOR_NAMES.join(', ')}.`, + '', + ' To add support for a new connector, see the "Wallet connectors"', + ` section of the README (${CONNECTOR_DOCS_URL})`, + ' and extend lib/wallet/config.ts.', + ].join('\n') +} + +function parseConnectorNames(csv) { + const configuredNames = splitCsv(csv) + const names = configuredNames.length > 0 ? configuredNames : ['injected'] + + return names.map((name) => { + if (!SUPPORTED_CONNECTOR_NAMES.includes(name)) { + throw new ConfigError(unsupportedConnectorMessage(name)) + } + return name + }) +} + +function parseWalletChainNames(csv) { + const configuredNames = splitCsv(csv) + const names = configuredNames.length > 0 ? configuredNames : DEFAULT_CHAIN_NAMES + + const validNames = names.map((name) => { + if (!SUPPORTED_CHAIN_NAMES.includes(name)) { + fail( + [ + `NEXT_PUBLIC_WALLET_CHAINS contains unsupported chain "${name}".`, + `Supported values: ${SUPPORTED_CHAIN_NAMES.join(', ')}.`, + ].join(' '), + ) + } + return name + }) + + const uniqueNames = validNames.filter( + (name, index, all) => all.findIndex((item) => item === name) === index, + ) + + if (uniqueNames.length === 0) { + fail('NEXT_PUBLIC_WALLET_CHAINS must include at least one supported chain.') + } + + return uniqueNames +} + +function rpcEnvNameFromChainName(name) { + return `NEXT_PUBLIC_WALLET_RPC_${String(name).toUpperCase()}` +} + +function validateBrowserUrl(value, envName) { + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + fail(`${envName} must use http:// or https://, got "${value}"`) + } + return value + } catch (error) { + if (error instanceof ConfigError) throw error + fail(`${envName} must be a valid absolute RPC URL, got "${value}"`) + } +} + +function validateWalletEnv(source = process.env) { + const chainNames = parseWalletChainNames(source.NEXT_PUBLIC_WALLET_CHAINS) + const connectorNames = parseConnectorNames(source.NEXT_PUBLIC_WALLET_CONNECTORS) + const rpcUrls = {} + + for (const chainName of chainNames) { + const envName = rpcEnvNameFromChainName(chainName) + const value = source[envName] + rpcUrls[envName] = value ? validateBrowserUrl(value, envName) : undefined + } + + return Object.freeze({ + chainNames: Object.freeze([...chainNames]), + connectorNames: Object.freeze([...connectorNames]), + rpcUrls: Object.freeze(rpcUrls), + }) +} + +module.exports = { + CONNECTOR_DOCS_URL, + DEFAULT_CHAIN_NAMES, + SUPPORTED_CHAIN_NAMES, + SUPPORTED_CONNECTOR_NAMES, + parseConnectorNames, + parseWalletChainNames, + rpcEnvNameFromChainName, + splitCsv, + unsupportedConnectorMessage, + validateBrowserUrl, + validateWalletEnv, +} diff --git a/package.json b/package.json index 654ead9..8aa724d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ }, "author": "GuildPass Team", "scripts": { + "check-env": "node scripts/check-env.js", + "predev": "npm run check-env", "dev": "next dev", "build": "next build", "start": "next start", diff --git a/scripts/check-env.js b/scripts/check-env.js new file mode 100644 index 0000000..081031d --- /dev/null +++ b/scripts/check-env.js @@ -0,0 +1,512 @@ +#!/usr/bin/env node + +const fs = require('node:fs') +const path = require('node:path') + +const { + SIWE_STATEMENT_MAX_LENGTH, + flag, + parseApiModeFromEnv, + resolveCoreApiUrl, + resolveWarningThresholdSeconds, + validateSiweStatement, +} = require('../lib/config-validation.js') +const { + SUPPORTED_CHAIN_NAMES, + parseConnectorNames, + parseWalletChainNames, + rpcEnvNameFromChainName, + validateBrowserUrl, +} = require('../lib/wallet/validation.js') + +const DEFAULT_ENV_FILE = '.env.local' +const DEFAULT_SIWE_STATEMENT = 'Sign in to GuildPass Admin' + +const FEATURE_FLAGS = [ + ['NEXT_PUBLIC_FEATURE_ADMIN_POLICIES', true], + ['NEXT_PUBLIC_FEATURE_ADMIN_SETTINGS', (mode) => mode === 'mock'], + ['NEXT_PUBLIC_FEATURE_EVENTS', (mode) => mode === 'mock'], + ['NEXT_PUBLIC_FEATURE_ANALYTICS', false], + ['NEXT_PUBLIC_FEATURE_RESOURCES', true], + ['NEXT_PUBLIC_FEATURE_GOVERNANCE', false], + ['NEXT_PUBLIC_FEATURE_REWARDS', false], + ['NEXT_PUBLIC_FEATURE_MULTI_COMMUNITY', false], + ['NEXT_PUBLIC_FEATURE_PROFILES', false], +] + +function printHelp() { + console.log(`Usage: node scripts/check-env.js [--file ] + +Validates the effective local environment before starting the dev server. +Values from the shell override values loaded from .env.local, matching Next.js +development behavior.`) +} + +function parseArgs(argv) { + let envFile = path.resolve(process.cwd(), DEFAULT_ENV_FILE) + let explicitEnvFile = false + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + + if (arg === '--file') { + const value = argv[index + 1] + if (!value) { + throw new Error('--file requires a path') + } + envFile = path.resolve(process.cwd(), value) + explicitEnvFile = true + index += 1 + continue + } + + if (arg.startsWith('--file=')) { + envFile = path.resolve(process.cwd(), arg.slice('--file='.length)) + explicitEnvFile = true + continue + } + + throw new Error(`Unknown argument: ${arg}`) + } + + return { envFile, explicitEnvFile } +} + +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key) +} + +function unescapeDoubleQuotedValue(value) { + return value + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') +} + +function findClosingQuote(value, quote) { + let escaped = false + + for (let index = 1; index < value.length; index += 1) { + const char = value[index] + + if (quote === '"' && char === '\\' && !escaped) { + escaped = true + continue + } + + if (char === quote && !escaped) { + return index + } + + escaped = false + } + + return -1 +} + +function parseDotenv(content) { + const values = {} + const errors = [] + const lines = content.replace(/^\uFEFF/, '').split(/\n/) + + lines.forEach((line, index) => { + const lineNumber = index + 1 + const trimmed = line.replace(/\r$/, '').trim() + + if (!trimmed || trimmed.startsWith('#')) { + return + } + + const withoutExport = trimmed.startsWith('export ') + ? trimmed.slice('export '.length).trimStart() + : trimmed + const match = withoutExport.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/) + + if (!match) { + errors.push(`line ${lineNumber}: expected KEY=value`) + return + } + + const [, key, rawValue] = match + const value = rawValue.trim() + + if (value.startsWith('"') || value.startsWith("'")) { + const quote = value[0] + const closingIndex = findClosingQuote(value, quote) + + if (closingIndex === -1) { + errors.push(`line ${lineNumber}: missing closing ${quote} quote for ${key}`) + return + } + + const remainder = value.slice(closingIndex + 1).trim() + if (remainder && !remainder.startsWith('#')) { + errors.push(`line ${lineNumber}: unexpected text after ${key}`) + return + } + + const quotedValue = value.slice(1, closingIndex) + values[key] = + quote === '"' ? unescapeDoubleQuotedValue(quotedValue) : quotedValue + return + } + + values[key] = value.replace(/\s+#.*$/, '').trim() + }) + + return { values, errors } +} + +function readEnvFile(envFile, explicitEnvFile) { + try { + const content = fs.readFileSync(envFile, 'utf8') + const parsed = parseDotenv(content) + return { + errors: [], + file: envFile, + missing: false, + values: parsed.values, + parseErrors: parsed.errors, + } + } catch (error) { + if (error && error.code === 'ENOENT') { + return { + errors: [], + file: envFile, + missing: true, + values: {}, + parseErrors: [], + } + } + + return { + errors: [`Unable to read ${envFile}: ${error.message}`], + file: envFile, + missing: false, + values: {}, + parseErrors: [], + } + } +} + +function relativeEnvFile(envFile) { + const relative = path.relative(process.cwd(), envFile) + return relative && !relative.startsWith('..') ? relative : envFile +} + +function statusLine(status, name, message, width) { + const prefix = `${status.padEnd(4)} ${name.padEnd(width)} ` + const lines = String(message).split('\n') + return [ + `${prefix}${lines[0] ?? ''}`, + ...lines.slice(1).map((line) => `${' '.repeat(prefix.length)}${line}`), + ].join('\n') +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error) +} + +function rawValue(env, name) { + return env[name] +} + +function isSet(env, name) { + const value = rawValue(env, name) + return value !== undefined && value !== '' +} + +function sourceFor(name, parsedEnv, shellEnv, envFileLabel) { + if (hasOwn(shellEnv, name)) return 'shell' + if (hasOwn(parsedEnv, name)) return envFileLabel + return 'default' +} + +function sourceSuffix(name, parsedEnv, shellEnv, envFileLabel) { + const source = sourceFor(name, parsedEnv, shellEnv, envFileLabel) + return source === 'default' ? '' : ` (${source})` +} + +function flagDefault(defaultValue, mode) { + return typeof defaultValue === 'function' ? defaultValue(mode) : defaultValue +} + +function describeFlag(env, name, defaultValue, mode, parsedEnv, shellEnv, envFileLabel) { + const raw = rawValue(env, name) + const resolved = flag(env, name, flagDefault(defaultValue, mode)) + + if (raw === undefined || raw === '') { + return `not set; defaults to ${resolved}` + } + + if (raw !== 'true' && raw !== 'false') { + return `set to "${raw}"; app treats this as ${resolved}${sourceSuffix( + name, + parsedEnv, + shellEnv, + envFileLabel, + )}` + } + + return `resolves to ${resolved}${sourceSuffix(name, parsedEnv, shellEnv, envFileLabel)}` +} + +function collectResults(effectiveEnv, parsedEnv, shellEnv, envFileLabel) { + const results = [] + + function add(status, name, message) { + results.push({ status, name, message }) + } + + function check(name, run) { + try { + add('OK', name, run()) + } catch (error) { + add('FAIL', name, errorMessage(error)) + } + } + + const mode = parseApiModeFromEnv(effectiveEnv) + + check('NEXT_PUBLIC_MOCK_MODE / NEXT_PUBLIC_DEMO_MODE', () => { + if (rawValue(effectiveEnv, 'NEXT_PUBLIC_MOCK_MODE') === 'true') { + return `api mode resolves to ${mode} (NEXT_PUBLIC_MOCK_MODE=true)` + } + if (rawValue(effectiveEnv, 'NEXT_PUBLIC_DEMO_MODE') === 'true') { + return `api mode resolves to ${mode} (NEXT_PUBLIC_DEMO_MODE=true)` + } + return `api mode resolves to ${mode}` + }) + + check('NEXT_PUBLIC_CORE_API_URL', () => { + if (mode === 'mock') { + const apiUrl = resolveCoreApiUrl(effectiveEnv, mode) + if (!isSet(effectiveEnv, 'NEXT_PUBLIC_CORE_API_URL')) { + return `not set; defaults to ${apiUrl} in mock mode` + } + return `set; optional in mock mode${sourceSuffix( + 'NEXT_PUBLIC_CORE_API_URL', + parsedEnv, + shellEnv, + envFileLabel, + )}` + } + resolveCoreApiUrl(effectiveEnv, mode) + return `valid URL${sourceSuffix( + 'NEXT_PUBLIC_CORE_API_URL', + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + + check('NEXT_PUBLIC_SIWE_DOMAIN', () => { + if (!isSet(effectiveEnv, 'NEXT_PUBLIC_SIWE_DOMAIN')) { + return 'not set; defaults to localhost:3000' + } + return `configured${sourceSuffix( + 'NEXT_PUBLIC_SIWE_DOMAIN', + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + + check('NEXT_PUBLIC_SIWE_STATEMENT', () => { + const value = + rawValue(effectiveEnv, 'NEXT_PUBLIC_SIWE_STATEMENT') ?? DEFAULT_SIWE_STATEMENT + const statement = validateSiweStatement(value) + return `${statement.length}/${SIWE_STATEMENT_MAX_LENGTH} chars; single-line${sourceSuffix( + 'NEXT_PUBLIC_SIWE_STATEMENT', + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + + check('NEXT_PUBLIC_SIWE_WARNING_SECONDS / NEXT_PUBLIC_SIWE_WARNING_MINUTES', () => { + const seconds = resolveWarningThresholdSeconds(effectiveEnv) + const secondsValue = rawValue(effectiveEnv, 'NEXT_PUBLIC_SIWE_WARNING_SECONDS') + const minutesValue = rawValue(effectiveEnv, 'NEXT_PUBLIC_SIWE_WARNING_MINUTES') + + if (secondsValue === undefined && minutesValue === undefined) { + return `not set; defaults to ${seconds}s` + } + + if ( + (secondsValue !== undefined && Number.isNaN(Number(secondsValue))) || + (secondsValue === undefined && + minutesValue !== undefined && + Number.isNaN(Number(minutesValue))) + ) { + return `non-numeric value is ignored by the app; resolves to ${seconds}s` + } + + return `resolves to ${seconds}s` + }) + + check('INTEGRATION_ALLOWED_ORIGIN', () => { + if (!isSet(effectiveEnv, 'INTEGRATION_ALLOWED_ORIGIN')) { + return 'not set; optional' + } + return `configured${sourceSuffix( + 'INTEGRATION_ALLOWED_ORIGIN', + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + + check('NEXT_PUBLIC_API_VALIDATION_LOG_ONLY', () => + describeFlag( + effectiveEnv, + 'NEXT_PUBLIC_API_VALIDATION_LOG_ONLY', + false, + mode, + parsedEnv, + shellEnv, + envFileLabel, + ), + ) + + for (const [name, defaultValue] of FEATURE_FLAGS) { + check(name, () => + describeFlag( + effectiveEnv, + name, + defaultValue, + mode, + parsedEnv, + shellEnv, + envFileLabel, + ), + ) + } + + let chainNames + check('NEXT_PUBLIC_WALLET_CHAINS', () => { + chainNames = parseWalletChainNames(rawValue(effectiveEnv, 'NEXT_PUBLIC_WALLET_CHAINS')) + if (!isSet(effectiveEnv, 'NEXT_PUBLIC_WALLET_CHAINS')) { + return `${chainNames.join(', ')} (default)` + } + return `${chainNames.join(', ')}${sourceSuffix( + 'NEXT_PUBLIC_WALLET_CHAINS', + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + + check('NEXT_PUBLIC_WALLET_CONNECTORS', () => { + const connectorNames = parseConnectorNames( + rawValue(effectiveEnv, 'NEXT_PUBLIC_WALLET_CONNECTORS'), + ) + if (!isSet(effectiveEnv, 'NEXT_PUBLIC_WALLET_CONNECTORS')) { + return `${connectorNames.join(', ')} (default)` + } + return `${connectorNames.join(', ')}${sourceSuffix( + 'NEXT_PUBLIC_WALLET_CONNECTORS', + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + + for (const chainName of SUPPORTED_CHAIN_NAMES) { + const envName = rpcEnvNameFromChainName(chainName) + check(envName, () => { + if (!chainNames) { + return 'skipped until NEXT_PUBLIC_WALLET_CHAINS is fixed' + } + + if (!chainNames.includes(chainName)) { + return isSet(effectiveEnv, envName) + ? 'set but not used by the current chain list' + : 'not used by the current chain list' + } + + if (!isSet(effectiveEnv, envName)) { + return 'not set; using wagmi default transport' + } + + validateBrowserUrl(rawValue(effectiveEnv, envName), envName) + return `configured; valid http(s) URL${sourceSuffix( + envName, + parsedEnv, + shellEnv, + envFileLabel, + )}` + }) + } + + return results +} + +function main() { + let args + try { + args = parseArgs(process.argv.slice(2)) + } catch (error) { + console.error(errorMessage(error)) + console.error('Run `npm run check-env -- --help` for usage.') + process.exitCode = 1 + return + } + + const loaded = readEnvFile(args.envFile, args.explicitEnvFile) + const envFileLabel = relativeEnvFile(args.envFile) + const effectiveEnv = { ...loaded.values, ...process.env } + const results = [] + + if (loaded.missing) { + results.push({ + status: args.explicitEnvFile ? 'FAIL' : 'WARN', + name: envFileLabel, + message: args.explicitEnvFile + ? 'file not found' + : 'not found; copy .env.example to .env.local or provide env vars in the shell', + }) + } else { + results.push({ + status: 'OK', + name: envFileLabel, + message: 'loaded', + }) + } + + for (const error of loaded.errors) { + results.push({ status: 'FAIL', name: envFileLabel, message: error }) + } + + for (const error of loaded.parseErrors) { + results.push({ status: 'FAIL', name: envFileLabel, message: error }) + } + + results.push(...collectResults(effectiveEnv, loaded.values, process.env, envFileLabel)) + + const width = Math.max(...results.map((result) => result.name.length)) + console.log('GuildPass environment check') + console.log('') + for (const result of results) { + console.log(statusLine(result.status, result.name, result.message, width)) + } + + const failed = results.some((result) => result.status === 'FAIL') + console.log('') + if (failed) { + console.log('Environment check failed. Fix the FAIL entries above, then run npm run check-env again.') + process.exitCode = 1 + return + } + + console.log('Environment check passed.') +} + +main() diff --git a/test/check-env.test.ts b/test/check-env.test.ts new file mode 100644 index 0000000..79d2461 --- /dev/null +++ b/test/check-env.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, test } from 'node:test' +import * as assert from 'node:assert/strict' + +const repoRoot = path.resolve(__dirname, '..', '..') +const scriptPath = path.join(repoRoot, 'scripts/check-env.js') + +function runCheckEnv(contents: string) { + const dir = mkdtempSync(path.join(tmpdir(), 'guildpass-check-env-')) + const envFile = path.join(dir, '.env.local') + writeFileSync(envFile, contents) + + try { + return spawnSync(process.execPath, [scriptPath, '--file', envFile], { + cwd: repoRoot, + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '', + }, + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('scripts/check-env.js', () => { + test('passes against a valid .env.local', () => { + const result = runCheckEnv(` +NEXT_PUBLIC_MOCK_MODE=true +NEXT_PUBLIC_CORE_API_URL=http://localhost:4000 +NEXT_PUBLIC_SIWE_STATEMENT="Sign in to GuildPass Admin" +NEXT_PUBLIC_WALLET_CHAINS=mainnet,base,sepolia +NEXT_PUBLIC_WALLET_CONNECTORS=injected +NEXT_PUBLIC_WALLET_RPC_MAINNET=https://mainnet.example.test/rpc +`) + + assert.equal(result.status, 0) + assert.match(result.stdout, /OK\s+NEXT_PUBLIC_CORE_API_URL\s+valid URL/) + assert.match(result.stdout, /OK\s+NEXT_PUBLIC_WALLET_CHAINS\s+mainnet, base, sepolia/) + assert.match(result.stdout, /Environment check passed\./) + }) + + test('flags app ConfigError cases with non-zero exit', () => { + const result = runCheckEnv(` +NEXT_PUBLIC_MOCK_MODE=false +NEXT_PUBLIC_CORE_API_URL=not-a-url +NEXT_PUBLIC_SIWE_STATEMENT="Line one\\nLine two" +NEXT_PUBLIC_WALLET_CHAINS=mainnet +NEXT_PUBLIC_WALLET_CONNECTORS=injected +`) + + assert.notEqual(result.status, 0) + assert.match(result.stdout, /FAIL\s+NEXT_PUBLIC_CORE_API_URL\s+NEXT_PUBLIC_CORE_API_URL must be a valid URL/) + assert.match(result.stdout, /FAIL\s+NEXT_PUBLIC_SIWE_STATEMENT\s+NEXT_PUBLIC_SIWE_STATEMENT must be a single line/) + assert.match(result.stdout, /Environment check failed\./) + }) + + test('flags wallet ConfigError cases with non-zero exit', () => { + const result = runCheckEnv(` +NEXT_PUBLIC_MOCK_MODE=true +NEXT_PUBLIC_WALLET_CHAINS=mainnet,optimism +NEXT_PUBLIC_WALLET_CONNECTORS=walletconnect +NEXT_PUBLIC_WALLET_RPC_MAINNET=ftp://mainnet.example.test/rpc +`) + + assert.notEqual(result.status, 0) + assert.match(result.stdout, /FAIL\s+NEXT_PUBLIC_WALLET_CHAINS\s+NEXT_PUBLIC_WALLET_CHAINS contains unsupported chain "optimism"/) + assert.match(result.stdout, /FAIL\s+NEXT_PUBLIC_WALLET_CONNECTORS\s+NEXT_PUBLIC_WALLET_CONNECTORS contains unsupported connector "walletconnect"/) + }) + + test('flags malformed RPC URLs for enabled wallet chains', () => { + const result = runCheckEnv(` +NEXT_PUBLIC_MOCK_MODE=true +NEXT_PUBLIC_WALLET_CHAINS=mainnet +NEXT_PUBLIC_WALLET_CONNECTORS=injected +NEXT_PUBLIC_WALLET_RPC_MAINNET=ftp://mainnet.example.test/rpc +`) + + assert.notEqual(result.status, 0) + assert.match(result.stdout, /FAIL\s+NEXT_PUBLIC_WALLET_RPC_MAINNET\s+NEXT_PUBLIC_WALLET_RPC_MAINNET must use http:\/\/ or https:\/\//) + }) +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index e03fb02..dd5309b 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -30,6 +30,8 @@ "../lib/wallet/chains.ts", "../lib/wallet/config.ts", "../lib/wallet/connectors.ts", + "../lib/config-validation.js", + "../lib/wallet/validation.js", "../lib/wallet/siwe-session.ts", "../lib/config.ts", "../lib/features.ts",