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
14 changes: 8 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@
# Lumora web environment configuration.
# Copy this file to `.env` and adjust the values, then restart the dev server.
#
# The three variables below are required by the development server (it shows a
# configuration error screen if one is missing or invalid). Production builds
# fall back to defaults instead - see "Environment Variables" in the README -
# so set all three explicitly when you deploy.
# The three variables below are the only required ones. A production build must
# set all three explicitly: there is no production fallback, so a missing value
# renders the configuration error screen instead of silently starting against
# localhost or testnet. In production `VITE_API_URL` must use https://, because
# session tokens travel over that connection. The development server falls back
# to the localhost defaults shown below only when a variable is unset.

# Base URL of the Lumora backend / API. Production default: http://localhost:4000 (not what you want).
# Base URL of the Lumora backend / API. Production must be an https:// origin.
VITE_API_URL=http://localhost:4000

# Stellar network to connect to: testnet or mainnet. Production default: testnet.
# Stellar network to connect to: testnet or mainnet. Production has no default.
VITE_STELLAR_NETWORK=testnet

# Public URL the web app is served from (links, redirects and SEO).
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,16 @@ Every `VITE_*` variable is inlined into the public client bundle at build time a

Access and refresh tokens are persisted by `useAuthStore` in `localStorage` so sessions survive reloads. Trade-off: any script running on the origin can read them, so an XSS bug could leak them. This is accepted for the MVP because the API uses bearer tokens; mitigations are short-lived access tokens, transparent refresh on 401 and never rendering untrusted HTML. Move the refresh token to an httpOnly cookie once the API supports it. Do not store tokens anywhere else.

## Untrusted URLs

Two helpers in `src/lib/url.ts` guard every URL the app does not control. Use them instead of passing a raw string to `navigate`, `href` or `src`:

- `safeRedirect` (via `resolvePostLoginRedirect`) for a post-login destination. It only accepts an app-relative path that stays on this origin, so `//evil.com`, `/\evil.com` and `/%2F%2Fevil.com` all fall back to the dashboard. Router state is attacker-influenceable: narrow it field by field instead of asserting a type.
- `safeExternalUrl` for a server-supplied URL rendered as a link or an image source. It returns `null` for anything that is not `https:` (plus `http:` in development), so render a non-clickable fallback instead of the URL.
- `redactUrl` before a URL reaches `console.*` or an analytics payload - a password-reset token travels in the path.

Interpolate every path parameter with `encodeURIComponent`, and set `skipToast: true` on background polls and probes so a retry loop cannot spam the user with toasts.

## Releases

Versions follow [Semantic Versioning](https://semver.org) and the changelog follows
Expand Down
44 changes: 22 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,38 +93,38 @@ the `VITE_` prefix and are defined in a `.env` file - copy `.env.example` to get
> anyone who loads the site can read them. Never put a secret (API keys, signing keys, passwords) in a `VITE_*`
> variable; secrets belong on a server.

| Variable | Default | Required | Description |
| Variable | Dev default | Required | Description |
| -------------------------- | ----------------------- | ------------------ | ------------------------------------------------------------- |
| `VITE_API_URL` | `http://localhost:4000` | Dev: yes. Prod: no | Base URL of the Lumora backend API |
| `VITE_STELLAR_NETWORK` | `testnet` | Dev: yes. Prod: no | Stellar network: `testnet` or `mainnet` |
| `VITE_APP_URL` | `http://localhost:5173` | Dev: yes. Prod: no | Public URL the web app is served from |
| `VITE_API_URL` | `http://localhost:4000` | Yes | Base URL of the Lumora backend API. Must be `https://` in prod |
| `VITE_STELLAR_NETWORK` | `testnet` | Yes | Stellar network: `testnet` or `mainnet` |
| `VITE_APP_URL` | `http://localhost:5173` | Yes | Public URL the web app is served from |
| `VITE_FF_MESSAGING` | unset (flag is off) | No | Feature flag override for messaging: `true` or `false` |
| `VITE_FF_NOTIFICATIONS` | unset (flag is off) | No | Feature flag override for notifications: `true` or `false` |
| `VITE_FF_ADVANCED_FILTERS` | unset (flag is off) | No | Feature flag override for advanced filters: `true` or `false` |

The three required variables fall back to the dev defaults above **only while developing**. A production build has no
fallback: set all three explicitly in your deployment environment.

The flag variables accept only `true` or `false`; anything else counts as unset, which leaves the flag at its safe
default (off). See `src/config/featureFlags.ts`.

### How validation actually behaves

Validation is gated on `import.meta.env.DEV` in `src/config/env.ts`:

- **Development (`npm run dev`)**: a missing or invalid `VITE_API_URL`, `VITE_STELLAR_NETWORK` or `VITE_APP_URL` is
reported with a clear message and `main.tsx` renders `ConfigErrorScreen` instead of the app. The dev server itself
still starts; it is the page that refuses to render.
- **Production builds**: nothing is required. Anything missing falls back to its default. An invalid value (for
example a malformed URL) makes `envSchema.parse` throw while the module loads, **before** `main.tsx` can read
`configError`, so the user gets a blank page rather than `ConfigErrorScreen`.

Two defaults are footguns in production:

- `VITE_STELLAR_NETWORK` silently defaults to **`testnet`**. A mainnet deployment that forgets this variable runs on
testnet with no warning.
- `VITE_API_URL` defaults to **`http://localhost:4000`**, so a production build without it points every request at
the visitor's own machine.

Always set all three explicitly in your deployment environment. Making production fail closed is tracked as a
follow-up.
`src/config/env.ts` resolves the configuration once and exposes the outcome as `loadResult` (`env`, `configError`,
`invalidKeys`). It never throws at module scope, so an invalid build reaches `main.tsx` and renders
`ConfigErrorScreen`, which lists the offending variable names:

- **Development (`npm run dev`)**: an unset required variable falls back to the dev default; a value that is present
but invalid (a malformed URL, an unknown network) is reported and `main.tsx` renders `ConfigErrorScreen`. The dev
server itself still starts; it is the page that refuses to render.
- **Production builds**: a missing `VITE_API_URL`, `VITE_STELLAR_NETWORK` or `VITE_APP_URL` **fails closed** — the app
renders `ConfigErrorScreen` rather than silently starting against `localhost` or `testnet`.
- **Production builds** additionally reject an `http://` `VITE_API_URL`: session tokens would otherwise travel over
the wire in plaintext.

The supported network passphrases are an explicit allow-list (`SUPPORTED_NETWORK_PASSPHRASES` in
`src/config/stellar.ts`), so a wallet that reports an unknown network is refused rather than producing a transaction
that can never settle.

`.env` is git-ignored; only `.env.example` is committed. Never read `import.meta.env` directly outside
`src/config/env.ts` - import `{ env }` from `@/config` instead.
Expand Down
25 changes: 23 additions & 2 deletions src/components/ui/ConfigErrorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,32 @@
* Boot-time fallback shown when the app refuses to start because the runtime
* environment configuration is invalid. Presentational only.
*/
export default function ConfigErrorScreen({ message }: { message: string }) {
export default function ConfigErrorScreen({
message,
invalidKeys = [],
}: {
message: string
/** Names of the variables that caused the rejection (#864). */
invalidKeys?: string[]
}) {
return (
<main className="mx-auto max-w-3xl px-6 py-16 text-foreground">
<h1 className="text-h2">Invalid environment configuration</h1>
<pre className="mt-4 overflow-x-auto whitespace-pre-wrap rounded-control border border-danger bg-danger/10 p-4 text-body">

{invalidKeys.length > 0 && (
<div className="mt-6 rounded-control border border-danger bg-danger/10 p-4">
<p className="text-caption font-semibold text-danger">
Check these {invalidKeys.length === 1 ? 'variable' : 'variables'}:
</p>
<ul className="mt-2 list-inside list-disc font-mono text-caption text-danger">
{invalidKeys.map((key) => (
<li key={key}>{key}</li>
))}
</ul>
</div>
)}

<pre className="mt-4 overflow-x-auto whitespace-pre-wrap rounded-control border border-line bg-surface-muted p-4 text-body">
{message}
</pre>
</main>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/ExplorerLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function ExplorerLink({
const pushToast = useUiStore((state) => state.pushToast)

const displayValue = isExpanded ? value : truncateMiddle(value, 8, 6)
const href = `${EXPLORER_ROOTS[network][type]}/${value}`
const href = `${EXPLORER_ROOTS[network][type]}/${encodeURIComponent(value)}`

return (
<span className={`inline-flex items-center gap-1.5 ${className}`}>
Expand Down
151 changes: 117 additions & 34 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import { z } from 'zod'
export const stellarNetworkSchema = z.enum(['testnet', 'mainnet'])

/**
* Runtime environment configuration. All variables use the `VITE_` prefix and are
* read from `import.meta.env` (see `.env.example`).
* Runtime environment configuration. All variables use the `VITE_` prefix and
* are read from `import.meta.env` (see `.env.example`).
*
* A default is provided for production safety so the app can still boot with a
* reasonable configuration when a variable has not been supplied at build time.
* There are deliberately **no schema defaults**: the dev-only defaults live in
* `DEV_DEFAULTS` and are applied only when the build is not a production build.
* A production build must supply every required variable explicitly, otherwise
* the app fails closed with a configuration error screen (#864).
*/
const envSchema = z.object({
VITE_API_URL: z.url().default('http://localhost:4000'),
VITE_STELLAR_NETWORK: stellarNetworkSchema.default('testnet'),
VITE_APP_URL: z.url().default('http://localhost:5173'),
VITE_API_URL: z.url(),
VITE_STELLAR_NETWORK: stellarNetworkSchema,
VITE_APP_URL: z.url(),
// Optional feature-flag overrides ("true"/"false"); anything else counts as unset.
VITE_FF_MESSAGING: z.stringbool().optional().catch(undefined),
VITE_FF_NOTIFICATIONS: z.stringbool().optional().catch(undefined),
Expand All @@ -27,6 +29,27 @@ const REQUIRED_KEYS = ['VITE_API_URL', 'VITE_STELLAR_NETWORK', 'VITE_APP_URL'] a
type EnvKey = (typeof REQUIRED_KEYS)[number]
type FlagEnvKey = 'VITE_FF_MESSAGING' | 'VITE_FF_NOTIFICATIONS' | 'VITE_FF_ADVANCED_FILTERS'

/**
* Values used only while developing, so a fresh clone runs without a `.env`.
* They are never applied to a production build.
*/
const DEV_DEFAULTS: Record<EnvKey, string> = {
VITE_API_URL: 'http://localhost:4000',
VITE_STELLAR_NETWORK: 'testnet',
VITE_APP_URL: 'http://localhost:5173',
}

/**
* Stand-in used when the configuration is rejected. The app never renders past
* the configuration error screen in that case, so this is never used to make a
* request — it exists so importing modules that read `env` cannot crash.
*/
const FALLBACK_ENV = {
VITE_API_URL: 'https://invalid.localhost',
VITE_STELLAR_NETWORK: 'testnet',
VITE_APP_URL: 'https://invalid.localhost',
}

/**
* Single access point for the raw `import.meta.env` values. No other module in
* the app should touch `import.meta.env` directly.
Expand All @@ -48,38 +71,98 @@ function formatIssues(error: z.ZodError<AppEnv>): string {
.join('\n')
}

function loadEnv(): { env: AppEnv; configError: string | null } {
const raw = readRawEnv()
/** Variable names mentioned by a validation failure, in `REQUIRED_KEYS` order. */
function invalidKeysOf(error: z.ZodError<AppEnv>): string[] {
const keys = error.issues
.map((issue) => issue.path[0])
.filter((key): key is string => typeof key === 'string')
return REQUIRED_KEYS.filter((key) => keys.includes(key))
}

function buildMessage(details: string): string {
return [
'Invalid environment configuration.',
details,
'Copy .env.example to .env and fix the values, then restart the dev server.',
].join('\n\n')
}

if (import.meta.env.DEV) {
const missing = REQUIRED_KEYS.filter((key) => raw[key] === undefined || raw[key] === '')
const validation = envSchema.safeParse(raw)
/** A production build must never talk to a plain-HTTP API: tokens would travel in the clear. */
function productionApiUrlIssue(apiUrl: string | undefined): string | null {
if (typeof apiUrl !== 'string' || apiUrl.trim() === '') return null
return apiUrl.trim().startsWith('https://')
? null
: 'VITE_API_URL must use https:// in a production build.'
}

export interface EnvLoadResult {
/** Always shaped, so consumers can read it; meaningless when `configError` is set. */
env: AppEnv
/** Copy for the configuration error screen, or `null` when the config is valid. */
configError: string | null
/** The variable names responsible for `configError`. */
invalidKeys: string[]
}

if (missing.length === 0 && validation.success) {
return { env: validation.data, configError: null }
/**
* Resolves the runtime configuration without throwing at module scope, so an
* invalid production build renders `ConfigErrorScreen` instead of a blank page
* or an unhandled module-scope exception (#864).
*/
function loadEnv(): EnvLoadResult {
const raw = readRawEnv()
const isProduction = import.meta.env.PROD

const values: Partial<Record<EnvKey | FlagEnvKey, string | undefined>> = { ...raw }
if (!isProduction) {
for (const key of REQUIRED_KEYS) {
if (values[key] === undefined || values[key] === '') values[key] = DEV_DEFAULTS[key]
}
}

const details = [
missing.length > 0
? `Missing variables:\n${missing.map((key) => ` - ${key}`).join('\n')}`
: '',
!validation.success ? `Invalid values:\n${formatIssues(validation.error)}` : '',
]
.filter(Boolean)
.join('\n')

const message = [
'Invalid environment configuration.',
details,
'Copy .env.example to .env and fix the values, then restart the dev server.',
].join('\n\n')

console.error(`[config:env] ${message}`)
return { env: envSchema.parse({}), configError: message }
const missing = REQUIRED_KEYS.filter(
(key) => values[key] === undefined || values[key] === '',
)

const apiUrlIssue = isProduction ? productionApiUrlIssue(values.VITE_API_URL) : null

const validation = envSchema.safeParse(values)
if (missing.length === 0 && !apiUrlIssue && validation.success) {
return { env: validation.data, configError: null, invalidKeys: [] }
}

// Production: safe defaults are used for anything that is not provided.
return { env: envSchema.parse(raw), configError: null }
const details = [
missing.length > 0
? `Missing variables:\n${missing.map((key) => ` - ${key}`).join('\n')}`
: '',
!validation.success ? `Invalid values:\n${formatIssues(validation.error)}` : '',
apiUrlIssue ? `Invalid values:\n - VITE_API_URL: ${apiUrlIssue}` : '',
]
.filter(Boolean)
.join('\n')

if (isProduction) {
console.error(`[config:env] ${buildMessage(details)}`)
}

const invalidKeys = [
...missing,
...(validation.success ? [] : invalidKeysOf(validation.error)),
...(apiUrlIssue ? (['VITE_API_URL'] as const).filter((k) => !missing.includes(k)) : []),
]

return {
env: validation.success ? validation.data : (FALLBACK_ENV as AppEnv),
configError: buildMessage(details),
invalidKeys: [...new Set(invalidKeys)],
}
}

export const { env, configError } = loadEnv()
/** Result of resolving the runtime configuration. Never throws. */
export const loadResult: EnvLoadResult = loadEnv()

/** Valid runtime configuration, or the fallback shape when it is invalid. */
export const env: AppEnv = loadResult.env

/** Configuration error copy for the boot screen, or `null` when the config is valid. */
export const configError: string | null = loadResult.configError
6 changes: 4 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Application configuration: environment variables and shared constants.
*/
export { configError, env } from './env'
export type { AppEnv, StellarNetwork } from './env'
export { configError, env, loadResult } from './env'
export type { AppEnv, EnvLoadResult, StellarNetwork } from './env'
export {
isFeatureEnabled,
loadRemoteFeatureFlags,
Expand All @@ -13,8 +13,10 @@ export type { FeatureFlag, FeatureFlags } from './featureFlags'
export {
activeNetworkPassphrase,
activeStellarNetwork,
isSupportedNetworkPassphrase,
STELLAR_NETWORK_LABELS,
stellarAccountExplorerUrl,
SUPPORTED_NETWORK_PASSPHRASES,
} from './stellar'
export {
ASSETS,
Expand Down
14 changes: 13 additions & 1 deletion src/config/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ export const activeStellarNetwork: StellarNetwork = env.VITE_STELLAR_NETWORK
/** Passphrase a connected wallet must report to match this build. */
export const activeNetworkPassphrase: string = STELLAR_NETWORK_PASSPHRASES[activeStellarNetwork]

/**
* Explicit allow-list of the passphrases a wallet connection may present. A
* wallet that reports anything else is not on this build's network, so signing
* is refused rather than producing a transaction that can never settle (#864).
*/
export const SUPPORTED_NETWORK_PASSPHRASES: readonly string[] =
Object.values(STELLAR_NETWORK_PASSPHRASES)

export function isSupportedNetworkPassphrase(passphrase: string): boolean {
return SUPPORTED_NETWORK_PASSPHRASES.includes(passphrase)
}

/** Explorer account URL for `address` on `network`. */
export function stellarAccountExplorerUrl(address: string, network: StellarNetwork): string {
return `${STELLAR_EXPLORER_URLS[network]}/account/${address}`
return `${STELLAR_EXPLORER_URLS[network]}/account/${encodeURIComponent(address)}`
}
Loading
Loading