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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions lib/config-validation.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export type EnvSource = Record<string, string | undefined>

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<FeatureFlagKey, boolean>

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
152 changes: 152 additions & 0 deletions lib/config-validation.js
Original file line number Diff line number Diff line change
@@ -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,
}
Loading